Redis `robj` (Redis Object):统一对象结构与引用计数

各位观众,晚上好!欢迎来到今天的“Redis探秘之对象戏法”讲座。今天咱们要聊的是Redis世界里的一个关键角色——robj,也就是Redis Object。别看它名字平平无奇,它可是Redis能玩转各种数据类型的幕后英雄,同时也是内存管理大师。

咱们先来想个问题:Redis为啥能存字符串、列表、集合、哈希表等等各种类型的数据?难道它内部针对每种类型都实现一套存储和操作逻辑?那也太累了吧!答案当然是No!Redis聪明的地方在于,它使用robj这个统一的对象结构,把各种类型的数据都包装起来,这样就可以用一套通用的机制来管理它们。

1. robj:万物皆对象

robj的定义在redis.h文件中,咱们先来看看它的庐山真面目(简化版):

typedef struct redisObject {
    unsigned type:4;  // 对象类型
    unsigned encoding:4; // 对象编码
    unsigned lru:REDIS_LRU_BITS; // LRU 时间(用于内存淘汰)
    int refcount; // 引用计数
    void *ptr; // 指向底层数据结构的指针
} robj;
  • type (对象类型):这个字段指明了robj所代表的数据类型,比如REDIS_STRING(字符串)、REDIS_LIST(列表)、REDIS_SET(集合)、REDIS_HASH(哈希表)、REDIS_ZSET(有序集合)。Redis根据这个类型来决定如何处理这个对象。

  • encoding (对象编码):同一个类型的数据,在底层可能采用不同的编码方式来存储,以提高效率。比如,字符串可以用RAW(简单动态字符串,SDS)编码,也可以用INT(整数)编码。列表可以用ziplist(压缩列表)编码,也可以用linkedlist(双向链表)编码。encoding字段就是用来标识底层编码方式的。

  • lru (LRU时间):这个字段记录了对象最后一次被访问的时间,用于LRU(Least Recently Used)算法,在内存不足时淘汰最近最少使用的对象。

  • refcount (引用计数):这个字段是robj实现内存管理的关键。它记录了有多少地方引用了这个对象。当refcount为0时,表示这个对象不再被使用,可以被释放。

  • ptr (指针):这个指针指向实际存储数据的底层数据结构。根据typeencoding的不同,ptr可能指向SDS、整数、压缩列表、跳跃表等等。

来个表格总结一下:

字段 含义 取值示例
type 对象类型 REDIS_STRING, REDIS_LIST, REDIS_SET, REDIS_HASH, REDIS_ZSET
encoding 对象编码 (底层数据结构) REDIS_ENCODING_RAW, REDIS_ENCODING_INT, REDIS_ENCODING_ZIPLIST, REDIS_ENCODING_LINKEDLIST, REDIS_ENCODING_HT, REDIS_ENCODING_SKIPLIST
lru LRU时间 (用于内存淘汰) 时间戳
refcount 引用计数 整数
ptr 指向底层数据结构的指针,根据typeencoding不同指向不同结构 sds, long, ziplist, list, dict, zskiplist

2. 对象类型与编码:千变万化

咱们来详细看看Redis支持的几种对象类型,以及它们可以使用的编码方式。

2.1 字符串对象 (String)

字符串对象是Redis中最基本的数据类型。它可以存储文本数据,也可以存储二进制数据。

  • type: REDIS_STRING
  • encoding:
    • REDIS_ENCODING_INT: 如果字符串对象的内容可以表示为一个整数,Redis会使用INT编码来存储,将整数直接保存在robjptr字段中。这种方式非常节省内存。
    • REDIS_ENCODING_RAW: 如果字符串对象的内容无法表示为一个整数,Redis会使用RAW编码来存储,将字符串保存在一个SDS(Simple Dynamic String,简单动态字符串)中,robjptr字段指向这个SDS。
    • REDIS_ENCODING_EMBSTR: 这是一种优化的RAW编码。当字符串长度小于等于REDIS_ENCODING_EMBSTR_SIZE_LIMIT(通常是39字节)时,Redis会使用EMBSTR编码,将robj对象头和SDS对象连续地存储在同一块内存中,这样可以减少内存分配的次数,提高效率。

