☰
Redis内部编码机制:数据类型背后的存储结构与切换规则
2026/10/1 14:03:59 网站建设 项目流程

先问一个问题:你在用Redis的时候,有没有想过一个问题——同样是一个key,为什么存储一个整数和一个长字符串,占用的内存差出好几倍?为什么同一个list,存5个元素和存5000个元素,底层结构完全不是一回事?

这个问题的答案,就是Redis的数据类型和内部编码。标题里我刻意把它们放在一起说,是因为很多人把这两个概念混为一谈:以为数据类型就是底层实现,实际上数据类型是你看得见的“门面”,内部编码才是门面背后真正干活的存储形态。搞懂这一层,你才算真正入门Redis,也才能真正理解为什么Redis可以那么快、那么省内存。

这篇文章适合刚接触Redis的初学者,也适合那些已经用了一段时间、但始终对“内部编码”理解模糊的同学。我会把五种常见数据类型为什么需要多种底层实现、内部编码到底是怎么切换的、以及实际场景里怎么利用这些特性做内存优化,一次讲透。

1. 一个列表背后的“多重人格”:数据类型与服务语义

1.1 Redis不是普通的键值存储

很多人第一次接触Redis,脑子里会把它当成一个“高级版Map”,觉得无非就是key-value存一下。这个理解不全面。

Redis真正的差异化在于:它不是一个简单的键值存储,而是一个数据结构服务器。每个value不只是“一堆字节”,而是一个有明确语义的数据结构。你往list里push,它知道这是列表;你往zset里添加元素,它知道每个元素都带着分值、要维护有序性。这种“语义感知”是Redis和Memcached这类纯缓存工具最本质的区别。

而数据类型,就是这一层语义的载体。官方把常用类型分成五种:字符串(String)、列表(List)、哈希(Hash)、集合(Set)、有序集合(Sorted Set),再加上Redis 5.0之后出现的Stream以及后续版本加入的新类型,本质都是在对不同业务场景提供“开箱即用的数据结构”。

但这只是“门面”。

1.2 五种核心数据类型到底解决什么问题

我把这五种的职责先快速过一遍,后面再讲内部编码的时候会反复提到它们。

String:最基础的类型,适合存计数器、缓存对象序列化、简单状态标记。它是key-value最直接的体现,也是唯一不需要“嵌套结构”的类型。

List:双向链表/压缩列表语义,适合做消息队列、时间线、最新列表。LPush + RPop的组合可以模拟队列,LIndex可以当数组用。

Hash:字段-值映射,适合存对象。一个用户一个key,里面放name、age、email这些字段,比把整个对象序列化成一个大字符串要灵活得多——你能单独改某个字段,而不必读出来重写。

Set:无序去重集合,适合做标签、好友关系、点赞去重。SADD、SISMEMBER这些操作都是O(1)复杂度,基于它在做集合运算(交集、并集、差集)时尤其顺手。

Sorted Set:带分值的去重集合,按分值排序,适合做排行榜、延迟队列、滑动窗口限流。它是Redis所有类型里语义最丰富的一个:既能去重,又能排序,还能范围查询。

每种类型都有自己的“语义”,但在内存里,Redis并不总是用同一种结构去实现这些语义。这就是内部编码登场的时机。

2. 内部编码:同一张脸,换着花样吃饭

2.1 为什么同一个数据类型还要“换内核”

核心原因有两个:内存效率和操作效率。

拿List举例。如果你在list里只存3个小整数,用标准的双向链表实现,每个节点都要维护prev、next两个指针,加上节点对象本身的开销,3个元素撑出来的内存损耗可能比数据本身还大。但如果元素只有几个,直接用一块连续内存把数据紧凑地串起来,省掉所有指针,内存占用立刻降下来——这就是压缩列表(ziplist)的思想。它的缺点是插入删除操作需要搬移数据,复杂度高,但元素少的时候这点开销毫无感知。

