刚开始学 Redis 的时候,记的是五种数据类型,SET 存字符串、LPUSH 存列表、ZADD 存排行榜,命令能用起来就算过了。第一次敲 OBJECT ENCODING,会看到几个不认识的返回值:
shell
127.0.0.1:6379> SET name tom
OK
127.0.0.1:6379> OBJECT ENCODING name
"embstr"
127.0.0.1:6379> SADD tags redis mysql
(integer) 2
127.0.0.1:6379> OBJECT ENCODING tags
"listpack"
embstr 和 listpack 这两个名字,五种类型里都没有,命令文档里也基本不出现。它们回答的是另一个问题,这份数据在内存里到底是怎么摆的。
五种类型
先把五种类型过一遍。这里的"类型"是给使用者看的接口,决定这个 key 能用哪些命令。
| 类型 | 常用命令 | 典型场景 |
|---|---|---|
| String | SET GET INCR SETNX |
缓存对象、计数器、分布式锁、Session |
| List | LPUSH RPUSH LPOP LRANGE |
消息队列、最新动态列表 |
| Hash | HSET HGET HINCRBY HGETALL |
存对象、购物车 |
| Set | SADD SISMEMBER SINTER SUNION |
去重、标签、共同好友、抽奖 |
| ZSet | ZADD ZRANGE ZRANK ZINCRBY |
排行榜、延时队列 |
几个不那么显然的点:
- String 的
INCR是原子的,计数器就靠它。分布式锁一般写SET key val NX EX 10而不是SETNX再EXPIRE,后者两条命令之间如果进程挂了,锁就永远不解开了 - List 做队列是
LPUSH进、BRPOP出,BRPOP可以一次传多个 key,按顺序找第一个有数据的,相当于优先级。但 List 没有 ACK,元素弹出去就没了,可靠性不够,真要当 MQ 得看 Stream - Hash 相比把整个对象序列化成 String 存,好处是能只改一个字段。String 存对象要读出来、改完再整个写回去,Hash 一条
HSET就够了 - Set 的价值在
SINTER、SUNION这些集合运算,比如两个人关注的共同好友 - ZSet 按 score 排序,把 score 设成时间戳,
ZRANGEBYSCORE就是延时队列
除了这五种,还有 Bitmap、HyperLogLog、GEO、Stream 这几个常被叫做"扩展类型"的东西。但严格说它们不都是新类型,Bitmap 和 HyperLogLog 的 TYPE 返回的是 string,GEO 返回的是 zset,底层直接复用了 String 和 ZSet,只是命令帮你把数据按位或者按坐标解释了一遍。真正新加的只有 Stream,5.0 才引入,TYPE 返回 stream。这几个放在后面 Bitmap、HyperLogLog 和 GEO 怎么选?和 Redis Stream 入门里单独讲。
类型、编码、底层结构
这三个词经常被混着说,其实是三层东西:
- 类型 (type):逻辑上的接口。
TYPE key返回它,只有stringlisthashsetzsetstream六种。 - 编码 (encoding):存储方式。
OBJECT ENCODING key返回它,同一个类型下会看到好几种。 - 底层结构 (data structure):编码对应的具体实现。
listpack对应 listpack,skiplist对应跳表加哈希表,hashtable对应dict。
一个类型可以对应多个编码,一个底层结构也可以被多个类型共用。跳表被 ZSet 用,dict 被 Hash 和 Set 用,SDS 被 String用,也被 Bitmap、HyperLogLog 借去当字节数组。
TYPE告诉我们这个 key 能干什么,至于它在内存里长什么样,得看OBJECT ENCODING。ZADD出来的 ZSet,元素少的时候底层根本没有跳表,就是一块 listpack 数组,跳表要等元素多到一定程度才会出现。
redisObject
同样一个 key,value 不是一个裸的指针,而是外面包了一层 redisObject:
c
typedef struct redisObject {
unsigned type:4; // 逻辑类型,TYPE 返回的就是它
unsigned encoding:4; // 物理编码,决定 ptr 指向什么
unsigned lru:LRU_BITS; // LRU 时间或 LFU 计数,24 bit
int refcount; // 引用计数
void *ptr; // 指向真正的底层结构
} robj;
type 和 encoding 各占 4 个 bit,最多表示 16 个值,塞下六种类型和十几种编码绰绰有余。ptr 指向什么完全由 encoding 决定,同一个字段,encoding 是 hashtable 时指向一个 dict,是 listpack 时指向一块 listpack 内存。
有一种情况例外:String 的 int 编码不去分配 SDS,直接把整数值本身塞进 ptr,写出来是 (void*)(long)value。指针本来就是 8 字节,装个 long long 刚好。
refcount 是做对象共享的。预建 0 到 9999 这 10000 个整数的对象,所有存这个范围内整数的 key 都指向同一份:
shell
127.0.0.1:6379> SET a 100
OK
127.0.0.1:6379> OBJECT REFCOUNT a
(integer) 2147483647
2147483647 就是 INT_MAX,表示"共享对象,永远不会被释放"。注意这个共享只在 maxmemory-policy 不是 LRU/LFU 的时候才启用,因为 LRU 需要在对象上记访问时间,共享对象记不了。
编码对照
下面这张表是现代版本(7.2 之后)的情况,同一份代码在老版本上看到的编码名会不一样,版本差异放到最后一节。
| 类型 | 编码 | 底层结构 | 什么时候是这种编码 |
|---|---|---|---|
| String | int |
无,整数存在 ptr 里 |
值是整数,且长度不超过 20 字节 |
| String | embstr |
SDS(和 robj 一起分配) | 长度不超过 44 字节的字符串 |
| String | raw |
SDS | 长度超过 44 字节 |
| List | quicklist |
listpack 节点组成的双向链表 | 3.2 之后只有这一种 |
| Hash | listpack |
listpack | 字段数 ≤ hash-max-listpack-entries( 默认 128 ) 且每个 field、value ≤ 64 字节 |
| Hash | hashtable |
dict | 上面任一条件被打破 |
| Set | intset |
整数集合 | 全是整数,且元素数 ≤ set-max-intset-entries( 默认 512 ) |
| Set | listpack |
listpack | 用不了 intset,但元素数 ≤ 128 且每个元素 ≤ 64 字节 |
| Set | hashtable |
dict | 上面任一条件被打破 |
| ZSet | listpack |
listpack | 元素数 ≤ 128 且每个 member ≤ 64 字节 |
| ZSet | skiplist |
跳表 + dict | 上面任一条件被打破 |
| Stream | stream |
rax + listpack | 固定,没有别的编码 |
Set 是三种编码里唯一有一条完整升级链的,选编码的顺序是 intset → listpack → hashtable:先看能不能用 intset(全是整数、不超过 512 个),用不了就退一步试 listpack(不超过 128 个、每个不超过 64 字节),两个条件都过不去才落到 hashtable。所以一个存了 600 个连续整数的 Set,在 7.2 之后不是 hashtable,而是 listpack。
那张表里 listpack 出现的次数最多,它是 Redis 里最通用的紧凑结构,Hash、ZSet、Set 的小对象,还有 List 的每个节点,用的都是它。它的具体设计在 List 那篇里讲,这里只要知道它是一块连续的、不存指针的内存。
观察编码切换
编码不是定死的,同一个 key 随着数据变多、变大,编码会在运行时切过去。用 redis-cli 一组一组看最直观。
String 的三种编码:
shell
127.0.0.1:6379> SET counter 100
OK
127.0.0.1:6379> OBJECT ENCODING counter
"int"
127.0.0.1:6379> SET greeting "hello redis"
OK
127.0.0.1:6379> OBJECT ENCODING greeting
"embstr"
127.0.0.1:6379> SET long "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
OK
127.0.0.1:6379> OBJECT ENCODING long
"raw"
还有一个细节,embstr 是只读的。往一个 embstr 上 APPEND,哪怕只加一个字节,也会变成 raw:
shell
127.0.0.1:6379> APPEND greeting "!"
(integer) 12
127.0.0.1:6379> OBJECT ENCODING greeting
"raw"
原因是 embstr 把 robj 和 SDS 连着分配在同一块内存里,一次分配搞定,代价是它被标成只读。只读对象没法原地改,所以任何修改操作都会先把整块内存重新分配成可变的 raw,再改。
Hash 从 listpack 掉到 hashtable,只要有一个 field 或 value 超过 64 字节就够:
shell
127.0.0.1:6379> HSET user:1 name tom age 18
(integer) 2
127.0.0.1:6379> OBJECT ENCODING user:1
"listpack"
127.0.0.1:6379> HSET user:1 bio "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
(integer) 1
127.0.0.1:6379> OBJECT ENCODING user:1
"hashtable"
Set 有三种编码,一路升上去:
shell
127.0.0.1:6379> SADD ids 1 2 3
(integer) 3
127.0.0.1:6379> OBJECT ENCODING ids
"intset"
127.0.0.1:6379> SADD ids tom
(integer) 1
127.0.0.1:6379> OBJECT ENCODING ids
"listpack"
ZSet 同理,一个超长的 member 就能把它推下去:
shell
127.0.0.1:6379> ZADD rank 90 tom 85 jerry
(integer) 2
127.0.0.1:6379> OBJECT ENCODING rank
"listpack"
127.0.0.1:6379> ZADD rank 60 "aaaa...(一个超过 64 字节的 member)"
(integer) 1
127.0.0.1:6379> OBJECT ENCODING rank
"skiplist"
List 因为没有紧凑和分散两种形态,OBJECT ENCODING 永远只返回 quicklist:
shell
127.0.0.1:6379> RPUSH queue a b c
(integer) 3
127.0.0.1:6379> OBJECT ENCODING queue
"quicklist"
想看更细的(比如 quicklist 分了几个节点、序列化后多大),用 DEBUG OBJECT:
shell
127.0.0.1:6379> DEBUG OBJECT rank
Value at:0x7f... refcount:1 encoding:skiplist serializedlength:31 lru:0 lru_seconds_idle:0
DEBUG OBJECT 是调试命令,线上一般禁掉,看编码用 OBJECT ENCODING 就够了,它本身是 O(1),没有遍历。
编码转换是单向的
上面这些转换只会往"更宽松"的方向走,listpack 掉到 hashtable,intset 掉到 listpack,都回不去。
把数据删回去,编码也不会退回来:
shell
127.0.0.1:6379> HSET user:1 bio "短的"
(integer) 0
127.0.0.1:6379> OBJECT ENCODING user:1
"hashtable"
原因是转换的检查只在写入路径上做。HSET 进来的时候,Redis 看一眼"这个字段长度超没超 64 字节""字段数有没有超过 128",超了就调用一次 hashTypeConvert 换成 hashtable。删字段走的是另一条路径,那条路上没有"要不要换回去"的判断。
这个设计是有意的。紧凑编码和哈希表之间的转换要重新分配内存、搬数据,成本不低;而且真实场景里数据只会越攒越多,来回抖动的概率很小,加反向检查反而多一份开销。
临界值附近的抖动
正因为转换是一次性的,数据刚好卡在临界值上就会有问题。hash-max-listpack-entries 默认 128,如果一个 Hash 稳定在 129、130 个字段,它就一直停在 hashtable 上,明明是接近小对象的数据量,却要付哈希表的内存开销。
这种情况要么把阈值调大,要么把对象拆成多个 key。反过来,阈值调得太大也不行,listpack 是 O(n) 查找,字段多了单次 HGET 会变慢。默认的 128 / 64 是走了很多年测出来的折中,没特殊需求别动。
和版本有关的坑
编码最坑的地方是它跟版本绑得很紧,同一份数据、同一段代码,换个 Redis 版本,OBJECT ENCODING 看到的就不一样。
ziplist 被 listpack 取代。 6.2 及之前,Hash、ZSet 的小对象编码叫 ziplist,List 的 quicklist 节点也是 ziplist。7.0 之后全部换成 listpack。原因是 ziplist 有个连锁更新(cascade update)的问题,改一个元素可能导致后面一连串元素都要搬动,listpack 从设计上避免了它,这部分在 List 那篇细讲。
配置项跟着改名。 编码名一改,控制它的配置也改了名:
text
6.2 及之前 7.0 之后
hash-max-ziplist-entries → hash-max-listpack-entries
hash-max-ziplist-value → hash-max-listpack-value
zset-max-ziplist-entries → zset-max-listpack-entries
zset-max-ziplist-value → zset-max-listpack-value
list-max-ziplist-size → list-max-listpack-size
从 6.x 升到 7.x 的时候,老配置文件里写的是旧名字,Redis 7 会直接报"这个参数不认识",但不会拦住启动,结果是这些配置静默失效、全部走默认值。升级时配置文件里搜一遍 ziplist 是必须的动作。
Set 的 listpack 是 7.2 才有的。 7.2 之前,Set 只有 intset 和 hashtable 两种编码,一旦有非整数元素就直接是 hashtable,没有中间的紧凑形态。7.2 加了 listpack 之后才有了 intset → listpack → hashtable 这条链,对应配置 set-max-listpack-entries 和 set-max-listpack-value。
所以看别人的文章或者排查线上问题时,先确认版本。OBJECT ENCODING 返回 ziplist 说明是 6.2 或更早,返回 listpack 说明是 7.0 以后,Set 返回 listpack 说明是 7.2 以后,光看编码名就能大致判断出版本。