【Redis性能探秘】数据怎么存、命令怎么执行、宕机了怎么办
你给项目里引入了Redis,缓存命中率上去了,接口响应时间从200ms降到了5ms。老板满意,你也觉得任务完成了。但有一个问题你可能从来没想过:Redis为什么能这么快?
一个内存操作而已,能有多复杂?如果你这么想,那你就错过了Redis最精彩的部分。这篇文章不打算罗列Redis的常用命令——这些官方文档写得比谁都清楚。我想带你从底层往上看:数据是怎么存的、命令是怎么执行的、宕机了数据怎么办。
读完你会发现,Redis的每一个设计决策,背后都有明确的权衡。
一、数据类型:你看到的是五种,底层是九种
Redis对外宣称提供了五种数据类型:String、List、Hash、Set、ZSet。但如果你去看源码,底层实际有九种数据结构。
这不是Redis在玩数字游戏。它的设计哲学是:对上层暴露统一的API,对下层根据数据特征动态选择最优的存储结构。
1.1 String:SDS比C字符串高明在哪
String是Redis最基础的类型,所有Key都是字符串。但Redis没有直接用C语言的char*,而是自己实现了一个叫SDS(Simple Dynamic String)的结构。
SDS长这样:
struct sdshdr { int len; // 已使用的长度 int free; // 未使用的空间 char buf[]; // 实际字符数组 };相比C字符串,SDS有三个优势:
O(1)获取长度:C字符串要靠遍历\0才能知道长度,SDS直接读len字段。
空间预分配:修改字符串时,Redis会多分配一些空闲空间。比如你给一个字符串追加内容,SDS会提前把空间备好,下次再追加就不用重新分配内存了。这个优化看似微小,但在高并发写入场景下,减少内存重分配带来的性能提升是量级的。
二进制安全:C字符串用\0判断结尾,存不了图片这类二进制数据。SDS用len判断长度,什么都能存。
String还有三种底层编码:纯数字用int(8字节直接存)、短字符串用embstr(≤44字节,一次内存分配)、长字符串用raw(>44字节,两次内存分配)。Redis连字符串怎么存都在抠细节。
1.2 List:从压缩列表到QuickList的演进
List的底层实现经历过三次迭代。
早期(3.0之前):数据量小时用压缩列表(ZipList)——一块连续的内存,极度节省空间;数据量大时用双向链表——插入删除快,但每个节点要存两个指针,内存开销大。
压缩列表有个致命缺陷叫连锁更新:插入或删除一个节点可能导致后续所有节点重新分配内存,时间复杂度O(N²)。
3.2版本:Redis推出了QuickList——双向链表和压缩列表的混合体。每个链表节点存的是一个压缩列表。既保留了链表的灵活插入,又通过压缩列表节省了内存。
最新版本:压缩列表被ListPack取代,彻底解决了连锁更新问题。
这个演进过程说明了一个道理:没有完美的数据结构,只有适合当前场景的权衡。
1.3 Hash:渐进式Rehash
Hash的底层是哈希表(Dict)。它维护了两个哈希表:ht[0]主表和ht[1]过渡表。
当数据量增长触发扩容时,Redis不会一次性把所有数据从ht[0]搬到ht[1]——那样会阻塞主线程几十毫秒甚至更久。它采用渐进式Rehash:每次增删改查操作时顺带搬几个,分多次搬完。
这个设计极其精妙:把一次性的大开销,分摊到每次操作中,用户几乎感知不到。
1.4 ZSet:跳表为什么比平衡树更讨喜
ZSet(有序集合)的底层是跳表(Skip List)+哈希表。哈希表负责O(1)的成员查找,跳表负责有序的范围查询。
跳表相比平衡树(如红黑树)有三个优势:
实现简单:不用复杂的旋转操作,代码量少一大截
范围查询高效:通过层级结构快速定位区间
并发友好:修改操作影响范围有限
跳表的本质是用空间换时间——多建几层索引,让查找从O(N)变成O(logN)。这个思路在数据库索引中随处可见,但Redis把它用到了极致。
二、单线程:你以为的短板,其实是设计哲学
"Redis是单线程的"——这句话你肯定听过。但如果你以为Redis只有一个线程在干活,那就大错特错了。
2.1 单线程到底指什么?
Redis的"单线程"仅指命令执行核心——处理GET、SET这些客户端请求的线程只有一个。但Redis还有:
IO线程:负责网络读写(接收请求、发送响应)
后台进程:负责RDB持久化和AOF重写
后台线程:负责清理过期Key等后台任务
所以准确的说法是:Redis的网络IO和命令执行是单线程的,但持久化等重操作是异步的。
2.2 为什么单线程反而快?
这听起来反直觉——多核时代,单线程怎么比多线程快?
原因一:内存操作,快到离谱。Redis所有数据都在内存里,读写速度是磁盘的10万倍。CPU不是瓶颈,不需要多核来帮忙。
原因二:没有上下文切换。多线程要频繁切换线程状态——保存寄存器、恢复堆栈、调度算法计算。单线程把这些开销全省了。
原因三:没有锁竞争。多线程要操作共享数据结构就得加锁,锁的争用会显著降低性能。单线程压根不需要锁,代码更简单,性能更稳。
原因四:I/O多路复用。Redis通过epoll(Linux)或kqueue(BSD)在同一线程里监听成千上万个客户端连接。事件循环不断调用epoll_wait,哪个连接有数据来了就处理哪个,全程非阻塞。
打个比方:多线程像一家有10个窗口的奶茶店,但每个窗口的店员要频繁切换做不同的事,还要抢原料(锁);Redis像一家只有1个窗口的奶茶店,但原料就在手边(内存),门口有个喇叭喊号(epoll),后厨还有人帮忙备料(后台线程)。10个窗口不一定比1个窗口快。
2.3 单线程的局限性
单线程模型也有短板:耗时操作会阻塞所有请求。
比如KEYS *扫描全量Key、FLUSHALL清空数据库、大Key的删除——这些操作如果直接在主线程执行,会阻塞所有其他请求。
Redis的解决方案:
禁用危险命令:生产环境把
KEYS、FLUSHALL重命名或直接禁掉惰性删除:用
UNLINK替代DEL,异步释放内存大Key拆分:避免单个Key数据量过大
Redis 6.0开始引入了多线程IO,但仅用于网络读写,命令执行依然是单线程。这个设计说明Redis团队很清楚:瓶颈在网络IO,不在命令执行。
三、持久化:内存数据怎么"活"到磁盘上
Redis是内存数据库,重启就什么都没了。持久化就是让数据"活"到磁盘上。
Redis提供了两种持久化方式:RDB和AOF。
3.1 RDB:快照,简单粗暴
RDB在指定时间间隔生成一个内存快照,保存为二进制文件(默认dump.rdb)。
触发方式有两种:
手动:
SAVE(阻塞主线程)或BGSAVE(后台子进程)自动:配置文件里配
save 900 1(900秒内至少1个Key变化就触发)
RDB的优点是恢复快——直接加载二进制文件,比一条条重放命令快得多。缺点是可能丢数据——如果两次快照之间宕机,中间的所有修改都丢了。
RDB生成时主进程fork出子进程,子进程和主进程共享同一块内存。利用操作系统的写时复制(Copy-on-Write)机制,主进程修改数据时才会复制内存页。这个设计让RDB对主进程的影响降到了最低。
3.2 AOF:日志,逐条记录
AOF把每个写操作都以文本形式追加到日志文件末尾。重启时逐条重放这些命令,恢复数据。
AOF的刷盘策略由appendfsync控制:
always:每次写操作都刷盘——最安全,性能最差
everysec(默认):每秒刷一次——性能和安全的平衡
no:交给操作系统决定——性能最好,最不安全
AOF的优点是数据更安全(everysec最多丢1秒数据),日志可读性强(就是文本文件)。缺点是文件大、恢复慢。
AOF文件会无限增长,Redis提供了AOF重写(Rewrite)机制——把当前数据状态用最少的命令重新表达一遍,生成一个新的AOF文件,替代旧文件。
3.3 混合持久化:各取所长
RDB恢复快但丢数据多,AOF丢数据少但恢复慢。能不能把两者的优点结合起来?
Redis 4.0给出了答案:混合持久化。
3.3.1 技术实现:RDB格式写入AOF文件
混合持久化的核心机制体现在AOF重写环节。
当开启混合持久化(aof-use-rdb-preamble yes)并触发AOF重写时,Redis会fork一个子进程。子进程在生成新AOF文件时,不再像传统AOF重写那样把数据转换成RESP命令写入,而是直接将当前内存数据以RDB二进制格式写入AOF文件头部。
用代码来理解会更直观:
int rewriteAppendOnlyFile(char *filename) { if (server.aof_use_rdb_preamble) { // 混合模式:以RDB格式保存全量数据 rdbSaveRio(&aof, &error, RDB_SAVE_AOF_PREAMBLE, NULL); } else { // 传统模式:以RESP命令格式写入 rewriteAppendOnlyFileRio(&aof); } // 后续追加增量命令... }RDB部分写入完成后,重写期间新执行的Redis命令,继续以AOF格式追加到文件末尾。
最终生成的AOF文件结构是:[RDB Header][RDB Data][AOF Commands]。
新旧文件切换也有讲究:新文件一开始不叫appendonly.aof,等到重写完成才会改名,覆盖原有AOF文件。
3.3.2 恢复流程:RDB快速加载 + AOF增量重放
开启混合持久化后,Redis重启时的恢复流程变成两步:
加载RDB部分:直接加载AOF文件头部的RDB二进制数据,快速恢复全量数据
重放AOF部分:再重放文件末尾的增量AOF命令,补全重写期间的新修改
相比传统AOF需要逐条重放所有历史命令,混合模式只需重放两次重写之间的增量命令,恢复效率大幅提升。
3.3.3 如何开启
开启混合持久化需要满足两个条件:
Redis版本 ≥ 4.0
同时开启AOF(
appendonly yes)和混合模式(aof-use-rdb-preamble yes)
>注意:Redis 4.0中该功能默认关闭,5.0版本开始默认开启。
3.3.4 混合持久化的优势
对比维度 | 纯RDB | 纯AOF | 混合持久化 |
恢复速度 | 快 | 慢(需重放所有命令) | 快(RDB快速加载+少量增量重放) |
数据安全 | 差(可能丢大量数据) | 好(取决于fsync策略) | 好(继承AOF的刷盘策略) |
文件大小 | 小(二进制压缩) | 大(文本命令) | 中等(RDB压缩+AOF增量) |
适用场景 | 备份、灾备 | 强数据安全 | 生产环境通用 |
混合持久化结合了RDB的快速恢复和AOF的按写持久化能力,在恢复速度和数据安全性之间找到了最佳平衡点。
3.3.5 注意事项
RDB文件依然存在:即使开启了混合持久化,同时配置了RDB的save参数,Redis仍然会生成独立的RDB文件,并不会被省略。两者是并行工作的。
兼容性问题:混合持久化生成的AOF文件,旧版本Redis无法读取。如果需要降级,要先把混合模式关掉,重新生成纯AOF文件。
重写期间的管道机制:子进程在AOF重写时,会通过管道从父进程持续读取增量数据并缓存下来。即使在以RDB格式保存全量数据的过程中,也会同时处理管道数据,不会造成管道阻塞。
3.4 生产环境怎么选?
场景 | 推荐方案 | 理由 |
纯缓存场景(数据丢了可从DB重建) | 不持久化 或 仅RDB | 追求极致性能,数据丢失可接受 |
一般业务缓存(用户信息、会话等) | RDB + AOF everysec | 平衡性能和安全,最多丢1秒数据 |
核心业务数据(交易状态、配置等) | RDB + AOF always | 数据安全优先,性能代价可接受 |
极致数据安全(金融级) | RDB + AOF always + 定期备份 | 多重保障,写入性能最差 |
四、写在最后:Redis的设计哲学
回到开头的那个问题:Redis为什么能这么快?
现在你可以回答得更完整了:
数据结构层面:SDS省去了
strlen的遍历、空间预分配减少了内存重分配、跳表让范围查询O(logN)、渐进式Rehash把大搬迁拆成了小操作线程模型层面:单线程消除了锁竞争和上下文切换、epoll让单线程能管上万连接、非阻塞IO让线程永远不会"干等"
持久化层面:fork+写时复制让RDB几乎不阻塞主线程、AOF每秒刷盘平衡了性能和安全
Redis的每一个设计,都在回答同一个问题:如何在保证功能的前提下,把性能压榨到极致?
它没有用最复杂的技术,而是用了最合适的技术。这大概就是Redis最大的启发:高性能不是堆砌复杂,而是做对每一次权衡。
最后,留几个问题供你思考:
Redis的过期删除策略为什么选用"定期删除+惰性删除"的组合,而不是一个定时器?
为什么Redis的跳表实现中,每个节点的层高是随机生成的,而不是固定的?
AOF重写时,主进程也在处理写入请求,新AOF文件如何保证包含了重写期间的新数据?
如果你能回答出这些问题,说明你已经真正理解了Redis的设计思路。
延伸阅读
Redis源码:github.com/redis/redis(源码注释清晰,值得一读)
Redis设计与实现(黄健宏著):Redis源码分析的经典读物
Redis官方文档:redis.io/docs(可能是最好的官方文档之一)
