Redis 深度教程 · 01

五大类型与底层编码

起点把 Redis 归结为两块基石;这一章拆第一块——每种数据类型在内存里到底用什么结构存,以及它为什么会随数据规模悄悄换一种存法。

本章你将建立的 schema

  • 类型是对外的 API 契约,编码是它在内存里的具体实现;两者不是一回事
  • 五大类型各自用哪些编码、在什么阈值从紧凑编码切到通用编码
  • 编码退化的触发条件,以及它在内存与延迟上要付的代价
本章 = 基石一

起点提出 Redis 的两块基石:为每种数据形态精选内存编码、单线程串行执行。本章就是基石一的展开。把「类型 / 编码」这层看穿,后面「大 key 为什么危险」「为什么 HGETALL 一个百万字段的 hash 会卡住整个实例」之类的问题,都会变成同一个机制的推论。

1.1类型之下还有编码

类型是对外承诺的操作集合,编码是底层真正用的内存结构;同一类型会按数据量和数据形态在多种编码间自动切换。

为什么需要它

把「类型」和「编码」分开,Redis 才能在不改变命令语义的前提下,对小数据用极省内存的紧凑结构、对大数据换成查询更快的通用结构。同一个 HSET,背后可能是一段连续内存,也可能是一张哈希表——调用方完全无感。这层间接是后面一切内存与延迟分析的起点。

面试里说的「Redis 有五大数据类型」——String、Hash、List、Set、ZSet——指的是对外暴露的逻辑类型:每种类型承诺一组命令和复杂度(Hash 承诺 O(1) 取字段,ZSet 承诺按分数范围查)。但同一个逻辑类型,底层可以由不同的内存结构实现,这层实现叫 encoding(编码)。OBJECT ENCODING 命令能把它揭出来。

每个键值在内部都包成一个 redisObject(常简称 robj):里面有 type(逻辑类型)、encoding(当前编码)、refcount(引用计数,给共享对象用)、以及指向真实数据的指针。类型由 API 决定,编码由数据当下的规模和形态决定,且只朝一个方向走:从紧凑编码升级到通用编码后,不会因为数据又变小而退回去。

redis-cli · 同一类型,两种编码bash
127.0.0.1:6379> HSET u:1 name alice age 30
(integer) 2
127.0.0.1:6379> OBJECT ENCODING u:1
"listpack"                      # 小 hash:一段连续内存

127.0.0.1:6379> HSET u:1 bio "$(python3 -c 'print("x"*100)')"
(integer) 1
127.0.0.1:6379> OBJECT ENCODING u:1
"hashtable"                     # value 超过 64 字节,已升级成哈希表

127.0.0.1:6379> HDEL u:1 bio
(integer) 1
127.0.0.1:6379> OBJECT ENCODING u:1
"hashtable"                     # 删回去也不退化——升级是单向的
Hash 类型 API 契约 listpack 连续内存 · 省 hashtable 哈希表 · 查得快 阈值触发 >128 或 >64B 默认 越阈值
图 1.1逻辑类型固定,编码随阈值单向切换。注意:箭头只从 listpack 指向 hashtable,没有反向——数据删小后编码不会自动退回。

1.2String:int / embstr / raw

String 是二进制安全的字节序列,但根据内容是否为整数、长度是否过短,分成 int、embstr、raw 三种编码,内存布局各不相同。

为什么需要它

String 是用得最多的类型,也最容易被当成「就是个字符串」。但一个纯整数计数器、一段 20 字节的短文本、一段 1KB 的 JSON,三者在内存里的开销和访问局部性差别很大。理解三种编码,才能解释为什么把整数存成字符串依然省、为什么短 value 比长 value 的相对开销低得多。

底层字符串结构叫 SDS(simple dynamic string,简单动态字符串):在字节数组前面带上已用长度和容量,所以取长度是 O(1),且能安全存任意二进制(不靠 \0 结尾)。三种编码的区别在于「整数特判」和「robj 与 SDS 是否一次分配」:

  • int:value 能解析成 long 时,直接把整数存进 robj 的指针位,不分配额外 SDS。INCR 类操作因此极省。
  • embstr:短字符串(≤ 44 字节,Redis 3.2 之前是 39),把 robj 和 SDS 一次性分配在一块连续内存里,只调一次 malloc、一次 free,CPU cache 友好。
  • raw:长字符串,robj 和 SDS 分两次分配,靠指针相连,分布在堆的不同位置。
