11

  本文将介绍Redis中一种重要的数据结构,这种数据结构是为了节省内存而设计的,这就是压缩列表(ZipList)。

1、简介

  压缩列表(ziplist)本质上就是一个字节数组,是Redis为了节约内存而设计的一种线性数据结构,可以包含任意多个元素,每个元素可以是一个字节数组或一个整数。
  Redis的有序集合、哈希以及列表都直接或者间接使用了压缩列表。当有序集合或哈希的元素数目比较少,且元素都是短字符串时,Redis便使用压缩列表作为其底层数据存储方式。列表使用快速链表(quicklist)数据结构存储,而快速链表就是双向链表与压缩列表的组合。
例如,使用下面命令创建一个哈希键并查看其编码:

127.0.0.1:6379> hmset person name zhangsan gender 1 age 22
OK
127.0.0.1:6379> object encoding person
"ziplist"

2、数据存储

2.1 编码

  Redis使用字节数组表示一个压缩列表,字节数组逻辑划分为多个字段,如图所示:

图 压缩列表结构示意图

各字段含义如下:

  • 1、zlbytes:压缩列表的字节长度,占4个字节,因此压缩列表最长(2^32)-1字节;
  • 2、zltail:压缩列表尾元素相对于压缩列表起始地址的偏移量,占4个字节;
  • 3、zllen:压缩列表的元素数目,占两个字节;那么当压缩列表的元素数目超过(2^16)-1怎么处理呢?此时通过zllen字段无法获得压缩列表的元素数目,必须遍历整个压缩列表才能获取到元素数目;
  • 4、entryX:压缩列表存储的若干个元素,可以为字节数组或者整数;entry的编码结构后面详述;
  • 5、zlend:压缩列表的结尾,占一个字节,恒为0xFF

假设char * zl指向压缩列表首地址,Redis通过以下宏定义实现了压缩列表各个字段的操作存取:

//zl指向zlbytes字段
#define ZIPLIST_BYTES(zl)       (*((uint32_t*)(zl)))

//zl+4指向zltail字段
#define ZIPLIST_TAIL_OFFSET(zl) (*((uint32_t*)((zl)+sizeof(uint32_t))))

//zl+zltail指向尾元素首地址;intrev32ifbe使得数据存取统一按照小端法
#define ZIPLIST_ENTRY_TAIL(zl)   ((zl)+intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))) 

//zl+8指向zllen字段
#define ZIPLIST_LENGTH(zl)   (*((uint16_t*)((zl)+sizeof(uint32_t)*2)))

//压缩列表最后一个字节即为zlend字段
#define ZIPLIST_ENTRY_END(zl)   ((zl)+intrev32ifbe(ZIPLIST_BYTES(zl))-1)

  了解了压缩列表的基本结构,我们可以很容易获得压缩列表的字节长度,元素数目等,那么如何遍历压缩列表的所有元素呢?我们已经知道对于每一个entry元素,存储的可能是字节数组或整数值;那么对任一个元素,我们如何判断存储的是什么类型?对于字节数组,我们又如何获取字节数组的长度?
回答这些问题之前,需要先看看压缩列表元素的编码结构,如图所示:

图片描述

  previous_entry_length字段表示前一个元素的字节长度,占1个或者5个字节;当前一个元素的长度小于254字节时,previous_entry_length字段用一个字节表示;当前一个元素的长度大于等于254字节时,previous_entry_length字段用5个字节来表示;而这时候previous_entry_length的第一个字节是固定的标志0xFE,后面4个字节才真正表示前一个元素的长度;假设已知当前元素的首地址为p,那么(p-previous_entry_length)就是前一个元素的首地址,从而实现压缩列表从尾到头的遍历;
  encoding字段表示当前元素的编码,即content字段存储的数据类型(整数或者字节数组),数据内容存储在content字段;为了节约内存,encoding字段同样是可变长度,编码如表-1所示:
<center>表-1 压缩列表元素编码表格</center>

