redisObject

redisObject 源码分析(对象系统,把编码串起来)

redisObject 解决的问题:Redis 对外只有 String/List/Set/ZSet/Hash 等几种类型,对内每种类型却有多套底层结构(省内存的紧凑结构 + 保复杂度的专用结构)。
robj 是包在所有 value 外面的统一外壳,用 type 记对外类型、encoding 记当前底层实现,让同一类型能随数据规模自动换编码,而命令层不感知。
类比 Java:type 是接口(List),encoding 是实现类(ArrayList/LinkedList),robj 就是那个多态引用,只是切换发生在运行时、按数据量自动。

注意版本:网上教程和 7.x 源码里 redisObject 定义在 server.h,refcountint
本地仓是 unstable(8.4 开发线),结构体已挪进独立的 object.h,refcount 压成 23 位位域,腾出的位给了新字段 iskvobj/metabits——为了把 key 和过期时间直接内嵌到对象里。
讲 7.x 经典版没问题,但要知道新版已经长这样。

代码块C · 51 行收起展开
// 基于本地 Redis 仓 (unstable, 8.4 开发线), src/server.h + src/object.h(7.x 时代这些定义都在 server.h)

// ---- src/server.h:type,TYPE 命令看到的就是它 ----
#define OBJ_STRING 0    /* String object. */
#define OBJ_LIST 1      /* List object. */
#define OBJ_SET 2       /* Set object. */
#define OBJ_ZSET 3      /* Sorted set object. */
#define OBJ_HASH 4      /* Hash object. */
#define OBJ_MODULE 5    /* Module object. */
#define OBJ_STREAM 6    /* Stream object. */

#define OBJ_SHARED_INTEGERS 10000   // 共享整数池的上界:0~9999

// ---- src/object.h:encoding,OBJECT ENCODING 命令看到的就是它 ----
#define OBJ_ENCODING_RAW 0     /* Raw representation */
#define OBJ_ENCODING_INT 1     /* Encoded as integer */             // 值直接塞 ptr 字段,零额外分配
#define OBJ_ENCODING_HT 2      /* Encoded as hash table */
#define OBJ_ENCODING_ZIPMAP 3  /* No longer used: old hash encoding. */
#define OBJ_ENCODING_LINKEDLIST 4 /* No longer used: old list encoding. */   // 废弃编码不复用编号,保 RDB 兼容
#define OBJ_ENCODING_ZIPLIST 5 /* No longer used: old list/hash/zset encoding. */  // 7.0 起被 listpack 全面取代
#define OBJ_ENCODING_INTSET 6  /* Encoded as intset */
#define OBJ_ENCODING_SKIPLIST 7  /* Encoded as skiplist */          // 实为 skiplist+dict 双结构
#define OBJ_ENCODING_EMBSTR 8  /* Embedded sds string encoding */
#define OBJ_ENCODING_QUICKLIST 9 /* Encoded as linked list of listpacks */
#define OBJ_ENCODING_STREAM 10 /* Encoded as a radix tree of listpacks */
#define OBJ_ENCODING_LISTPACK 11 /* Encoded as a listpack */
#define OBJ_ENCODING_LISTPACK_EX 12 /* Encoded as listpack, extended with metadata */  // 带 field TTL 的 hash 用

#define LRU_BITS 24

#define OBJ_REFCOUNT_BITS 23
#define OBJ_SHARED_REFCOUNT ((1 << OBJ_REFCOUNT_BITS) - 1) /* Global object never destroyed. */  // 共享对象的"永生"标记
#define OBJ_STATIC_REFCOUNT ((1 << OBJ_REFCOUNT_BITS) - 2) /* Object allocated in the stack. */
#define OBJ_FIRST_SPECIAL_REFCOUNT OBJ_STATIC_REFCOUNT

struct redisObject {
    unsigned type:4;                        // 对外类型
    unsigned encoding:4;                    // 对内实现,同一 type 可换多种 encoding
    unsigned refcount : OBJ_REFCOUNT_BITS;  // 7.x 是 32 位 int,压成 23 位为下面两个新字段腾位
    unsigned iskvobj : 1;   /* 1 if this struct serves as a kvobj base */   // 8.x 新增:key 内嵌在本对象尾部,省 dictEntry 的指针跳转

