redisObject 对象系统:12种编码与内存布局
第四章:redisObject 对象系统:12 种编码与内存布局
4.1 为什么需要 redisObject
Redis 不同于传统数据库,它在内存中直接操作数据结构。为了管理不同类型的值(字符串、列表、哈希等),Redis 设计了统一的对象表示层:redisObject(简称 robj)。
每一个 Redis 值都被包装在 robj 中,无论是 SET key hello 中的 "hello",还是 RPUSH list 1 2 3 中的每个元素。理解 robj 是理解 Redis 内存行为、编码选择和性能特征的基础。
4.2 robj 结构体完整解析
来源文件:src/server.h
typedef struct redisObject {
unsigned type:4; // 对象类型(OBJ_STRING / OBJ_LIST / OBJ_SET / OBJ_ZSET / OBJ_HASH)
unsigned encoding:4; // 底层编码实现(12 种)
unsigned lru:LRU_BITS; // LRU_BITS = 24;存储 LRU 时间戳或 LFU 计数器
int refcount; // 引用计数(共享对象时 > 1)
void *ptr; // 指向实际数据的指针
} robj;
4.2.1 字段宽度与内存布局
偏移量 字段 大小
0 type 4 bits
4 encoding 4 bits
8 lru 24 bits
─────────────────────────────
共 4 字节(第一个 uint32)
4 refcount 4 字节(int)
8 ptr 8 字节(64位系统指针)
─────────────────────────────
总计: 16 字节(不含指针所指向的数据)
关键点:type、encoding、lru 三个字段共享一个 32-bit 无符号整数,使用 C 的位域(bit-field)语法。这个技巧在 64 位系统上节省了 4 字节(否则三个字段至少占 12 字节)。
实际内存验证(DEBUG OBJECT key):
SET mykey "hello"
DEBUG OBJECT mykey
# 输出:Value at:0x7f8c2a0b1340 refcount:1 encoding:embstr serializedlength:5 lru:16234567 lru_seconds_idle:0 type:string
4.2.2 type 字段(4 bits)
#define OBJ_STRING 0 /* 字符串类型 */
#define OBJ_LIST 1 /* 列表类型 */
#define OBJ_SET 2 /* 集合类型 */
#define OBJ_ZSET 3 /* 有序集合类型 */
#define OBJ_HASH 4 /* 哈希表类型 */
/* 5-7 为模块类型保留 */
#define OBJ_STREAM 6 /* Stream 类型(Redis 5.0) */
这就是 TYPE key 命令的返回值来源。
4.2.3 encoding 字段(4 bits)—— 12 种编码
#define OBJ_ENCODING_RAW 0 // 原始 SDS 字符串(> 44 字节)
#define OBJ_ENCODING_INT 1 // 整数(long 类型直接存在 ptr 中)
#define OBJ_ENCODING_HT 2 // 哈希表(dict)
#define OBJ_ENCODING_ZIPMAP 3 // 旧版 zipmap(已废弃,不再使用)
#define OBJ_ENCODING_LINKEDLIST 4 // 旧版双向链表(已废弃,Redis 7.0 移除)
#define OBJ_ENCODING_ZIPLIST 5 // 紧凑列表(Redis 7.0 后被 listpack 替代)
#define OBJ_ENCODING_INTSET 6 // 整数集合
#define OBJ_ENCODING_SKIPLIST 7 // 跳表 + 字典(ZSet 大数据量)
#define OBJ_ENCODING_EMBSTR 8 // 嵌入式 SDS(<= 44 字节,连续内存)
#define OBJ_ENCODING_QUICKLIST 9 // 快速列表(listpack 节点的双向链表)
#define OBJ_ENCODING_STREAM 10 // Stream 对象(radix tree + listpack)
#define OBJ_ENCODING_LISTPACK 11 // listpack(小 List/Hash/ZSet)
命令验证:
SET intkey 100
OBJECT ENCODING intkey # → "int"
SET shortkey "hello"
OBJECT ENCODING shortkey # → "embstr"
SET longkey "this-is-a-string-that-exceeds-44-bytes-and-becomes-raw"
OBJECT ENCODING longkey # → "raw"
HSET myhash f1 v1
OBJECT ENCODING myhash # → "listpack"(小 hash)
HSET myhash f2 v2 # ... 添加更多字段直到超过阈值
OBJECT ENCODING myhash # → "hashtable"(大 hash)
4.3 12 种编码详细解析
4.3.1 INT 编码:整数直接嵌入
当 String 类型的值可以用 long 表示时(即 LLONG_MIN 到 LLONG_MAX 范围内的整数),Redis 不分配 SDS,而是将整数直接存在 ptr 指针的位置:
/* 整数直接存储在 ptr 字段(ptr 本身是 8 字节,可以放下 long) */
o->encoding = OBJ_ENCODING_INT;
o->ptr = (void *)((long)value); // 整数值转为指针
共享对象池:对于 0-9999 的小整数,Redis 预先创建并共享对象(server.h 中的 shared.integers[10000]):
/* object.c */
void createSharedObjects(void) {
for (j = 0; j < OBJ_SHARED_INTEGERS; j++) {
shared.integers[j] = makeObjectShared(
createObject(OBJ_STRING, (void*)(long)j)
);
}
}
当 SET key 42 时,Redis 不创建新对象,而是增加 shared.integers[42] 的 refcount。
为何只共享 0-9999:共享更大范围(如 0-100000)需要更多初始化时间和内存,而 0-9999 覆盖了绝大多数实际场景(计数器、ID、状态码)。超出范围时创建新的 INT 对象,代价仅为 16 字节(robj 本身,不含 SDS)。
# 验证共享对象
SET a 100
SET b 100
DEBUG OBJECT a # refcount:2147483647(INT_MAX,表示共享对象)
DEBUG OBJECT b # refcount:2147483647
4.3.2 EMBSTR 编码:嵌入式字符串(<= 44 字节)
EMBSTR 是 Redis 最精妙的内存优化之一。当字符串长度 <= 44 字节时,robj 和 sdshdr8(SDS 头)连续分配在同一块内存中:
内存布局(连续分配,一次 malloc):
┌──────────────────────────────────────────────────────────────┐
│ robj(16 字节) │ sdshdr8(3 字节) │ buf(字符串内容)│
│ type:4 │ len: uint8_t │ 'h','e','l','l', │
│ encoding:4 │ alloc: uint8_t │ 'o','\0' │
│ lru:24 │ flags: uint8_t │ │
│ refcount: 4 │ │ │
│ ptr: 8 ──────────┼──────────────────→ │ │
└──────────────────────────────────────────────────────────────┘
↑ ↑
jemalloc 分配单元:64 字节(刚好放下 robj + sdshdr8 + 44字节字符串 + \0)
44 字节阈值的来源:
jemalloc 分配单元:64 字节
- robj 大小:16 字节
- sdshdr8 头大小:3 字节(len + alloc + flags 各 1 字节)
- 字符串终止符 \0:1 字节
- 可用于字符串内容:64 - 16 - 3 - 1 = 44 字节
EMBSTR 为何设计为只读:embstr 创建后,robj 和 sdshdr8 紧密相邻,无法像 raw SDS 那样原地扩展(sdsMakeRoomFor 需要 realloc,可能移动内存地址)。因此 Redis 将 EMBSTR 视为不可变对象:任何修改操作(如 APPEND)都会先将其转换为 RAW 编码:
/* object.c: embstrEncoding 修改时的转换 */
if (object->encoding == OBJ_ENCODING_EMBSTR) {
/* embstr 不支持原地修改,转为 raw */
robj *decoded = getDecodedObject(object);
/* decoded 现在是 RAW 编码 */
// ... 执行修改操作
}
SET mykey "hello"
OBJECT ENCODING mykey # → embstr
APPEND mykey " world" # 触发 embstr → raw 转换
OBJECT ENCODING mykey # → raw(即使结果仍 <= 44 字节!)
4.3.3 RAW 编码:分离分配的 SDS
当字符串 > 44 字节时,robj 和 sdshdr 分别分配,ptr 字段存储 SDS 的地址:
robj(16 字节,独立 malloc) sdshdr32(大字符串时)
┌────────────────────────┐ ┌──────────────────────────────┐
│ type: OBJ_STRING │ │ len: uint32_t (4字节) │
│ encoding: RAW │ │ alloc: uint32_t (4字节) │
│ lru: ... │ ptr ──→ │ flags: uint8_t (1字节) │
│ refcount: 1 │ │ buf[]: 实际字符串内容 │
│ ptr: ─────────────────→│ └──────────────────────────────┘
└────────────────────────┘
4.3.4 LISTPACK 编码:紧凑内存序列
Listpack 是 Redis 7.0 引入的、替代 ziplist 的数据结构,用于存储小型 List/Hash/ZSet。
listpack 格式(二进制布局):
┌─────────────┬──────────────────────────────────────────────────┬─────┐
│ Total Bytes │ Num Elements │ Entry 0 │ Entry 1 │ ... │ Entry N │ 0xFF│
│ (4 bytes) │ (2 bytes) │ │ │ │ │ End │
└─────────────┴──────────────────────────────────────────────────┴─────┘
每个 Entry 格式:
┌─────────────────────────────────────────────────────┐
│ Encoding+Data (var len) │ Backlen (var len, 1-5 bytes)│
└─────────────────────────────────────────────────────┘
与 ziplist 的区别:listpack 的 Entry 不存储"前一个 entry 的长度"(ziplist 的 prevlen 字段),消除了连锁更新(cascade update)问题。当 ziplist 中一个 entry 长度变化时,可能导致后续所有 entry 的 prevlen 字段连锁更新,最坏 O(n) 复杂度,listpack 彻底解决了此问题。
4.3.5 QUICKLIST 编码:listpack 节点的双向链表
Quicklist(大型 List 的存储方式):
┌──────────┐ ┌──────────┐ ┌──────────┐
│ listpack │ ←── │ qlnode │ ──→ │ listpack │
│(128 元素)│ │(prev/next│ │(128 元素)│
└──────────┘ │ count │ └──────────┘
│ encoding)│
└──────────┘
↕
quicklist
(head/tail
len/count)
配置参数:
list-max-listpack-size 128:每个 listpack 节点最多存储 128 个元素(正数表示元素数量限制)list-max-listpack-size -2:每个节点最大 8KB(负数表示字节大小限制:-1=4KB, -2=8KB, -3=16KB, -4=32KB, -5=64KB)
4.3.6 HT 编码:哈希表
当 Hash 超过 hash-max-listpack-entries(默认 128)或任意 field/value 长度超过 hash-max-listpack-value(默认 64 字节)时,编码从 listpack 升级为 HT(哈希表)。
Redis 的哈希表实现(dict.c)使用链地址法(separate chaining)处理哈希冲突,并使用渐进式 rehash(incremental rehash)避免扩容时的性能抖动:
typedef struct dict {
dictType *type;
dictht ht[2]; // 两张哈希表(rehash 时同时使用)
long rehashidx; // -1 = 未在 rehash;>= 0 = 当前正在迁移的 bucket 索引
unsigned pauserehash; // > 0 时暂停 rehash
// ...
} dict;
渐进式 rehash 过程:
状态:ht[0](旧表)有 1000 个 bucket,负载因子 > 1,触发扩容
扩容:分配 ht[1](新表,2048 个 bucket)
rehashidx = 0
每次 dict 操作时(HGET/HSET/HDEL):
1. 将 ht[0].table[rehashidx] 这个 bucket 的所有元素迁移到 ht[1]
2. rehashidx++
3. 正常执行用户命令
查找时:先查 ht[0],再查 ht[1](确保查到迁移中的数据)
写入时:直接写到 ht[1](新表)
rehashidx == ht[0].size 时:rehash 完成,ht[0] = ht[1],ht[1] = empty
4.3.7 SKIPLIST 编码:跳表 + 字典
ZSet(有序集合)在元素超过 zset-max-listpack-entries(默认 128)或任意成员长度超过 zset-max-listpack-value(默认 64 字节)时,从 listpack 升级为 SKIPLIST 编码。
SKIPLIST 编码实际上包含两个数据结构:
typedef struct zset {
dict *dict; // 哈希表:member → score 映射,O(1) 查分
zskiplist *zsl; // 跳表:按 score 排序,O(log N) 范围查询
} zset;
为何两个结构同时维护?
ZSCORE key member:只需 dict 查表,O(1)ZRANGE key 0 -1:只需跳表范围遍历,O(log N + M)- 写操作时两者同步更新
跳表结构(简化):
Level 3: ───────────────────────────────────────── 50 ──────→ tail
Level 2: ─────────── 10 ─────────── 30 ──────────── 50 ──────→ tail
Level 1: ─────── 5 ─── 10 ─── 20 ─── 30 ─── 40 ─── 50 ──────→ tail
Level 0: head ─ 5 ─ 7 ─ 10 ─ 15 ─ 20 ─ 25 ─ 30 ─ 35 ─ 40 ─ 45 ─ 50 → tail
插入时随机生成节点层数(期望 O(log N) 层),查找时从最高层开始,逐层下降,平均时间复杂度 O(log N)。
4.3.8 INTSET 编码:整数集合
当 Set(集合)的所有成员都是整数,且元素数量 <= set-max-intset-entries(默认 512)时,使用 INTSET 编码:
typedef struct intset {
uint32_t encoding; // 编码类型:INTSET_ENC_INT16/32/64
uint32_t length; // 元素数量
int8_t contents[]; // 有序整数数组(实际类型由 encoding 决定)
} intset;
INTSET 是有序整数数组,使用二分查找,O(log N) 查找时间。升级规则:插入大于 INT32_MAX 的整数时,从 INT32 升级到 INT64(需要复制整个数组)。
4.4 编码升降级触发条件
4.4.1 String 编码转换
整数(可以用 long 表示) → INT
长度 <= 44 字节的字符串 → EMBSTR
长度 > 44 字节的字符串 → RAW
EMBSTR 执行任意修改操作后 → RAW(不可降回 EMBSTR)
4.4.2 Hash 编码转换
初始状态(所有字段满足条件) → LISTPACK
超过以下任一条件时升级为 HT:
- 元素数 > hash-max-listpack-entries(默认 128)
- 任意 key 或 value 长度 > hash-max-listpack-value(默认 64 字节)
注意:HT 不会降级回 LISTPACK(即使删除元素后满足条件)
# 演示编码升级
HSET myhash field1 "short-value"
OBJECT ENCODING myhash # → listpack
# 添加超过 64 字节的 value
HSET myhash field2 "this-is-a-very-long-value-that-exceeds-the-64-byte-threshold-for-listpack-encoding"
OBJECT ENCODING myhash # → hashtable
# 删除长 value
HDEL myhash field2
OBJECT ENCODING myhash # → hashtable(不降级!)
4.4.3 List 编码转换
初始状态 → LISTPACK
超过以下任一条件时升级为 QUICKLIST:
- 元素数 > list-max-listpack-size(默认 128)
- 任意元素长度 > list-max-listpack-size * -1(字节模式)
4.4.4 Set 编码转换
所有元素为整数且数量 <= set-max-intset-entries(512) → INTSET
存在非整数元素 或 数量超过阈值 → HT
INTSET 可以升级为 HT,不能降级
4.4.5 ZSet 编码转换
初始状态(满足条件) → LISTPACK
超过以下任一条件时升级为 SKIPLIST:
- 元素数 > zset-max-listpack-entries(默认 128)
- 任意 member 长度 > zset-max-listpack-value(默认 64 字节)
4.5 LRU/LFU 24bit 复用机制
robj 中的 lru 字段(24 bits)根据淘汰策略(maxmemory-policy)有不同含义:
4.5.1 LRU 模式
/* 存储最近一次访问的秒级时间戳(低 24 位) */
#define LRU_CLOCK_MAX ((1<<24)-1) /* 最大值 16777215,约 194 天 */
#define LRU_CLOCK_RESOLUTION 1000 /* 精度:1000ms = 1秒 */
/* 获取当前 LRU 时钟值 */
unsigned int getLRUClock(void) {
return (mstime()/LRU_CLOCK_RESOLUTION) & LRU_CLOCK_MAX;
}
LRU 时钟每 1 秒更新一次(server.lruclock 由 serverCron 更新),24 位能表示约 194 天的时间范围。超过 194 天未访问的 key,其 LRU 时间戳会回绕,但对淘汰策略影响有限(极少访问的 key 一般早已被淘汰)。
淘汰时,Redis 采用采样近似 LRU(而非精确 LRU):从所有 key 中随机采样 maxmemory-samples(默认 5)个,淘汰其中 lru 值最小(最久未访问)的那个。
4.5.2 LFU 模式
/* LFU 模式下,24 bits 分两部分:
高 16 bits = 最后一次访问的分钟时间戳(LDT = Last Decrement Time)
低 8 bits = Morris 计数器(访问频率的对数近似值)
*/
Morris 计数器是一种概率计数器:计数器值越大,增加越难:
uint8_t LFULogIncr(uint8_t counter) {
if (counter == 255) return 255;
double r = (double)rand()/RAND_MAX;
double baseval = counter - LFU_INIT_VAL;
if (baseval < 0) baseval = 0;
double p = 1.0/(baseval*server.lfu_log_factor+1);
if (r < p) counter++;
return counter;
}
计数器的近似含义:
- counter = 1:访问了约 1 次
- counter = 10:访问了约 10 次
- counter = 100:访问了约 10,000 次
- counter = 255(最大值):访问了约 10^7 次以上
衰减机制:每次访问时,根据距上次访问的分钟数,将计数器减去 lfu_decay_time(默认 1 分/分钟),使不常访问的 key 计数器随时间减小。
4.6 refcount 引用计数
/* 引用计数管理 */
void incrRefCount(robj *o) {
if (o->refcount < OBJ_FIRST_SPECIAL_REFCOUNT)
o->refcount++;
}
void decrRefCount(robj *o) {
if (o->refcount == 1) {
freeObject(o); // 引用计数归零,释放内存
} else {
o->refcount--;
}
}
共享对象的特殊 refcount:共享整数对象(shared.integers[0..9999])的 refcount = OBJ_SHARED_REFCOUNT = INT_MAX (2147483647),防止被误释放。
refcount 与内存泄漏:Redis 内部的内存管理完全依赖 refcount。OBJECT REFCOUNT key 命令可以查看某个 key 的 refcount 值(用于调试)。
4.7 内存占用实例计算
计算 SET key "hello" 中值对象的实际内存占用:
字符串 "hello" (5字节,EMBSTR 编码):
robj:
type (4 bits) + encoding (4 bits) + lru (24 bits) = 4 bytes
refcount = 4 bytes
ptr = 8 bytes
小计:16 bytes
sdshdr8:
len (uint8_t) = 1 byte
alloc (uint8_t) = 1 byte
flags (uint8_t) = 1 byte
小计:3 bytes
buf:
'h','e','l','l','o','\0' = 6 bytes
连续分配总计:16 + 3 + 6 = 25 bytes
jemalloc 对齐到 32 bytes(最近的 2 的幂次)
实际 jemalloc 分配:32 bytes
额外开销:key 名本身也是一个 robj(EMBSTR/RAW 编码),加上 dict 中的 dictEntry(约 24 字节)。一个完整的 KV 对(key="mykey", value="hello")实际占用约 128-160 字节(含 dict entry、两个 robj、两个 SDS)。
使用 MEMORY USAGE key 命令获取精确值:
SET mykey "hello"
MEMORY USAGE mykey # 单位:字节
# → 56(包含 key 的 robj + value 的 robj + dict entry 等)
4.8 小结
redisObject 系统的设计体现了 Redis 的核心权衡:在内存效率和操作性能之间动态平衡。12 种编码覆盖了从单个小整数到 TB 级数据集的所有场景,而编码的自动升降级机制对用户完全透明。
理解编码机制的实际价值在于:可以通过调整阈值参数(hash-max-listpack-entries 等)来精确控制内存使用与 CPU 性能的平衡点。对于内存敏感的场景,增大阈值可以让更多数据保持在紧凑编码中,减少内存消耗;对于 CPU 敏感的场景,减小阈值可以更早地升级到哈希表,提高操作速度。
下一章将深入 SDS(简单动态字符串)的实现细节,这是 Redis 所有字符串操作的底层基础。