encoding编码 encoding长度 content类型
00 bbbbbb(6比特表示content长度) 1字节 最大长度63的字节数组
01 bbbbbb xxxxxxxx(14比特表示content长度) 2字节 最大长度(2^14)-1字节最大长度的字节数组
10 __  aaaaaaaa bbbbbbbb cccccccc dddddddd(32比特表示content长度) 5字节 最大长度(2^32)-1的字节数组
11 00 0000 1字节 int16整数
11 01 0000 1字节 int32整数
11 10 0000 1字节 int64整数
11 11 0000 1字节 24比特整数
11 11 1110 1字节 8比特整数
11 11 xxxx 1字节 没有content字段;xxxx表示0~12之间的整数

  可以看出,根据encoding字段第一个字节的前2个比特,可以判断content字段存储的是整数,或者字节数组(以及字节数组最大长度);当content存储的是字节数组时,后续字节标识字节数组的实际长度;当content存储的是整数时,根据第3、4比特才能判断整数的具体类型;而当encoding字段标识当前元素存储的是0~12的立即数时,数据直接存储在encoding字段的最后4个比特,此时没有content字段。参照encoding字段的编码表格,Redis预定义了以下常量:

#define ZIP_STR_06B (0 << 6)
#define ZIP_STR_14B (1 << 6)
#define ZIP_STR_32B (2 << 6)
#define ZIP_INT_16B (0xc0 | 0<<4)
#define ZIP_INT_32B (0xc0 | 1<<4)
#define ZIP_INT_64B (0xc0 | 2<<4)
#define ZIP_INT_24B (0xc0 | 3<<4)
#define ZIP_INT_8B 0xfe

2.2 结构体entry

  2.1节分析了压缩列表的底层存储结构。但是我们发现对于任意的压缩列表元素,获取前一个元素的长度,判断存储的数据类型,获取数据内容,都需要经过复杂的解码运算才行,那么解码后的结果应该被缓存起来,为此定义了结构体zlentry,用于表示解码后的压缩列表元素:

typedef struct zlentry {
    unsigned int prevrawlensize;
    unsigned int prevrawlen;
     
    unsigned int lensize;                                 
    unsigned int len;
            
    unsigned int headersize; 
    
    unsigned char encoding;
      
    unsigned char *p;           
} zlentry;

  我们看到结构体定义了7个字段,而2.1节显示每个元素只包含3个字段。回顾压缩列表元素的编码结构,可变因素实际上不止三个;previous_entry_length字段的长度(字段prevrawlensize表示)、previous_entry_length字段存储的内容(字段prevrawlen表示)、encoding字段的长度(字段lensize表示)、encoding字段的内容(字段len表示数据内容长度,字段encoding表示数据类型)、和当前元素首地址(字段p表示)。而headersize字段则表示当前元素的首部长度,即previous_entry_length字段长度与encoding字段长度之和。
函数zipEntry用来解码压缩列表的元素,存储于zlentry结构体:

void zipEntry(unsigned char *p, zlentry *e) {
    ZIP_DECODE_PREVLEN(p, e->prevrawlensize, e->prevrawlen);
    ZIP_DECODE_LENGTH(p + e->prevrawlensize, e->encoding, e->lensize, e->len);
    e->headersize = e->prevrawlensize + e->lensize;
    e->p = p;
}

解码过程主要可以分为以下两个步骤:

  • 1) 解码previous_entry_length字段,此时入参ptr指向元素首地址;
#define ZIP_BIG_PREVLEN 254

#define ZIP_DECODE_PREVLEN(ptr, prevlensize, prevlen) do {     \
    if ((ptr)[0] < ZIP_BIG_PREVLEN) {                          \
        (prevlensize) = 1;                                     \
        (prevlen) = (ptr)[0];                                  \
    } else {                                                   \
        (prevlensize) = 5;                                     \
        memcpy(&(prevlen), ((char*)(ptr)) + 1, 4);             \
        memrev32ifbe(&prevlen);                                \
    }                                                          \
} while(0);
  • 2) 解码encoding字段逻辑,此时ptr指向元素首地址偏移previous_entry_length字段长度的位置:
#define ZIP_STR_MASK 0xc0