编码转换:

  • 如果对一个INT编码的字符串对象执行了某些命令,使得它不再能表示为一个整数,那么它会被转换为RAW编码。
  • 当创建一个新的字符串对象,且字符串长度小于等于REDIS_ENCODING_EMBSTR_SIZE_LIMIT时,会优先使用EMBSTR编码。

举个栗子:

// 创建一个整数类型的字符串对象
robj *createStringObjectFromLongLong(long long value) {
    robj *o = zmalloc(sizeof(robj));
    o->type = REDIS_STRING;
    o->encoding = REDIS_ENCODING_INT;
    o->ptr = (void*)((long)value); // 直接将整数值存储在 ptr 中
    o->refcount = 1;
    return o;
}

// 创建一个 SDS 类型的字符串对象
robj *createStringObject(const char *s, size_t len) {
    robj *o = zmalloc(sizeof(robj));
    o->type = REDIS_STRING;

    if (len <= REDIS_ENCODING_EMBSTR_SIZE_LIMIT) {
        // 使用 EMBSTR 编码
        size_t alloclen = sizeof(robj) + sdslen(sdsnewlen(s,len));
        o = zmalloc(alloclen);
        o->type = REDIS_STRING;
        o->encoding = REDIS_ENCODING_EMBSTR;
        o->ptr = sdscatlen(sdsnewlen(s,len),s,len);
    } else {
        // 使用 RAW 编码
        o->encoding = REDIS_ENCODING_RAW;
        o->ptr = sdsnewlen(s,len);
    }

    o->refcount = 1;
    return o;
}

2.2 列表对象 (List)

列表对象用于存储一个有序的字符串列表。

  • type: REDIS_LIST
  • encoding:
    • REDIS_ENCODING_ZIPLIST: 当列表对象同时满足以下两个条件时,Redis会使用ZIPLIST编码:
      • 列表对象保存的所有字符串元素的长度都小于list-max-ziplist-value配置项的值(默认64字节)。
      • 列表对象包含的元素数量小于list-max-ziplist-entries配置项的值(默认512个)。
    • REDIS_ENCODING_LINKEDLIST: 当列表对象不满足ZIPLIST编码的条件时,Redis会使用LINKEDLIST编码,底层是一个双向链表。

编码转换:

  • 当列表对象中插入了长度超过list-max-ziplist-value的字符串元素,或者列表对象的元素数量超过list-max-ziplist-entries时,ZIPLIST编码会被转换为LINKEDLIST编码。这种转换是不可逆的。

为啥要有两种编码?

  • ZIPLIST(压缩列表)是一种非常紧凑的数据结构,它可以节省大量的内存空间。但是,ZIPLIST的插入和删除操作的效率相对较低,因为可能需要移动大量的元素。
  • LINKEDLIST(双向链表)的插入和删除操作的效率很高,但是它会消耗更多的内存空间,因为每个节点都需要额外的指针来指向前一个和后一个节点。

举个栗子:

// 向列表中添加一个元素 (简化的例子)
void listAddNodeTail(list *list, void *value) {
    listNode *node = zmalloc(sizeof(listNode));
    node->value = value;

    if (list->len == 0) {
        list->head = list->tail = node;
        node->prev = node->next = NULL;
    } else {
        node->prev = list->tail;
        node->next = NULL;
        list->tail->next = node;
        list->tail = node;
    }
    list->len++;
}

2.3 集合对象 (Set)

集合对象用于存储一个无序的、唯一的字符串集合。

  • type: REDIS_SET
  • encoding:
    • REDIS_ENCODING_INTSET: 当集合对象同时满足以下两个条件时,Redis会使用INTSET编码:
      • 集合对象保存的所有元素都是整数值。
      • 集合对象包含的元素数量不超过set-max-intset-entries配置项的值(默认512个)。
    • REDIS_ENCODING_HT: 当集合对象不满足INTSET编码的条件时,Redis会使用HT编码,底层是一个哈希表,其中键是集合元素,值都是NULL

