REDIS 7.2 · OBJECT ENCODING · SDS · LISTPACK · QUICKLIST · INTSET · DICT · SKIPLIST

同一个类型,小时候挤在一格抽屉里,长大了才搬进柜子

你用的是 String、List、Hash、Set、ZSet 五种类型,Redis 底层却有好几种存法(编码)。数据少的时候,全部挤在一块连续内存里,省空间;数据一多,就换成用指针串起来的结构,操作快。OBJECT ENCODING key 能看到当前用的是哪一种。

String字符串
int→不是整数embstr→> 44 字节或被改过raw
List列表
listpack→> 8 KBquicklist
Hash哈希
listpack→> 128 个 或 值 > 64 字节hashtable
Set集合
intset→混进非整数listpack→> 128 个 或 值 > 64 字节hashtable
ZSet有序集合
listpack→> 128 个 或 值 > 64 字节skiplist
紧凑:一块连续内存,省空间,查找要挨个找散开:指针串起来,多占内存,查找快阈值是 Redis 7.2 的默认值,都能在配置里改
外壳redisObject · 16 字节

每个值外面都套一层,记着类型、编码和指向真实数据的指针。

TYPE 和 OBJECT ENCODING 读的就是它
字符串SDS

Redis 自己的字符串,记着长度,还会预留空间。

key 和大多数值都是它
一格抽屉listpack

所有元素挨着放在一块连续内存里,没有指针。

小 List、Hash、Set、ZSet 都用它
柜子hashtable · skiplist

每个元素单独分配,用指针串起来,大了也快。

升级后一般不会再降回去

每个值外面都套一层 redisObject

不管什么类型,Redis 拿到的都是一个 16 字节的外壳。类型和编码分开记,所以同一种类型可以随时换编码,命令那一层不用管。

redisObject 的 16 字节(按位宽画)

lru 字段给内存淘汰用:LRU 策略下存最近访问时间,LFU 策略下存访问频率。0~9999 这些小整数是共享的,整个实例只有一份,refcount 很大。

自己看一下redis-cli
> SET n 100
> TYPE n                 → string
> OBJECT ENCODING n      → "int"
> HSET u name tom age 18
> OBJECT ENCODING u      → "listpack"
> MEMORY USAGE u         → 这个 key 一共占多少字节(含外壳)

String:int、embstr、raw

字符串底层是 SDS(简单动态字符串)。它在头部记着已用长度和分配长度,所以取长度是 O(1),可以存任意二进制数据(中间有 \0 也行),追加时也不会写越界。

embstr:外壳和字符串一次分配,正好 64 字节

16 + 3 + 44 + 1 = 64,正好是内存分配器的一档。所以 44 字节就是 embstr 的上限。embstr 是只读的,一旦 APPEND、SETRANGE 改它,就先转成 raw。

raw:外壳和字符串分两次分配

追加时 SDS 会多分配一些:新长度小于 1 MB 就翻倍,否则多给 1 MB。之后再追加往往不用重新分配。

listpack:一块连续内存,从 ziplist 改进而来

小的 List、Hash、Set、ZSet 都用 listpack。所有元素一个挨一个放在同一块内存里,没有指针,非常省空间;代价是查找要从头挨个看,插入要移动后面的数据。所以只适合小数据。

listpack 的样子
总字节数4 字节 元素个数2 字节 编码name长度元素 1 编码tom长度元素 2 编码age长度元素 3 编码18长度元素 4 0xFF结束

每个元素末尾记着自己有多长,所以能从后往前遍历。Hash 存成“字段、值、字段、值”交替排列;ZSet 存成“成员、分数”交替。

ziplist 的毛病:连锁更新
新插入300 字节 prev 1→5253→257 prev 1→5253→257 prev 1→5…

ziplist 的每个元素记的是前一个元素的长度:前一个小于 254 字节用 1 字节记,否则用 5 字节。在一串 253 字节的元素前面插入一个大元素,后面第一个要变大 4 字节,变成 257,又逼着下一个变大……一路改到底。

listpack 的改法:只记自己的长度
新插入300 字节 不变253 不变253 不变…