反过来,如果list里有几十万个元素,你还用压缩列表硬扛,每次插入都触发一次内存复制,性能立刻崩。这时候双向链表的优势就出来了:插入删除只改指针,不搬数据,操作复杂度稳定。

所以Redis的取舍逻辑非常清晰:小数据量用紧凑结构换内存,大数据量用指针结构换效率。所谓内部编码,就是Redis针对同一类型、根据数据规模和元素特征自动选择的存储方案。

2.2 六种内部编码速览

聊内部编码绕不开几个基本概念,这里直接列出来:

编码名全称适用类型核心特征
int整数编码String整数值直接以长整型存储,无字符串对象开销
embstr嵌入式字符串String字符串对象和底层数据分配在同一块连续内存
raw原始字符串String标准字符串,SDC(简单动态字符串)存储
ziplist压缩列表List / Hash / ZSet连续内存块,多节点紧凑排列
linkedlist双向链表List标准链表,节点分散,指针相连
intset整数集合Set有序无重复整数数组,二分查找
hashtable哈希表Hash / Set / ZSet标准字典结构,适合大数据量

注意上面这个表里,同一种编码可能被多个数据类型共用,比如ziplist同时服务List、Hash和ZSet,hashtable同时服务Hash、Set和ZSet。这也说明了“数据类型”和“内部编码”是两个维度的概念:前者是逻辑层,后者是物理层。

2.3 编码切换的触发条件与规则

每种类型内部的编码切换,不是随机发生的,都有一套明确的阈值和规则。我用实际案例说明。

String类型:整数用int、短字符串用embstr、长字符串用raw。这里的“短”在Redis 3.2之后是44字节,在更早版本是39字节。为什么是44?因为Redis里一个字符串对象头占16字节,Redis 3.2之后对embstr做了优化,44字节刚好可以让对象头和底层数据放在同一块64字节的内存分配单元里,不浪费空间。超过这个长度就退化成raw,需要两次内存分配。

List类型:元素数量小于512且每个元素长度小于64字节时,用ziplist;否则切换为linkedlist。Redis 3.2之后引入了quicklist,本质是“多个ziplist用双向指针串起来”的混合结构,但核心的切换逻辑不变——小数据紧凑存,大数据灵活存。

再往后,Redis 7.0又引入了listpack替代ziplist,专门解决ziplist在极端情况下可能出现的“连锁更新”性能问题。这个细节先按下不表,后面在问题排查部分详细讲。

Hash类型:字段数量小于512且每个字段名和值的长度都小于64字节时,用ziplist;否则切换为hashtable。

Sorted Set类型:元素数量小于128且每个元素长度小于64字节时,用ziplist;否则切换为skiplist(跳表)。跳表是Redis自己实现的一种有序数据结构,查询效率跟平衡二叉树一个量级,但实现简单得多,范围查询也更顺手。

Set类型:当所有元素都是整数且元素数量小于512时,用intset;否则切换为hashtable。intset内部是有序数组,用二分查找判断元素是否存在,内存比哈希表省得多。

这套切换机制是Redis在启动时就固化在源码里的逻辑,一旦触发条件,编码自动升级,且升级是单向的——从紧凑编码升级到非紧凑编码之后,即使删掉大量元素导致数据规模降回阈值以下,也不会自动降级。这个特性很少有人提,但做内存治理的时候很关键。

3. 从热门场景看编码选择的影响

3.1 缓存场景:小对象与大对象的内存差异

Java后端做缓存,最常见的做法是把一个对象序列化成JSON字符串,然后SET进Redis。这个做法没问题,但如果你对内部编码有概念,就会意识到一个隐藏成本:当一个对象序列化后超过44字节,String类型就走raw编码,对象头和底层数据分开分配,内存有碎片化开销。

同样的对象,如果改用Hash存储,每个字段单独作为一个小字符串,很可能每个字段都落在embstr编码区间,整体内存反而更紧凑。再加上Hash支持单独更新某一个字段——比如用户只改了昵称,你不需要把整个对象读出来、反序列化、改字段、再序列化写回去——所以面向对象缓存的场景,Hash往往比String更优。