    /* metabits and lru are Relevant only when iskvobj is set: */
    unsigned metabits :8;  /* Bitmap of metadata (+expiry) attached to this kvobj */  // 过期时间等元数据也内嵌(放在对象前面)
    unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
                            * LFU data (least significant 8 bits frequency
                            * and most significant 16 bits access time). */   // 一字段两用,按 maxmemory-policy 解释
    void *ptr;              // 指向真正的底层结构 sds/quicklist/dict/intset/...;INT 编码时存的是值本身
};

/* robj - General purpose redis object */
typedef struct redisObject robj;

type 与 encoding 的对应关系(重点对应关系,一张表理清;箭头方向即超阈值后的转换方向):

  • OBJ_STRING:INT(≤20 位十进制整数)/ EMBSTR(≤44 字节)/ RAW(长串)
  • OBJ_LIST:LISTPACK → QUICKLIST
  • OBJ_SET:INTSET(全整数)→ LISTPACK(出现非整数但仍少)→ HT
  • OBJ_ZSET:LISTPACK → SKIPLIST
  • OBJ_HASH:LISTPACK / LISTPACK_EX → HT

集合类的转换阈值由 *-max-listpack-entries / *-max-listpack-value 等配置控制,转换代码在各类型自己的命令文件里(如 t_hash.c 的 hashTypeConvert),object.c 只负责字符串的编码压缩。转换全部单向,不会因为元素变少而缩回去。

// 基于本地 Redis 仓 (unstable, 8.4 开发线), src/object.c —— 对象创建
robj *createObject(int type, void *ptr) {
    robj *o = zmalloc(sizeof(*o));
    o->type = type;
    o->encoding = OBJ_ENCODING_RAW;
    o->ptr = ptr;
    o->refcount = 1;
    o->lru = 0;         // LRU/LFU 不在这里初始化,对象真正进 db 时才由 initObjectLRUOrLFU 补
    o->iskvobj = 0;
    o->metabits = 0;
    return o;
}

robj *createRawStringObject(const char *ptr, size_t len) {
    return createObject(OBJ_STRING, sdsnewlen(ptr,len));    // RAW:robj 和 SDS 各自 malloc,共两次分配、两次 free
}

robj *createEmbeddedStringObject(const char *val_ptr, size_t val_len) {
    /* Calculate size for embedded value (always SDS_TYPE_8) */
    size_t val_sds_size = sdsReqSize(val_len, SDS_TYPE_8);

    /* Allocate object memory */
    size_t bufsize = 0;
    robj *o = zmalloc_usable(sizeof(robj) + val_sds_size, &bufsize);    // EMBSTR:robj+SDS 连体,一次 malloc
    o->type = OBJ_STRING;
    o->encoding = OBJ_ENCODING_EMBSTR;
    o->refcount = 1;
    o->lru = 0;
    o->metabits = 0;
    o->iskvobj = 0;

    /* The memory after the struct where we embedded data. */
    char *data = (char *)(o + 1);       // 字符串紧跟在结构体后面,头和数据大概率同一 cache line

    size_t remaining_size = bufsize - (data - (char *)(void *)o);
    o->ptr = sdsnewplacement(data, remaining_size, SDS_TYPE_8, val_ptr, val_len);   // 就地构造 SDS:没法单独扩容,所以 embstr 天生只读
    return o;
}

/* The current limit of 44 is chosen so that the biggest string object
 * we allocate as EMBSTR will still fit into the 64 byte arena of jemalloc. */
#define OBJ_ENCODING_EMBSTR_SIZE_LIMIT 44   // 44 = 64(jemalloc 最小 size class) - 16(robj头) - 3(sdshdr8头) - 1('\0')
robj *createStringObject(const char *ptr, size_t len) {
    if (len <= OBJ_ENCODING_EMBSTR_SIZE_LIMIT)
        return createEmbeddedStringObject(ptr,len);
    else
        return createRawStringObject(ptr,len);
}