int robj 头 long 值 整数直接进指针位,零额外分配 embstr ≤ 44B robj 头 SDS(字节串) 一次 malloc · 连续 raw > 44B robj 头 + 指针 SDS(独立分配) 指针 两次 malloc
图 1.2三种编码的内存布局递增。注意:embstr 与 raw 的真正差别不是长度本身,而是 robj+SDS 是「一次连续分配」还是「两次分散分配」——这决定了访问时的 cache 命中。
共享整数池

Redis 启动时预创建了 0–9999 这一万个整数对象常驻共享。任何键被设成这区间的整数,复用同一个 robj(refcount 累加),不再单独分配。所以海量「值是 0–9999 小整数」的 key 比想象中更省。但开了 maxmemory + LRU/LFU 淘汰策略时共享整数池会被关闭,因为共享对象无法记录独立的访问时间。

版本变化 · 8.2

Redis 8.2 加宽了 robj 结构以容纳更多元数据,连带影响 embstr 的内存账本,使一部分原本算 embstr 的短串更早地落到 raw。具体阈值以所用版本的 OBJECT ENCODING 实测为准——这正是「涉及阈值就实测」原则的典型场景。

1.3Hash:listpack → hashtable

Hash 存字段到值的映射;字段少且值短时用 listpack(一段连续内存里顺序排放),超过阈值后转成真正的哈希表。

为什么需要它

一个只有几个字段的对象(用户的 name/age/city),若直接开一张哈希表,光是桶数组和每个节点的指针开销就远超数据本身。listpack 把所有字段值顺序塞进一块连续内存,省下全部指针,代价是查找退化为顺序扫描——但字段少时扫描比哈希更快,且 cache 友好。这是「小数据用紧凑编码」最典型的体现。

listpack(紧凑列表)是 Redis 7.0 引入、用来替代旧 ziplist 的连续内存结构:一块字节缓冲区,里面顺序排放每个 entry,每个 entry 自带长度信息,可以从两端遍历。它没有哈希表的桶,也没有链表的 next 指针,所以省内存;查找是 O(N) 顺序扫,但 N 小时极快。

切换阈值由两个配置控制,任一越界就升级为 hashtable:

Hash 编码切换
配置项默认值含义
hash-max-listpack-entries128字段数超过即升级
hash-max-listpack-value64任一字段或值的字节长度超过即升级
想一想 · 编码会是什么

一个 hash 里塞了 200 个字段,每个值都很短(比如都是 "1")。此时 OBJECT ENCODING 返回什么?为什么?

展开思路与答案

返回 hashtable。字段数 200 已超过 hash-max-listpack-entries 默认的 128,触发升级——只要 entries 或 value 任一条件越界就转哈希表,与值本身是否短无关。即便随后 HDEL 删到只剩 2 个字段,编码仍是 hashtable,不会退回 listpack。

字段级 TTL · 7.4

Redis 7.4 起 Hash 支持给单个字段设过期(HEXPIRE / HTTL 等)。当一个 listpack 编码的 hash 被赋予字段级 TTL,它会转成专门的 listpackex 编码(在 listpack 基础上为每个字段附带过期时间)。这是「编码不只是大小函数,也随能力需求变化」的一例。

1.4List:quicklist

List 是双端队列;现代实现是 quicklist——一个以 listpack 节点为元素的双向链表,兼顾两端操作快和内存紧凑。

为什么需要它

纯双向链表两端操作是 O(1),但每个元素都要存两个指针,元素一多内存开销巨大且严重碎片化;纯 ziplist 是一整块连续内存,省内存但任何插入都要整体 realloc 并搬数据,大列表写入代价高。quicklist 把两者拼起来——外层链表保证两端增删快,每个节点内部是一段 listpack 保证局部紧凑,取了中间最优解。

早期 List 在「linkedlist(双向链表)」和「ziplist(紧凑列表)」之间切换。Redis 3.2 起统一为 quicklist:它是一个双向链表,但每个链表节点不是单个元素,而是一段 listpack(7.0 前是 ziplist)。于是大列表的内存被切成多个有界的连续块,既不像纯链表那样每元素都背指针,也不像单块 ziplist 那样一次 realloc 整条列表。

每个节点内 listpack 的大小由 list-max-listpack-size 控制,默认 -2。负数是按字节算的档位,-2 表示每个节点上限 8KB(-1=4KB,-3=16KB……);正数则表示按元素个数限制。把单节点控制在几 KB,是在「节点太大则单次 realloc 贵」和「节点太小则退化回链表、指针开销回潮」之间的折中。

