搞Redis的人早晚会遇到一个问题:明明叫五种数据结构,String、List、Hash、Set、ZSet,可你去翻源码或者用OBJECT ENCODING看一眼,发现同一个key,有时候底层是int,有时候是embstr,有时候是quicklist,还有listpack、skiplist这些名字。网上八股文背了一堆,背完就忘,真到排查内存暴涨、大key阻塞、命令超时的时候,根本不知道怎么把这些知识点用起来。
这篇文章我把五种数据结构的底层实现拆开讲一遍,不讲虚的,直接落到源码逻辑、编码切换条件、配置参数和排查手法上。内容适合三类人:准备面试的开发者、正在做Redis存储方案选型的人、以及线上遇到内存或性能问题需要定位的人。看完之后,你至少能做到两件事:看一眼key就知道它底层用了什么结构,以及知道怎么通过配置和编码决策去控制内存和查询性能。
1. 先搞清楚一件事:Redis的“五种类型”只是冰山一角
很多人对Redis的理解停留在“有五种数据类型”,这是对外API层面的说法,离真实存储还隔着一层。Redis里每个键值对都是一个redisObject,里面除了存value的指针,还记录了type和encoding两个关键字段。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[]; // 字节数组 };实际源码里是按长度分成了sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64,目的就是根据字符串实际长度选择最短的header,避免为了存几个字节的字符串反而背上几字节的header开销。这个设计和我后面要讲的listpack思路是一致的:能省一点是一点,毕竟Redis是内存数据库,每字节都是钱。
SDS解决了C字符串的三个问题:len字段让长度查询变成O(1);用len判断字符串结束而不是\0,所以二进制安全;扩容时如果free不够就重新分配内存,不会越界写坏其他数据。
2.2 三种编码的取舍与切换
讲完SDS,回到String编码。一个String类型的key,底层可能是int、embstr或者raw,具体看存的值。
如果字符串能被解析成long类型整数,Redis直接把这个整数值存在redisObject的ptr指针里,不再额外分配SDS内存,这就是int编码。所以执行SET page:view:1024 10086之后,OBJECT ENCODING返回int。这个设计的好处是:像INCR、DECR这类操作,直接在指针上做整数运算,不需要任何内存分配和字符串解析,性能极高。
如果字符串长度小于等于44字节,用embstr编码;超过44字节,切成raw编码。embstr和raw底层都是SDS,区别在于内存分配方式:embstr把redisObject和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曾经有ziplist和linkedlist两种实现,3.2版本之后被quicklist取代,7.0又把节点里的ziplist换成了listpack。每一步设计都是为了解决同一个词:内存碎片。
3.1 老一代实现:ziplist和linkedlist为什么被抛弃
linkedlist就是标准的双向链表,每个节点有prev、next指针,增删快,但一个节点需要维护两个指针,加上void*的value指针,内存开销很大。更麻烦的是,节点在内存里东一个西一个,非常容易产生碎片。
ziplist是反过来,它把所有元素压成一块连续内存,像一个紧凑的数组。结构上是:头部有zlbytes、zllen,尾部有zlend,中间是连续的entry。每个entry由prevlen、encoding、data三部分组成。
问题出在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。
listpack和ziplist最核心的区别,是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,保存了key、value和指向下一个冲突节点的next指针。
这里有一个面试高频点:为什么阈值要设成128和64?因为Redis团队实测过,在这个规模下,listpack的线性扫描开销和hashtable的哈希计算、指针追踪开销相差无几,但listpack的内存占用要小得多。超过这个阈值,hashtable的O(1)查询优势才真正体现出来。
4.3 渐进式rehash和负载因子
hashtable的扩容不是一次性完成的,而是“渐进式”,这是Redis保证主线程不卡顿的关键设计。Redis的dict结构里保存了两个哈希表,扩容时先把新表准备好,然后通过一个rehashidx索引,把旧表中的条目一点一点搬过去,每执行一次增删改查,就顺手搬一部分,直到全部搬完。
负载因子的判断逻辑大概是:如果没有子进程在执行持久化,负载因子超过1就扩容;如果正在执行BGSAVE或BGREWRITEAOF,负载因子超过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)复杂度。插入为了保持有序,可能涉及元素移动,但数据量小的时候完全不是问题。
升级是单向的。一旦intset从int16升级到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同时用dict和skiplist,是因为单一结构无法满足前面说的三个需求。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 keySpace、redis-cli --bigkeys扫一遍存量key,确认没有明显的兼容性风险。Redis的RDB文件是向前兼容的,低版本RDB高版本能读,但高版本RDB低版本打不开,所以升级前一定要做好备份和数据校验。
有一个判断方法:在6.x环境里,对一个小列表执行OBJECT ENCODING,返回quicklist;在7.x环境里,返回同样是quicklist,但内部节点已经是listpack。字段层面看不出来,只有观察内存曲线才能感知差异。
7.2 Redis底层编码相关的核心配置
生产环境一般不推荐大改下面的参数,但知道它们在哪、默认是什么、影响什么,能帮你快速定位问题。我把它们列成一张表:
| 配置项 | 默认值 | 作用对象 | 影响 |
|---|---|---|---|
hash-max-listpack-entries | 128 | Hash | 字段数超过该值,listpack切换hashtable |
hash-max-listpack-value | 64 | Hash | 字段名或值超过64字节,listpack切换hashtable |
set-max-intset-entries | 512 | Set | 元素数超过该值,intset切换hashtable |
zset-max-listpack-entries | 128 | ZSet | 成员数超过该值,listpack切换skiplist+dict |
zset-max-listpack-value | 64 | ZSet | member或score长度超过64字节,listpack切换skiplist+dict |
list-max-listpack-size | 128 | List | 单个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确认内存占用。如果能通过HSCAN、SSCAN、ZSCAN分批取数据,尽量别用HGETALL全量拉取,避免阻塞主线程。
8. 常见问题与避坑实录
最后把常见的坑集中整理一下。这些坑我基本都踩过,写出来帮大家省点时间。
8.1 类型与编码对照速查表
快速记忆版本的对照关系:
| 类型 | 小数据量编码 | 大数据量编码 | 切换触发条件(默认) |
|---|---|---|---|
| String | int / embstr | raw | 长度超44字节或不可解析为整数 |
| List | quicklist(节点内listpack) | quicklist(节点内listpack) | 一直用quicklist,无整体切换 |
| Hash | listpack | hashtable | 字段数>128或字段/值>64字节 |
| Set | intset | hashtable | 元素数>512或出现非整数 |
| ZSet | listpack | skiplist + 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时只看maxmemory和INFO 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底层实现这块内容,说到底就是一句话:它在用“适合的才是最好的”原则,在内存和性能之间找平衡点。你理解了每个结构的设计动机,再遇到奇怪的线上现象,就不会只停留在“加内存”或者“加缓存”这两板斧上了。