#define LL2STROBJ_AUTO 0       /* automatically create the optimal string object */
// ...
robj *createStringObjectFromLongLongWithOptions(long long value, int flag) {
    robj *o;

    if (value >= 0 && value < OBJ_SHARED_INTEGERS && flag == LL2STROBJ_AUTO) {
        o = shared.integers[value];     // 0~9999 启动时预建好全局复用;共享对象计数不可变,这里连 refcount 都不用动
    } else {
        if ((value >= LONG_MIN && value <= LONG_MAX) && flag != LL2STROBJ_NO_INT_ENC) {
            o = createObject(OBJ_STRING, NULL);
            o->encoding = OBJ_ENCODING_INT;
            o->ptr = (void*)((long)value);      // 整数值伪装成指针存进 ptr,字符串本体的分配彻底省掉
        } else {
            char buf[LONG_STR_SIZE];
            int len = ll2string(buf, sizeof(buf), value);
            o = createStringObject(buf, len);   // 超出 long 范围(如 LLONG_MAX 在 32 位下)退回按字符串存
        }
    }
    return o;
}
// 基于本地 Redis 仓 (unstable, 8.4 开发线), src/object.c —— 编码压缩与引用计数
robj *tryObjectEncodingEx(robj *o, int try_trim) {
    long value;
    sds s = o->ptr;
    size_t len;

    serverAssertWithInfo(NULL,o,o->type == OBJ_STRING);     // 只处理字符串;集合类的编码转换归各类型命令管

    if (!sdsEncodedObject(o)) return o;     // 已是 INT 编码,没得再压

     if (o->refcount > 1) return o;         // 坑:被共享的对象不能原地改编码,别处还拿着旧指针/旧编码的假设

    /* Check if we can represent this string as a long integer.
     * Note that we are sure that a string larger than 20 chars is not
     * representable as a 32 nor 64 bit integer. */
    len = sdslen(s);
    if (len <= 20 && string2l(s,len,&value)) {
        /* This object is encodable as a long. */
        if (o->encoding == OBJ_ENCODING_RAW) {
            sdsfree(o->ptr);
            o->encoding = OBJ_ENCODING_INT;     // RAW 可原地改:释放 SDS,ptr 换存值
            o->ptr = (void*) value;
            return o;
        } else if (o->encoding == OBJ_ENCODING_EMBSTR) {
            decrRefCount(o);        // embstr 与 robj 连体,无法只释放字符串部分,只能整个换新对象
            return createStringObjectFromLongLongForValue(value);   // 走共享整数/INT;LRU/LFU 淘汰策略下会避开共享对象
        }
    }

    if (len <= OBJ_ENCODING_EMBSTR_SIZE_LIMIT) {
        robj *emb;

        if (o->encoding == OBJ_ENCODING_EMBSTR) return o;
        emb = createEmbeddedStringObject(s,sdslen(s));      // 短 RAW 串转 embstr(客户端解析大参数时可能先建成 RAW)
        decrRefCount(o);
        return emb;
    }

    if (try_trim)
        trimStringObjectIfNeeded(o, 0);     // 压不动了,至少把 SDS 尾部超 10% 的预留空间还给分配器

    /* Return the original object. */
    return o;
}

robj *tryObjectEncoding(robj *o) {
    return tryObjectEncodingEx(o, 1);
}

robj *makeObjectShared(robj *o) {
    serverAssert(o->refcount == 1);
    o->refcount = OBJ_SHARED_REFCOUNT;      // 打上"永生"标记后 incr/decr 都不再碰它,跨线程访问无需加锁
    return o;
}

void incrRefCount(robj *o) {
    if (o->refcount < OBJ_FIRST_SPECIAL_REFCOUNT - 1) {
        o->refcount++;      // 命令在主线程串行执行,普通 ++ 就够,不需要原子操作
    } else {
        if (o->refcount == OBJ_SHARED_REFCOUNT) {
            /* Nothing to do: this refcount is immutable. */
        } else if (o->refcount == OBJ_STATIC_REFCOUNT) {
            serverPanic("You tried to retain an object allocated in the stack");
        } else {
            serverPanic("You tried to retain an object with maximum refcount");     // 23 位上限的防御检查,7.x int 版没有
        }
    }
}

void decrRefCount(robj *o) {
    if (o->refcount == OBJ_SHARED_REFCOUNT)
        return; /* Nothing to do: this refcount is immutable. */

    if (unlikely(o->refcount <= 0)) {
        serverPanic("illegal decrRefCount for object with: type %u, encoding %u, refcount %d",
            o->type, o->encoding, o->refcount);
    }

    if (--(o->refcount) == 0) {
        void *alloc = o;

        if (o->iskvobj) {
            /* eval real allocation pointer */
            alloc = kvobjGetAllocPtr(o);    // kvobj 的元数据前缀在对象地址之前,free 的起点要往前挪
            // ...
        }

        if (o->ptr != NULL) {
            switch(o->type) {
            case OBJ_STRING: freeStringObject(o); break;    // 按 type 分发析构,C 里手写的"虚函数表"
            case OBJ_LIST: freeListObject(o); break;
            case OBJ_SET: freeSetObject(o); break;
            case OBJ_ZSET: freeZsetObject(o); break;
            case OBJ_HASH: freeHashObject(o); break;
            case OBJ_MODULE: freeModuleObject(o); break;
            case OBJ_STREAM: freeStreamObject(o); break;
            default: serverPanic("Unknown object type"); break;
            }
        }
        zfree(alloc);   // free 的是 alloc 不是 o:连元数据前缀一起还
    }
}