命名变迁要记牢

面试高频陷阱:ziplist 在 Redis 7.0 整体改名为 listpack,并修了旧 ziplist 的连锁更新(cascade update)隐患。读 7.0 之前的老资料看到的 ziplist,对应今天的 listpack。quicklist 这个名字没变,但它的节点从 ziplist 换成了 listpack。

1.5Set:intset / listpack / hashtable

Set 是无序去重集合;全是整数且数量不大时用 intset,含少量非整数成员时用 listpack,再大转 hashtable。

为什么需要它

Set 的成员形态差异很大:可能是一批用户 ID(纯整数),也可能是一批标签字符串。Redis 为这两种形态各留了一种紧凑编码——纯整数用 intset(有序整数数组,二分查找),少量字符串用 listpack——只有规模真的上来才付出哈希表的指针开销。这是「按数据形态而非仅按大小精选编码」最清晰的例子。

Set 有三种编码,按形态和规模递进:

  • intset(整数集合):成员全是整数且数量 ≤ set-max-intset-entries(默认 512)时使用。底层是一个升序排列的整数数组,按需用 int16 / int32 / int64 编码,查找走二分(O(logN))。无指针、极紧凑。
  • listpack:Redis 7.2 才为 Set 加入,用于「成员含非整数、但数量小」的情况——数量 ≤ set-max-listpack-entries(默认 128)且每个成员 ≤ set-max-listpack-value(默认 64 字节)。
  • hashtable:超过上述任一阈值时使用,成员作为哈希表的 key(value 为空)。
想一想 · 插入一个非整数后

一个 Set 当前是 intset(里面全是整数,比如 50 个用户 ID)。现在执行 SADD 加入一个字符串成员 "guest"。编码会怎样变?

展开思路与答案

intset 只能存整数,加入非整数成员后它无法继续——编码立刻转换。转成什么取决于版本与规模:在 7.2+ 上,若转换后仍满足 listpack 的数量/长度阈值(≤128 个、成员 ≤64B),会先转成 listpack;若超出则直接转 hashtable。7.2 之前没有 Set 的 listpack 编码,会直接转 hashtable。和前面一样,这个转换是单向的。

1.6ZSet:listpack → skiplist + dict

ZSet 是按分数排序的去重集合;小时用 listpack,大时用 skiplist 与 dict 两个结构并存——一个负责范围/排名,一个负责按成员查分数。

为什么需要它

ZSet 要同时支持两类操作:ZRANGEBYSCORE 这种按分数顺序的范围/排名查询,和 ZSCORE 这种「给成员问分数」的点查。单一结构无法让两类都快——跳表擅长有序范围但点查要 O(logN),哈希表擅长点查但完全无序。于是大 ZSet 同时维护两者,用空间换两种 O(logN)/O(1) 的最优查询。

小 ZSet 用 listpack(成员和分数成对顺序排放),阈值同样是 128 / 64(zset-max-listpack-entries / zset-max-listpack-value)。越界后转为重头戏的双结构:

  • skiplist(跳表):一个按分数有序的多层链表。底层是一条按分数升序的链,上面叠加若干「跳跃层」做索引,查找/插入/删除均摊 O(logN)。它支撑 ZRANGE / ZRANGEBYSCORE / ZRANK 这类范围与排名操作。
  • dict(哈希表):成员 → 分数 的映射,让 ZSCORE 做到 O(1)。

两者共享同一份成员对象(不重复存字符串),只是从两个角度索引同一批数据:一个排好序、一个建好哈希。

ZRANGEBYSCORE · ZRANK ZSCORE member skiplist 跳表 按分数有序 · O(logN) dict 哈希表 member → score 点查 · O(1) 同一份成员对象(共享)
图 1.3大 ZSet 用两个结构索引同一批成员。注意:两条朱红箭头说明两类查询各走各的结构——范围查走跳表、点查走哈希表,互不拖慢,代价是成员被两套索引各引用一次。
为什么用跳表,不用平衡树(红黑树/AVL)

antirez(Redis 作者)给过三条理由:① 实现与调试更简单——跳表靠随机层高维持平衡,没有平衡树繁琐的旋转;② 范围扫描的缓存局部性更好——底层就是一条有序链表,ZRANGE 顺着走即可,平衡树中序遍历要在节点间反复跳;③ 内存可调——通过调节「节点晋升到上一层」的概率 p,能在内存占用和查找速度之间权衡,比固定结构的平衡树更灵活。结论:在 ZSet 这种「范围查询是一等公民」的场景,跳表是工程上更合适的选择,而非性能上唯一可行的选择。

