Redis 为什么快?—— 底层数据结构
属于 S2 Redis 深入 · 第一篇 下一篇:持久化与高可用
Redis 最常被问的问题就是"为什么快"。答案通常是一串:纯内存、单线程、高效数据结构、IO 多路复用。但这四个词背后是有因果关系的,值得展开——尤其是"高效数据结构",它直接决定了 Redis 在内存和速度上的上限。
内存 + 单线程,是"快"的基础
数据在内存里,读写是纳秒级,这决定了上限。而"单线程"这个词要纠正一下:Redis 是"命令执行单线程",6.0 之后网络读写已经多线程了。单线程反而让 Redis 避开了锁竞争和线程切换的开销——多个命令排队执行,天然没有并发问题。代价是:一个耗时的命令(比如删一个大 key)会阻塞后面的所有命令,这是使用时要小心的。
SDS:为什么不用 C 字符串
Redis 存字符串,完全可以复用 C 的 char*,但它没有——因为 C 字符串有几个致命问题:获取长度要 O(n) 遍历、追加可能缓冲区溢出、遇到 \0 就截断(存不了二进制)。
Redis 自己实现了 SDS(简单动态字符串),加了个 len 字段直接 O(1) 拿长度,靠 len 判断结束而不是 \0,所以二进制安全;还做了空间预分配和惰性释放,减少频繁的内存重分配。这一串设计回答的都是"怎么又快又省地操作字符串"。
dict 的渐进 rehash:大表扩容怎么不卡
Redis 的 Hash、Set 底层是哈希表(dict)。哈希表扩容时,如果一次性把几百万个元素搬完,主线程会卡住——这对一个"快"的数据库是致命的。
Redis 的办法是渐进 rehash:分配一个新的、更大的哈希表(ht[1]),然后把搬迁分摊到每次操作里——每次增删改查时顺手搬一个桶过去,直到搬完。这样扩容的成本被均摊,服务不会卡顿。代价是 rehash 期间要"双写",查询要先查旧表再查新表。
省内存的艺术:ziplist / listpack / intset
内存是 Redis 的命根子,所以它连"怎么存小数据"都精打细算。当集合元素少、元素也小时,Redis 不用哈希表或跳表,而是用紧凑的连续内存结构:
- ziplist(压缩列表):一段连续内存把所有元素紧凑排列,省掉指针开销。
- listpack:Redis 7.0 用来替代 ziplist 的改进版。ziplist 有个"连锁更新"问题(每个节点记录前一个节点的长度,某个节点变长会引发级联更新),listpack 改成只记录自身长度,从根上消除了这个问题。
- intset:全是整数的 Set 用有序数组 + 二分,省内存。
用 OBJECT ENCODING key 可以看一个 key 底层到底是什么结构——数据量小的时候是这些紧凑结构,超过阈值就"升级"成 hashtable 或 skiplist。
zset 为什么用跳表,不用红黑树?
有序集合 zset 需要"按分值排序 + 范围查询"。红黑树能满足,但 Redis 选了跳表(skiplist):多层索引让查找平均 O(log n),而且实现简单、天然支持范围遍历——这对需要频繁 ZRANGEBYSCORE 的场景很友好。
zset 其实由两部分组成:一个 dict(成员 → 分值,O(1) 查分)+ 一个 skiplist(按分值有序,支持范围),两者共享节点,各司其职。
串起来
Redis 的"快"不是一句口号:内存和单线程定下基调,SDS 解决字符串操作的效率和安全性,渐进 rehash 让大表扩容不卡顿,ziplist/listpack/intset 在小数据上省内存,跳表支撑 zset 的有序范围查询。理解了这些底层结构,再看 Redis 为什么这么设计,就都说得通了。
下一篇讲持久化与高可用:Redis 是内存数据库,断电数据就没了,它是怎么在"快"和"不丢"之间做权衡的?