news 2026/10/21 3:07:58

Redis压缩列表底层原理剖析:内存布局、编码规则与连锁更新

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Redis压缩列表底层原理剖析:内存布局、编码规则与连锁更新

1. 压缩列表到底在解决什么问题

我当年第一次翻开Redis源码,看到ziplist这个词的时候,第一感觉是它跟平时理解的“列表”完全是两回事。压缩列表,Compressed List,字面意思很直白:把数据压缩到一块连续内存里。Redis愿意为此多花一点CPU去解析编码规则,换来的则是内存占用的大幅下降。在纯内存数据库这个场景里,这笔交换非常划算。

先说清楚它解决的根本问题:Redis的数据常驻内存,内存就是最珍贵的资源。如果你用普通双向链表来存一个小列表,在64位系统上光prev和next两个指针就占16字节,再加上每个节点独立malloc、内存碎片、CPU缓存命中率下降,一套组合拳下来开销很惊人。压缩列表恰恰相反,它把所有元素按顺序拼接在一块连续内存上,每个节点之间不留空洞,节点边界信息用尽量短的编码来表示,能省就省。小对象的内存消耗可以压得很低,同时也大幅减少了内存分配次数,加载到CPU缓存时也更友好。

压缩列表具体出现在哪里?最常见的三个地方:hash的小数据量编码、zset的小数据量编码,以及list在演进过程中经历的ziplist阶段。你往一个hash里塞几十个字段,往zset里加几十个成员,数据量还没超过阈值时,Redis默认就用压缩列表来存。数据量继续增长,才会转换成hashtable、skiplist这些更“常规”的结构。

什么人需要搞懂这块内容?一类是做Redis运维和调优的人,至少得明白OBJECT ENCODING命令查出来的ziplist到底意味着哪些性能特征;另一类是读源码、设计中间件、对内存布局敏感的工程师。这篇文章我会把字节层的设计决策和背后取舍尽量讲透,也把读源码时容易踩的坑一起说出来,帮你少走弯路。

2. 压缩列表的五个区段,逐字节拆开看

2.1 整体布局:三个头字段、若干节点、一个结束符

压缩列表在内存里就是一段连续字节。整块结构可以分成五段:zlbytes、zltail、zllen、entry列表、zlend。每个部分都承担明确职责,我一一展开。

字段字节数作用
zlbytes4字节记录整块压缩列表占用的总字节数,需要重新分配内存时直接取这个值
zltail4字节记录了尾节点相对于压缩列表起始地址的偏移量,用来实现尾部O(1)访问
zllen2字节记录节点数量,但上限只能到65535
entry1..entryN不定长实际存储的元素,每个节点内部还有自己的三字段结构
zlend1字节固定值0xFF,标记列表结束

zlbytes和zltail这两个字段是最容易被忽略却又很关键的设计。Redis的list命令经常要做LPUSH、RPUSH、LPOP、RPOP这类两端操作,如果在尾部也要从头遍历一次,性能直接崩掉。zltail就是为此存在的:它把尾节点地址偏移量提前存好,任何一个遍历操作都能迅速定位末尾。zlbytes则服务于内存管理,扩容或缩容时,系统只需要读取这个值就知道当前块占多大,不需要自己计算。

zllen这个2字节字段有个经典坑:它最大只能表示65535。当节点数超过65535时,zllen会被固定设置为65535,真正的节点数量只能通过遍历才能得到。这意味着,用zllen判断“列表是否为空”是安全的,判断“列表是否只有几百个节点”也是安全的,但如果你拿它当精确计数用,遇到大列表就会出问题。读源码的时候不了解这个限制,很容易产生误解。

2.2 一个空列表到底占多少内存

很多人以为空列表就是0字节,压缩列表可不是。即使一个元素都没有,一块ziplist也有固定的11字节开销:zlbytes占4字节,zltail占4字节,zllen占2字节,zlend占1字节,加起来正好11字节。

这11字节看起来不多,但会直接影响Redis的配置阈值设计。正因为它有固定开销,Redis在使用ziplist作为底层编码时,配套了一系列阈值参数,用来判断“到了多少数据量就不再划算了”。一旦元素数量超过阈值,立刻转成hashtable、skiplist等结构,避免让一个变小了的空间优势被固定开销抵消掉。

