在REDIS中,KV是基本的存储概念,而VALUE支持的type主要为5种:STRING,LIST,SET,ZSET(SORTED SET),HASH。本文将详细介绍这些结构的实现。
首先 这5种类型的宏定义可以在redis.h中找到
#define REDIS_STRING 0
#define REDIS_LIST 1
#define REDIS_SET 2
#define REDIS_ZSET 3
#define REDIS_HASH 4
其次,在reid的内存结构中,上面的5种类型都是采用一样的底层内存结构,—redisObject
typedef struct redisObject { unsigned type:4; unsigned encoding:4; unsigned lru:REDIS_LRU_BITS; /* lru time (relative to server.lruclock) */ int refcount; void *ptr; } robj;
也就是说,最上述的5种类型,对应的是redisObject中的type字段,refcount则是指的是此值的被引用的次数。ptr则是对应具体的值的指针。从这点而言,redis采用了PHP类似的ZVAL的存储结 构。而且,在最底层redis采用的是计数回收机制。
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--; }
熟悉PHP的人看到这样的结构以及这样的逻辑应该知道,此处有memeory leak的风险:如果有循环引用,这样的回收算法是必定有memory leak的(详见http://php.net/manual/en/features.gc.refcounting-basics.php)。PHP的解决方案在此不表。redis的解决方案就是:refcount被修改只有两种情况:初始化;STRING或者long被重用。也就是说,redis不支持redisObject的互相引用,也就是说,不会存在引用环的问题,也就解决了基于计数回收的memoryleak问题。
下面说<type,encoding,ptr>这三个元素构成的tulple。在redisObject中(下文皆简称robj),ptr是void类型,是因为对于不同的类型以及encoding,ptr的结构是不一样的。具体的encoding详见
#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 /* Encoded as zipmap */
#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_ENCODING_INT,REDIS_ENCODING_EMBSTR和EDIS_ENCODING_RAW对应的是STRING类型。换言之,redis没有原生的number类型。其他的encoding和type对应关系则是为了优化和持久化的考虑,详细为:
-
<REDIS_LIST,REDIS_ENCODING_LINKEDLIST>
-
<REDIS_LIST,REDIS_ENCODING_ZIPLIST>
-
<REDIS_SET,REDIS_ENCODING_HT>
-
<REDIS_SET,REDIS_ENCODING_INTSET>
-
<REDIS_ZSET,REDIS_ENCODING_SKIPLIST>
-
<REDIS_ZSET,REDIS_ENCODING_ZIPLIST>
-
<REDIS_HASH,REDIS_ENCODING_HT>
-
<REDIS_HASH,REDIS_ENCODING_ZIPLIST>
REDIS_ENCODING_ZIPMAP在源代码中没找到涉及到的地方,后来在官网发现这个encoding已经在2.6之后被remove掉了(参见http://redis.io/topics/memory-optimization)。对于同一种type,什么条件下会采用不同的encoding,下文会详述。
首先说STRING。STRING存储redis中最普通的数据:string,number,char。而其结构名称为sds,其定义为
typedef char *sds;
显然,sds只是一个char数组的指针。但是如果仅仅是数组的话,完全不满足redis的关于string的操作,比如append。于是redis又包装了另外一个结构
struct sdshdr { /* 值长度 */ unsigned int len; /* 未使用长度 */ unsigned int free; /* 值 */ char buf[]; };
故而,在redis内部,都是采用sdshds这个结构存储。每一次需要增加容量的时候,redis首先是得到需要追加的长度,然后采取策略将当前的char数组扩大,继而是写入,最后将尾部的空白清楚。具体步骤为
-
计算所需增加的长度
-
为新的buff[]开辟空间。具体是采用翻倍策略——如果当前长度小于1024*1024,则翻倍,否则增加1024*1024,更新free字段
-
将新的内容写入,并在最后添加\0作为结束符,同时更新len字段
这里有个细节问题,就是在append后,会存在空余空间,也就是说,free不再是0了。那下次如果再append的时候会如何?redis采取的策略是,——每次计算所需增加的长度的时候,是基于len这个字段增加的。于是redis最多空余len-1个长度的空间。同时,由于redis接受client的命令以及返回的时候都是采用sds结构,这就造成了在返回的时候很容易造成很大的free空间,而向client返回这个操作相对于内存操作而言耗时太长,于是在返回前会对返回内容进行空白回收,也就是sdsRemoveFreeSpace方法。
在redis中,所有sds的操作都可以在sds.c文件中找到,主要提供了以下的功能(下表参考于http://redisbook.readthedocs.org/en/latest/internal-datastruct/sds.html。若涉及任何版权问题,请联系本人)
sdsnewlen | 创建一个指定长度的 sds ,接受一个 C 字符串作为初始化值 | O(N) |
sdsempty | 创建一个只包含空白字符串 "" 的 sds | O(1) |
sdsnew | 根据给定 C 字符串,创建一个相应的 sds | O(N) |
sdsdup | 复制给定 sds | O(N) |
sdsfree | 释放给定 sds | O(N) |
sdsupdatelen | 更新给定 sds 所对应 sdshdr 结构的 free 和 len | O(N) |
sdsclear | 清除给定 sds 的内容,将它初始化为 "" | O(1) |
sdsMakeRoomFor | 对 sds 所对应 sdshdr 结构的 buf 进行扩展 | O(N) |
sdsRemoveFreeSpace | 在不改动 buf 的情况下,将 buf 内多余的空间释放出去 | O(N) |
sdsAllocSize | 计算给定 sds 的 buf 所占用的内存总数 | O(1) |
sdsIncrLen | 对 sds 的 buf 的右端进行扩展(expand)或修剪(trim) | O(1) |
sdsgrowzero | 将给定 sds 的 buf 扩展至指定长度,无内容的部分用 \0 来填充 | O(N) |
sdscatlen | 按给定长度对 sds 进行扩展,并将一个 C 字符串追加到 sds 的末尾 | O(N) |
sdscat | 将一个 C 字符串追加到 sds 末尾 | O(N) |
sdscatsds | 将一个 sds 追加到另一个 sds 末尾 | O(N) |
sdscpylen | 将一个 C 字符串的部分内容复制到另一个 sds 中,需要时对 sds 进行扩展 | O(N) |
sdscpy | 将一个 C 字符串复制到 sds | O(N) |
而涉及到的所有内存空间开辟都有唯一的信号量控制。其类型为pthread_mutex_t。
下面讲一下string对于number的支持。
redis对于String的赋值前,会调用tryObjectEncoding方法去检查应该采用什么样的编码。为了方便,在此贴出尝试转换为int的代码细节:
len = sdslen(s);
/* power(10,20) <power(2,64) < power(10,21) */
if (len <= 21 && string2l(s,len,&value)) {
/* This object is encodable as a long. Try to use a shared object.
* Note that we avoid using shared integers when maxmemory is used
* because every object needs to have a private LRU field for the LRU
* algorithm to work well. */
if ((server.maxmemory == 0 ||
(server.maxmemory_policy != REDIS_MAXMEMORY_VOLATILE_LRU &&
server.maxmemory_policy != REDIS_MAXMEMORY_ALLKEYS_LRU)) &&
value >= 0 &&
value < REDIS_SHARED_INTEGERS)
{
decrRefCount(o);
incrRefCount(shared.integers[value]);
return shared.integers[value];
} else {
if (o->encoding == REDIS_ENCODING_RAW) sdsfree(o->ptr);
o->encoding = REDIS_ENCODING_INT;
o->ptr = (void*) value;
return o;
}
}
显然,判断是否要转为int的条件有两个:len不超过21(此时传过来的还是字符型,也就是"123");转换成功(有些废话)。如果上述两个条件成立,则做一些后续工作(因为已经转换成功了)。其中shared是一个内存宏观变量,可以认为是共享常量池。在此我们只关心integer常量池。从代码完全可以看出,此integer常量池是一个数组,数组长度由内存参数REDIS_SHARED_INTEGERS指定,或者说,是一个0到REDIS_SHARED_INTEGERS的常量桶。也就是说,如果发现这个值是在桶里面的,则无需开辟空间,直接指向这个值就行了。如果不是,则直接将该值的指针指向这个转换过来的值(按照64位的long为8byte)。其中string2l中转换数字的算法比较漂亮,在此粘贴一下(算是仰慕一下大神):
while (plen < slen && p[0] >= '0' && p[0] <= '9') { if (v > (ULLONG_MAX / 10)) /* Overflow. */ return 0; v *= 10; if (v > (ULLONG_MAX - (p[0]-'0'))) /* Overflow. */ return 0; v += p[0]-'0'; p++; plen++; }
看了下前面写的内容,感觉本文写不完所有的data type了。干脆就讲下string的优化策略。在string中,存在一个EMB_STR的encoding,也就是说,在最终存值之前,会尝试是否能将string以EMB_STR的格式存储,具体代码如下:
if (len <= REDIS_ENCODING_EMBSTR_SIZE_LIMIT) { robj *emb; if (o->encoding == REDIS_ENCODING_EMBSTR) return o; emb = createEmbeddedStringObject(s,sdslen(s)); decrRefCount(o); return emb; }
从代码中可以看到,当整个string的长度少于参数REDIS_ENCODING_EMBSTR_SIZE_LIMIT(默认是39)的时候,会将此STR重新编码。而编码的方式也是比较简单:
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;
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);
}
如果要阐述为什么会如此优化,首先得说明一下一个robj的分配机制。要创建一个robj对象,第一步是基于robj的struct分配内存空间,然后根据值的长度分配对应的ptr空间。这个在前文也大致说过。但是对于embstr的方式,不是这样,他是将两次的内存分配合并成一个。也就是上述代码的第一行
robj *o = zmalloc(sizeof(robj)+sizeof(struct sdshdr)+len+1);
也就是说,省去了一次和内核打交道的过程以及减少一次pthread_mutex_t的操作。但是带来的问题也比较明显,由于是一次性分配,他的robj的内存和ptr的内存是放在一块儿的(o->ptr = sh+1)。也就是说,如果这个string发生变化了,就意味着在整个robj的内存块重新规划内存。这显然是很不方便的,这也就是为何上述代码的注释为(注意烈面的单词unmodified):
/* Create a string object with encoding REDIS_ENCODING_EMBSTR, that is * an object where the sds string is actually an unmodifiable string * allocated in the same chunk as the object itself. */
那么为何要设置一个阀值REDIS_ENCODING_EMBSTR_SIZE_LIMIT呢?很简单,内容越小,修改的需求越少,长度变更的可能性也越小。
From:鱼藏