在前面的博客中,介绍了Redis中用所有底层数据结构,如简单动态字符串(SDS)、链表、字典、跳跃表,整数集合和压缩链表。Redis就利用上述的六种底层数据结构来构造了五种数据库对象——字符串、列表、哈希、集合和有序集合。
我们知道Redis是一个“key-value”数据库,其中的key和value都是用对象表示。Redis中每个对象都是由RedisObject结构体表示:
typedefstruct redisObject {
// 类型
unsigned type:4;
// 编码
unsigned encoding:4;
// 对象最后一次被访问的时间
unsigned lru:REDIS_LRU_BITS; /* lru time(relative to server.lruclock) */
// 引用计数
int refcount;
// 指向实际值的指针
void *ptr;
}robj;
其中的type属性表示对象的类型,Redis中共定义了五种对象类型:
类型名称 | 代表常量 | 对象名称 |
REDIS_STRING | 0 | 字符串 |
REDIS_LIST | 1 | 列表 |
REDIS_SET | 2 | 集合 |
REDIS_ZSET | 3 | 有序集合 |
REDIS_HASH | 4 | 哈希 |
redisObject对象的ptr指针指向了对象的底层数据结构,这些数据结构由encoding属性决定:
/*Objects encoding. Some kind of objects like Strings and Hashes can be
* internally represented in multiple ways. The'encoding' field of the object
* is set to one of this fields for thisobject. */
// 对象编码
#defineREDIS_ENCODING_RAW 0 /* Rawrepresentation */
#defineREDIS_ENCODING_INT 1 /* Encoded asinteger */
#defineREDIS_ENCODING_HT 2 /* Encoded ashash table */
#defineREDIS_ENCODING_ZIPMAP 3 /* Encoded aszipmap */
#defineREDIS_ENCODING_LINKEDLIST 4 /* Encoded as regular linked list */
#defineREDIS_ENCODING_ZIPLIST 5 /* Encoded as ziplist */
#defineREDIS_ENCODING_INTSET 6 /* Encoded asintset */
#defineREDIS_ENCODING_SKIPLIST 7 /* Encoded asskiplist */
#defineREDIS_ENCODING_EMBSTR 8 /* Embedded sdsstring encoding */
每种对象都至少使用了两种以上的编码,Redis会根据实际情况来为对象设置不同的编码,这大大提高了效率及灵活性。以下我们就来分别介绍各个对象及其底层的编码等实现。
一、字符串对象
字符串对象的编码有int、raw和embstr。
如果一个字符串保存着整数,并且此整数可以用long类型表示,那么字符串对象的编码就是int。
普通的字符串有两种,embstr和raw。如果字符串对象的保存的字符串长度小于等于39字节,就用embstr编码保存此值。否则,字符串对象将用SDS(简单动态字符串)保存值,此时对象的编码为raw。
embstr是一种用于保存短字符串的优化编码方式。使用embstr编码的优势如下:
1. raw分配内存需要两次(一次创建redisObject,一次创建sdshdr),而embstr只需要一次内存分配,不需要创建sdshdr结构
2. 与第一点相对,释放内存的次数也由两次变为一次
3. embstr将所有数据都保存在一块连续的内存中,更好地利用了缓存带来的优势
另外,embstr编码的字符串对象是只读的。对embstr编码的字符串对象的修改实际上会将编码转化为raw再进行修改。
在object.c中包含着Redis中各种对象的创建等操作,字符串对象的创建逻辑如下:
#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);
}
字符串对象的相关命令实现在t_string.c文件中,为了节约篇幅(偷懒),就不介绍字符串API的实现了~
值得一提的是,在Redis初始化服务器时,创建了0-9999一共10000个整数的字符串对象,当Redis中的对象要使用这些整数字符串时,只需要将对象的指针指向这些整数并将被引用的整数字符串对象的引用计数加一即可!这可以节约内存。
二、列表对象
列表的编码可以是ziplist或linkedlist。其中ziplist编码的列表对象底层通过压缩列表(ziplist)实现,linkedlist底层通过链表(list)实现。
当列表对象保存的字符串元素长度小于64字节(REDIS_LIST_MAX_ZIPLIST_VALUE)时,且列表保存的元素数量小于512个(REDIS_LIST_MAX_ZIPLIST_ENTRIES),使用ziplist编码,否则使用linkedlist编码。REDIS_LIST_MAX_ZIPLIST_VALUE与REDIS_LIST_MAX_ZIPLIST_ENTRIES两个属性可以在redis.h文件中进行修改。请看在t_list.c文件中的向列表插入元素的逻辑:
if (inserted) {
/* Check if the length exceeds the ziplist length threshold. */
// 查看插入之后是否需要将编码转换为双端链表
if (subject->encoding == REDIS_ENCODING_ZIPLIST &&
ziplistLen(subject->ptr) >server.list_max_ziplist_entries)
listTypeConvert(subject,REDIS_ENCODING_LINKEDLIST);
signalModifiedKey(c->db,c->argv[1]);
notifyKeyspaceEvent(REDIS_NOTIFY_LIST,"linsert",
c->argv[1],c->db->id);
server.dirty++;
}else {
/* Notify client of a failed insert */
// refval 不存在,插入失败
addReply(c,shared.cnegone);
return;
}
其中的listTypeConvert函数完成了编码的转换,具体如下:
/*
* 将列表的底层编码从 ziplist 转换成双端链表
*/
void listTypeConvert(robj *subject, int enc) {
listTypeIterator *li;
listTypeEntry entry;
redisAssertWithInfo(NULL,subject,subject->type == REDIS_LIST);
// 转换成双端链表
if (enc== REDIS_ENCODING_LINKEDLIST) {
list *l = listCreate();
listSetFreeMethod(l,decrRefCountVoid);
/*listTypeGet returns a robj with incremented refcount */
// 遍历 ziplist ,并将里面的值全部添加到双端链表中
li= listTypeInitIterator(subject,0,REDIS_TAIL);
while (listTypeNext(li,&entry))listAddNodeTail(l,listTypeGet(&entry));
listTypeReleaseIterator(li);
// 更新编码
subject->encoding = REDIS_ENCODING_LINKEDLIST;
// 释放原来的 ziplist
zfree(subject->ptr);
// 更新对象值指针
subject->ptr = l;
} else{
redisPanic("Unsupported list conversion");
}
}
三、哈希对象
哈希对象的底层实现可以是ziplist或者hashtable。
ziplist编码的哈希对象是按照key1,value1,key2,value2这样的顺序存储键值对的。hashtable编码底层是通过字典来实现的。
当哈希对象中所有键值对的键与值的字符串大小都小于64字节,且键值对数目小于512个时,哈希对象使用ziplist为编码,否则使用hashtable。
有关于哈希对象的API在t_hash.c文件中
四、集合对象
集合对象的编码可以是intset或者hashtable。
当集合对象保存的所有元素都是整数值且元素数量不超过512个时使用intset编码,否则使用hashtable编码。
有关于集合对象的API在t_set.c文件中
五、有序集合对象
有序集合的编码可以是ziplist或者skiplist
使用ziplist作为编码时成员(member)和分值(score)顺序存放,并按照score从小到大顺序排列。
skiplist编码的有序集合对象底层使用zset结构作为实现,当有序集合中元素数量小于128且所有元素成员长度小于64字节时使用ziplist编码,否则使用skiplist编码
zset结构定义如下:
typedef struct zset {
// 字典,键为成员,值为分值
// 用于支持 O(1) 复杂度的按成员取分值操作
dict*dict;
// 跳跃表,按分值排序成员
// 用于支持平均复杂度为 O(log N) 的按分值定位成员操作
// 以及范围操作
zskiplist *zsl;
} zset;
zset结构其实是跳跃表(skiplist)与字典(dict)的结合。跳跃表按分值从小到大保存了所有有序集合中的元素,每一个节点保存一个元素,节点的object属性保存着元素的成员,score属性保存着元素的分值。通过跳跃表可以对有序集合进行范围的操作,如ZRANGE获取指定区间内的成员:
void zrangeGenericCommand(redisClient *c, intreverse) {
robj*key = c->argv[1];
robj*zobj;
intwithscores = 0;
longstart;
longend;
intllen;
intrangelen;
// 取出 start 和 end 参数
if((getLongFromObjectOrReply(c, c->argv[2], &start, NULL) != REDIS_OK) ||
(getLongFromObjectOrReply(c, c->argv[3], &end, NULL) !=REDIS_OK)) return;
// 确定是否显示分值
if(c->argc == 5 &&!strcasecmp(c->argv[4]->ptr,"withscores")) {
withscores = 1;
} elseif (c->argc >= 5) {
addReply(c,shared.syntaxerr);
return;
}
// 取出有序集合对象
if((zobj = lookupKeyReadOrReply(c,key,shared.emptymultibulk)) == NULL
||checkType(c,zobj,REDIS_ZSET)) return;
/*Sanitize indexes. */
// 将负数索引转换为正数索引
llen =zsetLength(zobj);
if (start< 0) start = llen+start;
if (end< 0) end = llen+end;
if(start < 0) start = 0;
/*Invariant: start >= 0, so this test will be true when end < 0.
* Therange is empty when start > end or start >= length. */
// 过滤/调整索引
if (start> end || start >= llen) {
addReply(c,shared.emptymultibulk);
return;
}
if (end>= llen) end = llen-1;
rangelen = (end-start)+1;
/*Return the result in form of a multi-bulk reply */
addReplyMultiBulkLen(c, withscores ? (rangelen*2) : rangelen);
if(zobj->encoding == REDIS_ENCODING_ZIPLIST) {
unsigned char *zl = zobj->ptr;
unsigned char *eptr, *sptr;
unsigned char *vstr;
unsigned int vlen;
long long vlong;
// 决定迭代的方向
if(reverse)
eptr = ziplistIndex(zl,-2-(2*start));
else
eptr = ziplistIndex(zl,2*start);
redisAssertWithInfo(c,zobj,eptr != NULL);
sptr = ziplistNext(zl,eptr);
// 取出元素
while (rangelen--) {
redisAssertWithInfo(c,zobj,eptr != NULL && sptr != NULL);
redisAssertWithInfo(c,zobj,ziplistGet(eptr,&vstr,&vlen,&vlong));
if (vstr == NULL)
addReplyBulkLongLong(c,vlong);
else
addReplyBulkCBuffer(c,vstr,vlen);
if (withscores)
addReplyDouble(c,zzlGetScore(sptr));
if (reverse)
zzlPrev(zl,&eptr,&sptr);
else
zzlNext(zl,&eptr,&sptr);
}
} elseif (zobj->encoding == REDIS_ENCODING_SKIPLIST) {
zset *zs = zobj->ptr;
zskiplist *zsl = zs->zsl;
zskiplistNode *ln;
robj *ele;
/*Check if starting point is trivial, before doing log(N) lookup. */
// 迭代的方向
if(reverse) {
ln = zsl->tail;
if (start > 0)
ln = zslGetElementByRank(zsl,llen-start);
}else {
ln = zsl->header->level[0].forward;
if (start > 0)
ln = zslGetElementByRank(zsl,start+1);
}
// 取出元素
while(rangelen--) {
redisAssertWithInfo(c,zobj,ln != NULL);
ele = ln->obj;
addReplyBulk(c,ele);
if (withscores)
addReplyDouble(c,ln->score);
ln = reverse ? ln->backward : ln->level[0].forward;
}
} else{
redisPanic("Unknown sorted set encoding");
}
}
而zset结构中的dict字典为有序集合创建了一个从成员到分值的映射。通过字典我们可以在O(1)时间内查找给定成员的分值:
void zscoreCommand(redisClient *c) {
robj*key = c->argv[1];
robj*zobj;
doublescore;
if((zobj = lookupKeyReadOrReply(c,key,shared.nullbulk)) == NULL ||
checkType(c,zobj,REDIS_ZSET)) return;
//ziplist
if(zobj->encoding == REDIS_ENCODING_ZIPLIST) {
// 取出元素
if(zzlFind(zobj->ptr,c->argv[2],&score) != NULL)
// 回复分值
addReplyDouble(c,score);
else
addReply(c,shared.nullbulk);
//SKIPLIST
} elseif (zobj->encoding == REDIS_ENCODING_SKIPLIST) {
zset *zs = zobj->ptr;
dictEntry *de;
c->argv[2] = tryObjectEncoding(c->argv[2]);
// 直接从字典中取出并返回分值
de= dictFind(zs->dict,c->argv[2]);
if(de != NULL) {
score = *(double*)dictGetVal(de);
addReplyDouble(c,score);
}else {
addReply(c,shared.nullbulk);
}
} else{
redisPanic("Unknown sorted set encoding");
}
}
可以看到,如果有序集合只有字典一种实现,那么因为字典是以无序的方式保存集合元素,所以在执行范围指令时需要对字典元素进行排序,此时的时间复杂度为0(NlogN),并需要额外的O(N)内存;而如果只有跳跃表一种实现,跳跃表对执行范围操作的效率是很高的,然而要使用根据成员查找分值(ZSCORE)指令时,因为没有直接的映射,此操作耗时为O(logN),而使用字典的耗时仅仅为O(1)。所以经过综合考虑,Redis同时使用了跳跃表以及字典来对有序集合进行实现。并且因为跳跃表以及字典都可以通过指针共享成员及分值,所以不会浪费额外的内存!这无疑是一种极为机智的做法!