举个直观的例子:你创建一个空hash对象,其实底层的ziplist已经分配好11字节了。这时候你再往里写两个字段,每个字段的key和value各占一点空间,整体内存会继续上涨。正是因为起点固定成本不算低,Redis才坚持在小数据量下才用ziplist,超出阈值就切换,避免为“省几个指针”付出不必要的解析代价。

2.3 每个entry节点的内部分工

真正存数据的部分由一组entry节点连续排列组成。每个entry节点内部又分成三段:prevlen、encoding、data。

prevlen记录前一个节点的字节长度,作用是在反向遍历时能直接定位前一个节点的起点。因为压缩列表是连续内存,没有链表式的指针,反向遍历所有节点就只能靠这个基准反推。encoding用来标记当前节点的数据类型和长度,data就是实际内容。

注意,压缩列表没有链表那种“头节点指针结构”,它对内存的利用做到了极致,但代价是节点定位只能顺序扫描。也就是说,压缩列表的读写复杂度本质上是O(N)。这也是它只适合小数据量的根因,一旦数据量大起来,顺序扫描的代价会超过内存节省带来的收益。

3. 节点编码方案:那些拼命省空间的细节

3.1 prevlen的两种形态

每个entry节点的开头,通过prevlen记录前一个节点的长度。它有两种编码形态:

  • 如果前一个节点长度小于254字节,prevlen只占1个字节,直接存长度值。
  • 如果前一个节点长度大于等于254字节,prevlen占5个字节:第一个字节固定是0xFE,后面4个字节才是真实长度。

这里有几个值得琢磨的设计细节。第一个是0xFE这个哨兵值的来源:zlend的结束符是0xFF,所以编码时把254以上的情况交给多字节表示,避免和结束符冲突。第二个是prevlen占用的字节数并不固定,一个节点本身大小变化,会直接影响它后面那个节点的prevlen占用空间。这为后面要讲的“连锁更新”埋下了伏笔。

在64位系统下,如果列表里每个节点都只有几十字节,prevlen几乎都能用1字节覆盖,整体开销非常小。但如果你在列表中间放了一个1KB的大节点,它后面那个节点就得用5字节的prevlen来记录它的存在。这个决策极其节能,同时也把节点间的耦合关系变复杂了。

3.2 encoding字段的编码规则

encoding字段是压缩列表能省内存的重头戏。它根据开头两个bit分成几个大类,源码里的判断逻辑基本都围绕这两个bit展开:

开头两位含义可表示范围
00短字符串,后6位记录长度长度不超过63字节
01中字符串,后14位记录长度长度不超过16383字节
10长字符串,后32位记录长度大于16383字节
11整数编码,具体类型由后续位决定见下文

从encoding的第一个字节的最高位开始读,就能判断当前节点属于哪种类型,这是解析节点最关键的一步。我读源码时养成的习惯是:先明确“现在读的是什么编码大类的节点”,再去解析对应字段,顺序一旦搞反,后面所有字节都会错位。

3.3 整数编码的节省诀窍

整数编码是压缩列表里非常有创造力的地方。数字100,如果直接转成字符串"100",要占3个字节;但如果用整数编码,通常只需要encoding加上2字节或者更少的data空间。如果数字落在0到12之间,还有更极端的处理方式:值本身可以直接塞进encoding字段,连data都不用单独分配。这种特别小的数字,Redis节省到连一个data字节都不愿意浪费。

具体到编码类型,整数编码包含几种子类型:int16、int32、int64、24位有符号整数等。Redis在写入时会先尝试把输入字符串解析成整数,如果成功且在支持范围内,就走整数编码。这个决策对日常调优的启示是:写hash的value时,如果值本身能用整数表示,就尽量别加无意义的格式化字符串,比如"100"和100,后者在压缩列表下能省出好几个字节。

4. 连锁更新:最容易被误判为性能灾难的问题

4.1 什么是连锁更新

连锁更新的根源,在prevlen的两种形态上。当一个节点的长度小于254字节时,它后面节点用1字节的prevlen就够了;一旦这个节点长度跨过254字节,后面节点的prevlen就得变宽到5字节。

连锁更新发生的场景非常极端:假设一个压缩列表里连续存在多个长度在250到253字节之间的节点,此时你在它们前面插入了一个长度超过254字节的大节点。第一个小节点原来的prevlen只有1字节,现在要记录大节点的长度,prevlen需要扩容到5字节,于是它自身的长度多了4字节。如果它原来的长度加上这4字节后超过了254字节,就会导致它后面的节点又要跟着扩容……这样一个接一个地传递,直到所有受影响的节点都完成调整。最坏情况下,这是一个O(N^2)的操作,因为每个节点可能被反复调整多次。

