Redis 底层数据结构:SDS、跳表、quicklist 和 listpack 到底解决什么问题
Redis 底层结构章节,梳理 SDS、双端链表、压缩列表、哈希表、整数集合、跳表、quicklist、listpack 的设计目的、优缺点和数据类型映射。
相关工具
先别急着背结构名
学 Redis 底层数据结构,最容易掉进一个坑:把 SDS、ziplist、quicklist、skiplist 这些名字背下来,却不知道它们为什么会出现。资料 这一段其实给了一条很清楚的线索:Redis 的基础类型面向业务使用,底层结构面向内存、查找、插入、删除和扩容成本。上层看起来只是 String、List、Hash、Set、Zset,底层却会根据数据规模和数据形态选择不同实现。
也就是说,Redis 的快不只是因为数据放在内存里。内存只是基础条件,真正让它在大量场景里顺手的,是这些结构在不同问题上各自取舍:字符串要能快速知道长度,列表要能在两端快速插入删除,小对象要尽量省内存,集合要能快速判断成员是否存在,排行榜还要兼顾排序和范围查询。
这一篇不按百科词条写,而是沿着“问题是什么,结构怎么解决,代价在哪里”的顺序讲。读完以后,再回头看 Redis 的五种基础数据类型,会更容易理解为什么同一个 Hash 或 Set,在小数据量和大数据量时底层实现可能不一样。

