redis源码分析与思考(八)——对象

本文深入解析Redis中的对象系统,包括字符串、列表、哈希、集合和有序集合对象的实现细节,探讨不同编码类型的选择及其对性能的影响。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

    谈及对象,我们不免会立即联想到Java、C++等面向对象的语言。而在C中是没有对象这一说法的,为了方便管理与代码整体的优化,redis基于前面几篇博客的数据结构自建了一套对象系统。这个系统包含着字符串对象、列表对象、哈希对象、集合对象以及有序集合对象,构建这一对象系统最为直接的好处就是该对象可以根据不同的场景去使用不同的数据结构来实现,提高了效率。
    说及对象,当然少不了对象的回收机制,java采用GC垃圾自动回收机制,而C++使用析构函数来回收对象,redis则实现了基于计数技术的回收机制,当程序中没有去使用该对象时 ,便对其进行回收,同样redis也实现了对象共享机制 ,当创建一个对象时 ,倘若这个对象已存在,那么便共享原来的对象空间(仅限大于0小于10000的整数)。

对象

    首先来看下在redis中如何定义的对象:

#define REDIS_LRU_BITS 24
typedef struct redisObject {
    // 类型
    unsigned type:4;
    // 编码
    unsigned encoding:4;
    // 对象最后一次被访问的时间,当前时间减去该值就是对象的空转时长
    unsigned lru:REDIS_LRU_BITS; 
    // 引用计数
    int refcount;
    // 指向值对象的指针
    void *ptr;
} robj;

    其中在结构体中refcount表示该对象被引用的次数,为0时,回收该对象。值得一提的是:

 unsigned type:4;

    这个语法的意思是申请4B的空间给type,类型不确定。而在redis中,我们是通过键值对的形式来存贮数据的,而从对象的角度看,每次存贮的时候,都会创建两个对象,键对象与值对象。

编码类型

    下面列出对象编码与对象类型:

// 对象类型
#define REDIS_STRING 0
#define REDIS_LIST 1
#define REDIS_SET 2
#define REDIS_ZSET 3
#define REDIS_HASH 4

// 对象编码
#define REDIS_ENCODING_RAW 0     /* Raw representation */
#define REDIS_ENCODING_INT 1     /* Encoded as integer */
#define REDIS_ENCODING_HT 2      /* Encoded as hash table */
#define REDIS_ENCODING_ZIPMAP 3  //已弃用
#define REDIS_ENCODING_LINKEDLIST 4 /* Encoded as regular linked list */
#define REDIS_ENCODING_ZIPLIST 5 /* Encoded as ziplist */
#define REDIS_ENCODING_INTSET 6  /* Encoded as intset */
#define REDIS_ENCODING_SKIPLIST 7  /* Encoded as skiplist */
#define REDIS_ENCODING_EMBSTR 8  /* Embedded sds string encoding */

    在前面几篇博客中有提到,一个对象的类型对应多种编码,现在列出它们之间的对应关系:


对象类型对象编码数据结构
REDIS_STRINGREDIS_ENCODING_INT、REDIS_ENCODING_EMBSTR、REDIS_ENCODING_RAW整数、embstr编码格式sds、raw编码格式sds
REDIS_LISTREDIS_ENCODING_ZIPLIST、REDIS_ENCODING_LINKEDLIST压缩列表、双端链表
REDIS_SETREDIS_ENCODING_INTSET、REDIS_ENCODING_HT整数集合、字典
REDIS_ZSETREDIS_ENCODING_SKIPLIST、REDIS_ENCODING_ZIPLIST跳跃表、压缩列表
REDIS_HASHREDIS_ENCODING_ZIPLIST、REDIS_ENCODING_HT压缩列表、字典

对象的创建

robj *createObject(int type, void *ptr) {

    robj *o = zmalloc(sizeof(*o));
    o->type = type;
    //因为有键对象与值对象,键对象默认的是字符串对象,raw编码
    o->encoding = REDIS_ENCODING_RAW;
    o->ptr = ptr;
    o->refcount = 1;
    //设置最后一次访问的时间
    o->lru = LRU_CLOCK();
    return o;
}

字符串对象

    字符串对象保存的数据是整型与字符串。字符串对象的底层有着三种实现结构:整数、embstr编码的sds字符串、raw编码的sds字符串。当使用set命令存入的是一个整型数据时,字符串对象就使用整型来保存数据。当存入的是一个不大于39字节的字符串时,就采用embstr编码的sds字符串保存数据,而大于39字节的字符串就采用raw编码的字符串来保存。代码如下:

// 创建一个 REDIS_ENCODING_RAW 编码的字符对象
// 对象的指针指向一个 sds 结构
robj *createRawStringObject(char *ptr, size_t len) {
    return createObject(REDIS_STRING,sdsnewlen(ptr,len));
}