1.7选型小结:选对类型,警惕退化

先按操作需求选逻辑类型,再用阈值意识防止编码在不知不觉中退化成通用结构、把内存和延迟一起拉高。

为什么需要它

类型选错,再多调优也救不回来(拿 String 拼 JSON 当对象用,就丧失了字段级读写和过期)。编码退化则更隐蔽:业务跑着跑着,某个 hash 字段变长、某个 ZSet 成员变多,编码悄悄从 listpack 翻成 hashtable / skiplist,内存翻几倍、单命令延迟上升,监控上却只看到「内存涨了」。把阈值刻进脑子,是把这两类问题挡在设计阶段的唯一办法。

五大类型编码总览(默认阈值,Redis 7.x)
类型紧凑编码通用编码关键阈值(默认)
Stringint / embstrrawembstr ≤ 44 字节
Hashlistpackhashtable128 条 / 值 64 字节
Listquicklist(节点为 listpack)节点 -2 = 8KB
Setintset / listpackhashtableintset 512 / listpack 128·64
ZSetlistpackskiplist + dict128 条 / 值 64 字节

记忆抓手:128 / 64 是 Hash、ZSet、Set-listpack 共用的那对阈值(条数 / 字节);Set 的整数特例阈值是 512;List 按字节走 8KB 一节点。看到「值很大」或「成员很多」,就该警觉编码正在或即将翻成通用结构。

·自测

  1. 类型(type)和编码(encoding)的区别是什么?为什么删掉数据后编码不会退回紧凑结构?
  2. 一个值为 "12345" 的 String,OBJECT ENCODING 返回 int 还是 embstr?把它改成 "12345x" 后又是什么?
  3. ZSet 转成 skiplist 编码后,为什么还要同时维护一个 dict?只用跳表不行吗?
  4. 设计判别:要记录 1000 万用户的「是否在线」(一个布尔位)。用「每个用户一个 String key(值 0/1)」还是「一个 bitmap、用户 ID 作位偏移」?两者差在哪?
展开全部答案

1. 类型是对外的 API 契约(承诺的命令与复杂度),编码是它在内存里的具体实现结构,由数据当前的规模/形态决定。升级是单向的:从紧凑编码转到通用编码后,Redis 不做反向检测与回收(回退要重新扫描判断、且会反复抖动),所以删小后仍保持通用编码。

2. "12345" 能解析成整数,返回 int;改成 "12345x" 后不再是整数,且长度 ≤ 44 字节,返回 embstr。

3. 跳表按分数有序,ZSCORE(给成员问分数)在跳表里是 O(logN) 查找;额外维护的 dict 提供 member→score 的 O(1) 点查。两个结构共享同一份成员对象,分别服务「范围/排名」和「点查」两类操作,缺一类就慢一类。

4. bitmap 完胜。bitmap 用 1 个 bit 表示 1 个用户,1000 万用户约 1.25MB 连续内存,且 SETBIT/GETBIT 是 O(1)、还能用 BITCOUNT 一次数出在线总数。string-per-user 则要 1000 万个独立 key,每个 key 都背一份 robj、SDS、以及全局哈希表里的 dictEntry 开销(每 key 几十字节量级),总内存到几百 MB,且无法一条命令聚合统计。差在「每 key 固定开销 × key 数」这一项。

进阶挑战

估算位图 vs 海量 key 的内存量级差

用户签到场景:要记录 1 亿用户「今天是否签到」。方案 A 用一个 bitmap(SETBIT sign:20260602 <uid> 1);方案 B 给每个签到用户建一个 String key(SET sign:20260602:<uid> 1)。估算两者的内存量级,并说出差距主要来自哪一项。

提示(不给全解)

方案 A:1 亿个 bit ≈ 1e8 / 8 字节,换算到 MB 是多少?(量级在十几 MB)。方案 B 的关键不在「值 1」本身——值小到可以忽略,真正吃内存的是每个 key 的固定开销:robj 头、key 字符串的 SDS、以及该 key 在全局 dict 里的 dictEntry(含指针与可能的 rehash 冗余)。把这份固定开销估成每 key 几十字节,乘以 1 亿,落在什么量级?两个量级一比,差距来自哪一项就一目了然了。