把 SDS、链表、ziplist、哈希表、整数集合、跳表、quicklist、listpack 放在一张图里看,更容易理解它们和 String、List、Hash、Set、Zset 的关系。
SDS:Redis 为什么不用普通 C 字符串
Redis 是用 C 写的,但它没有直接把 C 语言字符串当作主要字符串实现,而是设计了 SDS,也就是简单动态字符串。文中提到 SDS 的几个特点:可以存储普通字符串,也可以存储二进制数据;头部保存了长度信息;还能根据实际内容动态扩容。
普通 C 字符串靠空字符判断结尾,这在处理二进制数据时会受限制,因为二进制内容里本身可能出现空字符。SDS 不依赖这种方式判断字符串结束,它记录自己的长度,所以可以保存更广的内容。对于 Redis 来说,这很重要,因为 String 类型不只是保存一段文本,还可能保存序列化后的对象、图片片段、压缩数据或其他二进制内容。
SDS 另一个直接好处是获取长度快。普通字符串如果想知道长度,通常需要从头扫到尾;SDS 头部已经保存长度,取长度就是 O(1)。这看起来只是小优化,但 Redis 命令会被频繁调用,很多小成本叠在一起,就会变成明显差距。
动态扩容解决的是写入成本
SDS 还有一个现实意义:减少手动管理内存的麻烦。字符串变长时,它可以按需要扩展空间,不需要每次都像传统 C 字符串那样由使用者自己处理内存申请、复制和边界问题。文中把这一点称为动态扩容。
更具体地说,SDS 会记录已使用长度和已分配容量。写入新内容时,如果容量够,就直接追加;如果容量不够,再申请更大的空间。这样做的好处是避免每次追加一点内容都重新分配内存。对于 Redis 这种高频读写系统,少一次不必要的内存分配,就少一次性能抖动。
不过 SDS 也不是魔法。它适合表达“一个连续值”,所以 Redis String 用它很自然。但如果你要频繁改对象里的某个字段,用 String 存整段 JSON 就会显得粗糙:改一个字段也要读出整体、反序列化、改完再写回。这个时候,上层数据类型就该考虑 Hash,而不是让 SDS 扛下所有需求。
双端链表:两端操作快,但内存不连续
双端链表很好理解:每个节点既知道前一个节点,也知道后一个节点。文中提到,Redis 早期 List 可以用双端链表实现,表头和表尾都有指针,所以获取头尾节点是 O(1),在两端插入和删除元素也很快。
这种结构适合队列、栈、时间线这类场景。比如从左边放入任务,从右边取出任务;或者把最新数据放在头部,读取时从头部开始。只要操作集中在两端,链表就很舒服,不需要像数组那样挪动大量元素。
它的问题也明显。链表节点分散在内存里,节点之间靠指针连接,CPU 缓存利用不好。每个节点除了保存值,还要保存前后指针等结构信息,内存开销也比连续数组更高。数据量小的时候问题不大,数据多起来以后,指针和内存碎片会把成本推高。
压缩列表:省内存,但怕连锁更新
压缩列表 ziplist 是 Redis 为了节省内存设计的紧凑结构。文中的描述是:它由连续内存块组成,属于可变长度的顺序型数据结构,类似数组,曾用于存储列表和哈希表的数据。连续内存的好处很直接,空间利用率高,遍历时局部性也更好。
为什么 Redis 需要它?因为很多业务数据一开始都很小。一个 Hash 可能只有几个字段,一个 List 可能只有十来个短字符串。如果这种小对象一上来就用完整链表或哈希表,每个元素都带一堆额外结构,内存会浪费得很厉害。ziplist 就是在小数据场景里用更紧凑的方式存起来。
但 ziplist 有一个让人头疼的问题:连锁更新。由于它是连续内存,元素长度变化可能导致后续节点记录的信息也要调整。文中也强调,一旦发生连锁更新,压缩列表占用的内存空间可能多次重新分配,直接影响访问性能。所以 ziplist 只适合节点数量不多、元素不大的场景。
哈希表:快查找背后是空间换时间
哈希表保存 key-value,对查询非常友好。理想情况下,根据 key 算出哈希值,就能直接定位到桶,查询复杂度接近 O(1)。Redis 的很多结构都离不开哈希表,Hash 类型、Set 类型在数据量变大或元素不再适合紧凑编码时,都会走向哈希表实现。
文中提到 Redis 采用拉链法解决哈希冲突。不同 key 算出来的哈希值可能落到同一个桶里,这时 Redis 会把这些元素串起来。这样在不立刻扩容哈希表的情况下,也能保存冲突数据。当然,冲突链太长会影响查询效率,所以哈希表还需要扩容和渐进式 rehash 这类机制来控制负载。
从业务角度看,哈希表适合“我要快速判断有没有这个东西”或“我要快速拿到某个字段”的场景。它的代价是结构开销比紧凑列表大。小数据量时用 listpack 省内存,大数据量时切到哈希表提速度,这就是 Redis 经常做的取舍。
整数集合:专门为小整数集合省空间
整数集合 intset 是一个更有针对性的结构。文中说,它专门用于存储整数值,通过紧凑的二进制表示提高整数存储效率,常用于 Set 类型中元素全是整数且数量较少的情况。
它解决的是一个很具体的问题:如果一个集合里只是存少量整数,比如用户 ID、标签 ID、状态码,用普通哈希表当然也能做,但会带来额外结构开销。intset 把这些整数紧凑地放在连续内存中,并保持有序、无重复。这样既满足集合去重,又节省空间。
intset 的限制也来自它的优势。它适合整数、小集合;一旦元素不是整数,或者数量变多,Redis 就会切换到更通用的哈希表。这里能看到 Redis 的设计习惯:不是提前使用最强的通用结构,而是在条件允许时先用省内存结构,超过阈值再换实现。
跳表:排行榜为什么不只靠哈希表
Zset 需要两件事:快速找到成员,还要按 score 排序并做范围查询。只用哈希表,查成员很快,但排序和范围扫描不方便;只用普通有序数组,范围查询容易,插入删除又可能需要移动大量元素。跳表就是为这种需求准备的。
资料 对跳表的描述是:它是在链表基础上改进出来的多层有序链表,数据量很大时查找复杂度是 O(logN)。你可以把它想成给普通链表加了多层快速通道。底层链表保存完整顺序,上层链路跨过更多节点。查找时先从高层走,发现下一个节点超过目标,再往下一层走,逐层逼近结果。
跳表比较时不只看 score。文中写到,如果当前节点权重小于要查找的权重,就继续访问该层下一个节点;如果权重相等,还会比较 SDS 类型的数据。这样可以在分数相同时保持稳定的顺序判断。排行榜、积分榜、热度榜这类场景,Zset 能高频更新并做范围读取,跳表是重要原因。
quicklist:链表和压缩列表各退一步
链表两端操作快,但节点分散、内存开销大;ziplist 省内存,但元素一多就容易连锁更新。quicklist 的思路很务实:把两者拼起来。文中写得很明确,quicklist 是双向链表加压缩列表的组合,它本身是一个链表,链表里的每个节点又是一个压缩列表。
这样做之后,List 既保留了链表按节点连接、两端操作方便的特点,又避免每个元素都单独占一个链表节点。每个 quicklist 节点内部放一小段紧凑列表,元素数量或大小受到控制,连锁更新的影响被限制在一个小块里,不会牵动整个列表。
所以 quicklist 不是为了追求结构漂亮,而是为了处理现实矛盾:Redis List 既可能被当作短列表,也可能被当作队列;既要省内存,又不能让插入删除太慢。quicklist 让链表和紧凑存储各自发挥优势,也避开它们最糟糕的一面。
listpack:替代 ziplist 的关键是避开连锁更新
Redis 7.0 里,listpack 替代了很多原来由 ziplist 承担的工作。文中点出了它和 ziplist 的关键区别:listpack 不记录前一个节点的长度字段,只记录当前节点长度。新增元素时,不会因为前一个节点长度变化而影响其他节点长度字段,从而避免 ziplist 的连锁更新问题。
这句话看起来偏底层,但理解它很有价值。ziplist 的问题不在于连续内存本身,而在于节点之间对长度信息有依赖。当一个节点变长,后面节点记录前驱长度的字段可能也要变,接着再影响更后面的节点。listpack 改掉这个依赖,就保留了紧凑存储的好处,同时减少了更新时的扩散成本。
对应用使用者来说,不需要每天关心 listpack 的字节格式。但知道它的存在,可以帮助你理解 Redis 的版本变化:Redis 一直在为了内存效率和更新性能调整内部实现。上层命令可能没变,底层结构已经更适合真实业务里的频繁写入。
把结构和数据类型对应起来
回到上层类型,String 主要依赖 SDS;List 从早期链表、ziplist 发展到 quicklist,并在新版本里和 listpack 发生关系;Hash 小数据量时可以用紧凑结构,大了以后走哈希表;Set 如果全是少量整数,可以用整数集合,否则用哈希表;Zset 小数据量时可以用紧凑结构,大数据量和范围查询则离不开跳表。
这套对应关系不要死记成一张永远不变的表。Redis 会根据版本、元素数量、元素大小和配置阈值切换内部编码。更稳的理解方式是记住每种结构的性格:SDS 处理字符串和二进制安全;哈希表负责快速定位;整数集合负责小整数集合省内存;跳表负责有序范围查找;quicklist 和 listpack 负责在列表、哈希这类小而多的数据里平衡空间和更新成本。
面试里如果被问 Redis 底层结构,可以先答结构名,再解释它们解决的问题。比如 SDS 让字符串长度获取变快,且支持二进制安全;ziplist/listpack 让小集合更省内存;哈希表让查询接近 O(1);跳表让 Zset 能做排行榜;quicklist 则是在链表和紧凑列表之间折中。这样的回答比单纯背名词更像真正用过。
真正的重点是选型意识
Redis 底层结构讲到最后,落点还是业务选型。你不一定要在写每条命令时都猜内部编码,但要知道 Redis 为什么在小数据和大数据之间切换结构。小数据优先省内存,大数据优先查找和更新效率,这是很多 Redis 设计的底层逻辑。
如果你把所有对象都塞进 String JSON,Redis 仍然能跑,但你绕开了 Hash、Set、Zset 本来能帮你完成的工作。如果你把排行榜放进普通 List,也能勉强维护顺序,但更新分数和取 TopN 会越来越别扭。数据结构不是背诵题,它决定了后续命令是否自然,系统压力是否可控。
所以学完这一篇,最值得记住的不是哪个版本具体用了哪个编码,而是这句话:Redis 的数据类型是给业务看的,底层结构是给性能和内存看的。上层类型选得准,底层结构才能真正帮上忙。
常见问题
Redis 的 SDS 主要解决什么问题?
SDS 解决了普通 C 字符串在长度获取、二进制安全和动态扩容上的问题。它在头部保存长度信息,获取长度是 O(1),也不依赖空字符判断字符串结束。
ziplist 为什么后来被 listpack 替代?
ziplist 紧凑省内存,但元素长度变化可能引发连锁更新。listpack 不记录前一个节点长度,只记录当前节点长度,减少了更新时对后续节点的影响。
quicklist 是什么结构?
quicklist 可以理解为双向链表加紧凑列表的组合。链表中的每个节点内部保存一小段压缩数据,既保留两端操作能力,也控制内存开销和连锁更新范围。
Zset 为什么常用跳表?
Zset 既要按成员快速定位,也要按 score 排序和范围查询。跳表是多层有序链表,查找、插入、删除平均复杂度为 O(logN),适合排行榜这类频繁更新又要排序读取的场景。