Redis5设计与源码分析 (第4章 压缩列表)

压缩列表ziplist本质上就是一个字节数组,是Redis为了节约内存而设计的一种线性数据结构,可以包含多个元素,每个元素可以是一个字节数组或一个整数。

Redis的有序集合、散列和列表都直接或者间接使用了压缩列表。当有序集合或散列表的元素个数比较少,且元素都是短字符串时,Redis便使用压缩列表作为其底层数据存储结构。列表使用快速链表(quicklist)数据结构存储,而快速链表就是双向链表压缩列表的组合。

 

4.1 压缩列表的存储结构

Redis使用字节数组表示一个压缩列表,压缩列表结构示意如图4-1所示。

图4-1中各字段的含义如下。

1)zlbytes: 压缩列表的字节长度,占4个字节,因此压缩列表最多有2^ 32 -1个字节。

2)zltail: 压缩列表尾元素相对于压缩列表起始地址的偏移量,占4个字节。

3)zllen: 压缩列表的元素个数,占2个字节。zllen无法存储元素个数超过65535(2^ 16 -1)的压缩列表,必须遍历整个压缩列表才能获取到元素个数。

4)entryX: 压缩列表存储的元素,可以是字节数组或者整数,长度不限。entry的编码结构将在后面详细介绍。

5)zlend: 压缩列表的结尾,占1个字节,恒为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)

 

压缩列表元素的编码结构:

previous_entry_length字段:

表示前一个元素的字节长度,占1个或者5个字节,当前一个元素的长度小于254字节时,用1个字节表示;当前一个元素的长度大于或等于254字节时,用5个字节来表示。而此时previous_entry_length字段的第1个字节是固定的0xFE,后面4个字节才真正表示前一个元素的长度。假设已知当前元素的首地址为p,那么 p - previous_entry_length就是前一个元素的首地址,从而实现压缩列表从尾到头的遍历。

encoding字段:

表示当前元素的编码,即content字段存储的数据类型(整数或者字节数组),数据内容存储在content字段。为了节约内存,encoding字段同样长度可变。压缩列表元素的编码如表4-1所示。

根据encoding字段第1个字节的前2位,可以判断content字段存储的是整数或者字节数组(及其最大长度)。当content存储的是字节数组时,后续字节标识字节数组的实际长度

当content存储的是整数时,可根据第3、第4位判断整数的具体类型。而当encoding字段标识当前元素存储的是0~12的立即数时,数据直接存储在encoding字段的最后4位,此时没有content字段。

参照encoding字段的编码表格,Redis预定义了以下常量对应encoding字段的各编码类型:

#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

 

4.2 结构体

结构体zlentry,用于表示解码后的压缩列表元素;

typedef struct zlentry {

unsigned int prevrawlensize; /*用于编码前一个条目len的字节 */

unsigned int prevrawlen; /* 上一个条目len */

unsigned int lensize; /* 用于编码此条目 类型/ len 的字节。 */

unsigned int len; /*用于表示实际条目的字节。 */

unsigned int headersize; /* 当前元素的首部长度,即previous_entry_length字段长度与encoding字段长度之和 */

unsigned char encoding; /* */

unsigned char *p; /* 当前元素首地址, */

} zlentry;

 

函数zipEntry用来解码压缩列表的元素,存储于zlentry结构体。

/* Return a struct with all information about an entry. */

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_DECODE_PREVLEN(ptr, prevlensize, prevlen) do {

ZIP_DECODE_PREVLENSIZE(ptr, prevlensize);

if ((prevlensize) == 1) {

  (prevlen) = (ptr)[0];

} else if ((prevlensize) == 5) {

  assert(sizeof((prevlen)) == 4);

  memcpy(&(prevlen), ((char*)(ptr)) + 1, 4);

  memrev32ifbe(&prevlen);

}

} while(0);

 

2)解码encoding字段逻辑,此时入参ptr指向元素首地址偏移previous_entry_length字段长度的位置。

 

 

#define ZIP_DECODE_LENGTH(ptr, encoding, lensize, len) do {

ZIP_ENTRY_ENCODING((ptr), (encoding));