编码转换:

  • 当集合对象中插入了非整数值的元素,或者集合对象的元素数量超过set-max-intset-entries时,INTSET编码会被转换为HT编码。这种转换也是不可逆的。

INTSET 编码的优势:

  • INTSET(整数集合)是一种专门用于存储整数值集合的数据结构,它可以根据整数值的范围自动选择合适的存储类型(int16_tint32_tint64_t),从而节省内存空间。

举个栗子:

// 向整数集合中添加一个整数 (简化的例子)
int intsetAdd(intset *is, int64_t value, uint8_t *success) {
    uint8_t valenc = _intsetValueEncoding(value); // 获取值的编码类型
    uint32_t pos;

    if (!intsetSearch(is,value,&pos)) { // 检查值是否已经存在
        is = intsetResize(is,_intsetLen(is)+1); // 调整整数集合的大小
        if (valenc > is->encoding) { // 如果新值的编码类型大于当前编码类型,需要升级
            is = intsetUpgradeAndAdd(is,value);
            *success = 1;
            return is;
        } else {
            memmove(is->contents+((pos+1)*is->encoding),
                    is->contents+(pos*is->encoding),
                    ((_intsetLen(is)-pos)*is->encoding));
            _intsetSet(is,pos,value);
            is->length++;
            *success = 1;
            return is;
        }
    }
    *success = 0;
    return is;
}

2.4 哈希对象 (Hash)

哈希对象用于存储一个键值对集合,其中键和值都可以是字符串。

  • type: REDIS_HASH
  • encoding:
    • REDIS_ENCODING_ZIPLIST: 当哈希对象同时满足以下两个条件时,Redis会使用ZIPLIST编码:
      • 哈希对象保存的所有键和值的字符串长度都小于hash-max-ziplist-value配置项的值(默认64字节)。
      • 哈希对象包含的键值对数量小于hash-max-ziplist-entries配置项的值(默认512个)。
    • REDIS_ENCODING_HT: 当哈希对象不满足ZIPLIST编码的条件时,Redis会使用HT编码,底层是一个哈希表。

编码转换:

  • 当哈希对象中插入了键或值的字符串长度超过hash-max-ziplist-value,或者哈希对象的键值对数量超过hash-max-ziplist-entries时,ZIPLIST编码会被转换为HT编码。这种转换也是不可逆的。

举个栗子:

// 哈希表添加键值对 (简化的例子)
int dictAdd(dict *d, void *key, void *val) {
    // ... 省略一些检查和调整大小的代码 ...

    dictEntry *entry = zmalloc(sizeof(dictEntry));
    entry->key = key;
    dictSetVal(d, entry, val);
    entry->next = h->table[index];
    h->table[index] = entry;
    d->used++;
    return DICT_OK;
}

2.5 有序集合对象 (ZSet)

有序集合对象用于存储一个有序的、唯一的字符串集合,每个元素都关联一个double类型的分数(score)。Redis根据分数对元素进行排序。

  • type: REDIS_ZSET
  • encoding:
    • REDIS_ENCODING_ZIPLIST: 当有序集合对象同时满足以下两个条件时,Redis会使用ZIPLIST编码:
      • 有序集合对象保存的所有元素字符串长度都小于zset-max-ziplist-value配置项的值(默认64字节)。
      • 有序集合对象包含的元素数量小于zset-max-ziplist-entries配置项的值(默认128个)。
    • REDIS_ENCODING_SKIPLIST: 当有序集合对象不满足ZIPLIST编码的条件时,Redis会使用SKIPLIST编码,底层是一个跳跃表和一个哈希表的组合。跳跃表用于实现有序性,哈希表用于实现快速查找元素。

编码转换:

  • 当有序集合对象中插入了元素字符串长度超过zset-max-ziplist-value,或者有序集合对象的元素数量超过zset-max-ziplist-entries时,ZIPLIST编码会被转换为SKIPLIST编码。这种转换也是不可逆的。

跳跃表(Skip List)的优势:

  • 跳跃表是一种概率型数据结构,它可以在O(log N)的时间复杂度内完成查找、插入和删除操作,性能接近于平衡树,但实现起来比平衡树简单得多。