#define ZIP_DECODE_LENGTH(ptr, encoding, lensize, len) do {    \
    (encoding) = (ptr[0]);                                     \
    // ptr[0]小于11000000说明是字节数组,前两个比特为编码
    if ((encoding) < ZIP_STR_MASK) (encoding) &= ZIP_STR_MASK; \
    if ((encoding) < ZIP_STR_MASK) {                           \
        if ((encoding) == ZIP_STR_06B) {                       \
            (lensize) = 1;                                     \
            (len) = (ptr)[0] & 0x3f;                           \
        } else if ((encoding) == ZIP_STR_14B) {                \
            (lensize) = 2;                                     \
            (len) = (((ptr)[0] & 0x3f) << 8) | (ptr)[1];       \
        } else if ((encoding) == ZIP_STR_32B) {                \
            (lensize) = 5;                                     \
            (len) = ((ptr)[1] << 24) |                         \
                    ((ptr)[2] << 16) |                         \
                    ((ptr)[3] <<  8) |                         \
                    ((ptr)[4]);                                \
        } else {                                               \
            panic("Invalid string encoding 0x%02X", (encoding));\
        }                                                      \
    } else {                                                   \
        (lensize) = 1;                                         \
        (len) = zipIntSize(encoding);                          \
    }                                                          \
} while(0);

字节数组只根据ptr[0]的前2个比特即可判类型,而判断整数类型需要ptr[0]的前4个比特,代码如下:

unsigned int zipIntSize(unsigned char encoding) {
    switch(encoding) {
    case ZIP_INT_8B:  return 1;
    case ZIP_INT_16B: return 2;
    case ZIP_INT_24B: return 3;
    case ZIP_INT_32B: return 4;
    case ZIP_INT_64B: return 8;
    }
    
    // 4比特立即数
    if (encoding >= ZIP_INT_IMM_MIN && encoding <= ZIP_INT_IMM_MAX)     
        return 0; 
    panic("Invalid integer encoding 0x%02X", encoding);
    return 0;
}

3、基本操作

3.1 创建压缩列表

创建压缩列表的API定义如下,函数无输入参数,返回参数为压缩列表首地址:

unsigned char *ziplistNew(void);
创建空的压缩列表,只需要分配初始存储空间(11=4+4+2+1个字节),并对zlbytes、zltail、zllen和zlend字段初始化即可。
unsigned char *ziplistNew(void) {
    //ZIPLIST_HEADER_SIZE = zlbytes + zltail + zllen;
    unsigned int bytes = ZIPLIST_HEADER_SIZE+1;        
    unsigned char *zl = zmalloc(bytes);
 
    ZIPLIST_BYTES(zl) = intrev32ifbe(bytes);
    ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(ZIPLIST_HEADER_SIZE);
    ZIPLIST_LENGTH(zl) = 0;
    
    //结尾标识0XFF
    zl[bytes-1] = ZIP_END;             
    return zl;
}

3.2 插入元素

压缩列表插入元素的API定义如下,函数输入参数zl表示压缩列表首地址,p指向新元素的插入位置,s表示数据内容,slen表示数据长度,返回参数为压缩列表首地址。

unsigned char *ziplistInsert(unsigned char *zl, unsigned char *p,  
                                     unsigned char *s, unsigned int slen);

插入元素时,可以简要分为三个步骤:第一步需要将元素内容编码为压缩列表的元素,第二步重新分配空间,第三步拷贝数据。下面分别讨论每个步骤的实现逻辑。

  • 1) 编码

编码即计算previous_entry_length字段、encoding字段和content字段的内容。如何获取前一个元素的长度呢?这时候就需要根据插入元素的位置分情况讨论了,如图所示:

图片描述

  当压缩列表为空插入位置为P0时,此时不存在前一个元素,即前一个元素的长度为0;
  当插入位置为P1时,此时需要获取entryX元素的长度,而entryX+1元素的previous_entry_length字段存储的就是entryX元素的长度,比较容易获取;
  当插入位置为P2时,此时需要获取entryN元素的长度,entryN是压缩列表的尾元素,计算其元素长度需要将其三个字段长度相加,函数实现如下:

unsigned int zipRawEntryLength(unsigned char *p) {
    unsigned int prevlensize, encoding, lensize, len;
    ZIP_DECODE_PREVLENSIZE(p, prevlensize);
    ZIP_DECODE_LENGTH(p + prevlensize, encoding, lensize, len);
    return prevlensize + lensize + len;
}

  其中ZIP_DECODE_PREVLENSIZE和ZIP_DECODE_LENGTH在2.2节已经讲过,这里不再赘述。
  encoding字段标识的是当前元素存储的数据类型以及数据长度,编码时首先会尝试将数据内容解析为整数,如果解析成功则按照压缩列表整数类型编码存储,解析失败的话按照压缩列表字节数组类型编码存储。

if (zipTryEncoding(s,slen,&value,&encoding)) {
    reqlen = zipIntSize(encoding);
} else {
    reqlen = slen;
}
 
reqlen += zipStorePrevEntryLength(NULL,prevlen);
reqlen += zipStoreEntryEncoding(NULL,encoding,slen);

  程序首先尝试按照整数解析新添加元素的数据内容,数值存储在变量value,编码存储在变量encoding。如果解析成功,还需要计算整数所占字节数。
  变量reqlen最终存储的是当前元素所需空间大小,初始赋值为元素content字段所需空间大小,再累加previous_entry_length所需空间大小与encoding字段所需空间大小。

  • 2) 重新分配空间

图片描述

  由于新插入元素,压缩列表所需空间增大,因此需要重新分配存储空间。那么空间大小是否是添加元素前的压缩列表长度与新添加元素元素长度之和呢?并不完全是,如图中所示的例子。
  插入元素前,entryX元素长度为128字节,entryX+1元素的previous_entry_length字段占1个字节;添加元素entryNEW元素,元素长度为1024字节,此时entryX+1元素的previous_entry_length字段需要占5个字节;即压缩列表的长度不仅仅是增加了1024字节,还有entryX+1元素扩展的4字节。我们很容易知道,entryX+1元素长度可能增加4字节,也可能减小4字节,也可能不变。而由于重新分配空间,新元素插入的位置指针P会失效,因此需要预先计算好指针P相对于压缩列表首地址的偏移量,待分配空间之后再偏移即可。

size_t curlen = intrev32ifbe(ZIPLIST_BYTES(zl));
 
int forcelarge = 0;
nextdiff = (p[0] != ZIP_END) ? zipPrevLenByteDiff(p,reqlen) : 0;
if (nextdiff == -4 && reqlen < 4) {
    nextdiff = 0;
    forcelarge = 1;
}
 
//存储偏移量
offset = p-zl;
//调用realloc重新分配空间
zl = ziplistResize(zl,curlen+reqlen+nextdiff);
//重新偏移到插入位置P
p = zl+offset;

  那么nextdiff与forcelarge在这里有什么用呢?分析ziplistResize函数的3个输入参数,curlen表示插入元素前压缩列表的长度,reqlen表示插入元素元素的长度,而nextdiff表示的是entryX+1元素长度的变化,取值可能为0(长度不变)、4(长度增加4)和-4(长度减小4)。我们再思考下,当nextdiff等于-4,而reqlen小于4时会发生什么呢?没错,插入元素导致压缩列表所需空间减少了,即函数ziplistResize底层调用realloc重新分配的空间小于指针zl指向的空间。这可能会存在问题,我们都知道realloc重新分配空间时,返回的地址可能不变,当重新分配的空间大小反而减少时,realloc底层实现可能会将多余的空间回收,此时可能会导致数据的丢失。因此需要避免这种情况的发生,即重新赋值nextdiff等于0,同时使用forcelarge标记这种情况。
  可以再思考下,nextdiff等于-4时,reqlen会小于4吗?答案是可能的,连锁更新可能会导致这种情况的发生。连锁更新将在第4节介绍。

  • 3) 数据拷贝

  重新分配空间之后,需要将位置P后的元素移动到指定位置,将新元素插入到位置P。我们假设entryX+1元素的长度增加4(即nextdiff等于4),此时数据拷贝示意图如图所示:

图片描述

  从图中可以看到,位置P后的所有元素都需要移动,移动的偏移量是插入元素entryNew的长度,移动的数据块长度是位置P后所有元素长度之和再加上nextdiff的值,数据移动之后还需要更新entryX+1元素的previous_entry_length字段。