// ptr[0]<11000000说明是字节数组,前两个比特为字节数组编码类型

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;

}

// 0~12立即数

if (encoding >= ZIP_INT_IMM_MIN && encoding <= ZIP_INT_IMM_MAX)

return 0; /* 4 bit immediate */

panic("Invalid integer encoding 0x%02X", encoding);

return 0;

}

4.3 基本操作

4.3.1 创建压缩列表

函数无输入参数,返回参数为压缩列表首地址。

/* Create a new empty ziplist. */

unsigned char *ziplistNew(void) {

unsigned int bytes = ZIPLIST_HEADER_SIZE+ZIPLIST_END_SIZE;

unsigned char *zl = zmalloc(bytes);

ZIPLIST_BYTES(zl) = intrev32ifbe(bytes);

ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(ZIPLIST_HEADER_SIZE);

ZIPLIST_LENGTH(zl) = 0;

zl[bytes-1] = ZIP_END; // 结尾标识0XFF

return zl;

}

 

只需要分配初始存储空间11(4+4+2+1)个字节,并对zlbytes、zltail、zllen和zlend字段初始化即可 ;

 

4.3.2 插入元素

函数输入参数zl表示压缩列表首地址,p指向元素插入位置,s表示数据内容,slen表示数据长

度,返回参数为压缩列表首地址。

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

 

插入元素可以简要分为3个步骤:

①将元素内容编码;

②重新分配空间;

③复制数据。

下面分别讲解每个步骤的实现逻辑。

1.编码

编码即计算previous_entry_length字段、encoding字段和content字段的内容。

1)当压缩列表为空、插入位置为P0时,不存在前一个元素,即前一个元素的长度为0。

2)当插入位置为P1时,需要获取entryX元素的长度,而entryX+1元素的previous_entry_length字段存储的就是entryX元素的长度,比较容易获取。

3)当插入位置为P2时,需要获取entryN元素的长度,entryN是压缩列表的尾元素,计算元素长度时需要将其3个字段长度相加,函数实现如下。

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;

}

 

encoding字段标识的是当前元素存储的数据类型和数据长度

编码时首先尝试将数据内容解析为整数,如果解析成功,则按照压缩列表整数类型编码存储;如果解析失败,则按照压缩列表字节数组类型编码存储。

/* 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 zipStoreEntryEncoding will use the

  * string length to figure out how to encode it. */

  reqlen = slen;

}

 

/*需要空间来容纳上一个enrty的长度和有效负载的长度 */

reqlen += zipStorePrevEntryLength(NULL,prevlen);

reqlen += zipStoreEntryEncoding(NULL,encoding,slen);

 

上述程序尝试按照整数解析新添加元素的数据内容,数值存储在变量value中,编码存储在变量encoding中。如果解析成功,还需要计算整数所占字节数(zipIntSize)

 

变量reqlen最终存储的是当前元素所需空间大小,初始赋值为元素content字段所需空间大小,再累加previous_entry_length和encoding字段所需空间大小。

 

2.重新分配空间

由于新插入了元素,压缩列表所需空间增大,因此需要重新分配存储空间。

 

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;

zl = ziplistResize(zl,curlen+reqlen+nextdiff); // curlen表示插入元素前压缩列表的长度;reqlen表示新插入元素的长度;而nextdiff表示entryX+1元素长度的变化,取值可能为0(长度不变)、4(长度增加4)或-4(长度减少4)。

p = zl+offset;

 

nextdiff与forcelarge在这里有什么用呢?

函数ziplistResize内部调用realloc重新分配的空间小于指针zl指向的空间。我们知道realloc重新分配空间时,返回的地址可能不变(当前位置有足够的内存空间可供分配),当重新分配的空间减小时,realloc可能会将多余的空间回收,导致数据丢失。因此需要避免这种情况的发生,即重新赋值nextdiff=0,同时使用forcelarge标记这种情况。 (连锁更新)

 

3.数据复制

重新分配空间之后,需要将位置P后的元素移动到指定位置,将新元素插入到位置P。

我们假设entryX+1元素的长度增加4(即nextdiff=4),此时压缩列表数据复制示意如图4-5所示。

