ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Redis底层数据结构设计哲学:从SDS到listpack的演进与实战

Redis底层数据结构设计哲学:从SDS到listpack的演进与实战 干这行这么多年Redis 的底层数据结构一直是面试里的显眼包也是很多团队做技术分享时最爱讲的话题。但说实话我见过太多人把 SDS、跳表、压缩列表背得滚瓜烂熟真到了线上 Redis 出现内存暴涨、请求毛刺、甚至主线程阻塞的时候能把这些底层知识用起来的人却少之又少。这就是我今天想聊的主题从一个架构师的角度把 Redis 底层数据结构的设计哲学彻底拆开揉碎。不是为了背八股而是为了弄清楚 Redis 为什么快、为什么省内存、为什么在极端情况下会有坑以及我们平时做容量评估、性能调优、数据结构选型时应该怎么顺着它的设计思路做决策。这篇内容会比较硬核但我会尽量用大白话和实际工程场景把每个细节讲透。适合的人群很明确被 Redis 底层结构搞得一头雾水的后端开发、准备晋升答辩的技术骨干、以及所有想把会用 Redis提升到懂 Redis的同行。1. 不先理解这几点谈底层结构都是空中楼阁很多人一上来就盯着 SDS 的 header 结构、跳表的层数概率看这是典型的只背结果不看原因。我建议先把 Redis 这个项目最底层的三个设计前提搞清楚后面每一个数据结构的设计动机都会变得顺理成章。1.1 内存就是一切Redis 性能神话的真正地基Redis 之所以能以单线程模型扛住十万级 QPS最根本的原因就是它是基于内存的数据库。但你有没有想过内存本身也有层级访问栈上局部变量、访问堆上连续分配的内存、访问分散在多个页上的碎片内存延迟差异可以达到一个数量级以上。Redis 在设计底层数据结构时有一个贯穿始终的执念CPU 缓存友好和内存分配可控。举个例子如果直接用 C 标准库的链表来存数据每个节点都要做一次 malloc节点之间靠指针串联内存布局完全是离散的。当你遍历这个链表时CPU 需要不停地跳转到新的内存地址缓存行基本是失效状态性能自然上不去。而压缩列表、整数集合这类紧凑型结构把所有数据塞进一块连续的内存里遍历时 CPU 可以顺序预取数据性能提升非常明显。所以你在 Redis 源码里看到的很多反常识设计本质上都是在跟内存分配器和 CPU 缓存博弈。提示理解 Redis 性能不能只看时间复杂度。O(n) 的连续内存遍历在实际执行时往往比 O(logn) 的指针跳跃式查找更快。这是底层结构选型时非常重要的一个判断维度。1.2 两条铁律极端场景稳得住 每个字节都算计Redis 的定位是缓存界的瑞士军刀这意味着它必须同时满足两个看似矛盾的要求。第一条铁律是极端场景下的操作稳定性。Redis 的主线程是单线程事件循环任何一个命令的耗时都在阻塞其他所有客户端。所以 Redis 里几乎找不到会引发 O(n²) 级联开销的算法。如果某种结构在特定操作下可能出现这种问题Redis 团队会在后续版本里用新的结构替换它——zklist 到 quicklist、再到 listpack 的演进就是这条铁律的最好注脚后面我会专门讲。第二条铁律是最小内存占用的偏执。Redis 的键值数量轻松上亿每个 value 多 10 个字节整体就是几百 MB 的差距。所以你会看到 SDS 按字符串长度拆成 sdshdr8、sdshdr16、sdshdr32、sdshdr64 多种头部类型列表元素少时用紧凑编码元素多了才转成常规结构。这类细节在教科书里往往一笔带过但在实际生产环境里直接决定了你的 Redis 是挤在 4G 内存里舒服运行还是 32G 内存分分钟被打满。理解了这两条铁律再看每一个底层数据结构的演进历史都会有原来如此的感觉。2. 五大核心数据结构的设计细节与选型逻辑接下来进入正题逐个解构 Redis 底层的核心结构。每讲一个我都会先说明它解决了什么痛点再拆具体设计最后落到使用场景。2.1 SDS 字符串Redis 字符串的中层管理者SDSSimple Dynamic String简单动态字符串是 Redis 里出场率最高的结构String 类型的 value 底层基本都是它。为什么不直接用 C 语言的 char 数组主要是因为三个硬伤。第一个硬伤是获取长度的时间复杂度。C 字符串用\0结尾strlen 需要遍历整个字符串复杂度 O(n)。Redis 作为高频访问的中间件字符串长度读取是基础操作必须做到 O(1)。所以 SDS 在头部存了一个 len 字段。第二个硬伤是二进制安全问题。C 字符串遇\0就截断但写入 Redis 的 value 完全可能包含空字节——比如序列化后的 Protobuf、图片二进制数据。SDS 不依赖\0字符串结束符它靠 len 字段判断结束位置所以是真正的二进制安全结构。第三个硬伤是扩容策略。C 字符串扩容需要手动管理内存而且容易发生缓冲区溢出或内存泄漏。SDS 引入了空间预分配和惰性空间释放机制。具体逻辑是字符串长度小于 1MB 时扩容直接翻倍大于 1MB 时额外多分配 1MB 备用空间。惰性释放则是说缩短字符串时并不立即归还内存而是通过 free 字段把多余空间暂存起来等下次追加操作时直接用一来一回省掉了大量内存分配的系统调用。有意思的是SDS 的头部也做了精细化拆分。Redis 3.2 之后引入了 sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64 五种类型分别用 uint8 到 uint64 来存 len 和 alloc 字段。存一个 10 字节的短字符串用一个字节存长度就够了没必要浪费 8 个字节。这种连头部大小都要跟业务数据匹配的做法正是 Redis 在内存占用上的强逼症体现。2.2 双向链表与压缩列表Redis List 的两头下注很多刚入门的人会以为 Redis 的 List 底层就是一个普通的双向链表这个理解在 Redis 早期版本大体成立但后来细节发生了变化。标准的双向链表有 prev 和 next 指针插入、删除确实灵活但坏处也明显每个节点都有指针开销节点内存不连续导致缓存命中率低而且节点数量多时内存使用非常浪费。如果你用 List 存几千个小字段纯粹的链表结构很可能比数据本身还占内存。所以 Redis 的经典做法是在元素少、内容小的时候用压缩列表ziplist作为 List 的底层编码。压缩列表本质上是一块连续的内存区域元素紧挨着排布每个元素记录上一个元素的长度和自己的数据省掉了指针内存利用率很高。但压缩列表也有一个致命问题——级联更新。当某个中间元素变大导致 prevlen 字段从 1 字节扩张为 5 字节时后面的元素位置都要往后挪挪完又可能引发再后面的元素扩大最坏情况下会造成连锁的内存复制操作复杂度退化为 O(n²)。这也是为什么后来 Redis 在 3.2 版本推出了 quicklist快速列表它用双向链表 多个压缩列表的组合方式把大的列表切分成多个小块每一块内部是紧凑的压缩列表块与块之间用指针串联。这样既保留了紧凑内存的优势又避免了单个超长压缩列表的级联更新问题。2.3 跳表让有序集合变得更聪明跳表skiplist是 Redis Sorted Set 的底层核心之一。为什么要用跳表而不是红黑树这是面试里最高频的问题之一我从工程角度给你一个比较完整的答案。跳表的实现难度比红黑树低很多。红黑树的插入、删除、旋转逻辑极其繁琐出了 bug 极难调试。而跳表就是多层链表的组合每一层是下一层链表的快速通道查找时从最高层往下走每一层跳过部分节点把 O(n) 级别的链表查询变成 O(logn)。代码清晰简单出问题的概率低这是工程上的巨大优势。跳表对范围查询非常友好。Redis 的 ZRANGEBYSCORE、ZREVERSEANGE 这类命令需要按区间连续取一批数据。红黑树做范围查询需要做中序遍历相对繁琐跳表只需要从范围起始位置开始沿链表往后走就行性能非常稳定。跳表元素里还藏了一个 span 字段记录节点跨越的层级距离。借助这个字段Redis 可以直接算出某个元素的排名也就是 ZRANK 命令的底层支撑省去从头遍历的麻烦。另外要提一点Redis 的有序集合并不是单独用跳表实现的而是跳表 哈希表双结构。哈希表负责 O(1) 地按成员精确查找分数跳表负责按分数做排序和范围操作。这是非常经典的空间换时间组合思路值得做题时反复体会。2.4 整数集合极致省内存的整数仓库整数集合intset是 Set 类型在全是整数且数量不多时使用的底层结构。它的设计思路非常极致直接在一块连续内存里存放连续的整数元素并且按从小到大的顺序排列方便二分查找。intset 的底层支持 int16_t、int32_t、int64_t 三种类型由一个 encoding 字段标识当前数组里元素的位宽。当你要插入一个新元素发现这个元素的取值范围超过了当前编码的位宽intset 会触发一次升级重新分配内存、把所有元素转换成更大的编码类型、再把新元素插入合适位置。升级操作本身有一定开销但好处是只在 intset 里存储所有元素的最小位宽。比如存了几万个 1 到 100 之间的数字用 int16 就够了相比用一个指针数组每个指针 8 字节去存内存能省下好几倍。这也是在很多小规模场景下Redis 选择 intset 而不用哈希表的原因。值得注意的是升级是不可逆的。一旦 intset 升到 int64即使你删掉了所有大整数它也不会自动降回 int16仍然占用 int64 的存储空间。所以在实际使用中如果 Set 的成员是有界的比如固定的业务枚举值尽量保证整数范围稳定避免因为偶尔一个超大值引发内存永久升级。2.5 哈希表Redis 键空间与 Hash 类型的底座哈希表dict可能是 Redis 里地位最高的数据结构因为整个键空间本身就是一张哈希表。所有 key 的定位、过期字典的维护、Hash 类型大负载场景下的存储都靠它。哈希表在 Redis 中的实现比教科书版的数组链表多了一个关键设计渐进式 rehash。为什么需要渐进式因为 Redis 单线程如果一次 rehash 要拷贝上千万个桶节点期间所有命令都会被阻塞这在生产环境是不可接受的。渐进式 rehash 的思路是哈希表同时持有两个数组——ht[0] 是正在使用的ht[1] 是新分配的更大的桶数组。rehash 时不是在某一瞬间完成全部搬迁而是每次哈希表操作时顺带搬移一小部分比如 100 个桶分批处理。整个过程从扩容开始到搬迁结束新老表共存一段时间每条命令改查新老表即可。这样把耗时均匀分摊到多个命令周期里主线程几乎无感。哈希表的扩容条件有两条一是负载因子used/buckets大于 1二是当前没有子进程在执行 RDB 持久化或 AOF 重写。如果有子进程在跑负载因子必须超过 5 才允许扩容目的是尽量让子进程的内存快照保持稳定减少 fork 之后的写时复制开销。缩容条件则是负载因子小于 0.1及时释放内存给操作系统的内存分配器。这一个小细节其实就能回答后端同学常问的问题为什么 Redis 在 BGSAVE 期间内存会涨因为有子进程时扩容被推迟流量高的时候哈希表会积累更多空间但正常情况下这是安全且必要的。3. 版本演化中的设计取舍其实就是一部内存和性能的博弈史Redis 的底层数据结构不是一蹴而就的每个版本都有大量设计迭代。看源码时如果忽略版本背景很容易看不懂一些历史遗留结构。这里我梳理一条最清晰的主线讲透压缩列表的演化史。3.1 ziplist 的问题核心级联更新前面简单提过 ziplist 的级联更新这里展开说。ziplist 每个 entry 都有一个 prevlen 字段它记录的是前一个 entry 的总长度。当 prevlen 小于 254 时用一个字节就能存下大于等于 254 时需要用 5 个字节存储。设想一个极端场景列表里有大量长度在 250~253 字节之间的元素某个位置插入了新的 entry导致它后面的 entry 前一个元素长度超过了 254于是 prevlen 从 1 字节变成 5 字节整个 entry 需要向后挪动。挪动之后它后面的 entry 的 prevlen 指向的内容又改变了可能又引发新的扩张。这种连锁反应就是级联更新最坏情况下一次插入需要复制 O(n²) 字节。尽管 ziplist 在设计时尽量把 prevlen 变小但级联更新始终是个隐患。一旦列表里有大元素任何中间位置的修改都有可能引发性能毛刺。3.2 quicklist 的出现用分块治理大链表Redis 3.2 引入 quicklist思路非常工程化既然单个 ziplist 太长会出问题那就切小块。quicklist 由多个 ziplist 节点组成每个节点最多存储一定数量的元素或不超过一定字节数的数据节点之间用双向链表连接。开发者可以通过 list-max-ziplist-size 参数控制单个节点的大小。这个参数取正数时表示每个节点最多存储多少个元素取负数时特殊含义比如 -1 代表每个节点不超过 4KB-2 是不超过 8KB。取负数的场景适合 List 里每个元素体积都很大的情况保证单个节点在内存复制时可承受。quicklist 很好地平衡了查询速度和内存分配开销理论上是双向链表的灵活 压缩列表的紧凑的折中也是从 Redis 3.2 到 6.x 之间 List 类型的标准选择。3.3 listpack 为什么会成为终局Redis 7.0 之后ziplist 被彻底从 List、Hash、Zset 的默认编码中替换掉了取而代之的是 listpack。listpack 的设计目标很明确解决 ziplist 的级联更新问题。listpack 的每个 entry 不再记录前一个 entry 的长度而是记录当前 entry 自身的长度。由于每个元素的位置只由自身长度决定修改中间任一个元素都不会影响其他元素的位置和长度字段。这样就从根上废掉了级联更新让紧凑型结构的写入延迟可预期。另外listpack 的每个 entry 还会记录一个数量标记LP_EOF 作为结尾整体结构比 ziplist 简单且更健壮。7.0 里List 的 quicklist 节点内部也从 ziplist 换成了 listpackHash 和 Zset 在小数据量时的默认编码也从 ziplist 换成了 listpack。我个人的看法是Redis 团队之所以愿意用一个全新的结构来替换 ziplist核心动力正是稳定性和可预期性。8.0 之后的版本如果还有新的紧凑结构大概率也是沿着无级联更新、编码解码更轻量的方向演进。3.4 编码转换阈值什么时候从小编码升级到常规编码要真正用好这些底层结构必须知道它们什么时候变体。默认的转换阈值如下数据类型紧凑编码升级为常规编码的条件Hashlistpack元素数量超过 hash-max-listpack-entries默认 128或任一 value 长度超过 hash-max-listpack-value默认 64Listquicklist单个节点元素数或总字节数超过 list-max-ziplist-size / list-max-listpack-sizeSetintset元素个数超过 set-max-intset-entries默认 512或出现非整数元素Zsetlistpack元素数量超过 zset-max-listpack-entries默认 128或某个 member 长度超过 zset-max-listpack-value默认 64这些阈值在 redis.conf 中都是可调的。如果业务场景明确知道 Hash 会有大量字段比如用户属性聚合存储可以适当调大 listpack 阈值让它在更长时间内保持紧凑编码反过来如果 Hash 的 value 会频繁更新且长度不稳定调小阈值反而能规避紧凑编码下的内存复制开销。这里的核心判断原则还是回到第一条铁律让底层结构尽量匹配你的实际访问模式。4. 从底层细节反推上层排障与优化讲完设计哲学最后落地到实战。很多线上 Redis 问题表面上看是命令慢、内存超限深挖下去其实是底层数据结构选择不当。4.1 大 Key 和内存碎片问题大 Key 是所有 Redis 运维的噩梦。一个包含几百万元素的 List或者一个序列化后几百 KB 的 String不仅会影响单次命令的耗时还会阻塞主线程拖垮同实例上的所有其他业务。怎么快速定位大 Key如果只是日常巡检redis-cli --bigkeys 可以直接扫描所有 key输出占用元素数量最多的 Key但如果需要更精确的内存占用判断可以用 MEMORY USAGE key 命令它会返回这个 key 实际占用的字节数包含底层结构头部开销。真正删除大 Key 的时候一定不要直接 DEL。DEL 是同步操作对一个百万级元素的 ListDEL 可能阻塞几秒甚至几十秒。正确姿势是用 UNLINK key这个命令会在后台异步释放内存主线程立即返回不会造成命令毛刺。内存碎片的问题则与底层结构密切相关。因为 Redis 大量使用 malloc/分配器如果频繁创建、修改大小不同的 value内存碎片率就会升高。用 INFO memory 查看 mem_fragmentation_ratio正常情况下在 1 到 1.5 之间。如果超过 1.5说明碎片严重。Redis 默认用 jemalloc 内存分配器它按大小类划分内存对 Redis 这种大量小对象 频繁变化大小的场景更加友好。手动排查时可以用 MEMORY DOCTOR 命令快速获取内存健康建议。4.2 数据结构选择不当的典型症状我梳理了几个非常典型的上层问题全部可以追溯到底层数据结构选择。第一种是用 String 存复杂对象 JSON频繁改一个字段。比如用户体系里把整个 Profile 对象序列化成 JSON 字符串塞进 Redis每次只改一个字段也要把整个对象取出来、反序列化、改完再序列化。如果这个对象几十 KB每次操作都是巨大的序列化和内存分配开销。正确做法是拆成 Hash 存储底层用 listpack 紧凑组织单个字段查询和修改开销极低。第二种是Sorted Set 的 member 数量极大但 score 分布均匀导致跳表层数很高ZRANGEBYSCORE 范围查询影响面太大。这类场景要把排序需求拆细比如按天分 key不要让单个 Zset 无限膨胀。第三种是大量短生命周期的小 key 频繁创建删除导致内存碎片居高不下。这是 intset、listpack 这类紧凑结构频繁分配、释放的必然结果可以在创建 key 时做随机延迟过期或者在业务层面把 key 分桶减少碎片产生的频率。4.3 常用排查命令与调优参数速查最后分享一个我平时排障时固定在用的命令组合分享给同行参考看全局内存和碎片INFO memory看一个 key 的底层编码OBJECT ENCODING key看 key 实际占用内存MEMORY USAGE key看 key 的内部诊断信息DEBUG OBJECT key抓大 keyredis-cli --bigkeys异步删除大 keyUNLINK key调优参数方面优先关注这几个hash-max-listpack-entries、hash-max-listpack-value、set-max-intset-entries、zset-max-listpack-entries、active-defrag-threshold-lower和active-defrag-ignore-buffers。其中 activedefrag 相关配置可以让 Redis 在运行时自动整理碎片非常适合长期运行的大实例。注意调整紧凑编码阈值前建议先通过 OBJECT ENCODING 确认当前 key 的实际编码。改了配置只对之后新建的 key 生效存量 key 的编码不会自动转变。如果要让存量 key 应用新编码需要手动写脚本把数据重新写入一遍代价比较大改动前要做好充分的灰度评估。这些年踩过不少坑之后我最大的体会是Redis 的底层数据结构不是孤立的理论知识它直接决定了你在容量评估、数据建模、故障排查时的判断力。真正的高手排障从来不是靠猜而是靠理解 Redis 为什么这样设计——理解了设计哲学很多问题在出现之前就能提前避开。如果你也想深入源码验证今天聊的这些细节我建议从 object.c 里的 createStringObject、createListQuicklistObject 这些入口函数入手再对照 t_hash.c 和 t_zset.c 里的编码转换逻辑逐个看一遍。看代码时不要求快把每一次编码转换和内存分配动作与今天讲的几条铁律对应起来你会很快建立起属于自己的 Redis 底层知识地图。
返回列表