举个栗子:

// 跳跃表插入一个元素 (简化的例子)
zskiplistNode *zslInsert(zskiplist *zsl, double score, sds ele) {
    // ... 省略查找插入位置的代码 ...

    zskiplistNode *x = zmalloc(sizeof(zskiplistNode) + sizeof(struct zskiplistLevel)*(level+1));
    x->score = score;
    x->ele = ele;

    // ... 省略更新跳跃表指针的代码 ...

    zsl->length++;
    return x;
}

3. 引用计数:内存管理小能手

robjrefcount字段是实现内存管理的关键。Redis使用引用计数来跟踪对象的使用情况。

  • 创建对象时: refcount 初始化为 1。
  • 对象被引用时: refcount 加 1。
  • 对象不再被引用时: refcount 减 1。
  • refcount 为 0 时: 对象被释放,内存被回收。

引用计数的好处:

  • 简单高效: 引用计数实现简单,开销小,可以快速地回收不再使用的对象。
  • 避免内存泄漏: 只要正确地管理对象的引用计数,就可以避免内存泄漏。

举个栗子:

// 增加对象的引用计数
void incrRefCount(robj *o) {
    o->refcount++;
}

// 减少对象的引用计数
void decrRefCount(robj *o) {
    if (o->refcount <= 0) return; // 防止重复释放
    if (o->refcount == 1) {
        // 释放对象
        switch(o->type) {
        case REDIS_STRING: freeStringObject(o); break;
        case REDIS_LIST: freeListObject(o); break;
        case REDIS_SET: freeSetObject(o); break;
        case REDIS_HASH: freeHashObject(o); break;
        case REDIS_ZSET: freeZsetObject(o); break;
        default: break;
        }
        zfree(o);
    } else {
        o->refcount--;
    }
}

// 释放字符串对象
void freeStringObject(robj *o) {
    if (o->encoding == REDIS_ENCODING_RAW || o->encoding == REDIS_ENCODING_EMBSTR) {
        sdsfree(o->ptr);
    }
}

// 释放列表对象
void freeListObject(robj *o) {
    listRelease((list*)o->ptr);
}

// 释放集合对象
void freeSetObject(robj *o) {
    if (o->encoding == REDIS_ENCODING_INTSET)
        intsetFree((intset*)o->ptr);
    else
        dictRelease((dict*)o->ptr);
}

// 释放哈希对象
void freeHashObject(robj *o) {
    dictRelease((dict*)o->ptr);
}

// 释放有序集合对象
void freeZsetObject(robj *o) {
    zset *zs = o->ptr;
    dictRelease(zs->dict);
    zslFree(zs->zsl);
    zfree(zs);
}

共享对象:

为了进一步节省内存,Redis还使用了一种叫做“共享对象”的机制。Redis会预先创建一些常用的对象,比如0到9999的整数,以及一些常用的字符串,并将它们的refcount设置为一个很大的值。当需要使用这些对象时,可以直接引用它们,而不需要创建新的对象。

注意:

  • 只有不变对象(immutable object)才能被共享,因为共享对象可能会被多个地方引用,如果允许修改共享对象,可能会导致数据不一致。

4. 总结:robj 的意义

robj 在 Redis 中扮演着至关重要的角色,它:

  • 实现了统一的对象模型: 使得 Redis 可以用一套通用的机制来管理各种类型的数据。
  • 支持多种编码方式: 使得 Redis 可以根据数据的特点选择合适的编码方式,以提高效率和节省内存。
  • 使用引用计数进行内存管理: 使得 Redis 可以自动地回收不再使用的对象,避免内存泄漏。
  • 通过共享对象节省内存: 进一步优化内存使用。

总而言之,robj 是 Redis 高效运行的基础,它体现了 Redis 在数据结构和内存管理方面的精妙设计。 理解 robj 的原理,有助于我们更好地理解 Redis 的内部机制,从而更好地使用 Redis。

好了,今天的“Redis探秘之对象戏法”就到这里了。感谢各位的观看,希望大家有所收获!下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注