ARTICLE DETAIL

资讯详情

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

Redis底层数据结构与编码机制全解析:从SDS到listpack

Redis底层数据结构与编码机制全解析:从SDS到listpack 搞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还是Hashencoding才告诉你真正用什么结构把它存下来。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字符串先看SDSSimple 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字符串执行APPENDRedis发现长度要变了会先把embstr转成raw再做修改。所以频繁append的字符串底层一直是raw不会退化回embstr。3. List的底层从ziplist到quicklist再到listpackList的底层实现演变过好几轮这其实是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 ENCODINGList几乎只会看到quicklist。实际使用中List最常见的问题是“大key”。很多业务喜欢用List做消息队列一个队列塞了几百万条消息节点数非常多执行LRANGE全量读取时直接卡住主线程。我在线上遇到过类似问题最后方案是给List设置上限或者定期用LTRIM裁剪不让它无限增长。4. Hash的底层listpack与hashtableHash是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-entries7.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与hashtableSet的特点是元素唯一、无序。正因为无序它的底层实现可以比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 与 listpackZSet是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树我的理解主要有三点第一范围查询友好跳表从某一节点开始往右遍历就能取到一段范围内的所有数据红黑树找范围需要中序遍历相对笨重第二实现简单、易调试红黑树调整颜色和旋转的逻辑非常容易写错跳表只需要维护多层链表的插入删除第三内存可控通过概率因子p1/4控制每层节点数量层高期望值稳定最坏情况也不会高到离谱。6.2 dict skiplist的双重结构ZSet同时用dict和skiplist是因为单一结构无法满足前面说的三个需求。dict保存member到score的映射实现O(1)查scoreskiplist按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-entries128Hash字段数超过该值listpack切换hashtablehash-max-listpack-value64Hash字段名或值超过64字节listpack切换hashtableset-max-intset-entries512Set元素数超过该值intset切换hashtablezset-max-listpack-entries128ZSet成员数超过该值listpack切换skiplistdictzset-max-listpack-value64ZSetmember或score长度超过64字节listpack切换skiplistdictlist-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确认内存占用。如果能通过HSCAN、SSCAN、ZSCAN分批取数据尽量别用HGETALL全量拉取避免阻塞主线程。8. 常见问题与避坑实录最后把常见的坑集中整理一下。这些坑我基本都踩过写出来帮大家省点时间。8.1 类型与编码对照速查表快速记忆版本的对照关系类型小数据量编码大数据量编码切换触发条件默认Stringint / embstrraw长度超44字节或不可解析为整数Listquicklist节点内listpackquicklist节点内listpack一直用quicklist无整体切换Hashlistpackhashtable字段数128或字段/值64字节Setintsethashtable元素数512或出现非整数ZSetlistpackskiplist dict成员数128或member/score64字节注意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底层实现这块内容说到底就是一句话它在用“适合的才是最好的”原则在内存和性能之间找平衡点。你理解了每个结构的设计动机再遇到奇怪的线上现象就不会只停留在“加内存”或者“加缓存”这两板斧上了。
返回列表