memmove(p+reqlen,p-nextdiff,curlen-offset-1+nextdiff); 
//更新entryX+1元素的previous_entry_length字段字段
if (forcelarge)
    //entryX+1元素的previous_entry_length字段依然占5个字节;
    //但是entryNEW元素长度小于4字节
    zipStorePrevEntryLengthLarge(p+reqlen,reqlen);
else
    zipStorePrevEntryLength(p+reqlen,reqlen);
 
//更新zltail字段
ZIPLIST_TAIL_OFFSET(zl) =
    intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+reqlen);
 
zipEntry(p+reqlen, &tail);
if (p[reqlen+tail.headersize+tail.len] != ZIP_END) {
    ZIPLIST_TAIL_OFFSET(zl) =
        intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+nextdiff);
}
 
//更新zllen字段
ZIPLIST_INCR_LENGTH(zl,1);

  思考一下,第一次更新尾元素偏移量之后,为什么指向的元素可能不是尾元素呢?很显然,当entryX+1元素就是尾元素时,只需要更新一次尾元素的偏移量;但是当entryX+1元素不知尾元素,且entryX+1元素长度发生了改变,此时尾元素偏移量还需要加上nextdiff的值。

3.3 删除元素

  压缩列表删除元素的API定义如下,函数输入参数zl指向压缩列表首地址,*p指向待删除元素的首地址(参数p同时可以作为输出参数),返回参数为压缩列表首地址。

unsigned char *ziplistDelete(unsigned char *zl, unsigned char **p);

ziplistDelete函数只是简单调用底层__ziplistDelete函数实现删除功能;__ziplistDelete函数可以同时删除多个元素,输入参数p指向的是首个删除元素的首地址,num表示待删除元素数目。

unsigned char *ziplistDelete(unsigned char *zl, unsigned char **p) {
    size_t offset = *p-zl;
    zl = __ziplistDelete(zl,*p,1);
    *p = zl+offset;
    return zl;
}

删除元素同样可以简要分为三个步骤:第一步计算待删除元素总长度,第二步数据拷贝,第三步重新分配空间。下面分别讨论每个步骤的实现逻辑。

  • 1) 计算待删除元素总长度,其中zipRawEntryLength函数在3.2节已经讲过,这里不再详述;
//解码第一个待删除元素
zipEntry(p, &first);
 
//遍历所有待删除元素,同时指针p向后偏移
for (i = 0; p[0] != ZIP_END && i < num; i++) {
    p += zipRawEntryLength(p);
    deleted++;
}
//totlen即为待删除元素总长度
totlen = p-first.p;
  • 2) 数据拷贝;

第一步骤计算完成之后,指针first与指针p之间的元素都是待删除的。很显然,当指针p恰好指向zlend字段,不再需要数据的拷贝了,只需要更新尾节点的偏移量即可。下面分析另外一种情况,即指针p指向的是某一个元素而不是zlend字段。
分析类似于3.2节插入元素。删除元素时,压缩列表所需空间减少,减少的量是否仅仅是待删除元素总长度呢?答案同样是否定的,举个简单的例子,下图是经过第一步骤计算之后的示意图:

图片描述

  删除元素entryX+1到元素entryN-1之间的N-X-1个元素,元素entryN-1的长度为12字节,因此元素entryN的previous_entry_length字段占1个字节;删除这些元素之后,entryX称为了entryN的前一个元素,元素entryX的长度为512字节,因此元素entryN的previous_entry_length字段需要占5个字节。即删除元素之后的压缩列表的总长度,还与entryN元素长度的变化量有关。

//计算元素entryN长度的变化量
nextdiff = zipPrevLenByteDiff(p,first.prevrawlen);
 
//更新元素entryN的previous_entry_length字段
p -= nextdiff;
zipStorePrevEntryLength(p,first.prevrawlen);
 
//更新zltail
ZIPLIST_TAIL_OFFSET(zl) =
    intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))-totlen);
 
zipEntry(p, &tail);
if (p[tail.headersize+tail.len] != ZIP_END) {
    ZIPLIST_TAIL_OFFSET(zl) =
       intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+nextdiff);
}
 
//数据拷贝
memmove(first.p,p,
    intrev32ifbe(ZIPLIST_BYTES(zl))-(p-zl)-1);