我见过太多团队不管什么数据都往String里塞,几百字节的JSON存进去,内存翻着倍涨。后面聊内存优化时,我会给出具体的评估方法。

3.2 排行榜与计数器:Sorted Set的分值编码陷阱

排行榜基本上是Sorted Set的标准应用场景。但这里有个常见坑:如果你用Sorted Set存用户ID和分数,当用户ID是字符串形式的UUID时,每个元素都超过64字节,ziplist直接不可用,早早就切换到skiplist。skiplist的内存开销比ziplist大得多——每个节点要维护多层索引指针。

所以设计排行榜的key时,如果能用自增ID或者数值型用户ID,尽量用数值类型;如果业务上必须用长字符串,那就接受skiplist的代价,不要试图手动干预编码。

计数器场景更典型。INCR命令天然就是String类型,而整数在String里的int编码内存占用极低——一个长整型8字节,加上对象头也就24字节左右。这也是为什么热点新闻里那些“Redis incr不准”的问题,排查到最后往往不是编码的问题,而是竞态条件或持久化策略的问题,跟内部编码无关。

3.3 高基数集合与去重场景

Set的intset编码很有趣:只要元素全是整数且数量小于512,它就是一个排序后的数组,内存占用非常可观地低。但一旦元素数量超过512或者混入了非整数元素,立刻升级为hashtable,内存陡增。

从业务角度,如果做“用户浏览记录去重”这类场景,ID如果是自增主键,那天然就是整数,在数据量不大时可以用intset白嫖内存优化。但如果ID是UUID字符串,那Set从一开始就是hashtable,没有“小数据高效”的阶段。

另一个典型是布隆过滤器替代方案。有些人会用Set做大规模去重,遇到上亿级别的量,内存扛不住。正确做法是换布隆过滤器,但那属于另一个专题了。了解内部编码的意义在于:当你的Set还小的时候,它可能只占几百字节;当它变大,内存开销是跳跃式增长的,不是线性的。这个认知对容量规划很关键。

4. 实操:让内部编码为你所用

4.1 查看与诊断命令

“想了解一个key当前用了什么内部编码”,方法很简单,用OBJECT ENCODING命令:

> SET user:1 "hello" OK > OBJECT ENCODING user:1 "embstr" > SET num 123456 OK > OBJECT ENCODING num "int" > LPUSH list:1 a b c d OK > OBJECT ENCODING list:1 "quicklist" > HSET hash:1 name "jack" age 18 OK > OBJECT ENCODING hash:1 "ziplist"

注意上面list的输出结果是quicklist,不是ziplist或linkedlist。Redis 3.2之后List的底层统一改成了quicklist,它内部由多个ziplist组成,所以OBJECT ENCODING直接返回的是外层容器类型。在看Object Encoding输出时,要知道不同Redis版本可能返回不同的值,这点很考验经验。

还有一个更底层的命令:DEBUG OBJECT。它能输出key的序列化长度、LRU信息、编码类型、引用计数等详细状态。不过DEBUG命令在生产环境慎用,它会导致短暂的阻塞,而且输出格式在不同版本间有差异。

4.2 内存治理:利用编码特性做压缩

知道了内部编码,最直观的收益是做内存评估和优化。很多人问Redis内存老涨怎么办,第一反应是加内存条,实际上很多情况下数据结构的编码选择就有很大优化空间。

我举一个真实调整过的案例。有个项目做用户关注关系,用Set存每个用户关注的人的ID集合。早期上线时大家没在意,后来内存涨到几十GB。排查时发现:很多用户关注人数只有几十个,但ID是雪花算法生成的19位整数,超过intset对整数范围的要求?不,intset对整数位数没有限制,问题出在另一个地方:他们把用户ID当字符串存进去了,Set从一开始就升级到hashtable,完全没吃到intset的红利。

修复方式很简单:写入前先校验ID是数值类型,确保能走intset编码;同时把已经膨胀的大key拆成多个小key,让每个key重新回到intset区间。结果内存下降约30%,效果立竿见影。