4.2 它真的那么可怕吗

说实话,我在实际项目中碰到连锁更新的概率很低。要触发它,需要一整套节点长度整齐地卡在250到253字节这个狭窄区间,然后你还恰好在大节点前面做插入。Redis源码注释里也直接说了,即便触发连锁更新,分摊后的时间开销仍然是可接受的,并不会让Redis卡死。

不过有一个场景值得注意:删除大节点时也可能触发连锁更新。当你删除一个长度较大的节点后,它后面的节点prevlen可能从5字节缩回1字节,这种缩容同样会引发后续节点的字节数变化。所以不只是插入,删除也可能让压缩列表批量调整布局。

我踩过的坑是:在初始化设计阶段,没有关注value长度的分布,导致某个业务场景里的hash字段长度很均匀,普遍在250字节附近。虽然最后没有真的出现灾难性延迟,但分析问题时要排查“是否触发连锁更新”,就多了一层复杂度。所以如果你在设计一个数据模式,能让大量节点长度不要整齐地卡在临界值附近,那会省去后面很多麻烦。

5. 压缩列表在Redis里的应用与调优经验

5.1 三个主要使用场景

压缩列表作为Redis的底层编码,出现在三类对象中:

  • hash对象:当字段数量少且字段名、字段值的长度都较小时,底层使用ziplist。
  • zset对象:当成员数量少且member的长度较小时,底层使用ziplist。
  • list对象:在Redis 3.2之前,小列表直接由ziplist实现;3.2之后,list底层变成了quicklist,而quicklist的每个节点内部又用ziplist存数据。

这些事情用一条命令就能验证。对某个key执行OBJECT ENCODING,返回ziplist就说明当前数据正用压缩列表编码。我在排查线上内存问题时,几乎都会先批量跑一遍这条命令,把返回ziplist的key挑出来,再决定要不要调整参数。

5.2 一堆默认阈值,到底该怎么调

与压缩列表相关的核心配置包括下面这些:

结构配置项默认值
hashhash-max-ziplist-entries128
hashhash-max-ziplist-value64
zsetzset-max-ziplist-entries128
zsetzset-max-ziplist-value64
list(3.2前)list-max-ziplist-entries512
list(3.2前)list-max-ziplist-value64

这些参数的含义很清晰:entries是指最多容纳多少元素;value是指单个key或value的长度上限。只要其中一个超了,Redis就立即做结构转换。hash会从ziplist转成hashtable,zset会从ziplist转成skiplist加dict的混合结构。

在3.2版本之后,list对象原来的这两个配置被list-max-listpack-size和list-max-listpack-value取代了,因为list底层的quicklist内部节点已经换成了ziplist的变体,参数语义也有调整。如果你还在用旧版配置去调新版Redis的list,会发现参数根本没有生效,这个坑我见过不止一次。

5.3 真正值得依靠的收益点

压缩列表最核心的收益在于,它能让连续内存块在CPU缓存里更高效地被加载。少了指针跳转,少了碎小的分配块,小数据量场景下速度不但不慢,反而比普通链表更快。我调整过一个字段少、value短的hash类型业务,从hashtable切到ziplist编码之后,内存占用直接下降了约三成,读取延迟几乎没变。这种收益在数据量还小于转码阈值时非常明显。

调整阈值时要记住一个原则:不建议无脑调大hash-max-ziplist-entries。ziplist毕竟是O(N)遍历,数据量大了以后查找成本会线性上涨。折中方案是先用默认参数观察内存和延迟曲线,再用业务实际读写比例逐步试探。只要线上读多写少、字段长度稳定,适当调大阈值通常能换来可观的省内存收益;但如果是高并发随机访问,还是把阈值压低更稳妥。

6. 从ziplist到listpack,演进背后的取舍

6.1 quicklist:一个过渡方案

Redis 3.2引入quicklist的逻辑,是在ziplist和双向链表之间找一个折中。quicklist本质上是一个双向链表,但每个链表节点内部又嵌入一个ziplist。这样一来,两端的插入删除依然高效,中间区域的表示也保留紧凑内存的优势。

quicklist的节点数量、每个节点内部zip数量都可以通过配置调整。它吸收了双向链表对两端操作的支持,也吸收了ziplist节省内存的特点。旧的list对象在3.2版本之前,要么是ziplist、要么是linkedlist,quicklist用混合结构解决了“既要又要”的问题。

