redis系列指南(一)
2026年4月6日 · 7270 字 · 更新 2026年4月7日
Redis介绍
Redis是一个开源的内存数据库,同时也是一个高性能的Key-Value存储系统。它不仅仅是一个简单的缓存工具,还支持多种数据结构,并提供丰富的功能,例如:
- 缓存
- 消息队列
- 分布式锁
- 排行榜
- 计数器
事件驱动模型
Redis的高性能不仅来源于内存和数据结构设计,还依赖于其底层的网络模型。Redis在网络层采用的是一种典型的事件驱动模型 + I/O多路复用。这一切的核心,就是Redis自己实现的ae高性能事件库。
ae是Redis实现事件驱动机制的基础组件,它对底层复杂的操作系统I/O模型进行了封装,并向上提供了统一、简洁的接口。从设计上看ae将Redis所有需要处理的异步任务抽象为两大类事件:
- I/O事件:主要负责网络通信相关的操作。例如客户端连接建立、读取客户端命令、发送响应数据等
- 时间事件:用于执行周期性或定时任务。例如定期清理过期键、渐进式rehash、每秒执行一次的后台维护任务等
Redis启动后,核心逻辑本质上就是不断运行ae的事件循环。这个循环大致可以分为以下几个步骤:
- 处理时间事件:首先检查是否有已经到期的时间事件。如果有,就执行对应的回调函数。比如需要清理过期键
- 计算阻塞等待时间:接着,ae会计算“距离最近时间事件的时间差”。作为epoll_wait的timeout
- 随后,Redis调用底层多路复用函数,如epoll_wait,并传入上一步得到的timeout。线程会在这里阻塞,直到某些Socket上出现I/O事件,或timeout到期
- 处理I/O事件:如果多路复用调用返回,说明已有fd就绪。此时ae会遍历就绪列表,并调用这些fd预先注册好的事件处理函数
- 如果是可读事件,执行readQueryFromClient,读取客户端命令
- 如果是可写事件,执行sendReplyToClient,发送响应结果
- 进入下一轮事件循环:本轮时间事件和I/O事件处理完成后,事件循环重新开始,继续下一轮调度
I/O多路复用
如果使用传统的阻塞I/O,那一个线程只能处理一个连接,期间无法处理其他请求,无法支撑高并发场景。因此Redis使用I/O多路复用机制,其核心思想是:用一个线程,同时监听多个Socket。其执行流程如下(epoll):
- 所有连接注册到epoll
- 主线程调用epoll_wait()阻塞等待
- 一旦有事件发生,立即返回就绪的Socket FD列表
此时,Redis再对这些就绪的FD执行read()或write()操作时,不会因为“等待数据从网络到达”而阻塞。从而实现单线程即可同时处理成千上万个连接。
跨平台I/O适配
为了兼顾性能和可移植性,ae对不同操作系统提供了统一的抽象接口。Redis主程序只调用ae暴露的统一API,而无需关心底层到底使用的是哪一种I/O多路复用机制。不同平台上的适配关系通常如下:
- Linux:epoll
- macOS/BSD:kqueue
- Solaris:/dev/poll
这种设计让Redis既能充分利用各平台最高效的I/O能力,又能保持整体代码结构清晰统一。
单线程设计
Redis之所以长期采用单线程模型,核心原因并不是“技术做不到多线程”,而是单线程在它的场景下更简单、更高效。如果使用多线程,多个线程会同时访问和修改共享数据结构,此时就必须引入各种锁机制,而锁会带来额外成本。对于Redis这种以内存操作为主、追求极致性能的系统来说,这些代价往往并不划算。
单线程模型下,Redis命令按顺序串行执行。这样一来:
- 不需要考虑复杂的并发同步问题
- 多步命令天然具备原子性
- 数据一致性更容易保证
因此,Redis早期选择单线程,本质上是一种以简洁换性能、以确定性换复杂度的设计。
引入的多线程
虽然Redis通过I/O多路复用解决了“一个线程处理多个连接”的问题,但在高并发场景下,主线程仍然需要遍历就绪的Socket、执行数据读取、进行协议解析和写回响应结果。这些网络I/O相关工作本身也会消耗大量CPU时间。当客户端连接很多、网络吞吐很高时,主线程仍然可能成为瓶颈。因此,从Redis 6开始,引入了多线程I/O机制。
- 主线程:负责epoll_wait、事件分发、命令执行
- I/O线程:负责Socket数据读取、协议解析,以及响应数据写回
这样做既能降低主线程在网络读写上的压力,提升高并发场景下的吞吐能力,也能更好发挥多核CPU的能力。不过要注意的是,Redis并没有把“命令执行”也完全交给I/O线程。为了保持线程安全,并简化复制、持久化等机制的实现,命令的真正执行仍然由主线程完成。
也就是说,Redis 6的多线程更准确地说是:网络I/O多线程,命令执行仍然单线程。
数据持久化
Redis的持久化工作本身通常不在主线程中完成,而是通过fork出来的子进程在后台执行。但持久化的触发、调度和状态监控仍然由主线程负责。也就是说,主线程需要检测是否满足持久化条件,再决定是否发起fork创建后台子进程,并异步等待子进程完成并处理结果。
Redis之所以能在后台持久化时依然维持较好的请求处理能力,核心就在于fork + 写时复制机制。当Redis需要执行后台持久化时,主线程会调用fork创建子进程。此时并不会立刻复制整块内存,而是:
- 子进程先复制父进程的页表
- 父子进程最初共享同一份物理内存页
- 这些共享页会被标记为只读
- 后续如果主进程或子进程对某个页发生写操作,才会触发COW,复制该页
因此,fork后真正重的工作不是“复制全部内存数据”,而主要是复制页表。对于大内存实例,这一步仍可能带来几十到几百毫秒的阻塞,但阻塞通常只发生在fork瞬间。fork完成后,主线程会立即回到事件循环,继续处理客户端请求。
在Redis中,数据持久化的方式有三种:RDB、AOF和混合持久化。
RDB
RDB本质上是某一时刻内存数据的全量快照。RDB文件采用紧凑的二进制格式,体积小,恢复速度快,适合做定期备份和灾难恢复。但RDB保存的是生成瞬间的数据状态,因此它并不保证是实时最新数据。两次快照之间发生的新写入,如果Redis宕机,就可能丢失。
RDB的触发方式有两种:
- 手动触发:手动执行命令触发
- SAVE:主线程同步执行RDB,会阻塞主线程
- BGSAVE:主线程fork子进程异步执行,主线程基本不阻塞(除fork瞬间外)
- 自动触发:通过配置文件中的save规则触发,例如
save 900 1 # 900秒内至少1次写操作
save 300 10 # 300秒内至少10次写操作
save 60 10000 # 00秒内至少10000次写操作Redis主线程会在事件循环中通过时间事件周期性检查这些条件,一旦满足,就调度一次BGSAVE。RDB的执行流程如下:
- 调度与检查:主线程在事件循环中周期性执行时间事件,检查是否满足save配置
- fork子进程:如果满足条件,主线程调用fork创建子进程。此时父子进程共享同一份物理内存页,页表被复制,相关内存页标记为只读。这一步会让主线程产生一次短暂阻塞
- 子进程生成RDB文件:子进程遍历所有数据库,将当前内存中的数据按RDB格式序列化,并写入临时RDB文件
- 主线程继续处理请求,必要时触发COW:在子进程写RDB的过程中,主线程仍然继续响应客户端请求。如果此时有写请求修改了某个共享页,就会触发COW
- 内核为发生写入的主线程分配新的物理页
- 将旧页内容复制到新页
- 新页改为可写
- 后续写操作在新页上进行
- 子进程原子替换旧RDB文件:子进程写完临时文件后,会通过rename原子替换旧的RDB文件。在Linux中,rename是原子操作,因此不会出现“半写入”状态。如果成功,则直接切换为新文件。如果失败或中途崩溃,旧文件仍然保持完整
- 主线程收尾:子进程完成后会通知主线程,主线程更新内部状态。例如最近一次成功生成RDB的时间、当前bgsave状态。随后整个流程结束
AOF
AOF采用的是追加写命令日志的方式持久化数据。Redis在执行完一条写命令后,会把该命令以协议/命令形式追加到AOF缓冲区中,之后再根据配置决定何时真正写入磁盘。AOF几乎实时记录数据变更,数据安全性通常比RDB更高,更适合提升宕机后的数据完整性。但AOF文件通常比RDB更大,恢复速度通常也慢于RDB,长期运行后需要重写来压缩体积。
AOF持久化有两层触发机制:
- 写命令触发:当客户端执行写命令后,Redis主线程会在命令执行完成后,将该命令追加到AOF缓冲区。只有写命令会进入AOF,只读命令不会记录
- 刷盘触发:AOF缓冲区什么时候真正刷到磁盘,由appendfsync配置决定
- always:每条写命令后都执行fsync,安全性最高,性能最差
- everysec:每秒执行一次fsync,性能和安全性的平衡方案,也是最常用配置
- no:交给操作系统决定何时刷盘,性能最好,安全性最弱
这里要注意的是,命令写入AOF缓冲区由主线程完成。而真正刷盘,由后台BIO线程异步处理。所以,AOF的常规追加和everysec刷盘并不会触发COW,因为这个过程没有fork。
AOF重写机制
随着时间推移,AOF文件会越来越大。Redis会通过AOF重写生成一个更精简的新AOF文件,避免日志无限膨胀。重写触发方式有两种:
- 手动触发:BGREWRITEAOF
- 自动触发:由以下配置决定
- auto-aof-rewrite-percentage
- auto-aof-rewrite-min-sizeAOF重写会fork子进程,因此会触发COW机制。而且,AOF重写并不是对旧AOF文件做“原地压缩”,而是基于当前内存数据重新生成一份新的AOF文件。大致流程如下:
- 调度与检查:主线程在时间事件中检查AOF文件大小是否达到自动重写条件,或者收到BGREWRITEAOF命令
- fork子进程:一旦满足条件,主线程fork子进程。父子进程共享物理页,页表复制,相关页标记只读,主线程在fork瞬间短暂阻塞
- 子进程重建新AOF:子进程遍历当前内存数据,不是照搬旧AOF文件,而是把当前数据状态转换成更精简的命令集合写入临时AOF文件。例如多次INCR的结果,最终可能只需要一条SET来表达
- 主线程继续处理请求,并维护重写缓冲区:在子进程重写期间,主线程仍然正常处理客户端写请求。如果这些写请求修改了共享页,就会触发COW。同时,主线程会把新的写命令正常追加到现有AOF流程中,并额外写入到AOF重写缓冲区(rewrite buffer)中。这个缓冲区记录的是从fork开始到重写完成这段时间内产生的增量写命令
- 子进程补齐增量并替换文件:子进程完成主体重写后,会把rewrite buffer中积累的增量命令追加到临时AOF文件末尾。这样,新文件就补齐了fork之后的变更。然后再通过rename原子替换旧AOF文件
- 主线程收尾:主线程收到子进程完成通知后,会清理rewrite buffer,并更新AOF相关状态,例如新AOF文件大小、上次重写时间等。之后Redis开始继续向新的AOF文件追加写入
混合持久化
为了同时兼顾恢复速度快的RDB和数据完整性更高的AOF,Redis引入了混合持久化。它的核心思想是:在AOF重写时,不再单纯生成“纯命令格式”的AOF文件,而是先把当前内存数据以RDB格式写入文件前半部分,再把fork之后产生的增量写命令以AOF格式追加到后半部分。因此生成的新文件可以理解为:新AOF文件 = RDB快照 + AOF增量命令。在恢复时,前半部分的RDB内容加载速度非常快,而后半部分追加的AOF内容用来保证数据的实时完整性。
混合持久化下的文件也是AOF文件,正常的刷盘操作由appendfsync策略决定,每次新的内容都会追加到文件的末尾。同时它也支持AOF的重写策略,重写时的大致流程如下:
- 调度与检查:本质上仍然是在AOF重写场景下触发。无论是手动BGREWRITEAOF,还是自动重写条件满足,都会进入该流程
- fork子进程:主线程fork子进程,这一步仍然会涉及COW,主线程在fork瞬间有短暂阻塞
- 主线程继续处理请求:在子进程工作期间,主线程继续处理客户端请求。如果发生写操作,会触发COW,并且这些新写命令仍会被追加到旧AOF的写入链路和rewrite buffer中
- 子进程生成混合文件:子进程不再生成纯AOF,而是先把当前内存数据按照RDB格式写入文件头部。再把rewrite buffer中的增量写命令按AOF格式追加到后部
- 原子替换文件:写完后通过rename原子替换旧AOF文件
- 主线程收尾:主线程更新AOF状态,清理rewrite buffer,之后新命令继续写入新的AOF文件
内存管理
Redis作为一个以内存为核心的数据存储系统,其内存管理设计至关重要。为了在数据完整性、CPU开销以及内存利用率之间取得平衡Redis将内存管理分为两大核心机制:过期策略和淘汰策略。
过期策略
过期策略解决的是:已经设置了TTL的key,什么时候真正从内存中删除。
Redis允许用户通过EXPIRE、PEXPIRE、SETEX等命令为key设置生存时间(TTL)。当TTL到期后,key并不会保证在那一刻立即被删除,而是由Redis的过期机制决定其实际删除时机。Redis采用的是惰性删除 + 定期删除相结合的方式。
惰性删除
惰性删除就是只有当客户端访问某个key时,Redis才检查它是否过期。如果访问时发现该key已经过期,Redis会先删除该key,再返回空结果。删除动作只发生在访问时,不会为了清理过期数据而频繁扫描内存。但如果大量key已经过期,且一直没有被访问,这些key仍会继续占用内存,因此会带来一定的内存浪费。
也就是说,惰性删除更偏向“按需处理”,节省CPU,但不保证内存一定能及时回收。
lazyfree-lazy-eviction yes这个配置与“异步释放内存”相关,作用是让淘汰后的内存释放尽量在后台线程中执行,减少主线程阻塞。它并不是“惰性删除过期key”本身的开关,这一点要注意区分
定期删除
为了弥补惰性删除可能造成的内存浪费,Redis还会周期性地主动检查过期key。Redis在事件循环中会以固定频率执行过期扫描任务,默认每秒多次触发。每次执行时,会从设置了过期时间的key中随机抽样检查,发现过期就立即删除。
此种方式不会进行全量扫描,而是随机抽样,有时间上限控制。避免Redis为了清理过期key而进行大规模遍历,从而增加CPU消耗,影响主线程处理请求的能力。
定期删除能主动清理一部分“已经过期但未被访问”的key,提高内存回收效率,缓解惰性删除带来的内存滞留问题。但因为是随机抽样,不能保证所有过期key都会立即删除,删除过程仍然运行在主线程,会消耗一定CPU时间。所以,它更像是一种折中方案:不追求绝对实时清理,而是在可控的CPU成本下,尽可能回收过期数据。
Redis提供了多种设置过期时间的命令,常见如下:
- EXPIRE key seconds:设置key在指定秒数后过期
- EXPIRE key milliseconds:设置key在指定毫秒数后过期
- EXPIREAT key timestamp:设置key在某个时间戳后过期,精确到秒
- PEXPIREAT key millisecondsTimestamp:设置key在某个时间戳后过期,精确到毫秒
- PERSIST key:如果希望移除已有的过期时间时使用,它会让key重新变成持久key
淘汰策略
过期策略处理的是“已经设置TTL的key何时删除”,而淘汰策略处理的是“Redis内存满了以后该怎么办”。当Redis的内存使用达到maxmemory限制,并且此时又来了新的写请求,Redis就必须执行内存淘汰机制,删除一部分数据,为新写入腾出空间。具体采用哪种淘汰方式,由配置项maxmemory-policy决定,它支持以下几种选项:
- noeviction:默认配置,不淘汰任何数据。当内存达到上限后,新的写操作会直接返回错误
- allkeys-lru:在所有key中,按照近似LRU规则淘汰最近最少使用的key
- volatile-lru:只在设置了TTL的key中,按照近似LRU规则淘汰
- allkeys-lfu:在所有key中,按照近似LFU规则淘汰最不经常使用的key
- volatile-lfu:只在设置了TTL的key中,按照近似LFU规则淘汰
- allkeys-random:在所有key中随机淘汰
- volatile-random:只在设置了TTL的key中随机淘汰
- volatile-ttl:只在设置了TTL的key中,优先淘汰剩余TTL更短的key
在淘汰策略中,LRU和LFU很容易混淆,它们的关注点并不一样。LRU关注“最近一次访问时间”,谁最近最少被访问,谁更可能被淘汰;LFU关注“访问频率”,谁在一段时间内访问次数更少,谁更可能被淘汰。可以简单理解为:
- LRU看“最近有没有被用过”
- LFU看“平时是不是经常被用”
如果业务热点变化快,LRU往往更合适。而如果热点相对稳定,LFU通常更有优势。
什么是近似LRU/LFU
Redis并没有实现一个“全局精确”的LRU或LFU链表。因为如果要精确维护所有key的访问顺序或访问频率,会带来额外的内存开销和维护成本,影响性能。Redis作为高性能内存数据库,不会采用这种过重的实现方式。因此,Redis采用的是一种近似算法,核心思路是:
- 为key记录少量访问相关的元信息
- 在需要淘汰时,不是全量排序,而是随机采样一批key
- 再从样本中挑选出最符合LRU/LFU特征的key进行淘汰
这种方式本质上是在淘汰精度与运行性能之间做折中。虽然结果并不是绝对精确的LRU/LFU,但不需要维护复杂的全局数据结构,内存开销更低,淘汰计算也更轻量,更适合高并发场景。
主从同步
Redis支持一主多从的复制拓扑。一个主节点可以挂多个从节点;同时,一个从节点也可以继续作为其他节点的上游,形成级联复制结构。主从同步主要用于以下场景:
- 数据冗余备份
- 读写分离
- 故障恢复
- 数据迁移与升级
从复制过程看,Redis的主从同步主要分为两个阶段:全量同步、增量同步。
全量同步
全量同步通常发生在主从第一次建立复制关系,或者无法进行增量同步时。这是最完整的复制方式,但也是开销最大的一种方式。Redis全量同步在实现上有两种常见模式:diskful和diskless。
diskful
diskful是基于磁盘的全量复制,是比较经典的复制模式,其流程如下:
- 从节点向主节点发送
PSYNC ? -1,表示请求建立同步关系 - 主节点返回
+FULLRESYNC <replicationid> <offset>,告知当前复制ID和复制偏移量 - 主节点执行fork,由子进程生成RDB快照文件
- 在生成RDB的这段时间里,主节点接收到的新写命令不会丢失,而是会继续写入复制缓冲区
- RDB生成完成后,主节点将RDB文件通过socket发送给从节点
- 从节点接收完RDB后,清空旧数据并加载RDB到内存
- 随后主节点再把生成RDB期间积累的增量写命令发送给从节点
- 从节点执行这部分增量命令后,数据状态追平主节点,完成全量同步
全量同步结束后,主节点的后续写命令会持续异步传播给从节点,从节点按顺序执行这些命令,从而保持数据一致。diskful模式下,虽然数据状态保持最完整,但整体开销都比较大,在数据量大时,对主从两端的CPU、内存和磁盘压力都明显增加。
diskless
在diskful模式下,主节点需要先把RDB写入磁盘临时文件,再通过网络发送给从节点。这会带来额外的磁盘I/O开销。因此,Redis 从2.8.18/3.0开始提供了无盘复制能力。其核心思路是:
- 主节点fork出子进程后
- 直接把内存数据序列化成RDB格式
- 再通过socket直接发送给从节点
- 不再落地临时RDB文件
这样可以减少磁盘写入压力,尤其适合多从节点同时全量同步,磁盘I/O紧张的环境。对应的参数配置如下:
repl-diskless-sync yes # 开启无盘复制
repl-diskless-sync-delay 5 # 主节点在第一个从节点发起全量复制后,额外等待5秒,尽量让更多从节点一起加入,减少重复fork无盘复制开启后,从节点收到RDB数据后如何处理,由repl-diskless-load控制:
- disabled:先把接收到的RDB写入临时文件,全部接收完成后再从磁盘加载到内存。这是默认方式,最稳妥
- on-empty-db:只有当当前实例数据库为空时,才直接进行无盘加载
- swapdb:在内存中保留当前数据集副本,同时直接解析socket中收到的RDB数据。这种方式可以降低服务中断感知,但会占用更多内存。而且从节点需要配置持久化,不然内存中同步过来的数据会丢失
无盘复制只是优化了全量同步时RDB的传输路径,并不会改变Redis自身的RDB/AOF持久化机制。同时,在网络不稳定的场景下,传输失败的恢复成本较高。
增量同步
为了避免从节点每次断线重连都执行全量同步,Redis提供了增量同步机制。它的核心目标是:只补发从节点断线期间错过的那段复制命令流,而不是重新复制整个数据集。增量同步中有三个核心概念:
- repliocation id:可以理解为主节点当前复制历史的唯一标识。从节点会记录自己上次同步时对应的主节点replication id,重连时带给主节点,用来判断双方是否还处于同一条复制历史之上。如果主节点发生重启、角色切换或复制历史变化,replication id可能变化,此时通常无法继续增量同步
- offset:是复制流中的偏移量。主节点每产生一段复制流数据,偏移量就会持续递增;从节点也会记录自己已经处理到哪个位置。因此,replication id + offset可以唯一标识某个复制进度点
- backlog:主节点内部会维护一个复制积压缓冲区,也就是
repl_backlog。它本质上是一个环形缓冲区,用来保存最近一段时间的复制命令流。当从节点断线重连时,会把自己记录的replication id和offset发给主节点。如果主节点发现从节点缺失的那段数据仍然在backlog中,那么就可以直接把缺失部分补发给从节点,这就是增量同步的核心
增量同步的基本流程如下:
- 从节点向主节点发送
PSYNC <replicationid> <offset> - 主节点检查该replication id和offset是否仍可用于部分重同步
- 如果可以,主节点返回+CONTINUE,并从backlog中取出缺失的命令流补发给从节点
- 从节点接收并执行这部分命令,随后继续进入实时复制状态
如果出现以下情况,Redis会回退到全量同步:
- 主节点发生重启或复制历史变化,导致replication id不匹配
- 从节点请求的offset已经不在主节点的backlog范围内
- 主节点未启用或丢失backlog
- 断线时间过长,积压缓冲区早已被新数据覆盖
因此,增量同步是否成功,很大程度上取决于:网络抖动时间是否足够短以及repl_backlog是否足够大
repl_backlog可以通过
repl-backlog-size参数调整
复制可靠性
Redis默认采用的是异步复制。也就是说,主节点执行写命令后,通常会先向客户端返回成功,然后再把写命令异步发送给从节点。因此,客户端收到写成功,并不意味着:
- 从节点已经收到这条写命令
- 从节点已经执行这条命令
- 从节点已经把数据刷盘
这也是Redis复制高性能的来源之一,但同时也意味着:复制默认更偏向性能,而不是强一致。有两个提升复制可靠性的配置:
min-replicas-to-write 1 # 主节点在接受写请求前,至少需要有多少个“健康从节点”
min-replicas-max-lag 10 # 允许从节点与主节点之间的最大复制延迟,超过这个值就不再视为健康副本这两个配置配合使用,可以达到一种效果:如果当前健康从节点数量不足,主节点将拒绝新的写请求。这样做不能保证强一致,但能显著降低“主节点写成功、结果只有自己知道”的风险。
在多从环境下,如果业务希望在可用性和数据安全之间做更稳妥的平衡,这两个参数通常值得配置