// 创建一个 REDIS_ENCODING_EMBSTR 编码的字符对象
// 这个字符串对象中的 sds 会和字符串对象的 redisObject 结构一起分配
robj *createEmbeddedStringObject(char *ptr, size_t len) {
    robj *o = zmalloc(sizeof(robj)+sizeof(struct sdshdr)+len+1);
    struct sdshdr *sh = (void*)(o+1);
    o->type = REDIS_STRING;
    o->encoding = REDIS_ENCODING_EMBSTR;
    //指向sdshdr中保存字符的buf首地址,加1实际上加的是两个int类型,8个字节的大小
    o->ptr = sh+1;
    o->refcount = 1;
    o->lru = LRU_CLOCK();
    sh->len = len;
    sh->free = 0;
    if (ptr) {
        memcpy(sh->buf,ptr,len);
        sh->buf[len] = '\0';
    } else {
        memset(sh->buf,0,len+1);
    }
    return o;
}

//判断用哪种编码的sds保存数据
#define REDIS_ENCODING_EMBSTR_SIZE_LIMIT 39
robj *createStringObject(char *ptr, size_t len) {
    if (len <= REDIS_ENCODING_EMBSTR_SIZE_LIMIT)
        return createEmbeddedStringObject(ptr,len);
    else
        return createRawStringObject(ptr,len);
}


 // 根据传入的整数值,创建一个字符串对象
 //当超过整型最大值时转化为sds保存
 #define REDIS_SHARED_INTEGERS 10000
robj *createStringObjectFromLongLong(long long value) {
    robj *o;
    // value 的大小符合 REDIS 共享整数的范围
    // 那么返回一个共享对象
    if (value >= 0 && value < REDIS_SHARED_INTEGERS) {
        incrRefCount(shared.integers[value]);
        o = shared.integers[value];
    // 不符合共享范围,创建一个新的整数对象
    } else {
        // 值可以用 long 类型保存,
        // 创建一个 REDIS_ENCODING_INT 编码的字符串对象
        if (value >= LONG_MIN && value <= LONG_MAX) {
            o = createObject(REDIS_STRING, NULL);
            o->encoding = REDIS_ENCODING_INT;
            o->ptr = (void*)((long)value);
        // 值不能用 long 类型保存(long long 类型),将值转换为字符串,
        // 并创建一个 REDIS_ENCODING_RAW 的字符串对象来保存值
        } else {
            o = createObject(REDIS_STRING,sdsfromlonglong(value));
        }
    }
    return o;
}

    在保存整型的时候,用了对象共享的方法,当该数小于REDIS_SHARED_INTEGERS,即10000时,会先判断原来创建了一个与该值相等的对象没有,如果没有,则创建对象并将计数加1,否则共享对象,只将该对象计数加1,这样就在保存整数这一块节约了内存,但是不共享字符串。

    而raw编码的ads与embstr编码的sds主要的区别在于embstr编码的sds内存与redisObj内存是在一块的(因为一起分配的内存),所以保存的字符串是在其整体里,字符数据与对象共存亡。而且redis没有实现对embstr编码格式sds的修改程序,修改时需要进行编码转换,转化为raw编码或者整型,关于各个对象的命令操作实现将会在以后专门讲解,这篇博客只讲对象创建与编码格式。两者的图示如下:
在这里插入图片描述

    两者对象在执行命令时,产生的效果是一样的。embstr编码格式的sds是一种处理短字符串的优化方式。字符串对象选择embstr编码的来保存较短的字符串是基于以下几个原因的:

1. embstr将内存分配从两次减少到了一次,释放内存也从两次变为了一次,提高了保存字符串的效率。(raw编码的对象sds与redisObj分别需要创建)
2. embstr编码格式的字符串对象所有的数据都在一块内存里,可以更好的利用缓冲区缓存带来的优势。
3. redis中存贮着大量的键对象,每个键对象都是一个字符串对象,而且其大小一般都不超过39字节,采用emstr编码格式提高键对象的存贮效率。

    对浮点数的处理是传化为embstr字符串或raw字符串:

robj *createStringObjectFromLongDouble(long double value) {
    char buf[256];
    int len;
    // 使用 17 位小数精度,这种精度可以在大部分机器上被 rounding 而不改变
    len = snprintf(buf,sizeof(buf),"%.17Lf", value);
    // 移除尾部的 0 
    // 比如 3.1400000 将变成 3.14
    // 而 3.00000 将变成 3
    if (strchr(buf,'.') != NULL) {
        char *p = buf+len-1;
        while(*p == '0') {
            p--;
            len--;
        }
        // 如果不需要小数点,那么移除它
        if (*p == '.') len--;
    }
    // 创建对象
    return createStringObject(buf,len);
}

    字符串对象的复制与释放:

//都会返回引用计数为1的对象
robj *dupStringObject(robj *o) {
    robj *d;
    redisAssert(o->type == REDIS_STRING);
    switch(o->encoding) {
    case REDIS_ENCODING_RAW:
        return createRawStringObject(o->ptr,sdslen(o->ptr));
    case REDIS_ENCODING_EMBSTR:
        return createEmbeddedStringObject(o->ptr,sdslen(o->ptr));
    case REDIS_ENCODING_INT:
        d = createObject(REDIS_STRING, NULL);
        d->encoding = REDIS_ENCODING_INT;
        d->ptr = o->ptr;
        return d;
    default:
        redisPanic("Wrong encoding.");
        break;
    }
}
//释放
void freeStringObject(robj *o) {
    if (o->encoding == REDIS_ENCODING_RAW) {
        sdsfree(o->ptr);
    }
}

列表对象

    列表对象的编码可以是ziplist或者是linklist。列表对象里保存的是字符串对象,字符串对象是唯一一种可以被其它对象所嵌套的对象。
    当且满足以下两个原因时,就采用ziplist存贮数据,否则就会产生编码转换,将原来的数据采用linklist编码存贮:

  1. 保存的所有字符串对象其中的字符串的长度都小于64字节;
  2. 保存的字符串对象数量少于512个;

    创建与释放代码如下:

/*
 * 创建一个 LINKEDLIST 编码的列表对象
 */
robj *createListObject(void) {
    list *l = listCreate();
    robj *o = createObject(REDIS_LIST,l);
    //外部设置内存释放函数
    listSetFreeMethod(l,decrRefCountVoid);
    o->encoding = REDIS_ENCODING_LINKEDLIST;
    return o;
}

/*
 * 创建一个 ZIPLIST 编码的列表对象
 */
