Redis底层数据结构与编码机制全解析:从SDS到listpack
2026/9/18 12:26:39 网站建设 项目流程

搞Redis的人早晚会遇到一个问题:明明叫五种数据结构,String、List、Hash、Set、ZSet,可你去翻源码或者用OBJECT ENCODING看一眼,发现同一个key,有时候底层是int,有时候是embstr,有时候是quicklist,还有listpackskiplist这些名字。网上八股文背了一堆,背完就忘,真到排查内存暴涨、大key阻塞、命令超时的时候,根本不知道怎么把这些知识点用起来。

这篇文章我把五种数据结构的底层实现拆开讲一遍,不讲虚的,直接落到源码逻辑、编码切换条件、配置参数和排查手法上。内容适合三类人:准备面试的开发者、正在做Redis存储方案选型的人、以及线上遇到内存或性能问题需要定位的人。看完之后,你至少能做到两件事:看一眼key就知道它底层用了什么结构,以及知道怎么通过配置和编码决策去控制内存和查询性能。

1. 先搞清楚一件事:Redis的“五种类型”只是冰山一角

很多人对Redis的理解停留在“有五种数据类型”,这是对外API层面的说法,离真实存储还隔着一层。Redis里每个键值对都是一个redisObject,里面除了存value的指针,还记录了typeencoding两个关键字段。type告诉你这是String还是Hash,encoding才告诉你真正用什么结构把它存下来。

1.1 底层编码才是真正的实现

type就是那五种对外类型,encoding则是内部实现形态。同样是Hash类型,小哈希用listpack存,大哈希用hashtable存;同样是Set,全是整数且数量少时用intset,超出范围就切成hashtable;同样是ZSet,小数据走listpack,大数据走skiplist + dict

想知道一个key底层到底长什么样,Redis给了我们一个观察窗口:

127.0.0.1:6379> SET name "zhangsan" OK 127.0.0.1:6379> OBJECT ENCODING name "embstr" 127.0.0.1:6379> SET page:view:1024 10086 OK 127.0.0.1:6379> OBJECT ENCODING page:view:1024 "int"

同一个String类型,一个返回embstr,一个返回int。原因很简单:存的字符串能不能被解释成整数,直接影响Redis选哪条存储路径。这个命令平时看着不起眼,线上排查的时候是神器,后面我会专门讲怎么通过它定位问题。

1.2 为什么设计成“一类型多实现”

Redis的核心竞争力是内存存储,同样的数据,用不同的结构存储,内存开销可以差好几倍。问题是,紧凑的结构往往读写性能差一些,高性能的结构往往占用内存多一些。于是Redis的做法是:数据量小的时候用“省内存但略慢”的紧凑结构,数据量大了再切换成“费内存但快速”的高效结构。

举个生活中的例子:出远门装几件衣服,拿个塑料袋就行,既轻便又不占地方;搬家的时候必须上行李箱,虽然笨重,但能装、好拖。Redis就是按数据规模自动给你换“行李箱”和“塑料袋”的那位管家。所以理解底层实现,核心就是搞清楚两件事:每种结构长什么样,以及什么条件下触发切换。

2. String的底层:SDS与int、embstr、raw三种编码

String是最常用的类型,但它的底层实现经常被误解。网上很多文章说String底层是SDS,这个说法不准确——更准确地说,只有当字符串没法被当作整数处理,或者长度超过阈值时,才会用到SDS。字符串能解释成整数时,Redis压根不建SDS。

2.1 为什么Redis不用C字符串

先看SDS(Simple Dynamic String,简单动态字符串)。C语言的字符串用char[]\0结尾,这么设计有三大痛点:第一,想拿到字符串长度必须从头遍历,O(n)复杂度;第二,中间如果出现\0,字符串就“断”了,没法存二进制数据;第三,拼接字符串时如果忘记分配内存,直接缓冲区溢出。

Redis里的SDS结构大致长这样:

struct sdshdr { int len; // 已使用长度 int free; // 未使用空间 char buf[]; // 字节数组 };

实际源码里是按长度分成了sdshdr5sdshdr8sdshdr16sdshdr32sdshdr64,目的就是根据字符串实际长度选择最短的header,避免为了存几个字节的字符串反而背上几字节的header开销。这个设计和我后面要讲的listpack思路是一致的:能省一点是一点,毕竟Redis是内存数据库,每字节都是钱。

SDS解决了C字符串的三个问题:len字段让长度查询变成O(1);用len判断字符串结束而不是\0,所以二进制安全;扩容时如果free不够就重新分配内存,不会越界写坏其他数据。

2.2 三种编码的取舍与切换

讲完SDS,回到String编码。一个String类型的key,底层可能是intembstr或者raw,具体看存的值。

如果字符串能被解析成long类型整数,Redis直接把这个整数值存在redisObjectptr指针里,不再额外分配SDS内存,这就是int编码。所以执行SET page:view:1024 10086之后,OBJECT ENCODING返回int。这个设计的好处是:像INCRDECR这类操作,直接在指针上做整数运算,不需要任何内存分配和字符串解析,性能极高。

如果字符串长度小于等于44字节,用embstr编码;超过44字节,切成raw编码。embstrraw底层都是SDS,区别在于内存分配方式:embstrredisObject和SDS头还有数据分配在一块连续内存里,一次分配搞定;raw需要两次分配,先分配redisObject,再分配SDS。

为什么阈值是44?因为Redis默认内存分配器是jemalloc,它在64字节以内的分配非常高效。redisObject固定占16字节,SDS头加结束符占了20多字节,64减去这些,正好余下44字节给数据本身。超过44字节,一个64字节的块装不下,只能走raw的两次分配。

实操里有一个很关键的坑:embstr是不可变的。如果你对一个embstr字符串执行APPEND,Redis发现长度要变了,会先把embstr转成raw,再做修改。所以频繁append的字符串,底层一直是raw,不会退化回embstr

3. List的底层:从ziplist到quicklist,再到listpack

List的底层实现演变过好几轮,这其实是Redis演进史的一个缩影。如果你看过老版本的源码,会发现List曾经有ziplistlinkedlist两种实现,3.2版本之后被quicklist取代,7.0又把节点里的ziplist换成了listpack。每一步设计都是为了解决同一个词:内存碎片。

3.1 老一代实现:ziplist和linkedlist为什么被抛弃

linkedlist就是标准的双向链表,每个节点有prevnext指针,增删快,但一个节点需要维护两个指针,加上void*的value指针,内存开销很大。更麻烦的是,节点在内存里东一个西一个,非常容易产生碎片。

ziplist是反过来,它把所有元素压成一块连续内存,像一个紧凑的数组。结构上是:头部有zlbyteszllen,尾部有zlend,中间是连续的entry。每个entry由prevlenencodingdata三部分组成。

问题出在prevlen字段。它记录前一个entry的长度,为了省内存,Redis规定:如果前一个entry长度小于254字节,prevlen用1字节表示;如果大于等于254字节,prevlen要用5字节表示。这就引发了一个著名的“连锁更新”问题:一个entry变大,导致下一个entry的prevlen从1字节变成5字节,下一个entry变大又引发下下个entry的prevlen变化,最坏情况下需要对整个ziplist做一次遍历更新,复杂度O(n)。

这在老版本里确实引发过线上性能问题。你往一个list中间插入一个超大元素,最坏情况下Redis在“修修补补”上花费的时间远超你的预期。所以3.2之后,Redis引入了quicklist

3.2 quicklist:双向链表套压缩列表

quicklist的思路一句话概括:把一个大list切成很多小段,每一段用ziplist紧凑存储,段与段之间用双向链表连接。

这个设计很巧妙,它同时拿到了两个好处:每段内部内存连续,天然抗碎片;段和段靠指针连接,插入删除不用移动大段数据。相当于一个仓库里放了很多集装箱,集装箱里又做了隔断,既能装又能搬。

quicklist有两个控制参数,老版本叫list-max-ziplist-size,配置项是:

list-max-ziplist-size -2

正数表示每个节点的entry个数的上限,负数表示每个节点的内存上限。-2代表每个节点最多8KB。如果设置成-1是4KB,-3是16KB,依次翻倍。生产环境一般保持默认值就够了,除非你非常清楚自己的key大小分布,否则乱调容易适得其反。

3.3 7.0的listpack:彻底解决连锁更新

7.0版本把quicklist节点里的ziplist换成了listpack,节点配置项也改成了list-max-listpack-size。列表这种类型的底层就变成:一个quicklist,节点里是listpack

listpackziplist最核心的区别,是entry里不再保存前一个节点的长度,改成了保存当前节点自己的长度,并且用了一种特殊编码,能用一个连续的区域同时表示“当前元素类型”和“当前元素长度”。这就从根上消除了连锁更新的问题:每个entry的长度变化,不需要通知前后节点。

这个改动带来的实际收益是:无论list怎么变,都不会再出现一次操作触发全量更新的极端情况。对生产环境来说,这是一个非常值得升级7.0的理由。

3.4 List类型相关的实战经验

需要注意,List和Hash、Set、ZSet不同,它没有一个“超过阈值就整体切换结构”的机制,而是从始至终都用quicklist,只是节点内部的listpack大小可以配置。你在7.0环境里执行OBJECT ENCODING,List几乎只会看到quicklist

实际使用中,List最常见的问题是“大key”。很多业务喜欢用List做消息队列,一个队列塞了几百万条消息,节点数非常多,执行LRANGE全量读取时直接卡住主线程。我在线上遇到过类似问题,最后方案是给List设置上限,或者定期用LTRIM裁剪,不让它无限增长。

4. Hash的底层:listpack与hashtable

Hash是Redis里最灵活的类型,可以理解成一个微型的键值对集合。它的底层实现有两个阶段:小哈希用listpack,大哈希用hashtable。因为7.0以后ziplist全面被listpack取代,我直接讲新版本的结构。

4.1 小哈希用listpack存储

当哈希的字段数量少、字段名和字段值都比较短时,Redis选择用listpack存储。所有字段和值按照“字段1、值1、字段2、值2”的顺序连续排布在一整块内存里。

这种存储方式查询是O(n)的,必须从头到尾扫描才能找到目标字段。但别忘了,这是在小数据量前提下,n本身很小,线性扫描的性能损耗几乎可以忽略。换来的好处是内存极度紧凑——没有指针,没有额外链表节点,每个字节都在干正事。

触发切换成hashtable的阈值由一个关键配置控制。

4.2 大哈希用hashtable

当哈希的字段数量超过hash-max-listpack-entries(7.0默认128),或者任意字段名或字段值的长度超过hash-max-listpack-value(默认64字节),Redis就把整个Hash转成hashtable

hashtable就是标准的哈希表实现,不过在Redis里叫dict,结构上是一组桶数组加链表解决冲突。每个字段是一个dictEntry,保存了keyvalue和指向下一个冲突节点的next指针。

这里有一个面试高频点:为什么阈值要设成128和64?因为Redis团队实测过,在这个规模下,listpack的线性扫描开销和hashtable的哈希计算、指针追踪开销相差无几,但listpack的内存占用要小得多。超过这个阈值,hashtable的O(1)查询优势才真正体现出来。

4.3 渐进式rehash和负载因子

hashtable的扩容不是一次性完成的,而是“渐进式”,这是Redis保证主线程不卡顿的关键设计。Redis的dict结构里保存了两个哈希表,扩容时先把新表准备好,然后通过一个rehashidx索引,把旧表中的条目一点一点搬过去,每执行一次增删改查,就顺手搬一部分,直到全部搬完。

负载因子的判断逻辑大概是:如果没有子进程在执行持久化,负载因子超过1就扩容;如果正在执行BGSAVEBGREWRITEAOF,负载因子超过5才扩容。缩容则是在负载因子低于0.1时触发。之所以区分有没有子进程,是因为fork出来的子进程在写时复制,如果此时大规模扩容,会复制大量内存页,导致父进程内存翻倍,极端情况下直接OOM。

实操中我见过最典型的Hash问题是:一个小hash因为某个字段超长,突然切换成hashtable,内存占用飙升。你用HMSET写入100万个小hash没问题,如果其中某几个hash的字段值超过64字节,它们就会“升级”成大表,内存从几百MB涨到几个GB。排查方法很简单,用redis-cli --bigkeys扫一遍,重点看hash类型的大key。

5. Set的底层:intset与hashtable

Set的特点是元素唯一、无序。正因为无序,它的底层实现可以比Hash更极端地省内存。Redis给Set准备了两套方案:元素全是整数且数量少时用intset,其他情况用hashtable

5.1 整数集合intset与升级机制

intset是一个有序的整数数组,结构如下:

typedef struct intset { uint32_t encoding; // 编码方式:int16/int32/int64 uint32_t length; // 元素个数 int8_t contents[]; // 元素数组 } intset;

contents虽然声明成int8_t数组,实际存储时按encoding来决定每个元素占多少字节。初始可能是int16,当插入一个超出int16范围的大整数时,整个集合会“升级”到int32甚至int64。升级过程要重新分配内存、搬运元素、调整encoding,代价不小,但好处是对于小整数集合,能用最短的字节存下每个元素。

因为数组有序,intset查找用的是二分查找,O(log n)复杂度。插入为了保持有序,可能涉及元素移动,但数据量小的时候完全不是问题。

升级是单向的。一旦intsetint16升级到int64,即使你后续把所有大整数都删了,它也不会降回int16。这是我在优化内存时踩过的坑:某些集合曾经短暂写入过大整数,之后就一直占着高端内存。

5.2 什么时候切换成hashtable

当集合元素个数超过set-max-intset-entries(默认512),或者出现一个非整数元素(比如字符串),Set就从intset切换成hashtable。此时哈希表的key存集合元素,value统一为空指针。

intset升级到hashtable是不可逆的,哪怕后面元素删到只剩几个整数,Redis也不会自动切回intset。这个特性和上面说的整数升级一样,属于“上了高楼就不走楼梯”。

有个细节值得注意:如果你用SADD往Set里加一个字符串,无论集合多小,直接切hashtable。所以用Set存纯整数ID列表时,要保证所有地方都传整数,别传字符串形式的数字,否则底层结构会提前升级,白白多耗内存。

6. ZSet的底层:skiplist + dict 与 listpack

ZSet是Redis里最复杂、也最能体现设计功力的一种类型。它既要支持按member精确查score,又要支持按score范围查member,还要能快速算出排名。这个需求放在任何一个数据结构里都不好做,Redis给出的答案是:一个小数据量时用listpack,大数据量时用“跳表+哈希表”组合方案。

6.1 跳表为什么能替代平衡树

先介绍跳表(skiplist)。普通链表查找是O(n),跳表在链表基础上增加多级索引:最底层是全部数据,上层每隔几个节点抽一个出来做索引。查找的时候从最高层开始,往右和往下找,跳过大量节点,平均复杂度O(log n)。

Redis的跳表实现里,每个节点用一个zskiplistNode表示,Level数组里存了forward(前进指针)和span(跨度)。这个span非常关键,它记录的是当前节点到下一个节点跨越了多少个底层节点。做ZREVRANK这类排名操作时,正是靠累加span来快速算出排名,不需要遍历整个跳表。

面试里经常被问:为什么Redis选跳表而不选红黑树或B+树?我的理解主要有三点:第一,范围查询友好,跳表从某一节点开始往右遍历就能取到一段范围内的所有数据,红黑树找范围需要中序遍历,相对笨重;第二,实现简单、易调试,红黑树调整颜色和旋转的逻辑非常容易写错,跳表只需要维护多层链表的插入删除;第三,内存可控,通过概率因子p=1/4控制每层节点数量,层高期望值稳定,最坏情况也不会高到离谱。

6.2 dict + skiplist的双重结构

ZSet同时用dictskiplist,是因为单一结构无法满足前面说的三个需求。dict保存member到score的映射,实现O(1)查score;skiplist按score排序,实现O(log n)范围查询和排名计算。两者配合,各司其职。

如果你好奇为什么ZSet不直接用skiplist或者dict之一:只用跳表的话,按member查score得O(log n),不够快;只用哈希表的话,没法做范围查询。这种“冗余存储”在内存数据库里是刻意设计的,为了功能完整性付出double的空间。

6.3 小ZSet用listpack

当ZSet的成员数量少于zset-max-listpack-entries(默认128)、成员长度和score长度都小于zset-max-listpack-value(默认64字节)时,ZSet直接用listpack存储。此时所有元素按照score从小到大排列,存成连续内存。

查询时按顺序扫描,插入时可能需要移动后续元素,但数据量小,无所谓。只有超过阈值,才切换成skiplist + dict结构。

这个切换值得关注:ZSet的listpack模式下,如果只是偶尔插入一个超大score,或者某个member很长,整个ZSet就可能直接切到skiplist结构,内存占用成倍上升。我有一个业务场景,本来存了几百万个短member的ZSet,某天有设备异常上报了一个特别长的异常堆栈作为member,导致整个key切换结构,内存一下涨了80%。从那以后,我对所有写入ZSet的member和score都加了长度校验。

7. 版本演进与内存优化实操

搞懂了五种结构各自的底层实现,你可能会问:这些知识对日常开发有什么用?作用非常大。Redis的每一项“内部优化”,最终都表现在两个可观测指标上:内存占用和命令延迟。下面我把实操相关的配置、工具和排查方法串一遍。

7.1 从ziplist到listpack,版本升级带来了什么

如果你还在跑6.x甚至5.x的老版本,我建议你重点评估一下升级到7.x。虽然对外API完全不变,但内部结构变化很大:List、Hash、ZSet里的ziplist全部被listpack替换,连锁更新的极端性能问题被彻底根除。

升级前最好用INFO keySpaceredis-cli --bigkeys扫一遍存量key,确认没有明显的兼容性风险。Redis的RDB文件是向前兼容的,低版本RDB高版本能读,但高版本RDB低版本打不开,所以升级前一定要做好备份和数据校验。

有一个判断方法:在6.x环境里,对一个小列表执行OBJECT ENCODING,返回quicklist;在7.x环境里,返回同样是quicklist,但内部节点已经是listpack。字段层面看不出来,只有观察内存曲线才能感知差异。

7.2 Redis底层编码相关的核心配置

生产环境一般不推荐大改下面的参数,但知道它们在哪、默认是什么、影响什么,能帮你快速定位问题。我把它们列成一张表:

配置项默认值作用对象影响
hash-max-listpack-entries128Hash字段数超过该值,listpack切换hashtable
hash-max-listpack-value64Hash字段名或值超过64字节,listpack切换hashtable
set-max-intset-entries512Set元素数超过该值,intset切换hashtable
zset-max-listpack-entries128ZSet成员数超过该值,listpack切换skiplist+dict
zset-max-listpack-value64ZSetmember或score长度超过64字节,listpack切换skiplist+dict
list-max-listpack-size128List单个quicklist节点内listpack最大entry数

注意,不同版本默认值可能有差异,7.0的hash-max-listpack-entries是128,早期版本叫hash-max-ziplist-entries,默认是512。如果你在网上查资料,一定要确认版本,别拿老参数去套新环境。

7.3 观察底层结构的三个实用命令

第一个是OBJECT ENCODING key,查看key的底层编码。第二个是MEMORY USAGE key,查看key占用的字节数。第三个是redis-cli --bigkeys,扫描全库的大key。

我排查内存问题的标准流程是:先用redis-cli --bigkeys扫出大key,再用OBJECT ENCODING看大key的编码类型,最后用MEMORY USAGE确认内存占用。如果能通过HSCANSSCANZSCAN分批取数据,尽量别用HGETALL全量拉取,避免阻塞主线程。

8. 常见问题与避坑实录

最后把常见的坑集中整理一下。这些坑我基本都踩过,写出来帮大家省点时间。

8.1 类型与编码对照速查表

快速记忆版本的对照关系:

类型小数据量编码大数据量编码切换触发条件(默认)
Stringint / embstrraw长度超44字节或不可解析为整数
Listquicklist(节点内listpack)quicklist(节点内listpack)一直用quicklist,无整体切换
Hashlistpackhashtable字段数>128或字段/值>64字节
Setintsethashtable元素数>512或出现非整数
ZSetlistpackskiplist + dict成员数>128或member/score>64字节

注意List这行特殊:它没有编码层面的整体切换,只是节点内部始终用listpack,节点大小可以配置。

8.2 我踩过的几个典型问题

第一次踩坑是误以为Hash字段数很小就一定是listpack。后来发现有一个字段值是个很长的JSON字符串,超过64字节,整个Hash提前切到hashtable。排查了很长时间才发现是OBJECT ENCODING返回了hashtable,再逐个字段检查才找到那个长value。后来我写了一个巡检脚本,专门扫描那些“预想应该是listpack但实际是hashtable”的小key,用来自查业务数据是否符合预期。

第二个坑是ZSet里混入超长member。当时有个排行榜key,设计时预估只有几十个成员,结果某个member是用户上传的一个超长文本,直接导致编码切换,内存涨了不少。后来我在业务代码里对member长度做了截断和校验,彻底避免了这个问题。

第三个坑和String有关。我们用String存短信验证码,验证码是数字,底层走int编码,这没问题。但后来需求变更,验证码前面加了字母前缀,变成了字符串,底层编码切换成embstr,内存占用上升并不明显,但INCR之类的操作没法用了。这个例子说明,编码决策是跟着数据特征走的,存什么类型的数据,会影响能复用哪些Redis能力。

第四个坑比较隐蔽:很多人在排查OOM时只看maxmemoryINFO memory,忽略了大key对内存碎片的影响。当一个Hash从listpack切换成hashtable时,listpack的整块内存释放后,jemalloc可能不会立刻归还操作系统,而是留在进程的内存池里。表面上看used_memory降了,但used_memory_rss还很高,这就是内存碎片率飙升的原因。遇到这种情况,可以考虑开启activedefrag yes,或者用MEMORY PURGE手动整理(线上慎用)。

8.3 排查手段和经验建议

我想特别强调一点:线上环境一定要先把OBJECT ENCODING用起来,它是定位Redis问题的第一把钥匙。不要只盯着命令延迟和慢日志,很多时候性能问题的根源,是某个key因为数据增长切换到了高开销的底层结构,而你还在用“小数据量”的预期去评估它。

另外,别迷信“默认配置就是最优配置”。默认值适合通用场景,但你的业务如果明确知道某个ZSet就是长期几千个成员,那就应该提前调整zset-max-listpack-entries到512甚至1024,让它在更长阶段保持listpack存储,省下skiplist和dict的冗余空间。反过来,如果某个Hash字段就是特别大,那也别硬撑着用listpack,早点切hashtable反而稳定。

还有一个我习惯用的手段:在新版本上线之前,写一个小脚本往Redis灌入模拟数据,然后逐个key执行OBJECT ENCODING,把实际编码和预期编码做比对。这一步能提前发现很多“数据不符合预期”的问题,成本极低,收益很高。

Redis底层实现这块内容,说到底就是一句话:它在用“适合的才是最好的”原则,在内存和性能之间找平衡点。你理解了每个结构的设计动机,再遇到奇怪的线上现象,就不会只停留在“加内存”或者“加缓存”这两板斧上了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询