每个元素只记自己的长度,插入一个元素不会改动别人。Redis 7.0 起,所有用 ziplist 的地方都换成了 listpack。

大 List 用 quicklist:一串 listpack 用链表连起来
节点 1 · ≤ 8 KB⇄ 节点 2 · 可压缩⇄ 节点 3 · 可压缩⇄ 节点 4 · ≤ 8 KB

每个节点是一个不超过 8 KB 的 listpack(list-max-listpack-size -2)。两头的节点常被访问,中间的节点可以用 LZF 压缩(list-compress-depth,默认 0 不压缩)。LPUSH、RPOP 在两头操作,很快;LINDEX 取中间要一个节点一个节点地走。

编码观察器:往里加东西,看它什么时候换编码

选一种类型,加元素、加长值、混进字符串,看 OBJECT ENCODING 什么时候变。规则按 Redis 7.2 的默认配置。

类型:

        

    相关配置Redis 7.2 默认值;老的 *-ziplist-* 名字仍然能用
    hash-max-listpack-entries 128
    hash-max-listpack-value 64
    zset-max-listpack-entries 128
    zset-max-listpack-value 64
    set-max-intset-entries 512
    set-max-listpack-entries 128       # 7.2 起
    set-max-listpack-value 64          # 7.2 起
    list-max-listpack-size -2          # 负数按字节:-2 = 每个节点 ≤ 8 KB
    list-compress-depth 0

    hashtable:两张表,一点一点搬家

    Redis 的哈希表(dict)用数组 + 链表解决冲突。元素数达到桶数时就扩容。但它不会一口气把所有元素搬到新表,那样大表会卡住主线程;而是同时留着新旧两张表,每次有人读写时顺手搬一个桶,叫渐进式 rehash。整个 Redis 的 key 空间也是这样一张大 dict。

    渐进式 rehash

    搬家期间怎么查

    先查旧表,没有再查新表。新写入的 key 直接放进新表,所以旧表只会越来越空。空闲时,后台定时任务也会每次花 1 毫秒批量搬一些(activerehashing yes)。

    什么时候扩、什么时候缩

    元素数 ≥ 桶数就扩容到下一个 2 的幂。有子进程在做 RDB 或 AOF 重写时,会放宽到比例大很多才扩,避免大量写时复制。填充率低于 10% 时缩容。

    intset 和跳表

    Set 全是整数时用 intset:一个有序的整数数组,用二分查找。ZSet 大了以后用跳表 + 哈希表:哈希表按成员查分数是 O(1),跳表按分数和排名查范围是 O(log n)。

    intset 升级:插入一个更大的数,整个数组换宽度
    int16,每个 2 字节
    15300
    插入 70000 后 int32,每个 4 字节
    1530070000

    70000 超出了 int16 的范围(±32767),所有元素都要扩成 4 字节。升级后不会再降回去。

    跳表:多层索引,一层层往下跳

    底层是按分数排好序的链表,每个节点随机长出几层(每多一层的概率是 1/4,最多 32 层)。查找从最高层开始:下一个比目标小就往右,否则往下一层。

    查找分数:

    每层指针还记着跨过了几个节点(span),沿路加起来就是排名,所以 ZRANK 也是 O(log n)。作者选跳表而不是平衡树,是因为它更好写、范围查询方便、改层数概率就能调内存。

    常用命令有多快

    小数据用紧凑编码时,很多操作其实是 O(n),但 n 不超过 128,内存又连续,实际很快。阈值调得太大,这个 O(n) 就会变慢。

    命令紧凑编码时升级后说明
    GET / SETO(1)先在全局 dict 里找 key
    HGET / HSETO(n),n ≤ 128O(1)listpack 要挨个比字段
    SISMEMBERintset O(log n);listpack O(n)O(1)intset 用二分查找
    ZADDO(n)O(log n)跳表找位置 + 哈希表存分数
    ZSCOREO(n)O(1)升级后直接查哈希表
    ZRANGE / ZRANKO(n)O(log n + m)m 是返回的个数
    LPUSH / RPOPO(1)在两头操作
    LINDEX / LSETO(n)要从头或尾一个个数过去
    HGETALL / SMEMBERSO(n)大集合会卡住主线程,用 HSCAN / SSCAN 分批
    另外几种类型,底下也是它们

    Bitmap 就是 String,按位读写;HyperLogLog 也是 String,最多 12 KB 就能估算上亿个不重复元素;Geo 是 ZSet,分数是经纬度算出的 geohash;Stream 用基数树(rax)+ listpack。

    Redis 版本,以及 Java 这边

    编码随 Redis 版本变过好几次。Java 8 还是 17、Boot 2 还是 3 不影响编码,影响编码的是 Java 这边怎么序列化。

    Redis 6.x 及以前

    ziplist
    • 小 Hash、小 ZSet 用 ziplist;List 用 quicklist,节点是 ziplist
    • 配置项叫 hash-max-ziplist-entries 等
    • 非整数的小 Set 直接用 hashtable

    Redis 7.0 / 7.2

    listpack
    • 7.0:ziplist 全部换成 listpack,只在读老 RDB 时还认识它;配置改名为 *-listpack-*
    • 7.2:小 List 直接用 listpack 编码,变小后还会从 quicklist 降回来
    • 7.2:非整数的小 Set 也用 listpack
    Java 这边Spring 的 RedisTemplate 默认用 JDK 序列化(Boot 2 和 3 都是):key user:1 存进去变成 \xac\xed\x00\x05t\x00\x06user:1,整数 100 变成约 81 字节的二进制,于是本该是 int 的变成 raw,本该是 listpack 的 Hash 因为值超过 64 字节直接变 hashtable。用 StringRedisTemplate,或者给 RedisTemplate 配字符串和 JSON 序列化器。
    没变的:redisObject 外壳SDS小数据用紧凑编码升级看个数和长度渐进式 rehash跳表 + 哈希表

    常踩的坑

    内存突然涨、命令突然慢,常常是编码变了,或者一个 key 太大了。

    redis-cli 里看到 key 前面一串 \xac\xed

    RedisTemplate 用了默认的 JDK 序列化,key 和值都变长,编码也变差。换 StringRedisTemplate 或配置序列化器。

    某个 Hash 的内存突然涨了好几倍

    有一个值超过了 64 字节,整个 Hash 从 listpack 变成 hashtable,而且删掉那个值也不会降回去。控制值的长度,长内容单独存。

    为了省内存把阈值调到上万,CPU 却涨了

    listpack 查找是 O(n),插入要移动后面的数据。阈值调大省了内存,但每次操作变慢。一般在几百以内。

    DEL 一个大 key,Redis 卡住好几秒

    几百万个元素的 Hash、List 要一个个释放。用 UNLINK 在后台删,或者打开 lazyfree-lazy-user-del。

    HGETALL、SMEMBERS、LRANGE 0 -1 把 Redis 卡住

    大集合一次全取,O(n) 都在主线程上。用 HSCAN、SSCAN 分批,或者 LRANGE 只取一段。

    用 LINDEX 当数组随机访问,越来越慢

    quicklist 取中间位置要逐个节点走。需要按下标随机访问的,换成 Hash 或 ZSet。

    整数 Set 里混进一个字符串,内存变大

    intset 一遇到非整数就升级成 listpack 或 hashtable,回不去了。存 ID 时保持纯数字。

    排行榜的 ZSet 占了很多内存

    成员存的是一整段 JSON,跳表和哈希表各存一份引用,节点又多。成员只存 ID,详情放到别处。

    几千万个小 key,内存比数据本身大得多

    每个 key 都有 dict 节点、key 的 SDS、redisObject 这些外壳,各几十字节。可以按 ID 分桶存进 Hash,比如 user:1234 存成 Hash user:12 的字段 34,让小 Hash 走 listpack。

    不知道哪个 key 最占内存

    redis-cli --bigkeys 按元素个数找、--memkeys 按内存找;单个 key 用 MEMORY USAGE 和 OBJECT ENCODING 看。