6.2 listpack为什么能根治连锁更新

listpack和ziplist最本质的区别是,listpack不再把前一个节点的长度放在当前节点开头,而是把每个节点的长度信息放在节点末尾,从后往前读取时先读长度,再往前定位。这样在插入或删除节点时,影响范围被严格限制在当前节点附近,不会出现因为前节点长度变化而引发的连锁反应。

正是因为去掉了连锁更新这一隐患,Redis 7.0在hash、zset、list的小数据量场景都逐步切到了listpack实现。现在你关心的对象编码名可能已经不在乎叫不叫ziplist,新代码只要避开那个设计顽疾,就能获得同等甚至更好的内存效率。这也是我在实际调优中建议关注新版本的原因之一:即使没有特殊参数改动,底层结构的改进也能带来额外收益。

6.3 回头看压缩列表给我的启发

虽然压缩列表正在被listpack替代,但我依然认为它是理解Redis演进脉络的最佳入口之一。它真正教会我的,不是某一个具体实现,而是一套思路:如何在内存开销、解析复杂度和操作复杂度三者之间做折中。下次设计任何需要极致节省内存的小型容器,我都会自然地问自己:能不能把元素压进连续内存,用变长编码标记边界,并且在读写时尽量降低解析成本。

这套手艺,是ziplist留给后来者最有价值的东西。你在自己的项目里哪怕只复刻其中一两个思想,比如把短字符串用紧凑编码表示、用偏移量替代指针,就能在特定场景中获得非常可观的内存收益。我当时把类似的连续内存编码思路用在一个自研的小型缓存组件上,虽然功能远不及Redis庞大,但内存效率的提升确实立竿见影。这也是我从压缩列表里收获最深的经验。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 3:07:15

OpenClaw智能体落地指南:自动日报系统开发实战

先说一个背景。我负责的某个内部系统,每天要汇总开发进度、工单处理、线上告警和客户反馈,之前基本靠手工去几个后台复制粘贴再拼成日报,少则二十分钟,多则一小时,而且漏项是常态。后来我把这套流程迁到了 OpenClaw 企…

作者头像 李华
网站建设 2026/10/21 3:07:14

DeepSeek Harness:三步跑通第一个插件

DeepSeek Harness:三步跑通第一个插件 【免费下载链接】deepseek-harness DeepSeek Harness: Everything is a Plugin. 项目地址: https://gitcode.com/gh_mirrors/de/deepseek-harness [hello-plugin] plugin loaded!这是你跟着本文操作完、会在终端里看到的…

作者头像 李华
网站建设 2026/10/21 3:06:59

Seay源代码审计系统实战:PHP漏洞扫描与规则扩展

简介:Seay源代码审计系统是一款基于C#开发的PHP代码安全审计工具,主要运行于Windows平台,面向网络安全初学者、CTF/AWD参赛选手及渗透测试人员,用于快速定位PHP项目中的安全隐患。它能够识别SQL注入、代码执行、命令执行、文件包含…

作者头像 李华
网站建设 2026/10/21 3:06:42

extract-xiso 源码解析与实战:Xbox 镜像列表、提取与重建

简介:Extract-xiso 是一款面向 Xbox 游戏备份与镜像处理场景的开源工具,主要用于创建和提取 Xbox 游戏光盘映像(.iso),适合需要备份自有游戏、研究镜像结构或进行跨平台光盘处理的玩家与开发者使用。资源包共 66 个文件…

作者头像 李华
网站建设 2026/10/21 3:06:32

SpringBoot+Vue+MySQL实战:构建可应对并发的水果电商系统

在动手做这个系统之前,我其实挺纠结的。市面上电商系统教程一抓一大把,随便搜都是"XX商城",但大部分都停留在玩具级别——写几个 CRUD 接口、套个前端模板就算完事。真正做一套能卖给果农合作社、能在芒果成熟季扛住并发下单的系统…

作者头像 李华
网站建设 2026/10/21 3:06:29

ObjectARX zh-chs语言包安装配置:VS编译环境完整指南

简介:该资源是针对Autodesk ObjectARX 2010开发环境的中文本地化补充包,面向在Visual Studio 2008中搭建ObjectARX插件开发环境的开发者。主要解决安装ObjectARX向导后工具箱图标缺失或无法正常显示的问题,通过将zh-chs包内文件放置于ArxAddI…

作者头像 李华