robj *createZiplistObject(void) {
    unsigned char *zl = ziplistNew();
    robj *o = createObject(REDIS_LIST,zl);
    o->encoding = REDIS_ENCODING_ZIPLIST;
    return o;
    
/*
 * 释放列表对象
 */
void freeListObject(robj *o) {
    switch (o->encoding) {
    case REDIS_ENCODING_LINKEDLIST:
        listRelease((list*) o->ptr);
        break;
    case REDIS_ENCODING_ZIPLIST:
        zfree(o->ptr);
        break;
    default:
        redisPanic("Unknown list encoding type");
    }
}

哈希对象

    哈希对象保存的是多个键值对,每个键值对的键对象与值对象都是字符串对象。哈希对象的编码可以是ziplist或者是字典。当哈希对象采用ziplist来实现其功能时,键值对的键对象与值对象的存贮总是在一块的,键对象在前,值对象在后。采用字典来存贮时,字典中每个键值对都保存一个哈希对象中的键值对。与列表对象一样,当且满足以下条件时,就采用ziplist编码存贮,否则就会发生编码转换:

  1. 保存的所有字符串对象其中的字符串的长度都小于64字节;
  2. 保存的键值对数量少于512个;

    创建与销毁代码如下:

/*
 * 创建一个 ZIPLIST 编码的哈希对象
 */
robj *createHashObject(void) {
    unsigned char *zl = ziplistNew();
    robj *o = createObject(REDIS_HASH, zl);
    o->encoding = REDIS_ENCODING_ZIPLIST;
    return o;
}

/*
  释放哈希对象
 */
void freeHashObject(robj *o) {
    switch (o->encoding) {
    case REDIS_ENCODING_HT:
        dictRelease((dict*) o->ptr);
        break;
    case REDIS_ENCODING_ZIPLIST:
        zfree(o->ptr);
        break;
    default:
        redisPanic("Unknown hash encoding type");
        break;
    }
}

集合对象

    集合对象保存的是多个字符串对象。集合对象的编码可以是inset或者字典。当集合对象采用intset来实现其功能时,所有的集合数据都保存早在intset集合里;而采用字典来实现时,字典中的每个键保存一个集合数据,而其值指向NULL。当且满足以下要求时采用intset编码,否则进行编码转换:

1.保存的所有元素是整型;
2.保存的元素个数不超过512个。

    创建销毁代码如下:

/*
 * 释放集合对象
 */
void freeSetObject(robj *o) {
    switch (o->encoding) {
    case REDIS_ENCODING_HT:
        dictRelease((dict*) o->ptr);
        break;
    case REDIS_ENCODING_INTSET:
        zfree(o->ptr);
        break;
    default:
        redisPanic("Unknown set encoding type");
    }
}

/*
 * 创建一个 字典 编码的集合对象
 */
robj *createSetObject(void) {
    dict *d = dictCreate(&setDictType,NULL);
    robj *o = createObject(REDIS_SET,d);
    o->encoding = REDIS_ENCODING_HT;
    return o;
}

/*
 * 创建一个 INTSET 编码的集合对象
 */
robj *createIntsetObject(void) {
    intset *is = intsetNew();
    robj *o = createObject(REDIS_SET,is);
    o->encoding = REDIS_ENCODING_INTSET;
    return o;
}

有序集合

    有序集合的编码方式可以是ziplist或者skiplist。保存的数据包含两部分,第一部分是分值,第二部分是成员。
    采用ziplist来保存数据时,每个集合元素都采用两个紧挨着的节点保存,第一个几点保存元素的成员,第二个节点保存分值。集合元素按升序进行排列。而采用skiplist来保存时,有序集合对象会采用结构体zset来实现:

typedef struct zset {
    // 字典,键为成员,值为分值
    // 用于支持 O(1) 复杂度的按成员取分值操作
    dict *dict;
    // 跳跃表,按分值排序成员
    // 用于支持平均复杂度为 O(log N) 的按分值定位成员操作
    // 以及范围操作
    zskiplist *zsl;
} zset;

    zsl跳跃表按分值从小到大存贮集合数据,而dict字典会产生一个成员到分值的映射,使其可以以O(1)的时间复杂度拿到对应的数据。不管是zsl还是dict,两者都会共享所有的集合元素,也就是说,zset表面上看存贮了两份的数据,实际上只存了一份数据。但是不管是字典还是跳跃表都可以单独的实现有序集合的功能。那么为什么要用两种结构来实现呢?因为单独用字典,虽然查找时间复杂度是O(1),但是排序会消耗大量时间,单独使用跳跃表,排序会较快(O(n)),查找又会不方便(O(n)),为了让有序集合在查找和排序上尽可能的快,同时使用两者来实现其功能。当且满足以下条件时,采用ziplist编码:

  1. 保存的元素小于128个;
  2. 保存的所有元素成员的长度都小于64字节。

    创建与销毁:

/*
 * 创建一个 SKIPLIST 编码的有序集合
 */
robj *createZsetObject(void) {
    zset *zs = zmalloc(sizeof(*zs));
    robj *o;
    zs->dict = dictCreate(&zsetDictType,NULL);
    zs->zsl = zslCreate();
    o = createObject(REDIS_ZSET,zs);
    o->encoding = REDIS_ENCODING_SKIPLIST;
    return o;
}

/*
 * 创建一个 ZIPLIST 编码的有序集合
 */
robj *createZsetZiplistObject(void) {
    unsigned char *zl = ziplistNew();
    robj *o = createObject(REDIS_ZSET,zl);
    o->encoding = REDIS_ENCODING_ZIPLIST;
    return o;
}

/*
 * 释放有序集合对象
 */
void freeZsetObject(robj *o) {
    zset *zs;
    switch (o->encoding) {
    case REDIS_ENCODING_SKIPLIST:
        zs = o->ptr;
        dictRelease(zs->dict);
        zslFree(zs->zsl);
        zfree(zs);
        break;
    case REDIS_ENCODING_ZIPLIST:
        zfree(o->ptr);
        break;
    default:
        redisPanic("Unknown sorted set encoding");
    }
}

对象的回收

    上文说了,redis对象有着自己一套的对象回收方法,它采用了计数回收的技术,即在每个对象下添加一个属性,该属性记录该对象被程序引用的次数,当该属性值为0时,回收该对象:

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

 //为对象的引用计数减一
 //当对象的引用计数降为 0 时,释放对象。
void decrRefCount(robj *o) {
    if (o->refcount <= 0) redisPanic("decrRefCount against refcount <= 0");
    // 释放对象
    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_ZSET: freeZsetObject(o); break;
        case REDIS_HASH: freeHashObject(o); break;
        default: redisPanic("Unknown object type"); break;
        }
        zfree(o);
    // 减少计数
    } else {
        o->refcount--;
    }
}

//作用于特定数据结构的释放函数包装
void decrRefCountVoid(void *o) {
    decrRefCount(o);
}

//将引用重置,不释放,该对象会被重新调用
robj *resetRefCount(robj *obj) {
    obj->refcount = 0;
    return obj;
}

总结

1. redis中每个键值对都是两个对象,每个对象对应着多种编码
2. redis共享1到9999的字符串对象,而不共享内容为字符串的字符串对象,采用计数回收方式进行对象回收
3. 对象会自己记录自己最后被访问的时间,这个属性用来计算对象的空转时长,当服务器达到内存上限时,会回收空转时间较长的对象

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值