这里还要提一下Redis的MEMORY USAGE命令,它可以直接查看某个key实际占用的字节数,做对比分析非常方便:

> MEMORY USAGE user:1 (integer) 72

拿同一份数据分别用String和Hash测试,MEMORY USAGE一对比,方案高下立判。

4.3 配置项与调优边界

Redis提供了若干配置来调整编码切换的阈值。注意,它们不是推荐你随便改,而是告诉你“存在一种可能性”。

配置项默认值作用
hash-max-ziplist-entries512Hash字段数超过该值则切换hashtable
hash-max-ziplist-value64Hash字段值/名长度超过该值则切换hashtable
list-max-ziplist-size-2quicklist每个ziplist的最大字节数(-2表示8KB)
set-max-intset-entries512Set整数元素数量超过该值则切换hashtable
zset-max-ziplist-entries128ZSet元素数量超过该值则切换skiplist
zset-max-ziplist-value64ZSet元素长度超过该值则切换skiplist

这里一定要说清楚:不要随意调大这些阈值。表面上看,调大阈值可以让更多数据走紧凑编码、省内存,但压缩列表的写操作是O(n)级别的,而且极端情况下会产生连锁更新(cascade update),一次写入可能导致大量内存拷贝,P99延迟直接起飞。默认值是Redis作者经过反复测试选出来的平衡点,绝大多数场景建议保持默认。

非要调,有个建议:如果你确定某个Hash的字段数必定在1000以内、且字段值都是短字符串,可以考虑把hash-max-ziplist-entries适当提高。但前提是你能承受极端情况下的写延迟。线上环境至少压测一周再上。

5. 聊几个容易忽略的关键机制

5.1 编码升级的单向性

前面提到过编码升级是单向的。这个特性带来的实际影响比大多数人意识到的更大。

举例,一个Hash key原本字段少,走ziplist,后来业务迭代不断加字段,超过512,升级为hashtable。之后某次大促结束,大量字段被删除,字段数回到200,但编码依然是hashtable。内存并不会降回来。

做容量规划时如果忽略了这一点,很容易被“数据量明明在减少但内存没降”的现象误导。Redis的内存不是实时紧凑的,除了编码不可逆,还有碎片率问题。所以如果业务有明显的周期性涨跌,要么定期做内存碎片整理,要么提前预留20%-30%的内存缓冲。

5.2 ziplist的“连锁更新”问题

理解了ziplist的物理布局才能理解这个坑。ziplist是一块连续内存,每个节点用“前一个节点的长度”来定位上一个节点的起始位置。如果前一个节点被修改后变长了,后一个节点里记录的“前节点长度”字段就得跟着扩容,放不下了就触发内存重分配,进而影响再后面一个节点……就像多米诺骨牌,这就是连锁更新。

这个问题在纯ziplist时代确实存在,极端情况下会造成明显延迟。Redis后来的解法是分两步:先用quicklist把大ziplist切成小段,限制单次连锁更新的影响范围;再在7.0之后引入listpack彻底替换ziplist——listpack每个节点记录的是自己这一段的长度,不再依赖前一个节点,从根上杜绝了连锁更新。

这也是我说“内部编码会随版本演进”的原因。做技术选型时,如果你把Redis当黑盒使用,这些版本差异可能永远遇不到;但一旦你写运维脚本或者做底层调优,就必须知道自己的Redis版本对应哪套编码实现。

5.3 序列化方式对编码的影响

顺着前面缓存场景往下说。现在很多框架(比如Spring Data Redis)默认用JDK序列化或Jackson序列化,序列化后的字节流通常比原始数据大很多。如果你的对象序列化后有几百字节,String类型必然走raw,Hash的字段值也可能超过64字节,编码直接跳到非紧凑状态。

这里有个实际操作经验:能用二进制协议就用二进制协议,能手工序列化尽量手工序列化。比如用Protocol Buffers序列化代替JSON,同样的业务对象体积能缩小一半以上,间接让更多key留在紧凑编码的阈值内。这不是Redis的问题,是接入层的选择问题。

