1. 从一次内存暴涨的排查说起:为什么要啃 redisObject
几年前我维护过一套缓存集群,某个业务线突然反馈内存使用率在两天内从 40% 飙到了 85%,但 QPS 几乎没变。当时第一反应是 key 数量暴增,结果DBSIZE一看,key 总量只涨了不到 5%。这就很反直觉了——key 没多几个,内存怎么涨这么快?
后来用MEMORY USAGE逐个采样,才发现问题出在一批新写入的 Hash 结构上:业务方把原本用 String 存的 JSON 拆成了 Hash,字段数不多,但每个字段的值都很短。按理说 Hash 在字段少的时候会用一种极省内存的编码,可这批 key 偏偏走了最占内存的那种。根因就藏在redisObject这个结构体里——它决定了每个 value 用什么底层编码、占多少内存、怎么被回收。
这件事之后我把redisObject的源码从头到尾读了一遍,才发现它是理解 Redis 内存模型的钥匙。你平时调优用的OBJECT ENCODING、MEMORY USAGE、DEBUG OBJECT,背后全是它在支撑。很多人学 Redis 源码一上来就扎进dict、skiplist、quicklist,结果越看越晕,其实应该先把这个只有 16 字节的小结构吃透,后面所有数据结构都是它的"租客"。
这篇内容适合两类人:一类是写过 Redis 但没看过源码、想搞懂"为什么我的内存和预期对不上"的开发者;另一类是想系统学 Redis 源码、但被各种数据结构劝退的人。我会从redisObject的字段逐个拆起,讲清楚每个 bit 为什么这么设计,再结合编码转换、内存回收、共享对象这些实战场景,把"它到底怎么影响你的线上服务"讲透。全程基于 Redis 7.x 的源码结构,但核心逻辑从 3.x 到 7.x 基本没变,老版本读者也能对照看。
提示:本文涉及的源码路径以 Redis 7.x 为准,核心结构体定义在
server.h,编码常量在server.h和object.c中。不同大版本字段顺序可能微调,但语义一致。
2. redisObject 的 16 字节里到底塞了什么
2.1 结构体定义与字段全景
先看源码里最原始的定义(Redis 7.x,server.h):
typedef struct redisObject { unsigned type:4; unsigned encoding:4; unsigned lru:LRU_BITS; /* LRU_BITS = 24 */ int refcount; void *ptr; } robj;就这五个字段,加起来正好 16 字节(在 64 位系统上)。别小看这 16 字节,Redis 里每一个 value 都是一个robj,一个存了 1000 万个 key 的实例,光这些对象头就占 160MB。所以理解它的内存布局,是理解 Redis 内存占用的第一步。
我习惯把这五个字段分成三组来记:
- 元信息组:
type和encoding,回答"这是什么类型、底层怎么存的" - 生命周期组:
refcount和lru,回答"它还被谁引用、多久没被访问了" - 数据指针组:
ptr,指向真正的数据
这个分组方式不是官方文档里的,是我自己读源码时总结的,因为它对应了三个完全不同的关注点:类型系统、内存管理、数据存储。下面逐个拆。
2.2 type 和 encoding:为什么要把"类型"和"编码"分开
这是初学者最容易困惑的地方:为什么一个 value 要同时记录type和encoding?直接记一个不行吗?
答案在于 Redis 的设计哲学——对外暴露的是逻辑类型,对内选择的是物理编码。type是给用户看的,你TYPE key命令返回的就是它,只有五种:OBJ_STRING、OBJ_LIST、OBJ_SET、OBJ_ZSET、OBJ_HASH。而encoding是给 Redis 自己看的,同一个type可以有多种encoding。
举个最典型的例子:一个 List 类型的 key,type永远是OBJ_LIST,但encoding可能是OBJ_ENCODING_LISTPACK(元素少且短)、OBJ_ENCODING_QUICKLIST(元素多)。你写代码时只关心它是 List,但 Redis 内部会根据数据规模自动切换编码,这就是所谓的"编码转换"。
type和encoding各占 4 个 bit,所以理论上各能表示 16 种。目前type用了 5 种,encoding用了 11 种左右,都还有余量。用 bitfield 而不是int的原因很直接——省内存。如果这两个字段各用一个int,光这一项每个对象就多占 4 字节,一亿个对象就是 400MB 的浪费。
| 字段 | 位宽 | 取值范围 | 作用 |
|---|---|---|---|
| type | 4 bit | 0-15,已用 5 种 | 逻辑数据类型,对外可见 |
| encoding | 4 bit | 0-15,已用约 11 种 | 底层物理编码,对内使用 |
| lru | 24 bit | 0-16777215 | LRU 时间戳或 LFU 计数 |
| refcount | 32 bit | 0-2^31-1 | 引用计数 |
| ptr | 64 bit | 指针 | 指向实际数据 |
2.3 lru 字段的 24 bit 玄机:LRU 和 LFU 如何共用一块内存
lru这个字段名字叫 lru,但它其实是个"变形金刚"——在 LRU 策略下它存的是访问时间戳,在 LFU 策略下它存的是访问频率和衰减时间。同一块 24 bit 的内存,两种策略复用,这是 Redis 省内存的又一个巧思。
先说 LRU 模式。24 bit 能表示的最大值是 16777215,Redis 用这个值作为时钟精度,单位是秒。也就是说 LRU 时钟大约每 194 天(16777215 秒)回绕一次。每次访问一个 key,就把当前时钟的低 24 位写进lru。淘汰时算一下"当前时钟 - 对象的 lru",差值越大说明越久没访问。
这里有个细节很多人不知道:LRU 时钟不是精确的秒级时间戳,而是一个全局递增的近似时钟。Redis 有个server.lruclock,默认每 100ms 更新一次(LRU_CLOCK_RESOLUTION),所以 LRU 精度其实是 100ms 级别,不是 1 秒。这个设计是为了避免每次访问都去调time()系统调用,用空间换时间。
再说 LFU 模式。当你配置maxmemory-policy为allkeys-lfu或volatile-lfu时,这 24 bit 被拆成两半:高 16 bit 存访问频率计数器(logistic counter),低 8 bit 存上次衰减的时间。计数器不是简单加一,而是用了一个概率递增算法——访问越频繁,加一的概率越低,这样 8 bit 就能表示很大的频率范围。低 8 bit 的衰减时间单位是分钟,所以 LFU 的衰减精度是分钟级。
注意:LRU 和 LFU 共用
lru字段意味着你不能同时用两种策略,切换maxmemory-policy后已有对象的lru字段语义会变,需要时间"预热"才能准确。线上切换淘汰策略后,别指望立刻生效。
2.4 refcount 与共享对象:为什么 0-9999 的整数能省内存
refcount是引用计数,这个大家应该不陌生。但 Redis 对它的用法有个特殊之处——共享对象池。
Redis 启动时会预先创建 0 到 9999 这 10000 个整数的字符串对象(OBJ_SHARED_INTEGERS),放在一个全局数组里。当你要SET key 100时,Redis 不会新建一个对象,而是直接把ptr指向预建好的那个对象,然后refcount++。这样无论多少个 key 存的是 100,内存里只有一份。
这个优化为什么只对 0-9999 做?因为这是经验值——大部分业务里的小整数集中在这个范围,再往上命中率就急剧下降,预建太多反而浪费内存。而且共享对象有个前提:对象必须是只读的。所以只有整数共享,字符串不共享,因为字符串可能被APPEND修改。
refcount还有几个特殊值需要记住:
refcount > 1:正常共享中refcount == 1:独占,可以被安全修改refcount == 0:即将被释放,实际上很少见到这个中间态
当你对一个共享对象执行写操作时,Redis 会先检查refcount,如果大于 1 就复制一份出来改(写时复制),保证不污染共享对象。这个逻辑在dbAdd、dbOverwrite等函数里反复出现。
2.5 ptr 指针:它指向的东西才是真正的"数据"
ptr是个void*,指向真正的数据。但这里有个关键认知:ptr指向的东西类型取决于encoding。
- 如果
encoding是OBJ_ENCODING_INT,ptr本身就被当成一个整数用(不是指向整数,而是把指针的值直接当整数),这是 Redis 的一个 hack,省了一次内存分配。 - 如果
encoding是OBJ_ENCODING_EMBSTR,ptr指向一块连续内存,robj和字符串数据挨在一起。 - 如果
encoding是OBJ_ENCODING_RAW,ptr指向一个独立的sds。 - 如果是集合类型,
ptr指向dict、listpack、quicklist等具体结构。
所以看ptr不能孤立看,必须结合encoding。这也是为什么我说redisObject是理解 Redis 内存的钥匙——它把"类型、编码、数据"三者绑在一起,任何一层变化都会影响另外两层。
3. 编码转换:一个 key 是怎么"变形"的
3.1 从 OBJECT ENCODING 命令看编码的真实面目
你在 Redis 里敲OBJECT ENCODING key,会返回类似embstr、int、listpack、hashtable这样的字符串。这些就是encoding字段的可读形式。我列一下常见的对应关系:
| encoding 常量 | OBJECT ENCODING 返回 | 典型场景 |
|---|---|---|
| OBJ_ENCODING_INT | int | 整数值的 String |
| OBJ_ENCODING_EMBSTR | embstr | 短字符串(≤44 字节) |
| OBJ_ENCODING_RAW | raw | 长字符串(>44 字节) |
| OBJ_ENCODING_LISTPACK | listpack | 小 Hash、小 ZSet、小 List |
| OBJ_ENCODING_QUICKLIST | quicklist | 大 List |
| OBJ_ENCODING_HT | hashtable | 大 Hash、大 Set |
| OBJ_ENCODING_INTSET | intset | 全整数的 Set |
| OBJ_ENCODING_SKIPLIST | skiplist | 大 ZSet |
| OBJ_ENCODING_STREAM | stream | Stream 类型 |
这张表建议背下来,因为线上排查内存问题时,OBJECT ENCODING是你第一个要看的命令。看到hashtable就要警惕——它比listpack占内存多得多。
3.2 String 的三种编码:int、embstr、raw 的切换边界
String 是最简单的类型,但它有三种编码,切换逻辑很值得研究。
int 编码:当你SET key 12345时,如果值能被解析成long范围内的整数,Redis 直接把ptr当整数存,encoding设为OBJ_ENCODING_INT。这是最省内存的——不需要额外的 sds 分配。
embstr 编码:当字符串长度 ≤ 44 字节时,Redis 用embstr。它的特点是robj和sds在同一块连续内存里,一次malloc搞定。为什么是 44?因为robj占 16 字节,sdshdr8头占 3 字节,加上结尾的\01 字节,16+3+1=20,而 jemalloc 的内存分配档位里,64 字节是常见档,64-20=44。所以 44 这个数字是算出来的,不是拍脑袋定的。
raw 编码:超过 44 字节就用raw,robj和sds分开分配,两次malloc。
这里有个坑:APPEND命令会让 embstr 变成 raw。因为 embstr 是只读的(设计上不允许修改),一旦你要追加内容,Redis 必须先转成 raw 再改。所以如果你有一个短字符串频繁APPEND,它会一直是 raw,享受不到 embstr 的连续内存优势。
127.0.0.1:6379> SET k1 12345 OK 127.0.0.1:6379> OBJECT ENCODING k1 "int" 127.0.0.1:6379> SET k2 "hello" OK 127.0.0.1:6379> OBJECT ENCODING k2 "embstr" 127.0.0.1:6379> APPEND k2 " world" (integer) 11 127.0.0.1:6379> OBJECT ENCODING k2 "raw"3.3 Hash 和 ZSet 的 listpack 阈值:两个参数决定你的内存
Hash 和 ZSet 都有"小数据用 listpack,大数据用 hashtable/skiplist"的机制,触发转换的阈值由两个配置控制:
hash-max-listpack-entries 128 hash-max-listpack-value 64 zset-max-listpack-entries 128 zset-max-listpack-value 64含义是:Hash 的字段数 ≤ 128 且每个字段名和值的长度都 ≤ 64 字节时,用 listpack;任一条件不满足就转 hashtable。ZSet 同理。
这两个参数是线上调优的重灾区。我见过一个业务把hash-max-listpack-entries调到 1000,结果每个 Hash 都很大,listpack 查找是 O(n) 的,读性能直接崩了。也见过有人把它调到 10,导致大量 Hash 提前转 hashtable,内存翻倍。
我的经验值是:如果你的 Hash 字段数稳定在 100 以内、字段值都很短,保持默认 128 没问题;如果字段数经常在 100-200 之间波动,可以考虑调到 256,但要压测读性能。因为 listpack 在字段数接近阈值时,查找开销已经不小了。
还有一个隐蔽的坑:转换是单向的。一旦 Hash 从 listpack 转成 hashtable,即使你删掉字段让它重新变小,也不会转回 listpack。这个设计是为了避免频繁转换带来的性能抖动,但代价是内存不会自动回收。所以如果你有一批 key 曾经很大后来变小了,它们的编码可能还停留在 hashtable。
3.4 Set 的 intset 到 hashtable:什么时候会"退化"
Set 的编码有三种:intset(全整数且元素少)、listpack(Redis 7.2+ 新增,非整数但元素少且短)、hashtable(其他情况)。
intset转hashtable的触发条件有两个:元素数超过set-max-intset-entries(默认 512),或者插入了一个非整数元素。注意第二个条件——只要插入一个字符串,整个 intset 立刻转 hashtable,哪怕其他元素全是整数。
127.0.0.1:6379> SADD s1 1 2 3 (integer) 3 127.0.0.1:6379> OBJECT ENCODING s1 "intset" 127.0.0.1:6379> SADD s1 "abc" (integer) 1 127.0.0.1:6379> OBJECT ENCODING s1 "hashtable"这个"一票否决"机制意味着:如果你的 Set 里混了一个字符串,intset 的内存优势就全没了。所以设计数据模型时,尽量让 Set 保持纯整数,或者干脆用 String 拼接来替代。
3.5 List 的 quicklist 与 listpack:7.x 的变化
List 的编码在 Redis 7.x 有个重要变化。早期版本 List 只有quicklist一种编码(quicklist 内部是多个 ziplist 组成的双向链表)。Redis 7.0 之后,小 List 直接用listpack,大 List 才用quicklist。
触发转换的阈值是list-max-listpack-size(默认 128)。注意这个参数在旧版本叫list-max-ziplist-size,含义也略有不同——旧版本控制的是每个 ziplist 节点的元素数,新版本控制的是整个 List 用不用 listpack。
这个变化对内存的影响是正向的:小 List 现在更省内存了。但如果你从旧版本升级上来,要注意OBJECT ENCODING的返回值可能从quicklist变成listpack,监控脚本里的判断逻辑要跟着改。
4. 内存回收与淘汰:refcount 和 lru 的实战意义
4.1 引用计数什么时候会大于 1
前面说了共享整数对象,但refcount > 1的场景不止这一种。我梳理了几种常见情况:
- 共享整数:0-9999 的整数对象,被多个 key 引用
- 命令参数复用:某些命令内部会临时增加引用计数,避免拷贝
- RDB/AOF 持久化:fork 子进程时,父子进程共享内存页,但这是操作系统层面的 COW,不是
refcount层面的
真正需要你关注的是第一种。如果你发现MEMORY USAGE算出来的总内存比实际used_memory小很多,很可能就是因为大量 key 共享了整数对象,MEMORY USAGE是按单个 key 算的,没算共享节省的部分。
4.2 LRU 淘汰的近似算法:为什么 Redis 不维护完整链表
标准 LRU 需要维护一个双向链表,每次访问都把节点移到头部,淘汰时从尾部删。但 Redis 没这么做,因为维护链表的开销太大——每次读写都要改指针,还要加锁。
Redis 用的是近似 LRU:每个对象记录一个lru时间戳,淘汰时随机采样 N 个 key(maxmemory-samples,默认 5),从中挑lru最老的淘汰。采样数越大越接近真实 LRU,但 CPU 开销也越大。
这个设计的精妙之处在于:它用极小的空间(24 bit)和极小的 CPU 开销,达到了接近真实 LRU 的效果。官方做过测试,采样数为 10 时,近似 LRU 的命中率和真实 LRU 差距已经很小了。
线上调优建议:如果你的缓存命中率对淘汰策略敏感,把maxmemory-samples从 5 调到 10,CPU 开销增加可忽略,但命中率会有可感知的提升。再往上调到 20 收益就递减了。
4.3 LFU 的计数器衰减:8 bit 怎么表示大频率
LFU 模式下的lru字段,高 16 bit 是频率计数器,低 8 bit 是衰减时间。计数器最大 255(8 bit 实际只用了一部分),但访问频率可能上万,怎么表示?
Redis 用了一个概率递增算法:计数器值越大,加一的概率越低。具体公式在lfuIncr函数里,大致是概率 = 1 / (counter * lfu_log_factor + 1)。lfu_log_factor默认 10,可以调。
这个算法的效果是:访问 100 次和访问 1000 次,计数器值可能只差几个数,但已经能区分"热"和"很热"了。低 8 bit 的衰减时间单位是分钟,每次访问时检查是否过了一个衰减周期,过了就把计数器减一。
注意:LFU 的计数器是"只增不减"的(除了衰减),所以一个曾经很热后来变冷的 key,需要经过多次衰减才能被淘汰。如果你的业务有明显的热点切换,LFU 可能不如 LRU 反应快。
4.4 内存碎片与 redisObject 的关系
redisObject的 16 字节大小,和 jemalloc 的内存分配档位是配合设计的。jemalloc 的档位是 8、16、32、48、64、80...,16 字节正好是一个档位,所以一个robj单独分配时不会浪费。
但embstr的设计更巧妙:robj(16) +sdshdr8(3) + 数据 +\0(1),总共 20 + 数据长度。当数据 ≤ 44 时,总大小 ≤ 64,正好落在 jemalloc 的 64 字节档位,不浪费。超过 44 就跳到 80 字节档位,浪费 16 字节。这就是 44 这个阈值的由来。
理解这一点后,你就能明白为什么短字符串用 embstr 比 raw 省内存——不只是少一次malloc,还因为内存对齐更紧凑。线上如果有大量短字符串,确保它们走 embstr 编码,能省下可观的内存。
5. 线上排查实战:用 redisObject 的知识定位三个真实问题
5.1 案例一:Hash 字段值超长导致内存翻倍
回到开头那个内存暴涨的案例。业务方把 JSON 拆成 Hash 后,大部分字段值都很短,但有一个字段是"备注",偶尔会写入很长的文本。一旦某个 Hash 的备注字段超过 64 字节,整个 Hash 就从 listpack 转成 hashtable,内存直接翻几倍。
排查过程:
# 1. 找到内存占用大的 key redis-cli --bigkeys # 2. 看具体编码 redis-cli OBJECT ENCODING user:12345:profile "hashtable" # 3. 看内存占用 redis-cli MEMORY USAGE user:12345:profile (integer) 2048 # 4. 对比一个正常的 redis-cli OBJECT ENCODING user:12346:profile "listpack" redis-cli MEMORY USAGE user:12346:profile (integer) 320同样的字段数,hashtable 版本是 listpack 版本的 6 倍多。解决方案有两个:一是把长文本字段拆出去单独用 String 存,二是调大hash-max-listpack-value。我们选了方案一,因为长文本本来就不该和结构化字段混在一起。
5.2 案例二:Set 混入字符串导致 intset 失效
另一个案例是用户标签系统。原本标签 ID 都是整数,Set 用 intset 存,很省内存。后来产品加了个"自定义标签"功能,允许用户输入文字标签,结果一混入字符串,所有 Set 都转成了 hashtable。
这个问题的隐蔽之处在于:转换是永久的。即使后来把字符串标签删了,Set 也不会转回 intset。所以修复方案不是删数据,而是重建 key——用SUNIONSTORE或SDIFFSTORE把数据导到一个新 key,新 key 会重新判断编码。
# 重建一个 Set,让它重新选择编码 redis-cli SUNIONSTORE user:12345:tags:new user:12345:tags redis-cli RENAME user:12345:tags:new user:12345:tags这个技巧对所有"编码退化"的场景都适用:重建 key 是让 Redis 重新选择编码的唯一可靠方法。
5.3 案例三:LRU 时钟回绕导致的误淘汰
有个客户反馈说,某些 key 明明刚写入不久,却被淘汰了。排查发现是 LRU 时钟回绕问题。
前面说过,lru字段只有 24 bit,大约 194 天回绕一次。如果 Redis 实例运行超过 194 天,server.lruclock会回绕到 0,而老对象的lru值可能还很大。计算"当前时钟 - 对象 lru"时,如果没处理回绕,会得到一个巨大的正数,导致这个对象被误判为"最久未访问"而优先淘汰。
Redis 源码里其实处理了这个问题(estimateObjectIdleTime函数里有回绕判断),但如果你自己写监控脚本去读lru字段算空闲时间,就很容易踩这个坑。我的建议是:不要自己解析lru字段,用OBJECT IDLETIME命令,它内部处理了回绕。
redis-cli OBJECT IDLETIME user:12345:profile (integer) 3600这个命令返回的是秒数,比你自己算靠谱得多。
5.4 排查工具箱:几个必会的命令
把上面案例里用到的命令整理成一张表,方便你排查时对照:
| 命令 | 作用 | 注意事项 |
|---|---|---|
| OBJECT ENCODING key | 查看编码 | 最常用,先看这个 |
| OBJECT REFCOUNT key | 查看引用计数 | 共享整数会返回 >1 |
| OBJECT IDLETIME key | 查看空闲时间 | 内部处理了 LRU 回绕 |
| MEMORY USAGE key | 查看内存占用 | 不含共享对象节省的部分 |
| DEBUG OBJECT key | 查看详细信息 | 生产环境慎用,有性能开销 |
| redis-cli --bigkeys | 扫描大 key | 会阻塞,建议从从库跑 |
提示:
DEBUG OBJECT在 Redis 7.x 里被标记为不稳定命令,生产环境尽量用OBJECT系列替代。如果一定要用,在从库上跑,别在主库上跑。
6. 从 redisObject 延伸出去的几个源码学习建议
6.1 先读 object.c 再读 t_*.c
很多人学 Redis 源码的顺序是错的——一上来就读t_hash.c、t_zset.c,结果被各种编码转换逻辑绕晕。我的建议是先读object.c,它只有几百行,但把redisObject的创建、释放、编码转换、引用计数全讲清楚了。
读完object.c,你会发现t_hash.c里的hashTypeSet、hashTypeConvert这些函数,本质上就是在操作robj的encoding字段。有了这层认知,再看具体类型的实现就顺了。
6.2 用 gdb 打印一个真实的 robj
光看源码不够直观,我建议你用 gdb attach 到一个测试实例上,打印一个真实的robj看看:
gdb -p $(pidof redis-server) (gdb) p *(robj*)0x7f1234567890 $1 = {type = 0, encoding = 8, lru = 12345678, refcount = 1, ptr = 0x7f12345678a0}type = 0是OBJ_STRING,encoding = 8是OBJ_ENCODING_EMBSTR。对照源码里的常量定义,你就能把抽象的数字和具体的编码对应起来。这种"看到真实内存"的体验,比读十遍源码都管用。
6.3 关注编码转换的触发点
学redisObject的最终目的,是能预判"什么操作会触发编码转换"。我整理了几个高频触发点:
APPEND让 embstr 变 rawSETRANGE让 int 变 raw- Hash 字段值超长让 listpack 变 hashtable
- Set 插入非整数让 intset 变 hashtable
- List 元素超长让 listpack 变 quicklist
把这些触发点记在脑子里,写业务代码时就能主动规避。比如你知道APPEND会破坏 embstr,就不会用APPEND去拼短字符串,而是用SET整体覆盖。
6.4 一个容易被忽略的细节:robj 的对齐
最后分享一个我在读源码时发现的细节。redisObject的字段顺序是type、encoding、lru、refcount、ptr,这个顺序不是随便排的。
type和encoding各 4 bit,合起来 8 bit,正好一个字节。lru24 bit,加上前面的 8 bit,正好 32 bit,4 字节对齐。refcount32 bit,ptr64 bit。整个结构体在 64 位系统上是 16 字节,8 字节对齐,没有任何 padding 浪费。
如果字段顺序换一下,比如把ptr放前面,就会产生 padding,结构体可能变成 24 字节。所以这个字段顺序是精心设计过的,目的是让robj尽可能小。这种对内存的极致抠门,正是 Redis 能在单机撑起百万级 QPS 的底层原因之一。
我自己在实际项目里踩过最深的坑,就是低估了编码转换对内存的影响。一个看似无害的字段值变长,可能让整个 key 的内存占用翻好几倍。后来我养成了一个习惯:任何新上的缓存结构,上线前先用OBJECT ENCODING和MEMORY USAGE验证一遍编码和内存,确认符合预期再放量。这个习惯帮我避免了好几次线上内存告警。如果你也在维护 Redis 集群,建议把这个检查加进你的上线流程里。