原理串讲

SET count 123 走一遍完整链路。协议解析阶段,参数就已经被包成 robj:networking 层对每个参数调 createStringObject,“123” 只有 3 字节,走 createEmbeddedStringObject,robj 头和 SDS 一次 zmalloc_usable 分配在连续 64 字节内。
到了 t_string.c 的 setGenericCommand,存库前先调 tryObjectEncoding 压缩 value:sdsEncodedObject 确认还是字符串形态,refcount > 1 确认没被共享,然后 string2l 发现 “123” 能解析成 long。
因为当前是 embstr(连体块没法只换字符串部分),走 decrRefCount 整个丢弃,转手调 createStringObjectFromLongLongForValue:123 落在 OBJ_SHARED_INTEGERS(10000)以内,直接返回启动时预建的 shared.integers[123],一个字节的新内存都不分配。
最后对象进 db,8.x 里还会经 kvobjSet 把 key 字符串内嵌到 value 对象尾部,连 dictEntry 里单独的 key 指针都省了。
读路径同理:GET 拿到 robj 后按 encoding 分发,INT 编码临时 ll2string 转回字符串输出(getDecodedObject)。

第一处为什么:EMBSTR 的分界为什么恰好是 44?jemalloc 按 size class 分配,64 字节是常用的小对象档位。
robj 头 16 字节 + sdshdr8 头 3 字节 + 结尾 \0 1 字节 = 20 字节开销,64 - 20 = 44。
也就是说 44 这个数字是”倒推”出来的:目标是让最大的 embstr 对象恰好塞满一个 64 字节 arena,一次分配、一次释放、一条 cache line 读完。
超过就退回 RAW 两段式,因为 RAW 的 SDS 可以独立 realloc,APPEND/SETRANGE 才有原地扩容的余地。

第二处为什么:tryObjectEncodingEx 开头为什么见到 refcount > 1 就放弃?改编码是”析构旧结构 + 换 ptr + 改 encoding”的原地手术,而引用计数大于 1 意味着这个对象同时活在别处(比如另一个 key、或某个正在执行的命令栈上),那边还按旧编码解释 ptr
原地一改,别处立刻悬空。同理,makeObjectShared 把共享对象的 refcount 钉死在 OBJ_SHARED_REFCOUNT,incrRefCount/decrRefCount 见到这个值直接跳过读写。
计数永不变化,就没有数据竞争,I/O 线程、模块线程访问共享小整数都不用锁,这是单线程模型之外少数需要考虑并发的点,Redis 用”不可变”而非”加锁”解决。

第三处为什么:转换为什么单向?元素从 200 掉回 10,Hash 也不会从 HT 缩回 listpack。
判断”该不该缩”每次写操作都要做,而阈值附近的抖动会导致 listpack 与 HT 之间反复整体重建,代价远超过紧凑结构省下的那点内存。
单向转换等于把复杂度上界一次性买断,缩回去的收益等 key 被删除重建时自然兑现。

设计取舍

  • 位域抠到极致:type/encoding/refcount/iskvobj/metabits/lru 全部挤进 8 字节,加 ptr 恰好 16 字节头。上亿 key 的场景下,头部每省 1 字节都是百 MB 级收益。
  • embstr 只读:任何修改(APPEND/SETRANGE/GETSET 后再改)都先转 RAW 再动手,改完哪怕又小于 44 也不会转回 embstr。
  • 共享整数与淘汰策略冲突:lru 字段存在对象里,共享对象只有一份 lru,没法为每个 key 单独记热度。
    所以 maxmemory-policy 配了 LRU/LFU 时,createStringObjectFromLongLongForValue 会放弃共享、给每个 value 单独建 INT 对象。
  • OBJECT ENCODING key 可以直接观察本文全部结论,OBJECT REFCOUNT/OBJECT IDLETIME 分别看计数和空转时间,动手验证比背表可靠。
  • 8.x 的 kvobj 改造(iskvobj/metabits)把 key 和过期时间内嵌进 value 对象,砍掉 dictEntry 的指针跳转和独立 expires 字典的开销,是近年对象系统最大的一次结构变更,聊到 7.x 经典版之余可以当加分项。

延伸阅读