另一个常见问题是不同语言客户端写入的数据格式不统一,导致同一个key在不同时间点被写入不同的编码。比如先用Python存了一个短字符串,后来用Java存了一个长对象到同一个key,编码从embstr直接跳raw,之前的内存优化全部失效。多语言团队必须约法三章,同一个key的数据格式必须全局一致。

6. 面试与架构选型中的“送分题”

之所以这个标题能在热搜词里跟“redis面试题”挂钩,是因为内部编码几乎是Redis面试的必考方向。面试官问“String的底层实现是什么”听的不是你背出“SDS”三个字母,而是想看你有没有深入到内存布局和设计取舍。

几个常见的追问方向:

第一,为什么Redis用SDS而不是C语言原生字符串?因为SDS存了长度信息,取长度是O(1);SDS有空间预分配和惰性释放,减少内存重分配次数;SDS是二进制安全的,能存\0以内的任意字节。这三个点,分别对应效率、性能和通用性。

第二,为什么Hash用ziplist存小对象、用hashtable存大对象?因为小对象追求内存紧凑,大对象追求读写效率。ziplist的存储密度接近数组,但修改代价高;hashtable每个节点都有指针开销,但读写稳定。

第三,为什么Sorted Set底层用跳表而不是红黑树?这个问题要从实现复杂度、范围查询、内存占用三个维度回答。跳表实现简单、调试容易,做范围查询只需要沿着一层索引往下走;红黑树的平衡调整逻辑复杂,工程实现容易出bug。Redis用跳表是在可维护性和性能之间做的权衡。

第四,为什么有了快照持久化还要有AOF?这个问题跟编码无关,但面试官喜欢把它跟“Redis为什么快”放在一起问。回答要点是RDB恢复快但可能丢数据,AOF丢数据少但恢复慢,两者结合才能在性能和可靠性之间取得平衡。

这些“送分题”的背后逻辑都是同一个:理解设计权衡,而不是背答案。内部编码就是Redis整个设计哲学里,关于“空间与时间权衡”最直观的体现。

7. 我的实操体会与踩坑记录

文章最后,分享几个我实际踩过的坑。这些经验可能不是教科书里有的,但都是真金白银换来的。

第一个坑:编码切换导致的突刺延迟。前几年维护一个排行榜服务,Sorted Set数据量到临界值时,底层从ziplist切换成skiplist,那一次切换带来了毫秒级的阻塞。如果Redis实例的QPS很高,这种切换可能会在某一瞬间拖垮整体延迟。现在我对这类临界数据会做提前预估,在业务低峰期主动预热,让切换发生在流量低谷。

第二个坑:DEBUG OBJECT在生产环境不能用。我见过有人用scan遍历所有key调DEBUG OBJECT做全网内存分析,结果Redis直接卡住,报警电话被打爆。后面我把分析方案改成:抽样 + 离线分析,而不是全量扫描。想了解编码分布,用随机的sample就足够估算了,别追求100%准确。

第三个坑:依赖默认编码做业务逻辑。有人写代码时判断“如果我Key比较小,就用ziplist,速度快”——这是个误区。内部编码是Redis自动管理的,你无法直接指定一个key用哪种编码,只能通过调整阈值参数间接影响。过度依赖某个特定编码做设计,一旦Redis版本升级、编码实现变化,你的设计假设就崩了。把内部编码当作性能优化的参考,而不是业务正确性的依赖。

最后一个提醒。很多人学Redis有一个误区,认为“记住五种类型和常用命令就算精通了”。实际上,Redis的深度恰恰藏在门面背后——内部编码、持久化策略、内存淘汰机制、集群通信协议,这些才是决定它在高并发生产环境下稳定运行的关键。而内部编码,是理解这一切的第一块基石。

从数据类型看到内部编码,从内部编码想到内存布局,从内存布局推演出容量规划,这条链路走通了,你再看Redis的很多事情都会清晰得多。

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

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

立即咨询