Redis被称为key/value应用中的瑞士军刀,除了其丰富的数据结构支持,更重要的是高效的内存使用,分析源码可以发现作者使用每一个byte都精打细算。在hashtable实现中,Redis引入了zipmap数据结构,保证在hashtable刚创建以及元素较少时,用更少的内存来存储,同时对查询的效率也不会受太大的影响。下面就以源码和例子结合的方式来分析一下zipmap的内存布局。
先来看一下zipmap提供的和存储相关的3个API:
zipmapNew:创建一个zipmap字符串。zipmap创建时只有2个字节,后面会随着set和delete操作动态扩展和收缩。
zipmapSet: 加入新的key/value或者修改zipmap中已有key对应的value。
zipmapDel:从zipmap中删除key/value。
下面给出一段伪代码并分析其内存布局的变化,如下图:
zipmapNew();
zipmapSet(key1,value1);
zipmapSet(key2,value2);
zipmapSet(key1,value3);
zipmapDel(key2);
zipmapSet(key1,value4);
1. zipmapNew();
创建一个zipmap结构体,包含两个字节,第一个字节(zmlen)是长度为1个字节的无符号整数,用来保存zipmap当前元素个数(而非字符串长度)。当zipmap的元素个数大于等于254时,zmlen将不再起作用,zipmap需要遍历整个字符串来获取当前元素个数。最后一个字节为255,表示zipmap的结束。
2. zipmapSet(key1, value1)
一个元素(key/value)在zipmap中有5部分组成: 。
表示紧跟其后的string(key或者value)的长度。如果string的长度小于254(这里代码和注释不统一,注释是253,但代码是254,以代码为准),用一个字节就可以表示(254和255有特殊含义),如果string的长度大于等于254,需要5个字节来表示,第一个字节设置为254,紧跟其后的4个字节通过编码(按主机字节序)来表示的值。zipmapEncodeLength和zipmapDecodeLength就是用来对进行编解码的。和是char型string,在第6步进行说明。
3. zipmapSet(key2, value2)
调用zipmapSet加入新的key/value时,zipmap将根据key2/value2的长度调用zipmapResize扩展空间,并将key2/value2插入到新分配的空间。同时将zipmap元素的个数加1(如果小于254)。
4. zipmapSet(key1,value3)
调用zipmapSet对已有的key修改其value,且新的value值大于现有value占用的空间时(加free的空间),zipmap将再次调用zipmapResize扩展空间,并调用memmove将key1/vaule1之后的字符串向后顺移。这里只调用一次memmove,不会对性能有太大影响。
5. zipmapDel(key2)
调用zipmapDel删除key2/value2时,zipmap将把key2/value2之后的字符串前移,并调用zipmapResize收缩占用的内存空间。同时将zipmap元素个数减1。
6. zipmapSet(key1, value4)
调用zipmapSet对已有的key1修改其value,且新的value值小于现有value占用的空间时,zipmap不会马上去调用zipmapResize做内存空间收缩,而是将空闲字节数存入free中,用于后面对这个key再次修改value时,避免调用zipmapResize(要根据新value的长度而定)。当然free的空间也不能太多,否则会造出空间的浪费。zipmap在free字节数大于等于ZIPMAP_VALUE_MAX_FREE(代码中定义为4)时,就对free的空间进行收缩。
以上就是zipmap内存布局和扩展收缩的过程,你可能会问zipmapGet岂不是O(n)的吗?没错,但因为key和value都是确定长度的字符串,所以这个n是zipmap中元素的个数,而不是zipmap整个串的长度。只要在使用zipmap时保证元素个数不是很多,就可以在时间复杂度和空间复杂度两方面找到很好的平衡点。redis.conf中默认配置hash-max-zipmap-entries为512。
如何实现一个海量用户的实时排名系统?或许可以用mysql搞一个纠结的方案;但要是选择了redis,那绝对是既简单又优雅。Redis的zset本身就是一种支持排序的集合,而zset的实现,则使用了skip list数据结构。Skip list是一种多层次的有序链表,通过随机地选择层数来实现插入、查找和删除都是O(logn)的时间复杂度(和平衡树同样的效率,但实现比平衡树简单很多)。关于skip list的具体介绍可以参见William Pugh的论文:Skip Lists: A Probabilistic Alternative to Balanced Trees 。下面我们来分析一下redis中skip list的实现。
Redis中skip list主要有zskiplist和zskiplistNode两个数据结构:
typedef struct zskiplistNode {
robj *obj;
double score;
struct zskiplistNode *backward;
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned int span;
} level[];
} zskiplistNode;
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
} zskiplist;
其中zskiplistNode中包含一个zskiplistLevel数组,数组的大小根据节点所在的层数(level)决定。backward指针是为了方便向后遍历而对skip list做的改进。
主要的API有:
zslCreate 创建一个zskiplist,并添加一个具有最高层数ZSKIPLIST_MAXLEVEL(代码中定义为32)的节点来管理分层的链表。
zslInsert 插入一个节点到zskiplist,并调整每一个层级的链表都是有序的。
zslDelete 从zskiplist删除一个节点,并调整剩余节点在每个层级都是有序的。
zslRandomLevel 为新加入的节点随机产生一个不超过ZSKIPLIST_MAXLEVEL的层数。
zslInsert和zslDelete函数都需要首先查找到合适的位置或节点,查找的代码很简单,直接包含在了这两个函数内:
x = zsl->header;
for (i = zsl->level-1; i >= 0; i–) {
/* store rank that is crossed to reach the insert position */
rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];
while (x->level[i].forward &&
(x->level[i].forward->score < score ||
(x->level[i].forward->score == score &&
compareStringObjects(x->level[i].forward->obj,obj) < 0))) {
rank[i] += x->level[i].span;
x = x->level[i].forward;
}
update[i] = x;
}
查找是从zskiplist现有的最高层开始向前,并在查找的过程中根据规则转向低层的链表继续,一直到skip list的最低层为止。同时看到redis的实现中允许相同的score存在(这时按对象的字符串进行比较),但不允许具有相同值的对象并存(集合的特性)。
下面通过一个例子来说明skip list的建立过程。
按顺序执行下列语句:
zslInsert(zsl, 5, obj1); //level=1;
zslInsert(zsl, 3, obj2); //level=2;
zslInsert(zsl, 4, obj3); //level=1;
zslInsert(zsl, 1, obj4); //level=3;
zslInsert(zsl, 2, obj5); //level=1;
现在的zsl结构如下图所示,其中level array的数组下标是为了图例更直观,实际不占存储空间。为了保证图例的简洁,backward的指针没有画出,对应level 0红色指针相反方向的指针。
祝大家玩儿的开心!(学霸哥^_^)。