与3.2节插入元素更新zltail字段相同,当entryX+1元素就是尾元素时,只需要更新一次尾元素的偏移量;但是当entryX+1元素不是尾元素时,且entryX+1元素长度发生了改变,此时尾元素偏移量还需要加上nextdiff的值。

  • 3) 重新分配空间

逻辑与3.2节插入元素逻辑基本类似,这里就不再详述。代码如下:

offset = first.p-zl;
zl = ziplistResize(zl, intrev32ifbe(ZIPLIST_BYTES(zl))-totlen+nextdiff);
p = zl+offset;
ZIPLIST_INCR_LENGTH(zl,-deleted);

  思考一下:在3.2节我们提到,调用ziplistResize函数重新分配空间时,如果重新分配的空间小于指针zl指向的空间大小时,可能会出现问题。而这里由于是删除元素,压缩列表的长度肯定是减少的。为什么又能这样使用呢?
根本原因在于删除元素时,我们是先拷贝数据,再重新分配空间,即调用ziplistResize函数时,多余的那部分空间存储的数据已经被拷贝了,此时回收这部分空间并不会造成数据的丢失。

3.5 遍历压缩列表

  遍历就是从头到尾(前向遍历)或者从尾到头(后向遍历)访问压缩列表中的每一个元素。压缩列表的遍历API定义如下,函数输入参数zl指向压缩列表首地址,p指向当前访问元素的首地址;ziplistNext函数返回后一个元素的首地址,ziplistPrev返回前一个元素的首地址。

//后向遍历
unsigned char *ziplistNext(unsigned char *zl, unsigned char *p);
//前向遍历
unsigned char *ziplistPrev(unsigned char *zl, unsigned char *p);

  我们已经知道压缩列表每个元素的previous_entry_length字段存储的是前一个元素的长度,因此压缩列表的前向遍历相对简单,表达式(p-previous_entry_length)即可获取前一个元素的首地址,这里不做详述。后向遍历时,需要解码当前元素,计算当前元素长度,才能获取后一个元素首地址;ziplistNext函数实现如下:

unsigned char *ziplistNext(unsigned char *zl, unsigned char *p) {
    //zl参数无用;这里只是为了避免警告
    ((void) zl);
 
    if (p[0] == ZIP_END) {
        return NULL;
    }
 
    p += zipRawEntryLength(p);
    if (p[0] == ZIP_END) {
        return NULL;
    }
 
    return p;
}

4、连锁更新

  如下图所示,删除压缩列表zl1位置P1的元素entryX,或者在压缩列表zl2位置P2插入元素entryY,此时会出现什么情况呢?

连锁更新示意图

  压缩列表zl1,元素entryX之后的所有元素entryX+1、entryX+2等长度都是253字节,显然这些元素的previous_entry_length字段的长度都是1字节。当删除元素entryX时,元素entryX+1的前驱节点改为元素entryX-1,长度为512字节,此时元素entryX+1的previous_entry_length字段需要5字节才能存储元素entryX-1的长度,则元素entryX+1的长度需要扩展至257字节;而由于元素entryX+1长度的增加,元素entryX+2的previous_entry_length字段同样需要改变。以此类推,由于删除了元素entryX,之后的所有元素entryX+1、entryX+2等长度都必须扩展,而每次元素的扩展都将导致重新分配内存,效率是很低下的。压缩列表zl2,插入元素entryY同样会产生上面的问题。
  上面的情况称之为连锁更新。从上面分析可以看出,连锁更新会导致多次重新分配内存以及数据拷贝,效率是很低下的。但是出现这种情况的概率是很低的,因此对于删除元素与插入元素的操作,redis并没有为了避免连锁更新而采取措施。redis只是在删除元素与插入元素操作的末尾,检查是否需要更新后续元素的previous_entry_length字段,其实现函数_ziplistCascadeUpdate,主要逻辑如下图所示:

连锁更新实现逻辑

5、总结

  本文首先介绍了压缩列表的编码与数据结构,随后介绍了压缩列表的基本操作:创建压缩列表、插入元素、删除元素与遍历,最后分析了压缩列表连锁更新的出现以及解决方案。


AI及LNMPRG研究
7.2k 声望12.8k 粉丝

一群热爱代码的人 研究Nginx PHP Redis Memcache Beanstalk 等源码 以及一群热爱前端的人