Redis 基础与核心原理
一、Redis 是什么
Redis(Remote Dictionary Server)的本质定位
Redis 的全称是 Remote Dictionary Server,即"远程字典服务"。从名字可以看出,Redis 的核心抽象就是一个通过网络远程访问的字典(Dictionary)。它是一个开源的、基于内存的、支持持久化的键值对(Key-Value)存储系统,由 Salvatore Sanfilippo(antirez)于 2009 年开发,目前由 Redis Ltd. 维护。
Redis 最初的设计目标非常简单------解决传统关系型数据库在高并发场景下的性能瓶颈。随着生态的不断发展,Redis 已经从一个单纯的内存缓存工具,演变为一个功能丰富的多模数据平台(Multi-model Data Platform)。
官方定义的五大角色
Redis 承担以下五大核心角色:
| 角色 | 英文 | 说明 |
|---|---|---|
| 数据库 | Database | 作为持久化的内存数据库,支持事务(Transaction)、Lua 脚本、Module 扩展 |
| 缓存 | Cache | 最经典的用法,利用内存的高速读写特性缓存热点数据,减轻后端数据库压力 |
| 消息代理 | Message Broker | 通过 Pub/Sub 机制实现消息的发布与订阅,支持消息分发 |
| 流处理引擎 | Streaming Engine | Redis 5.0 引入的 Stream 数据类型,支持消费者组(Consumer Group),可实现类似 Kafka 的消息流处理 |
| 向量数据库 | Vector Store | Redis 8+ 原生支持向量搜索(Vector Search),可用于 AI/ML 场景中的相似性检索(Similarity Search) |
值得注意的是,这五大角色并非互斥,在实际生产环境中,一个 Redis 实例往往同时承担多种角色。
核心特点
Redis 之所以能在工业界被广泛采用,源于以下核心特点的协同:
- 内存存储(In-memory Storage):所有数据驻留在内存中,读写延迟极低(微秒级)
- 单线程事件循环(Single-threaded Event Loop):命令执行线程为单线程,避免了锁竞争等并发问题
- 高性能(High Performance):官方基准测试可达百万级 QPS(Queries Per Second)
- 丰富数据结构(Rich Data Structures):String、List、Hash、Set、ZSet、Stream、HyperLogLog、Bitmap、Geospatial 等
- 持久化(Persistence):支持 RDB 快照和 AOF 日志两种持久化方式,内存数据可落盘
- 复制(Replication):主从复制(Master-Slave Replication),支持读写分离
- 集群(Cluster):Redis Cluster 提供分布式分片方案,支持水平扩展
- 事务(Transaction):通过 MULTI/EXEC/WATCH 提供事务支持
- Lua 脚本(Lua Scripting):支持原子性执行的 Lua 脚本,实现复杂逻辑
- Module 扩展(Module System):可加载第三方模块扩展功能,如 RediSearch、RedisJSON、RedisTimeSeries 等
Redis 与 Memcached 的关键区别
| 对比维度 | Redis | Memcached |
|---|---|---|
| 数据结构 | 丰富(String/List/Hash/Set/ZSet/Stream 等) | 仅支持简单的 Key-Value(String) |
| 持久化 | 支持 RDB + AOF,数据可落盘 | 纯内存,重启数据丢失 |
| 集群 | 原生支持 Redis Cluster 分片 | 需要客户端一致性哈希分片 |
| 线程模型 | 命令执行单线程,IO 多线程(Redis 6+) | 多线程 |
| 内存管理 | 自定义内存分配器(jemalloc),支持多种淘汰策略 | Slab Allocator,LRU 淘汰 |
| 事务 | 支持 MULTI/EXEC/WATCH | 不支持事务 |
| 消息模型 | 支持 Pub/Sub 和 Stream | 不支持 |
| Lua 脚本 | 支持 | 不支持 |
| 适用场景 | 缓存 + 数据库 + 消息队列等多角色 | 纯缓存场景 |
总结:Memcached 是一个纯粹的内存缓存系统,而 Redis 是一个多功能的内存数据平台。在新项目中,Redis 几乎完全取代了 Memcached 的生态位。
二、Redis 为什么这么快
Redis 的高性能并非由单一因素决定,而是多个设计决策协同作用的结果。下面逐一拆解。
2.1 基于内存
内存 vs SSD vs HDD 的访问延迟对比
| 存储介质 | 典型访问延迟 | 数量级 |
|---|---|---|
| CPU L1 Cache | 0.5 ns | 纳秒级 |
| CPU L2 Cache | 5 ns | 纳秒级 |
| 内存(DRAM) | 100 ns | 纳秒级 |
| SSD(Solid State Drive) | 100,000 ns (0.1 ms) | 微秒级 |
| HDD(Hard Disk Drive) | 10,000,000 ns (10 ms) | 毫秒级 |
从上表可以看出,内存的访问速度是 SSD 的约 1000 倍,是 HDD 的约 100,000 倍。这意味着:即使磁盘数据库使用了再精妙的索引结构(如 B+Tree),其访问延迟也无法与直接操作内存相提并论。
Redis 快的本质不是算法快,而是数据存储位置不同
很多人误以为 Redis 快是因为使用了某种"黑科技算法"。实际上,Redis 快的根本原因是数据存储在内存中。内存的随机访问延迟约为 100 纳秒,而磁盘的随机访问延迟约为 10 毫秒,两者差了 5 个数量级。这意味着,即使 Redis 使用最简单的线性查找,在内存中的效率也远超磁盘数据库使用 B+Tree 的效率。
当然,Redis 并非只靠内存------它在数据结构和网络模型上也做了大量优化,这些优化让 Redis 把内存的性能潜力发挥到了极致。
伪代码:内存读取 vs 磁盘读取的耗时对比
pseudocode
// 内存读取
function memoryRead(key):
address = hash(key) % memoryTableSize // O(1) 哈希定位
value = memory[address] // 约 100ns
return value
// 磁盘读取(即使有 B+Tree 索引)
function diskRead(key):
rootPage = readFromDisk(rootPageOffset) // 第1次磁盘IO,约 10ms
childPage = readFromDisk(rootPage.child) // 第2次磁盘IO,约 10ms
leafPage = readFromDisk(childPage.child) // 第3次磁盘IO,约 10ms
value = leafPage.find(key)
return value
// 耗时对比:
// memoryRead: ~100ns = 0.0001ms
// diskRead: ~30ms
// 内存读取比磁盘读取快约 300,000 倍
2.2 单线程模型
为什么 Redis 选择单线程:瓶颈在网络 IO 而非 CPU
Redis 的核心设计哲学是:对于内存数据库来说,性能瓶颈不在 CPU 计算,而在网络 IO。一次 Redis 命令的典型执行流程如下:
- 从网络读取请求(网络 IO)------ 微秒级
- 解析命令(CPU)------ 纳秒级
- 在内存中查找并执行命令(CPU + 内存)------ 纳秒级
- 将结果写回网络(网络 IO)------ 微秒级
可以看到,CPU 计算只占极小的比例,真正耗时的是网络 IO。因此,用多线程来加速 CPU 计算意义不大,反而会引入复杂的并发控制问题。
单线程避免的问题
- 锁竞争(Lock Contention):多线程访问共享数据结构需要加锁,锁竞争会导致线程频繁阻塞和唤醒,降低吞吐量
- 上下文切换(Context Switch):线程切换需要保存和恢复寄存器、缓存等状态,频繁切换浪费 CPU 资源
- Cache Miss:多线程在不同 CPU 核心上运行,可能导致 CPU Cache Line 的频繁失效(False Sharing),增加内存访问延迟
Redis 6 的多线程 IO:仅网络 IO 多线程,命令执行仍单线程
Redis 6 引入了 IO 多线程(Threaded I/O),但需要注意的是:多线程仅用于网络读写,命令的执行仍然是单线程的。这样既提升了网络 IO 的吞吐量,又保证了命令执行的原子性和无锁特性。
伪代码:单线程事件循环的核心逻辑
pseudocode
// Redis 单线程事件循环的核心逻辑
function eventLoop():
while server is running:
// 1. 通过 epoll 收集就绪的事件
readyEvents = epoll_wait(epfd, events, timeout)
// 2. 遍历所有就绪事件
for event in readyEvents:
if event.type == ACCEPT:
// 新连接到达
clientFd = accept(listenFd)
setNonBlocking(clientFd)
epoll_ctl(epfd, ADD, clientFd, READ_EVENT)
else if event.type == READ:
// 客户端请求到达
request = read(event.fd)
command = parseCommand(request)
// 关键:命令执行是单线程的,无需加锁
result = executeCommand(command)
// 将结果加入写缓冲区
appendToWriteBuffer(event.fd, result)
else if event.type == WRITE:
// 将缓冲区数据写回客户端
flushWriteBuffer(event.fd)
// 3. 执行定时任务
processTimedEvents()
2.3 IO 多路复用
Linux IO 模型演进:select -> poll -> epoll
在介绍 Redis 的 IO 模型之前,先理解 Linux IO 多路复用的演进:
select(早期方案):
- 使用固定长度的位图(bitmap)表示文件描述符(File Descriptor, FD)集合
- 每次调用需要将整个 FD 集合从用户态拷贝到内核态
- 支持的最大 FD 数量受限(默认 1024,由 FD_SETSIZE 决定)
- 时间复杂度 O(n),每次都需要遍历全部 FD
poll(改进方案):
- 用动态数组替代位图,取消了 1024 的数量限制
- 但仍需将整个 FD 集合拷贝到内核态
- 时间复杂度仍为 O(n)
epoll(现代方案):
- 使用红黑树管理 FD,支持百万级连接
- 通过事件回调机制(Callback),只返回就绪的 FD,时间复杂度 O(1)
- 使用 mmap 共享内存区域,避免用户态和内核态之间的数据拷贝
- 支持 ET(边缘触发)和 LT(水平触发)两种模式
Redis 默认使用 epoll,一个线程监听数万 TCP 连接
Redis 在 Linux 上默认使用 epoll 作为 IO 多路复用的实现(在 macOS 上使用 kqueue,在 Windows 上使用 select)。epoll 的高效事件通知机制使得 Redis 仅用一个线程就能同时监听数万个 TCP 连接,并在连接就绪时进行处理。
epoll 的 ET(边缘触发)vs LT(水平触发)模式
| 模式 | 英文 | 触发条件 | 特点 |
|---|---|---|---|
| LT | Level Triggered | 只要缓冲区有数据,就持续触发 | 编程简单,不容易漏事件;但可能频繁触发 |
| ET | Edge Triggered | 缓冲区从空变为非空时触发一次 | 效率更高,但必须一次性读完所有数据,否则会丢失事件 |
Redis 使用的是 LT(水平触发) 模式。原因在于 Redis 的事件循环是单线程的,LT 模式编程更简单,不需要担心一次性未读完数据导致的事件丢失问题。
伪代码:epoll 事件循环伪代码
pseudocode
// epoll 事件循环伪代码
function epollEventLoop():
// 1. 创建 epoll 实例
epfd = epoll_create1(0)
// 2. 将监听套接字注册到 epoll
epoll_ctl(epfd, EPOLL_CTL_ADD, listenFd, {
events: EPOLLIN, // 关注可读事件
data: { type: ACCEPT } // 标记为连接事件
})
// 3. 事件循环
while True:
// 等待事件就绪,返回就绪的 FD 列表
// 只返回有事件的 FD,不需要遍历全部连接
readyList = epoll_wait(epfd, maxEvents, timeout)
for event in readyList:
if event.data.type == ACCEPT:
// 新连接到达
clientFd = accept(listenFd)
setNonBlocking(clientFd) // 设置为非阻塞
epoll_ctl(epfd, EPOLL_CTL_ADD, clientFd, {
events: EPOLLIN,
data: { type: CLIENT, fd: clientFd }
})
else if event.data.type == CLIENT:
if event.events & EPOLLIN:
// 客户端数据可读
data = read(event.data.fd)
if data.length > 0:
processCommand(data)
else:
// 连接关闭
epoll_ctl(epfd, EPOLL_CTL_DEL, event.data.fd)
if event.events & EPOLLOUT:
// 可以向客户端写数据
flushWriteBuffer(event.data.fd)
2.4 非阻塞 IO
accept()/read()/write() 全部非阻塞
Redis 将所有网络套接字都设置为非阻塞模式(Non-blocking IO)。这意味着:
- accept():如果没有新连接到达,立即返回 EAGAIN,不会阻塞等待
- read():如果缓冲区没有数据,立即返回 EAGAIN,不会阻塞等待
- write():如果缓冲区已满,立即返回 EAGAIN,不会阻塞等待
非阻塞 IO 配合 epoll 的事件通知机制,使得 Redis 的事件循环永远不会因为某个慢连接而卡住。
为什么一个线程就能处理大量连接
核心原理:不要等待,轮询就绪。传统阻塞 IO 模型中,一个线程只能处理一个连接,因为 read() 会阻塞。而 Redis 的做法是:
- 将所有连接设置为非阻塞
- 用 epoll 监听所有连接的就绪状态
- 只有当连接有数据可读/可写时,才去处理
- 处理完立即返回事件循环,继续处理其他连接
这样,一个线程可以高效地在数万个连接之间切换处理,没有任何阻塞等待的时间浪费。
2.5 高效数据结构
Redis 底层不是简单 HashMap,而是精心优化的数据结构
很多人以为 Redis 就是把数据放在一个大的 HashMap 里,实际上 Redis 对每种数据类型的底层实现都做了深度的优化,针对不同场景使用不同的编码方式,在内存占用和操作效率之间取得了极佳的平衡。
数据结构概览表
| 数据类型 | 底层编码 | 核心优势 |
|---|---|---|
| String | SDS(Simple Dynamic String) | O(1) 获取长度;二进制安全;杜绝缓冲区溢出;预分配+惰性释放减少内存重分配 |
| Hash | ListPack(小数据)/ Hashtable(大数据) | ListPack 紧凑存储省内存;Hashtable O(1) 查找;自动根据数据量切换编码 |
| List | QuickList(双向链表 + ListPack) | 兼顾链表的快速插入删除和 ListPack 的紧凑存储;节点间用 ziplist/ListPack 压缩 |
| Set | IntSet(纯整数)/ Hashtable(混合类型) | IntSet 有序紧凑存储纯整数集合;Hashtable O(1) 查找 |
| ZSet | SkipList + Hashtable | SkipList O(logN) 范围查询;Hashtable O(1) 按成员查分数;双重索引 |
每种底层编码的核心优势简述:
SDS(Simple Dynamic String):Redis 自己实现的字符串,而非直接使用 C 语言的 char*。SDS 在头部记录了字符串长度,因此获取长度的时间复杂度为 O(1)(C 语言的 strlen 需要 O(N) 遍历)。SDS 还通过空间预分配和惰性释放策略,大幅减少了内存重分配的次数。
ListPack:Redis 7.0 用 ListPack 替代了 ziplist。ListPack 是一种紧凑的线性存储结构,所有元素紧密排列在一块连续内存中,内存利用率极高。当 Hash 中的字段数量较少时,使用 ListPack 存储比 Hashtable 节省大量内存。
QuickList:QuickList 是 List 的底层实现,它是一个双向链表,每个节点是一个 ListPack(或 ziplist)。这种设计兼顾了链表在两端快速插入/删除的优势,以及 ListPack 紧凑存储节省内存的优势。
IntSet:当 Set 中的元素全部是整数且数量较少时,使用 IntSet 存储。IntSet 是一个有序的整数数组,虽然查找是 O(logN),但内存占用极小。
SkipList(跳表):ZSet 的范围查询核心。跳表通过多级索引实现 O(logN) 的查找效率,同时保持有序性。相比于红黑树,跳表的实现更简单,范围查询更高效(只需找到起点后沿底层链表遍历即可)。
三、Redis 为什么不用 B+Tree
B+Tree 的设计目标:减少磁盘 IO
B+Tree 是为磁盘数据库量身定制的索引结构。它的核心设计目标是:将随机 IO 转化为顺序 IO,尽量减少磁盘 IO 次数。
B+Tree 的每个节点通常设置为 4KB~16KB(与磁盘页大小对齐),这样一次磁盘 IO 就能读取一个完整的节点。一棵 3 层的 B+Tree(根节点常驻内存)只需 2 次磁盘 IO 就能定位到数据,可以索引约 2000 万条记录。
但这一切优化都建立在一个前提之上------数据存储在磁盘上,磁盘 IO 是主要瓶颈。
Redis 是内存数据库,不需要考虑磁盘 IO 优化
Redis 的数据全部驻留在内存中,内存的随机访问延迟约为 100 纳秒,不存在"减少 IO 次数"的问题。在内存中,随机访问和顺序访问的速度几乎一样快,B+Tree 的核心优势在内存场景下不复存在。
Hash O(1) 和 SkipList O(logN) 在内存中已经足够高效
- Hash 表:等值查找的时间复杂度为 O(1),在内存中性能极佳
- SkipList:范围查找的时间复杂度为 O(logN),在内存中纳秒级的操作,logN 的常数极小
以 1000 万条数据为例:
- Hash 查找:O(1),约 100ns
- SkipList 查找:O(logN) = O(24),约 2.4us
- B+Tree 查找:O(logN),约 2.4us + 额外的节点分裂/合并维护开销
在内存场景下,Hash 和 SkipList 的性能已经足够好,而 B+Tree 反而因为需要维护平衡(节点分裂/合并)而增加了额外的复杂度和开销。
对比表格:Redis vs MySQL 的索引结构选择
| 对比维度 | Redis | MySQL (InnoDB) |
|---|---|---|
| 存储介质 | 内存 | 磁盘 |
| 核心瓶颈 | 网络 IO | 磁盘 IO |
| 等值查找 | Hash O(1) | B+Tree O(logN) |
| 范围查询 | SkipList O(logN + M) | B+Tree O(logN + M),叶子节点链表顺序读 |
| 维护成本 | Hash 扩容 / SkipList 插入简单 | B+Tree 节点分裂/合并开销大 |
| 内存效率 | 不同数据结构各有优化 | B+Tree 节点对齐页大小,内存利用率一般 |
| 设计哲学 | 内存场景下追求极致速度 | 磁盘场景下减少 IO 次数 |
四、Redis 的线程模型演进
4.1 Redis 4.x 及之前:纯单线程
在 Redis 4.x 及之前的版本中,Redis 采用严格的单线程模型:
- 网络 IO:单线程通过 epoll 处理
- 命令执行:单线程串行执行
- 持久化:Fork 子进程处理 RDB/AOF(但不影响主线程的命令执行)
- 删除操作:同步删除(DEL 命令),大 Key 删除会阻塞主线程
这个阶段的优点是简单可靠,无需考虑并发安全问题;缺点是在高并发场景下,网络 IO 成为瓶颈,单线程无法充分利用多核 CPU。
4.2 Redis 6.x:IO 多线程
io-thread-read 和 io-thread-write 配置
Redis 6 引入了 Threaded I/O,通过以下配置控制:
io-threads 4 // IO 线程数(含主线程)
io-threads-do-reads yes // 是否开启读线程
注意:io-threads 的值包含主线程本身。例如设置为 4,表示 1 个主线程 + 3 个 IO 线程。
多线程仅处理网络读写,命令执行仍单线程
Threaded I/O 的工作流程如下:
- 主线程通过 epoll 获取就绪的客户端连接
- 读阶段:将客户端的读任务分配给 IO 线程,IO 线程负责读取和解析请求
- 执行阶段:主线程串行执行所有命令(这是保证原子性的关键)
- 写阶段:将写回任务分配给 IO 线程,IO 线程负责将结果写回客户端
关键保证:命令执行仍然在主线程中串行进行,不存在并发安全问题。
伪代码:IO 多线程的处理流程
pseudocode
// Redis 6 IO 多线程处理流程
function processClientsWithThreadedIO():
// ========== 读阶段(多线程) ==========
// 将客户端分配给 IO 线程读取请求
for client in readableClients:
assignToIOThread(client, READ_TASK)
// 等待所有 IO 线程完成读取
waitForIOThreadsCompletion()
// ========== 执行阶段(单线程) ==========
// 主线程串行执行所有命令
for client in readableClients:
while client.hasPendingCommand():
command = client.nextCommand()
result = executeCommand(command) // 单线程执行,无锁
client.setResponse(result)
// ========== 写阶段(多线程) ==========
// 将客户端分配给 IO 线程写回响应
for client in writableClients:
assignToIOThread(client, WRITE_TASK)
// 等待所有 IO 线程完成写入
waitForIOThreadsCompletion()
4.3 Redis 7.x:进一步优化
lazy-free(异步删除)的引入
Redis 4.0 就引入了 lazy-free 机制,但在 Redis 7.x 中得到进一步优化和广泛应用。lazy-free 的核心思想是:将耗时的删除操作放到后台线程异步执行,避免阻塞主线程。
相关配置:
lazyfree-lazy-eviction yes // 异步淘汰
lazyfree-lazy-expire yes // 异步过期
lazyfree-lazy-server-del yes // 异步删除(如 rename 覆盖旧值)
replica-lazy-flush yes // 从节点异步全量同步时异步清空
lazyfree-lazy-user-del yes // 用户调用 DEL 时异步删除
lazyfree-lazy-user-flush yes // 用户调用 FLUSHDB/FLUSHALL 时异步清空
多线程对大 Key 删除的优化
当一个包含数百万元素的 Hash 或 Set 被删除时:
- 同步删除(DEL):主线程遍历并释放所有元素,可能导致数百毫秒的阻塞
- 异步删除(UNLINK):主线程仅将 Key 从数据库中摘除(O(1)),实际的内存释放交给后台线程异步完成
pseudocode
// 同步删除 vs 异步删除
function DEL(key):
value = db.remove(key) // 从字典中摘除 O(1)
freeMemory(value) // 同步释放内存 ------ 大 Key 时阻塞!
return OK
function UNLINK(key):
value = db.remove(key) // 从字典中摘除 O(1)
enqueueToLazyFreeQueue(value) // 放入异步释放队列 ------ 立即返回
return OK
// 后台 lazy-free 线程
function lazyFreeThread():
while True:
value = dequeueFromLazyFreeQueue()
freeMemory(value) // 在后台线程中释放内存,不阻塞主线程
五、Redis 的全局数据库结构
16 个默认数据库(db0 - db15)
Redis 默认提供 16 个数据库,编号从 0 到 15。客户端连接后默认使用 db0,可以通过 SELECT index 命令切换数据库。
配置项:databases 16(可在 redis.conf 中修改)
注意:Redis Cluster 模式下只能使用 db0,不支持多数据库。
每个数据库是一个 dict(字典)
每个 Redis 数据库的核心就是一个 dict(字典),即哈希表。所有 Key-Value 都存储在这个字典中。Redis 的 dict 使用链地址法(Separate Chaining)解决哈希冲突,并支持渐进式 Rehash(Incremental Rehashing),避免一次性 Rehash 导致的长时间阻塞。
过期键存储在 expires 字典中
Redis 为每个数据库维护了两个字典:
- dict:存储所有 Key-Value 数据
- expires:存储设置了过期时间的 Key 及其过期时间戳
这种分离存储的设计使得过期检查非常高效:只需在 expires 字典中查找 Key 即可判断是否过期,无需在主字典中嵌入过期时间字段。
伪代码:redisDb 结构体
pseudocode
// Redis 数据库结构(简化版)
struct redisDb:
dict *mainDict // 主字典:存储所有 Key-Value
// Key -> RedisObject 的映射
dict *expires // 过期字典:存储设置了 TTL 的 Key
// Key -> 过期时间戳(毫秒精度)
dict *blocking_keys // 阻塞字典:记录正在阻塞等待的 Key
// 如 BLPOP 等待的列表 Key
dict *ready_keys // 就绪字典:阻塞 Key 被推送后标记为就绪
dict *watched_keys // 事务监控字典:WATCH 命令监控的 Key
// 用于乐观锁(Optimistic Locking)
int id // 数据库编号(0-15)
long long avg_ttl // 平均 TTL,用于统计
unsigned long expires_cursor // 过期扫描游标,用于渐进式过期
// 使用示例
redisDb db0:
mainDict = {
"user:1" -> RedisObject(Hash, {name: "Tom", age: 25}),
"counter" -> RedisObject(String, "100"),
"session" -> RedisObject(String, "abc123")
}
expires = {
"session" -> 1720000000000 // session 将在此时间戳过期
}