本篇导读:本文是《Redis内部数据结构详解》系列的第四篇,介绍ziplist。ziplist的操做相对来讲比较复杂,建议本文分两次阅读:先一口气读完ziplist的数据结构的介绍,这一部分基本不包含代码,应该能够在10分钟内读完;而后建议你休息片刻,并将本文收藏。而后在时间充裕的时候再阅读后半部分。祝阅读愉快!redis
在本文中,咱们首先介绍一个新的Redis内部数据结构——ziplist,而后在文章后半部分咱们会讨论一下在robj, dict和ziplist的基础上,Redis对外暴露的hash结构是怎样构建起来的。
数组
咱们在讨论中还会涉及到两个Redis配置(在redis.conf中的ADVANCED CONFIG部分):数据结构
hash-max-ziplist-entries 512 hash-max-ziplist-value 64
本文的后半部分会对这两个配置作详细的解释。app
Redis官方对于ziplist的定义是(出自ziplist.c的文件头部注释):curl
The ziplist is a specially encoded dually linked list that is designed to be very memory efficient. It stores both strings and integer values, where integers are encoded as actual integers instead of a series of characters. It allows push and pop operations on either side of the list in O(1) time.ide
翻译一下就是说:ziplist是一个通过特殊编码的双向链表,它的设计目标就是为了提升存储效率。ziplist能够用于存储字符串或整数,其中整数是按真正的二进制表示进行编码的,而不是编码成字符串序列。它能以O(1)的时间复杂度在表的两端提供push
和pop
操做。函数
实际上,ziplist充分体现了Redis对于存储效率的追求。一个普通的双向链表,链表中每一项都占用独立的一块内存,各项之间用地址指针(或引用)链接起来。这种方式会带来大量的内存碎片,并且地址指针也会占用额外的内存。而ziplist倒是将表中每一项存放在先后连续的地址空间内,一个ziplist总体占用一大块内存。它是一个表(list),但其实不是一个链表(linked list)。性能
另外,ziplist为了在细节上节省内存,对于值的存储采用了变长的编码方式,大概意思是说,对于大的整数,就多用一些字节来存储,而对于小的整数,就少用一些字节来存储。咱们接下来很快就会讨论到这些实现细节。this
ziplist的数据结构组成是本文要讨论的重点。实际上,ziplist仍是稍微有点复杂的,它复杂的地方就在于它的数据结构定义。一旦理解了数据结构,它的一些操做也就比较容易理解了。编码
咱们接下来先从整体上介绍一下ziplist的数据结构定义,而后举一个实际的例子,经过例子来解释ziplist的构成。若是你看懂了这一部分,本文的任务就算完成了一大半了。
从宏观上看,ziplist的内存结构以下:
<zlbytes><zltail><zllen><entry>...<entry><zlend>
各个部分在内存上是先后相邻的,它们分别的含义以下:
<zlbytes>
: 32bit,表示ziplist占用的字节总数(也包括<zlbytes>
自己占用的4个字节)。
<zltail>
: 32bit,表示ziplist表中最后一项(entry)在ziplist中的偏移字节数。<zltail>
的存在,使得咱们能够很方便地找到最后一项(不用遍历整个ziplist),从而能够在ziplist尾端快速地执行push或pop操做。
<zllen>
: 16bit, 表示ziplist中数据项(entry)的个数。zllen字段由于只有16bit,因此能够表达的最大值为2^16-1。这里须要特别注意的是,若是ziplist中数据项个数超过了16bit能表达的最大值,ziplist仍然能够来表示。那怎么表示呢?这里作了这样的规定:若是<zllen>
小于等于2^16-2(也就是不等于2^16-1),那么<zllen>
就表示ziplist中数据项的个数;不然,也就是<zllen>
等于16bit全为1的状况,那么<zllen>
就不表示数据项个数了,这时候要想知道ziplist中数据项总数,那么必须对ziplist从头至尾遍历各个数据项,才能计数出来。
<entry>
: 表示真正存放数据的数据项,长度不定。一个数据项(entry)也有它本身的内部结构,这个稍后再解释。
<zlend>
: ziplist最后1个字节,是一个结束标记,值固定等于255。
上面的定义中还值得注意的一点是:<zlbytes>
, <zltail>
,<zllen>
既然占据多个字节,那么在存储的时候就有大端(big endian)和小端(little endian)的区别。ziplist采起的是小端模式来存储,这在下面咱们介绍具体例子的时候还会再详细解释。
咱们再来看一下每个数据项<entry>
的构成:
<prevrawlen><len><data>
咱们看到在真正的数据(<data>
)前面,还有两个字段:
<prevrawlen>
: 表示前一个数据项占用的总字节数。这个字段的用处是为了让ziplist可以从后向前遍历(从后一项的位置,只需向前偏移prevrawlen个字节,就找到了前一项)。这个字段采用变长编码。
<len>
: 表示当前数据项的数据长度(即<data>
部分的长度)。也采用变长编码。
那么<prevrawlen>
和<len>
是怎么进行变长编码的呢?各位读者打起精神了,咱们终于讲到了ziplist的定义中最繁琐的地方了。
先说<prevrawlen>
。它有两种可能,或者是1个字节,或者是5个字节:
若是前一个数据项占用字节数小于254,那么<prevrawlen>
就只用一个字节来表示,这个字节的值就是前一个数据项的占用字节数。
若是前一个数据项占用字节数大于等于254,那么<prevrawlen>
就用5个字节来表示,其中第1个字节的值是254(做为这种状况的一个标记),然后面4个字节组成一个整型值,来真正存储前一个数据项的占用字节数。
有人会问了,为何没有255的状况呢?
这是由于:255已经定义为ziplist结束标记<zlend>
的值了。在ziplist的不少操做的实现中,都会根据数据项的第1个字节是否是255来判断当前是否是到达ziplist的结尾了,所以一个正常的数据的第1个字节(也就是<prevrawlen>
的第1个字节)是不可以取255这个值的,不然就冲突了。
而<len>
字段就更加复杂了,它根据第1个字节的不一样,总共分为9种状况(下面的表示法是按二进制表示):
|00pppppp| - 1 byte。第1个字节最高两个bit是00,那么<len>
字段只有1个字节,剩余的6个bit用来表示长度值,最高能够表示63 (2^6-1)。
|01pppppp|qqqqqqqq| - 2 bytes。第1个字节最高两个bit是01,那么<len>
字段占2个字节,总共有14个bit用来表示长度值,最高能够表示16383 (2^14-1)。
|10__|qqqqqqqq|rrrrrrrr|ssssssss|tttttttt| - 5 bytes。第1个字节最高两个bit是10,那么len字段占5个字节,总共使用32个bit来表示长度值(6个bit舍弃不用),最高能够表示2^32-1。须要注意的是:在前三种状况下,<data>
都是按字符串来存储的;从下面第4种状况开始,<data>
开始变为按整数来存储了。
|11000000| - 1 byte。<len>
字段占用1个字节,值为0xC0,后面的数据<data>
存储为2个字节的int16_t类型。
|11010000| - 1 byte。<len>
字段占用1个字节,值为0xD0,后面的数据<data>
存储为4个字节的int32_t类型。
|11100000| - 1 byte。<len>
字段占用1个字节,值为0xE0,后面的数据<data>
存储为8个字节的int64_t类型。
|11110000| - 1 byte。<len>
字段占用1个字节,值为0xF0,后面的数据<data>
存储为3个字节长的整数。
|11111110| - 1 byte。<len>
字段占用1个字节,值为0xFE,后面的数据<data>
存储为1个字节的整数。
|1111xxxx| - - (xxxx的值在0001和1101之间)。这是一种特殊状况,xxxx从1到13一共13个值,这时就用这13个值来表示真正的数据。注意,这里是表示真正的数据,而不是数据长度了。也就是说,在这种状况下,后面再也不须要一个单独的<data>
字段来表示真正的数据了,而是<len>
和<data>
合二为一了。另外,因为xxxx只能取0001和1101这13个值了(其它可能的值和其它状况冲突了,好比0000和1110分别同前面第7种第8种状况冲突,1111跟结束标记冲突),而小数值应该从0开始,所以这13个值分别表示0到12,即xxxx的值减去1才是它所要表示的那个整数数据的值。
好了,ziplist的数据结构定义,咱们介绍了完了,如今咱们看一个具体的例子。
上图是一份真实的ziplist数据。咱们逐项解读一下:
这个ziplist一共包含33个字节。字节编号从byte[0]到byte[32]。图中每一个字节的值使用16进制表示。
头4个字节(0x21000000)是按小端(little endian)模式存储的<zlbytes>
字段。什么是小端呢?就是指数据的低字节保存在内存的低地址中(参见维基百科词条Endianness{:target="_blank"})。所以,这里<zlbytes>
的值应该解析成0x00000021,用十进制表示正好就是33。
接下来4个字节(byte[4..7])是<zltail>
,用小端存储模式来解释,它的值是0x0000001D(值为29),表示最后一个数据项在byte[29]的位置(那个数据项为0x05FE14)。
再接下来2个字节(byte[8..9]),值为0x0004,表示这个ziplist里一共存有4项数据。
接下来6个字节(byte[10..15])是第1个数据项。其中,prevrawlen=0,由于它前面没有数据项;len=4,至关于前面定义的9种状况中的第1种,表示后面4个字节按字符串存储数据,数据的值为"name"。
接下来8个字节(byte[16..23])是第2个数据项,与前面数据项存储格式相似,存储1个字符串"tielei"。
接下来5个字节(byte[24..28])是第3个数据项,与前面数据项存储格式相似,存储1个字符串"age"。
接下来3个字节(byte[29..31])是最后一个数据项,它的格式与前面的数据项存储格式不太同样。其中,第1个字节prevrawlen=5,表示前一个数据项占用5个字节;第2个字节=FE,至关于前面定义的9种状况中的第8种,因此后面还有1个字节用来表示真正的数据,而且以整数表示。它的值是20(0x14)。
最后1个字节(byte[32])表示<zlend>
,是固定的值255(0xFF)。
总结一下,这个ziplist里存了4个数据项,分别为:
字符串: "name"
字符串: "tielei"
字符串: "age"
整数: 20
(好吧,被你发现了~~tielei实际上固然不是20岁,他哪有那么年轻啊......)
实际上,这个ziplist是经过两个hset
命令建立出来的。这个咱们后半部分会再提到。
好了,既然你已经阅读到这里了,说明你仍是颇有耐心的(其实我写到这里也已经累得不行了)。能够先把本文收藏,休息一下,回头再看后半部分。
接下来我要贴一些代码了。
咱们先不着急看实现,先来挑几个ziplist的重要的接口,看看它们长什么样子:
unsigned char *ziplistNew(void);
unsigned char *ziplistMerge(unsigned char **first, unsigned char **second);
unsigned char *ziplistPush(unsigned char *zl, unsigned char *s, unsigned int slen, int where);
unsigned char *ziplistIndex(unsigned char *zl, int index);
unsigned char *ziplistNext(unsigned char *zl, unsigned char *p);
unsigned char *ziplistPrev(unsigned char *zl, unsigned char *p);
unsigned char *ziplistInsert(unsigned char *zl, unsigned char *p, unsigned char *s, unsigned int slen);
unsigned char *ziplistDelete(unsigned char *zl, unsigned char **p);
unsigned char *ziplistFind(unsigned char *p, unsigned char *vstr, unsigned int vlen, unsigned int skip);
unsigned int ziplistLen(unsigned char *zl);
咱们从这些接口的名字就能够粗略猜出它们的功能,下面简单解释一下:
ziplist的数据类型,没有用自定义的struct之类的来表达,而就是简单的unsigned char *。这是由于ziplist本质上就是一块连续内存,内部组成结构又是一个高度动态的设计(变长编码),也无法用一个固定的数据结构来表达。
ziplistNew: 建立一个空的ziplist(只包含<zlbytes><zltail><zllen><zlend>
)。
ziplistMerge: 将两个ziplist合并成一个新的ziplist。
ziplistPush: 在ziplist的头部或尾端插入一段数据(产生一个新的数据项)。注意一下这个接口的返回值,是一个新的ziplist。调用方必须用这里返回的新的ziplist,替换以前传进来的旧的ziplist变量,而通过这个函数处理以后,原来旧的ziplist变量就失效了。为何一个简单的插入操做会致使产生一个新的ziplist呢?这是由于ziplist是一块连续空间,对它的追加操做,会引起内存的realloc,所以ziplist的内存位置可能会发生变化。实际上,咱们在以前介绍sds的文章中提到过相似这种接口使用模式(参见sdscatlen函数的说明)。
ziplistIndex: 返回index参数指定的数据项的内存位置。index能够是负数,表示从尾端向前进行索引。
ziplistNext和ziplistPrev分别返回一个ziplist中指定数据项p的后一项和前一项。
ziplistInsert: 在ziplist的任意数据项前面插入一个新的数据项。
ziplistDelete: 删除指定的数据项。
ziplistFind: 查找给定的数据(由vstr和vlen指定)。注意它有一个skip参数,表示查找的时候每次比较之间要跳过几个数据项。为何会有这么一个参数呢?其实这个参数的主要用途是当用ziplist表示hash结构的时候,是按照一个field,一个value来依次存入ziplist的。也就是说,偶数索引的数据项存field,奇数索引的数据项存value。当按照field的值进行查找的时候,就须要把奇数项跳过去。
ziplistLen: 计算ziplist的长度(即包含数据项的个数)。
ziplist的相关接口的具体实现,仍是有些复杂的,限于篇幅的缘由,咱们这里只结合代码来说解插入的逻辑。插入是颇有表明性的操做,经过这部分来一窥ziplist内部的实现,其它部分的实现咱们也就会很容易理解了。
ziplistPush和ziplistInsert都是插入,只是对于插入位置的限定不一样。它们在内部实现都依赖一个名为__ziplistInsert的内部函数,其代码以下(出自ziplist.c):
static unsigned char *__ziplistInsert(unsigned char *zl, unsigned char *p, unsigned char *s, unsigned int slen) { size_t curlen = intrev32ifbe(ZIPLIST_BYTES(zl)), reqlen; unsigned int prevlensize, prevlen = 0; size_t offset; int nextdiff = 0; unsigned char encoding = 0; long long value = 123456789; zlentry tail; /* Find out prevlen for the
* entry that is inserted. */ if (p[0] != ZIP_END) { ZIP_DECODE_PREVLEN(p, prevlensize, prevlen); } else { unsigned char *ptail = ZIPLIST_ENTRY_TAIL(zl); if (ptail[0] != ZIP_END) { prevlen = zipRawEntryLength(ptail); } }
/* See if the entry can be encoded */ if (zipTryEncoding(s,slen,&value,&encoding)) {
/* 'encoding' is set to the
* appropriate integer encoding */ reqlen = zipIntSize(encoding); } else {
/* 'encoding' is untouched,
* however zipEncodeLength will use
* the string length to figure out
* how to encode it. */ reqlen = slen; }
/* We need space for both the length
* of the previous entry and * the length of the payload. */ reqlen += zipPrevEncodeLength(NULL,prevlen); reqlen += zipEncodeLength(NULL,encoding,slen); /* When the insert position is not
* equal to the tail, we need to * make sure that the next entry can
* hold this entry's length in * its prevlen field. */ nextdiff = (p[0] != ZIP_END) ? zipPrevLenByteDiff(p,reqlen) : 0; /* Store offset because a realloc
* may change the address of zl. */ offset = p-zl; zl = ziplistResize(zl,curlen+reqlen+nextdiff); p = zl+offset; /* Apply memory move when necessary
* and update tail offset. */ if (p[0] != ZIP_END) {
/* Subtract one because of
* the ZIP_END bytes */ memmove(p+reqlen,p-nextdiff,curlen-offset-1+nextdiff); /* Encode this entry's raw
* length in the next entry. */ zipPrevEncodeLength(p+reqlen,reqlen); /* Update offset for tail */ ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+reqlen); /* When the tail contains more
* than one entry, we need to take * "nextdiff" in account as well.
* Otherwise, a change in the * size of prevlen doesn't have an
* effect on the *tail* offset. */ zipEntry(p+reqlen, &tail); if (p[reqlen+tail.headersize+tail.len] != ZIP_END) { ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+nextdiff); } } else {
/* This element will be the new tail. */ ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(p-zl); }
/* When nextdiff != 0, the raw
*length of the next entry has changed, so * we need to cascade the update
* throughout the ziplist */ if (nextdiff != 0) { offset = p-zl; zl = __ziplistCascadeUpdate(zl,p+reqlen); p = zl+offset; }
/* Write the entry */ p += zipPrevEncodeLength(p,prevlen); p += zipEncodeLength(p,encoding,slen); if (ZIP_IS_STR(encoding)) { memcpy(p,s,slen); } else { zipSaveInteger(p,value,encoding); } ZIPLIST_INCR_LENGTH(zl,1); return zl;
}
咱们来简单解析一下这段代码:
这个函数是在指定的位置p插入一段新的数据,待插入数据的地址指针是s,长度为slen。插入后造成一个新的数据项,占据原来p的配置,原来位于p位置的数据项以及后面的全部数据项,须要统一贯后移动,给新插入的数据项留出空间。参数p指向的是ziplist中某一个数据项的起始位置,或者在向尾端插入的时候,它指向ziplist的结束标记<zlend>
。
函数开始先计算出待插入位置前一个数据项的长度prevlen
。这个长度要存入新插入的数据项的<prevrawlen>
字段。
而后计算当前数据项占用的总字节数reqlen
,它包含三部分:<prevrawlen>
, <len>
和真正的数据。其中的数据部分会经过调用zipTryEncoding
先来尝试转成整数。
因为插入致使的ziplist对于内存的新增需求,除了待插入数据项占用的reqlen
以外,还要考虑原来p位置的数据项(如今要排在待插入数据项以后)的<prevrawlen>
字段的变化。原本它保存的是前一项的总长度,如今变成了保存当前插入的数据项的总长度。这样它的<prevrawlen>
字段自己须要的存储空间也可能发生变化,这个变化多是变大也多是变小。这个变化了多少的值nextdiff
,是调用zipPrevLenByteDiff
计算出来的。若是变大了,nextdiff
是正值,不然是负值。
如今很容易算出来插入后新的ziplist须要多少字节了,而后调用ziplistResize
来从新调整大小。ziplistResize的实现里会调用allocator的zrealloc
,它有可能会形成数据拷贝。
如今额外的空间有了,接下来就是将原来p位置的数据项以及后面的全部数据都向后挪动,并为它设置新的<prevrawlen>
字段。此外,还可能须要调整ziplist的<zltail>
字段。
最后,组装新的待插入数据项,放在位置p。
hash是Redis中能够用来存储一个对象结构的比较理想的数据类型。一个对象的各个属性,正好对应一个hash结构的各个field。
咱们在网上很容易找到这样一些技术文章,它们会说存储一个对象,使用hash比string要节省内存。实际上这么说是有前提的,具体取决于对象怎么来存储。若是你把对象的多个属性存储到多个key上(各个属性值存成string),固然占的内存要多。但若是你采用一些序列化方法,好比Protocol Buffers,或者Apache Thrift,先把对象序列化为字节数组,而后再存入到Redis的string中,那么跟hash相比,哪种更省内存,就不必定了。
固然,hash比序列化后再存入string的方式,在支持的操做命令上,仍是有优点的:它既支持多个field同时存取(hmset
/hmget
),也支持按照某个特定的field单独存取(hset
/hget
)。
实际上,hash随着数据的增大,其底层数据结构的实现是会发生变化的,固然存储效率也就不一样。在field比较少,各个value值也比较小的时候,hash采用ziplist来实现;而随着field增多和value值增大,hash可能会变成dict来实现。当hash底层变成dict来实现的时候,它的存储效率就无法跟那些序列化方式相比了。
当咱们为某个key第一次执行 hset key field value
命令的时候,Redis会建立一个hash结构,这个新建立的hash底层就是一个ziplist。
robj *createHashObject(void) { unsigned char *zl = ziplistNew(); robj *o = createObject(OBJ_HASH, zl); o->encoding = OBJ_ENCODING_ZIPLIST; return o;
}
上面的createHashObject
函数,出自object.c,它负责的任务就是建立一个新的hash结构。能够看出,它建立了一个type = OBJ_HASH
但encoding = OBJ_ENCODING_ZIPLIST
的robj对象。
实际上,本文前面给出的那个ziplist实例,就是由以下两个命令构建出来的。
hset user:100 name tielei hset user:100 age 20
每执行一次hset
命令,插入的field和value分别做为一个新的数据项插入到ziplist中(即每次hset
产生两个数据项)。
当随着数据的插入,hash底层的这个ziplist就可能会转成dict。那么到底插入多少才会转呢?
还记得本文开头提到的两个Redis配置吗?
hash-max-ziplist-entries 512 hash-max-ziplist-value 64
这个配置的意思是说,在以下两个条件之一知足的时候,ziplist会转成dict:
当hash中的数据项(即field-value对)的数目超过512的时候,也就是ziplist数据项超过1024的时候(请参考t_hash.c中的hashTypeSet
函数)。
当hash中插入的任意一个value的长度超过了64的时候(请参考t_hash.c中的hashTypeTryConversion
函数)。
Redis的hash之因此这样设计,是由于当ziplist变得很大的时候,它有以下几个缺点:
每次插入或修改引起的realloc操做会有更大的几率形成内存拷贝,从而下降性能。
一旦发生内存拷贝,内存拷贝的成本也相应增长,由于要拷贝更大的一块数据。
当ziplist数据项过多的时候,在它上面查找指定的数据项就会性能变得很低,由于ziplist上的查找须要进行遍历。
总之,ziplist原本就设计为各个数据项挨在一块儿组成连续的内存空间,这种结构并不擅长作修改操做。一旦数据发生改动,就会引起内存realloc,可能致使内存拷贝。