第 4 章

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 字节(不含指针所指向的数据)

关键点typeencodinglru 三个字段共享一个 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 字节时,robjsdshdr8(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 创建后,robjsdshdr8 紧密相邻,无法像 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 字节时,robjsdshdr 分别分配,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)

配置参数:

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;

为何两个结构同时维护?

跳表结构(简化):

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;
}

计数器的近似含义:

衰减机制:每次访问时,根据距上次访问的分钟数,将计数器减去 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 所有字符串操作的底层基础。

本章评分
4.5  / 5  (83 评分)

💬 留言讨论