位置P后的所有元素都需要移动,移动的偏移量是待插入元素entryNEW的长度,移动的数据块长度是位置P后所有元素的长度之和加上nextdiff的值,数据移动之后还需要更新entryX+1元素的previous_entry_length 字段。

//为什么减1呢?zlend结束表示恒为0xFF,不需要移动

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);

 

/* 当尾部包含多个条目时,我们还需要考虑" nextdiff"。否则,prevlen大小的改变不会影响* tail 偏移量 */

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);

 

4.3.3 删除元素

函数输入参数zl指向压缩列表首地址;

*p指向待删除元素的首地址(参数p同时可以作为输出参数);返回参数为压缩列表首地址。

_ziplistDelete函数可以同时删除多个元素, 参数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;

}

删除元素同样可以简要分为三个步骤:①计算待删除元素的总长度;②数据复制;③重新分配空间。下面分别讨论每个步骤的实现逻辑。

  • 计算待删除元素的总长度 。

//解码第一个待删除元素

zipEntry(p, &first);

// 遍历所有待删除元素,同时指针p向后偏移

for (i = 0; p[0] != ZIP_END && i < num; i++) {

  p += zipRawEntryLength(p);

  deleted++;

}

// totlen即为待删除元素总长度

totlen = p-first.p;

 

  • 数据复制

第1步完成之后,指针first与指针p之间的元素都是待删除的。当指针p恰好指向zlend字段时,不再需要复制数据,只需要更新尾节点的偏移量即可。

下面分析另一种情况,即指针p指向的是某一个元素,而不是zlend字段。删除元素时,压缩列表所需空间减小,减小的量是否仅为待删除元素的总长度呢?答案同样是否定的。举个简单的例子,图4-6是经过第1步之后的压缩列表示意。

删除元素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);

当entryN元素就是尾元素时,只需要更新一次尾元素的偏移量;

但是当entryN元素不是尾元素且entryN元素的长度发生了改变时,尾元素偏移量还需要加上nextdiff的值。

 

  • 重新分配空间 。
跟前面的重新分配类似;

offset = first.p-zl;

zl = ziplistResize(zl, intrev32ifbe(ZIPLIST_BYTES(zl))-totlen+nextdiff);

ZIPLIST_INCR_LENGTH(zl,-deleted);

p = zl+offset;

 

4.3.4 遍历压缩列表

遍历就是从头到尾(后向遍历)或者从尾到头(前向遍历)访问压缩列表中的每个元素。

压缩列表的遍历API定义如下,

函数输入参数zl指向压缩列表首地址

p指向当前访问元素的首地址;

ziplistNext函数返回后一个元素的首地址,

ziplistPrev返回前一个元素的首地址。

 

unsigned char *ziplistNext(unsigned char *zl, unsigned char *p) {

((void) zl);

//zl参数无用;这里只是为了避免警告

if (p[0] == ZIP_END) {

  return NULL;

}

p += zipRawEntryLength(p);

if (p[0] == ZIP_END) {

  return NULL;

}

return p;

}

 

压缩列表每个元素的previous_entry_length字段存储的是前一个元素的长度,因此压缩列表的前向遍历相对简单,表达式p-previous_entry_length即可获取前一个元素的首地址,这里不做详述。后向遍历时,需要解码当前元素,计算当前元素的长度,才能获取后一个元素首地址;

 

4.4 连锁更新

当元素中间插入新元素或者中间删除元素情况会导致后续的所有元素需要重新分配内存;

Redis并没有为了避免连锁更新而采取措施。Redis只是在删除元素和插入元素操作的末尾,检查是否需要更新后续元素的previous_entry_length字段,其实现函数为_ziplistCascadeUpdate;

图4-8 连锁更新实现逻辑

4.5 本章小结

本章首先介绍了压缩列表的存储结构,随后从源码层详细分析了压缩列表的基本操作:创建压缩列表、插入元素、删除元素和遍历压缩列表,最后分析了压缩列表连锁更新的原因及解决方案。

posted @ 2020-10-27 16:31  将军上座  阅读(323)  评论(0)    收藏  举报