二.Redis 数据结构(上) SDS、ListNode、ZipList 。 by TEnth丶

573 阅读27分钟

二.Redis 数据结构

Redis运行非常快,除了它是内存数据库,使得所有的操作都在内存上进行之外,还有一个重要因素,它实现的数据结构,使得我们对数据进行增删查改操作时,Redis 能高效的处理。

本文所分析的源码是2022.4.28在Redis的github主页上下载的7.2unstable版本。

1.SDS

Redis 是用 C 语言实现的,但是它没有直接使用 C 语言的 char* 字符数组来实现字符串,而是自己封装了一个名为简单动态字符串(simple dynamic string,SDS) 的数据结构来表示字符串,也就是 Redis 的 String 数据类型的底层数据结构是 SDS。

C 语言的字符串存在的缺陷以及可以改进的地方:

  • 获取字符串长度的时间复杂度为 O(N);
  • 字符串的结尾是以 “\0” 字符标识,字符串里面不能包含有 “\0” 字符,因此不能保存二进制数据;
  • 字符串操作函数不高效且不安全,比如有缓冲区溢出的风险,有可能会造成程序运行终止;

1.1结构

下图就是 Redis 的 SDS 的数据结构:

img

(图片出处:小林Coding)

结构中的每个成员变量分别介绍下:

  • len,记录了字符串长度。这样获取字符串长度的时候,只需要返回这个成员变量值就行,时间复杂度只需要 O(1)。
  • alloc,分配给字符数组的空间长度。这样在修改字符串的时候,可以通过 alloc - len 计算出剩余的空间大小,可以用来判断空间是否满足修改需求,如果不满足的话,就会自动将 SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用 SDS 既不需要手动修改 SDS 的空间大小,也不会出现前面所说的缓冲区溢出的问题。
  • flags,用来表示不同类型的 SDS。一共设计了 5 种类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64,后面在说明区别之处。
  • buf[],字符数组,用来保存实际数据。不仅可以保存字符串,也可以保存二进制数据。

总的来说,Redis 的 SDS 结构在原本字符数组之上,增加了三个元数据:len、alloc、flags,用来解决 C 语言字符串的缺陷。

typedef char *sds;
​
/* 注意:sdshdr5从未使用过,我们只是直接访问标志字节。然而,这里是为了记录类型5的SDS字符串的布局。 */
struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* used */
    uint8_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
/*
可以看到:
- sdshdr16 类型的 len 和 alloc 的数据类型都是 uint16_t,表示字符数组长度和分配空间大小不能超过 2 的 16 次方。
- sdshdr32 则都是 uint32_t,表示表示字符数组长度和分配空间大小不能超过 2 的 32 次方。
*/
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; /* used */
    uint32_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; /* used */
    uint64_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
​

1.2改进的 SDS 的优点:

  1. O(1)复杂度获取字符串长度

    C 语言的字符串长度获取 strlen 函数,需要通过遍历的方式来统计字符串长度,时间复杂度是 O(N)。

    而 Redis 的 SDS 结构因为加入了 len 成员变量,那么获取字符串长度的时候,直接返回这个成员变量的值就行,所以复杂度只有 O(1)

  1. 二进制安全

    因为 SDS 不需要用 “\0” 字符来标识字符串结尾了,而是有个专门的 len 成员变量来记录长度,所以可存储包含 “\0” 的数据。但是 SDS 为了兼容部分 C 语言标准库的函数, SDS 字符串结尾还是会加上 “\0” 字符。

    因此, SDS 的 API 都是以处理二进制的方式来处理 SDS 存放在 buf[] 里的数据,程序不会对其中的数据做任何限制,数据写入的时候时什么样的,它被读取时就是什么样的。

    通过使用二进制安全的 SDS,而不是 C 字符串,使得 Redis 不仅可以保存文本数据,也可以保存任意格式的二进制数据。

  1. 不会发生缓冲区溢出

    C 语言的字符串标准库提供的字符串操作函数,大多数(比如 strcat 追加字符串函数)都是不安全的,因为这些函数把缓冲区大小是否满足操作需求的工作交由开发者来保证,程序内部并不会判断缓冲区大小是否足够用,当发生了缓冲区溢出就有可能造成程序异常结束。

    所以,Redis 的 SDS 结构里引入了 alloc 和 len 成员变量,这样 SDS API 通过 alloc - len 计算,可以算出剩余可用的空间大小,这样在对字符串做修改操作的时候,就可以由程序内部判断缓冲区大小是否足够用。

    而且,当判断出缓冲区大小不够用时,Redis 会自动将扩大 SDS 的空间大小(小于 1MB 翻倍扩容,大于 1MB 按 1MB 扩容) ,以满足修改所需的大小。

    在扩展 SDS 空间之前,SDS API 会优先检查未使用空间是否足够,如果不够的话,API 不仅会为 SDS 分配修改所必须要的空间,还会给 SDS 分配额外的「未使用空间」。

    这样的好处是,下次在操作 SDS 时,如果 SDS 空间够的话,API 就会直接使用「未使用空间」,而无须执行内存分配,有效的减少内存分配次数

    所以,使用 SDS 即不需要手动修改 SDS 的空间大小,也不会出现缓冲区溢出的问题。

  1. 节省内存空间

    SDS 结构中有个 flags 成员变量,表示的是 SDS 类型。

    Redos 一共设计了 5 种类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64。

    这 5 种类型的主要区别就在于,它们数据结构中的 len 和 alloc 成员变量的数据类型不同

之所以 SDS 设计不同类型的结构体,是为了能灵活保存不同大小的字符串,从而有效节省内存空间。比如,在保存小字符串时,结构头占用空间也比较少。

1.3 attribute语法是什么,C/C++ 中的内存对齐是什么规则?

内存对齐

除了设计不同类型的结构体,Redis 在编程上还使用了专门的编译优化来节省内存空间,即在 struct 声明了 __attribute__ ((packed)) ,它的作用是:告诉编译器取消结构体在编译过程中的优化对齐,按照实际占用字节数进行对齐

如果你足够熟悉C/C++,那么你一定知道结构体中的内存对齐,这甚至既是笔试又是面试的高频考点。WIKI中是这么说的:

The C struct directly references a contiguous block of physical memory, usually delimited (sized) by word-length boundaries. The contents of a struct are stored in contiguous memory.

翻译:C结构直接引用一个连续的物理内存块,通常由字长边界分隔(大小)。结构的内容存储在连续内存中。

其实也就是为了更快速的找到结构体中的某个数据,系统会以一个固定的步长去找,我们看下面这个例子:

class B {
    char a;
    double b;
    int c;
};
int main() {
    cout << sizeof(B) << endl;  //输出24
}

因为double是8个字节,所以我们要让char a 浪费7个字节、int c 浪费4个字节,以步长为8的方式去直接找到每一个数据。

并且,在不同的编译器中,内存对齐的实现方式是不同的,以下例子是我在VS2019 和 gcc 7.5.0中分别跑的结果:

//                  左为VS 2019   右为GCC 7.5.0
class E {
    char a;               //    8       8
    double b;             //    8       8
    int c[3];             //    12      16
    char* d;              //    4       8
};
int main() {
    cout << sizeof(E) << endl;// 32       40
}
    

attribute

如果不想编译器使用字节对齐的方式进行分配内存,可以采用了 __attribute__ ((packed)) 属性定义结构体,这样一来,结构体实际占用多少内存空间,编译器就分配多少空间。

关键字attribute 也可以对结构体(struct )或共用体(union )进行属性设置。大致有六个参数值可以被设定,即:aligned, packed, transparent_union, unused, deprecated 和 may_alias 。

在使用attribute 参数时,你也可以在参数的前后都加上“ ” (两个下划线),例如,使用aligned__而不是aligned ,这样,你就可以在相应的头文件里使用它而不用关心头文件里是否有重名的宏定义。

1. aligned (alignment)

该属性设定一个指定大小的对齐格式(以字节 为单位),例如:

struct S {
    short b[3];
} __attribute__ ((aligned (8)));
​
typedef int int32_t __attribute__ ((aligned (8)));

该声明将强制编译器确保(尽它所能)变量类 型为struct S 或者int32_t 的变量在分配空间时采用8 字节对齐方式。

如上所述,你可以手动指定对齐的格式,同样,你也可以使用默认的对齐方式。如果aligned 后面不紧跟一个指定的数字值,那么编译器将依据你的目标机器情况使用最大最有益的对齐方式。例如:

struct S {
    short b[3];
} __attribute__ ((aligned));

这里,如果sizeof (short )的大小为2 (byte ),那么,S 的大小就为6 。取一个2 的次方值,使得该值大于等于6 ,则该值为8 ,所以编译器将设置S 类型的对齐方式为8 字节。

aligned 属性使被设置的对象占用更多的空间,相反的,使用packed 可以减小对象占用的空间。

需要注意的是,attribute 属性的效力与你的连接器也有关,如果你的连接器最大只支持16 字节对齐,那么你此时定义32 字节对齐也是无济于事的。

2. packed

使用该属性对struct 或者union 类型进行定义,设定其类型的每一个变量的内存约束。当用在enum 类型 定义时,暗示了应该使用最小完整的类型(it indicates that the smallest integral type should be used)。

下面的例子中,packed_struct 类型的变量数组中的值将会紧紧的靠在一起,但内部的成员变量s 不会被“pack” ,如果希望内部的成员变量也被packed 的话,unpacked-struct 也需要使用packed 进行相应的约束。

struct unpacked_struct
{
   char c;
   int i;
};   
​
struct packed_struct
{
   char c;
   int i;
   struct unpacked_struct s;
}__attribute__ ((__packed__));

下面的例子中使用attribute 属性定义了一些结构体及其变量,并给出了输出结果和对结果的分析。

struct p{
    int a;
    char b;
    short c;
}__attribute__((aligned(4))) pp;
​
struct m{
    char a;
    int b;
    short c;
}__attribute__((aligned(4))) mm;
​
struct o{
    int a;
    char b;
    short c;
}oo;
​
struct x{
    int a;
    char b;
    struct p px;
    short c;
}__attribute__((aligned(8))) xx;
​
struct __attribute__((__packed__)) m1 {
    int a;
    char b;
    short c;
};
​
int main(){
    printf("sizeof(int)=%d,sizeof(short)=%d.sizeof(char)=%d\n", sizeof(int), sizeof(short), sizeof(char));
    printf("pp=%d,mm=%d \n", sizeof(pp), sizeof(mm));
    printf("oo=%d,xx=%d \n", sizeof(oo), sizeof(xx));
    cout << sizeof(m1) << endl;
    /*  输出结果:
        sizeof(int)=4,sizeof(short)=2.sizeof(char)=1
        pp=8,mm=12 
        oo=8,xx=24 
        7                                           */
}
​
​

2.链表

这个应该是大家最熟悉的了,至少学过数据结构的人应该都能默写一个双向链表node的结构体。

2.1结构

typedef struct listNode {
    struct listNode *prev;
    struct listNode *next;
    void *value;
} listNode;
​
typedef struct listIter {
    listNode *next;
    int direction;
} listIter;
​
typedef struct list {
    listNode *head;
    listNode *tail;
    //可以自定义实现的3个操作函数
    void *(*dup)(void *ptr);        
    void (*free)(void *ptr);
    int (*match)(void *ptr, void *key);
    unsigned long len;
} list;
​

img

(图片出处:小林Coding)

2.2 链表的优势与缺陷

优点:

  • 获取某个节点的前置节点或后置节点的时间复杂度只需O(1),而且这两个指针都可以指向 NULL,所以链表是无环链表
  • 因为提供了表头指针 head 和表尾节点 tail,所以获取链表的表头节点和表尾节点的时间复杂度只需O(1)
  • 因为提供了链表节点数量 len,所以获取链表中的节点数量的时间复杂度只需O(1)
  • listNode 链表节使用 void* 指针保存节点值,链表节点可以保存各种不同类型的值

缺陷:

  • 链表每个节点之间的内存都是不连续的,意味着无法很好利用 CPU 缓存。能很好利用 CPU 缓存的数据结构就是数组,因为数组的内存是连续的,这样就可以充分利用 CPU 缓存来加速访问。
  • 保存一个链表节点的值都需要一个链表节点结构头的分配,内存开销较大

因此,Redis 3.0 的 List 对象在数据量比较少的情况下,会采用「压缩列表」作为底层数据结构的实现,它的优势是节省内存空间,并且是内存紧凑型的数据结构。

不过,压缩列表存在性能问题(具体什么问题,下面会说),所以 Redis 在 3.2 版本设计了新的数据结构 quicklist,并将 List 对象的底层数据结构改由 quicklist 实现。

然后在 Redis 5.0 设计了新的数据结构 listpack,沿用了压缩列表紧凑型的内存布局,最终在最新的 Redis 版本,将 Hash 对象和 Zset 对象的底层数据结构实现之一的压缩列表,替换成由 listpack 实现。


3.压缩列表

设想下现在有两个元素: 整数5和字符串'ten',数据本身占8个字节(4+4),如果用单链表的话要多加两个向后指针,占用24字节 (每个node有int,char和next4+4+4=12) ,链表节点数据占用16个字节是数据本身大小的两倍。 而使用ziplist的话,存储只需要每个节点记录前一节点长度(1字节)、节点本身编码信息(1字节),总共占用10个字节,这样就很好的做到了节约内存

/* Create a new empty ziplist. */
unsigned char *ziplistNew(void) {
    unsigned int bytes = ZIPLIST_HEADER_SIZE+ZIPLIST_END_SIZE;
    unsigned char *zl = zmalloc(bytes);
    ... 
    return zl;
}
  • 从上可以看出,压缩列表是一种为节约空间而实现的线性数据结构,本质上是字节数组。
  • 压缩列表元素可以为整数或字符串。
  • 压缩列表在快速列表、列表对象和哈希对象中都有使用。
  • 压缩列表添加(平均复杂度O(n))与删除节点(平均复杂度O(n)),可能会触发连锁更新(平均复杂度O(n^2)),因为触发机率不高所以不影响性能。
  • 因为节点存储数据可能为字符串,而字符串匹配为O(n)复杂度,所以压缩列表查找节点平均复杂度为O(n^2)

3.1结构

压缩列表是 Redis 为了节约内存而开发的,它是由连续内存块组成的顺序型数据结构,有点类似于数组。

image

(图片出处:小林Coding)

//该结构体为逻辑上结构体,便于理解,并非最新版代码
struct ziplist<T>{
    int32 zlbytes;           //整个压缩列表占用的字节数
    int32 zltail_offset;     //最后一个元素距离压缩列表起始位置的偏移量,用于快速定位到最后一个节点
    int16 zllength;          //元素个数
    T[] entries;             //元素内容列表,紧密存储
    int8 zlend;              //标志压缩列表的结束,值恒为0XFF(Bx255)
}

压缩列表在表头有三个字段:

  • zlbytes,记录整个压缩列表占用的内存字节数;
  • zltail,记录压缩列表「尾部」节点距离起始地址由多少字节,也就是列表尾的偏移量;
  • zllen,记录压缩列表包含的节点数量;
  • zlend,标记压缩列表的结束点,固定值 0xFF(十进制255)。

在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,复杂度是 O(1)。而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了,因此压缩列表不适合保存过多的元素

另外,压缩列表节点(entry)的构成如下:

img

(图片出处:小林Coding)

压缩列表节点包含三部分内容:

  • prevlen,记录了「前一个节点」的长度;
  • encoding,记录了当前节点实际数据的类型以及长度;
  • data,记录了当前节点的实际数据,可能是字符串也可能是整型;
//该结构体为逻辑上结构体,便于理解,并非最新版代码
struct entry {
    int<var> prevlen;           //前一个元素长度,用于快速定位到下一个元素的位置
    int<var> encoding;          //元素类型编码
    optional byte[] content;    //内容
}

当我们往压缩列表中插入数据时,压缩列表就会根据数据是字符串还是整数,以及数据的大小,会使用不同空间大小的 prevlen 和 encoding 这两个元素里保存的信息,这种根据数据大小和类型进行不同的空间大小分配的设计思想,正是 Redis 为了节省内存而采用的

分别说下,prevlen 和 encoding 是如何根据数据的大小和类型来进行不同的空间大小分配。

压缩列表里的每个节点中的 prevlen 属性都记录了「前一个节点的长度」,而且 prevlen 属性的空间大小跟前一个节点长度值有关,比如:

  • 如果前一个节点的长度小于 254 字节,那么 prevlen 属性需要用 1 字节的空间来保存这个长度值;
  • 如果前一个节点的长度大于等于 254 字节,那么 prevlen 属性需要用 5 字节的空间来保存这个长度值;

encoding 属性的空间大小跟数据是字符串还是整数,以及字符串的长度有关,这是由源码中一个匹配表决定的,具体匹配规则本文不详细展开:

  • 如果当前节点的数据是整数,则 encoding 会使用 1 字节的空间进行编码。
  • 如果当前节点的数据是字符串,根据字符串的长度大小,encoding 会使用 1 字节/2字节/5字节的空间进行编码,不同的编码都有特定的前缀,如“01……”或者“11……”,以保证可以识别这个序列到底由几个字节组成。

ziplistNew

#define ZIPLIST_HEADER_SIZE     (sizeof(uint32_t)*2+sizeof(uint16_t)) //总字节长度(4字节),尾节点偏移量(4字节),节点数量(2字节)= 10 字节
#define ZIPLIST_END_SIZE        (sizeof(uint8_t))                   //特殊结束符(1字节) 
//通过列表的开始地址向后偏移尾节点偏移量个字节,可以以O(1)时间复杂度获取尾节点信息。
#define ZIP_END 255 
/* Create a new empty ziplist. */
unsigned char *ziplistNew(void) {
    unsigned int bytes = ZIPLIST_HEADER_SIZE+ZIPLIST_END_SIZE;//11字节
    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;                                  // 255特殊结尾值
    return zl;
}

intrev32ifbe函数为大小端转换函数,统一转换为小端存储。压缩列表的操作中涉及到的位运算很多,如果不统一的话会出现混乱。后续的所有位运算都是在小端存储的基础上进行的。下面应用维基百科的解释:

在几乎所有的机器上,多字节对象都被存储为连续的字节序列。例如在C语言中,一个类型为int的变量x地址为0x100,那么其对应地址表达式&x的值为0x100。且x的四个字节将被存储在存储器0x100, 0x101, 0x102, 0x103位置。[1]

而存储地址内的排列则有两个通用规则。一个多位的整数将按照其存储地址的最低或最高字节排列。如果最低有效位最高有效位的前面,则称小端序;反之则称大端序。在网络应用中,字节序是一个必须被考虑的因素,因为不同机器类型可能采用不同标准的字节序,所以均按照网络标准转化。

例如假设上述变量x类型为int,位于地址0x100处,它的十六进制为0x01234567,地址范围为0x100~0x103字节,其内部排列顺序依赖于机器的类型。大端法从首位开始将是:0x100: 01, 0x101: 23,..。而小端法将是:0x100: 67, 0x101: 45,..

zlentry结构体及宏定义

img

(图片出处:掘金社区 @knight0xffff)

typedef struct zlentry {
    unsigned int prevrawlensize; // 前一节点长度信息的长度
    unsigned int prevrawlen;      // 前一节点长度
    unsigned int lensize;         // 当前节点长度信息长度
    unsigned int len;           // 当前节点长度
    unsigned int headersize;     // 当前节点头部信息长度 //prevrawlensize + lensize. 
    unsigned char encoding;       // 当前节点数据编码
                                /* Set to ZIP_STR_* or ZIP_INT_* depending on
                                    the entry encoding. However for 4 bits
                                    immediate integers this can assume a range
                                    of values and must be range-checked. */
    unsigned char *p;            /* Pointer to the very start of the entry, that
                                    is, this points to prev-entry-len field. */
} zlentry;
​
​
// 宏定义1
#define ZIP_DECODE_PREVLEN(ptr, prevlensize, prevlen) do {                     \
    ZIP_DECODE_PREVLENSIZE(ptr, prevlensize); /*见宏定义2*/                     \
    if ((prevlensize) == 1) {                                                  \
        (prevlen) = (ptr)[0];                                                  \
    } else { /* prevlensize == 5 */                                            \
        (prevlen) = ((ptr)[4] << 24) |                                         \
                    ((ptr)[3] << 16) |                                         \
                    ((ptr)[2] <<  8) |                                         \
                    ((ptr)[1]);                                                \
    }                                                                          \
} while(0)
// 宏定义2
#define ZIP_DECODE_PREVLENSIZE(ptr, prevlensize) do {                          \
    if ((ptr)[0] < ZIP_BIG_PREVLEN) { /* ZIP_BIG_PREVLEN = 254*/               \
        (prevlensize) = 1;        /* 如果前节点长度小于254就用1个字节保存长度信息*/ \
    } else {                                                                   \
        (prevlensize) = 5;/* 如果前节点长度大于254就用5个字节保存长度信息,首位固定为254标志位*/  \
    }                                                                          \
} while(0)
// 宏定义3
#define ZIP_ENTRY_ENCODING(ptr, encoding) do {  \
    (encoding) = ((ptr)[0]); \
    if ((encoding) < ZIP_STR_MASK) (encoding) &= ZIP_STR_MASK; /* ZIP_STR_MASK=0xc0,也即是10进制的192 */ \
} while(0)
// 宏定义4
#define ZIP_DECODE_LENGTH(ptr, encoding, lensize, len) do {                    \
    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) = ((uint32_t)(ptr)[1] << 24) |                               \
                    ((uint32_t)(ptr)[2] << 16) |                               \
                    ((uint32_t)(ptr)[3] <<  8) |                               \
                    ((uint32_t)(ptr)[4]);                                      \
        } else {                                                               \
            (lensize) = 0; /* bad encoding, should be covered by a previous */ \
            (len) = 0;     /* ZIP_ASSERT_ENCODING / zipEncodingLenSize, or  */ \
                           /* match the lensize after this macro with 0.    */ \
        }                                                                      \
    } else {                                                                   \
        (lensize) = 1;                                                         \
        if ((encoding) == ZIP_INT_8B)  (len) = 1;                              \
        else if ((encoding) == ZIP_INT_16B) (len) = 2;                         \
        else if ((encoding) == ZIP_INT_24B) (len) = 3;                         \
        else if ((encoding) == ZIP_INT_32B) (len) = 4;                         \
        else if ((encoding) == ZIP_INT_64B) (len) = 8;                         \
        else if (encoding >= ZIP_INT_IMM_MIN && encoding <= ZIP_INT_IMM_MAX)   \
            (len) = 0; /* 4 bit immediate */                                   \
        else                                                                   \
            (lensize) = (len) = 0; /* bad encoding */                          \
    }                                                                          \
} while(0)
//宏定义5
#define assert(_e) (likely((_e))?(void)0 : (_serverAssert(#_e,__FILE__,__LINE__),redis_unreachable()))static inline void zipEntry(unsigned char *p, zlentry *e) {
    // 前一节点长度信息解析
    ZIP_DECODE_PREVLEN(p, e->prevrawlensize, e->prevrawlen);
    ZIP_ENTRY_ENCODING(p + e->prevrawlensize, e->encoding);
    
    // 当前节点数据长度与编码信息解析
    ZIP_DECODE_LENGTH(p + e->prevrawlensize, e->encoding, e->lensize, e->len);
    assert(e->lensize != 0); /* check that encoding was valid. */
    
    e->headersize = e->prevrawlensize + e->lensize;
    e->p = p;
}

宏定义中为什么要用do{}while{}语句

这里注意到所有的宏定义都是用do{} while{0}语句实现的,是因为宏定义自身特点——直接替换原文中代码,如下情况就会出错:

#define func() func1(); func2();
if (flag == 1)
    func();
//上述代码会被解析为:
if (flag == 1){
     func1();
}
func2();
//显然逻辑有错//但是如下代码又会出现语法错误
#define func() {func1(); func2();} //报错!//所以统一使用do{} while(0)语句让do语句执行一次:
#define func() do{func1(); func2();} while(0);
if (flag == 1)
    func();
​
//等同于
if (flag == 1){
    do{
        func1(); 
        func2();
    } 
    while(0);
}
//正确运行!

3.2连锁更新

压缩列表新增某个元素或修改某个元素时,如果空间不不够,压缩列表占用的内存空间就需要重新分配。而当新插入的元素较大时,可能会导致后续元素的 prevlen 占用空间都发生变化,从而引起「连锁更新」问题,导致每个元素的空间都要重新分配,造成访问压缩列表性能的下降

假设一个压缩列表中有多个连续的、长度在 250~253 之间的节点,如下图:

img

(图片出处:小林Coding)

因为这些节点长度值小于 254 字节,所以 prevlen 属性需要用 1 字节的空间来保存这个长度值。

这时,如果将一个长度大于等于 254 字节的新节点加入到压缩列表的表头节点,即新节点将成为 e1 的前置节点,如下图:

img

(图片出处:小林Coding)

因为 e1 节点的 prevlen 属性只有 1 个字节大小,无法保存新节点的长度,此时就需要对压缩列表的空间重分配操作,并将 e1 节点的 prevlen 属性从原来的 1 字节大小扩展为 5 字节大小。

多米诺牌的效应就此开始。

img

(图片出处:小林Coding)

e1 原本的长度在 250~253 之间,因为刚才的扩展空间,此时 e1 的长度就大于等于 254 了,因此原本 e2 保存 e1 的 prevlen 属性也必须从 1 字节扩展至 5 字节大小。

正如扩展 e1 引发了对 e2 扩展一样,扩展 e2 也会引发对 e3 的扩展,而扩展 e3 又会引发对 e4 的扩展.... 一直持续到结尾。

这种在特殊情况下产生的连续多次空间扩展操作就叫做「连锁更新」,就像多米诺牌的效应一样,第一张牌倒下了,推动了第二张牌倒下;第二张牌倒下,又推动了第三张牌倒下....

连锁更新发生,就会导致压缩列表占用的内存空间多次重新分配,就直接影响到压缩列表的访问性能

所以,虽然压缩列表紧凑型的内存布局能节省内存开销,但是如果保存的元素数量增加或是元素变大了,会导致内存重新分配,最糟糕的是会有「连锁更新」的问题。因此,压缩列表只会用于保存的节点数量不多的场景,只要节点数量足够小,即使发生连锁更新,也是能接受的。

更新节点ziplistPush,ziplistInsert

unsigned char *ziplistPush(unsigned char *zl, unsigned char *s, unsigned int slen, int where) {
    unsigned char *p;
    //由where决定插在哪里,0是头部,1是尾部
    p = (where == ZIPLIST_HEAD) ? ZIPLIST_ENTRY_HEAD(zl) : ZIPLIST_ENTRY_END(zl);
    //调用实际操作函数
    return __ziplistInsert(zl,p,s,slen);
}
​
/* 在特定p指向位置的前面插入 */
unsigned char *ziplistInsert(unsigned char *zl, unsigned char *p, unsigned char *s, unsigned int slen) {
    return __ziplistInsert(zl,p,s,slen);
}
​
/* 实际操作函数。是在特定p指向位置的前面插入 */
unsigned char *__ziplistInsert(unsigned char *zl, unsigned char *p, unsigned char *s, unsigned int slen) {
    //转为小端存储
    size_t curlen = intrev32ifbe(ZIPLIST_BYTES(zl)), reqlen, newlen;
    unsigned int prevlensize, prevlen = 0;
    size_t offset;
    int nextdiff = 0;
    unsigned char encoding = 0;
    long long value = 123456789; /* 官方说这里初始化一个方便看的数字,为了一眼就能看出value是否初始化了(真的有这作用?。。。) */
    zlentry tail;
/*
------------1号节点-----------       ----2号节点-p指针指向的节点----
| prevlen | encoding | data |       | prevlen | encoding | data |
-----------------------------       -----------------------------
                             ↑插入3号
                            ------------3号节点----------      
                            | prevlen | encoding | data |       
                            -----------------------------                
*/
    
    /* 找到3号节点的prevlen */
    if (p[0] != ZIP_END) {  //p指向的不是结束符,所以是插入在中间
        ZIP_DECODE_PREVLEN(p, prevlensize, prevlen); // 设定插入节点(3号)的prevlensize和prevlen,详见<zlentry结构体及宏定义>
    } else {            //p指向的是ziplist的结束符,所以是插入在尾部
        unsigned char *ptail = ZIPLIST_ENTRY_TAIL(zl);  
        if (ptail[0] != ZIP_END) {      //表尾不为空
            prevlen = zipRawEntryLengthSafe(zl, curlen, ptail); //返回由表尾节点的总字节数,并赋给prevlen
        }
    }
​
    /* See if the entry can be encoded */
    // slen 是data字段的长度,
    if (zipTryEncoding(s,slen,&value,&encoding)) {
        /* encoding字段设定为整数编码,使用zipIntSize()看整数需要多少字节,并且加入reqlen
        这里有一种很特殊的情况就是当无符号整数小于12时,数据是直接存储于coding字段中的,所以此时的reqlen就为0  */
        reqlen = zipIntSize(encoding);
    } else {
        /* encoding字段将设定为字符串编码,所以reqlen直接就为字符串的长度了 */
        reqlen = slen;
    }
    /* reqlen继续累加节点长度 */
    reqlen += zipStorePrevEntryLength(NULL,prevlen);    //累加前一节点长度信息字段的长度
    reqlen += zipStoreEntryEncoding(NULL,encoding,slen);    //累加编码字段的长度,现在reqlen就是当前节点的总长度了
​
    /* 当插入位置不是尾部时,我们需要确保下一个条目可以在其prevlen字段中保留该条目的长度。 */
    int forcelarge = 0;
    nextdiff = (p[0] != ZIP_END) ? zipPrevLenByteDiff(p,reqlen) : 0;
    // ↓ 详细原理看下一节。
    /*这里是一个修复原来bug的代码,简单来说就是某节点a的prevlen本身为5字节,加上coding和data字段后正好节点大小大于254,
    但是前面节点变动了,此时prevlen应改为1字节,节点a的大小正好就小于等于254了,后续节点b的prevlen本应从5字节变为1字节的,
    但是又为了避免连锁更新导致复杂度过高,所以不缩容继续使用5字节保存。此时节点大小小于255的节点c插入到节点a和节点b中间,
    在使用zipPrevlenByteDiff函数求需要扩容的差值时,会发现原本b节点的prevlen是5字节,现在需要1字节,差值居然是-4!
    所以在此处对nextdiff进行一个判断,如果为-4的话,就不需要扩容就好了。         */
    if (nextdiff == -4 && reqlen < 4) {
        nextdiff = 0;
        forcelarge = 1;
    }
​
    /* 存储偏移量,因为realloc可能会更改zl的地址。 */
    offset = p-zl;
    newlen = curlen+reqlen+nextdiff;
    zl = ziplistResize(zl,newlen);
    p = zl+offset;
​
    /* 必要时应用内存移动并更新尾部偏移。 */
    if (p[0] != ZIP_END) {  //p不是尾部节点
        /* 移动2号节点和2号节点后面的所有节点,给新插入的3号节点腾出位置 */
        memmove(p+reqlen,p-nextdiff,curlen-offset-1+nextdiff);
​
        /* 写入2号节点的prevlen */
        if (forcelarge)
            zipStorePrevEntryLengthLarge(p+reqlen,reqlen);
        else
            zipStorePrevEntryLength(p+reqlen,reqlen);
​
        /* 更新尾部节点的偏移量 */
        ZIPLIST_TAIL_OFFSET(zl) =
            intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+reqlen);
​
        /* 当尾部包含多个条目时,我们还需要考虑nextdiff 
        否则,prevlen大小的更改不会对*尾*偏移产生影响  */
        assert(zipEntrySafe(zl, newlen, p+reqlen, &tail, 1));
        if (p[reqlen+tail.headersize+tail.len] != ZIP_END) {
            ZIPLIST_TAIL_OFFSET(zl) =
                intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+nextdiff);
        }
    } else {
        /* p就是尾部节点了,只用修改一下尾部偏移量 */
        ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(p-zl);
    }
​
    /* 当nextdiff != 0时,下一个节点的长度已经更新了,所以我们要挨个更新整个ziplist
    也就是说可能会发生连锁更新,我们要挨个去看是否发生连锁更新   */
    if (nextdiff != 0) {
        offset = p-zl;
        //连锁更新,见下文
        zl = __ziplistCascadeUpdate(zl,p+reqlen);
        p = zl+offset;
    }
​
    // 写入前一节点长度信息
    p += zipStorePrevEntryLength(p,prevlen);
    // 写入节点编码与长度信息
    p += zipStoreEntryEncoding(p,encoding,slen);
    // 写入数据
    if (ZIP_IS_STR(encoding)) {
        memcpy(p,s,slen);
    } else {
        zipSaveInteger(p,value,encoding);
    }
    // 增加列表长度
    ZIPLIST_INCR_LENGTH(zl,1);
    return zl;
}

有意思的小bug—— nextdiff = -4 ? 难道说我们要扩容的大小居然还能是负值?

//代码出自上一节中__ziplistInser函数
nextdiff = (p[0] != ZIP_END) ? zipPrevLenByteDiff(p,reqlen) : 0;
//这里居然要先判断nextdiff是否为-4? 难道说我们要扩容的大小居然还能是负值?
if (nextdiff == -4 && reqlen < 4) {
    nextdiff = 0;
    forcelarge = 1;
}

首先我们看看zipPrevLenByteDiff是如何计算扩容的值的

//宏定义 根据p[0]的值得出prevlensize,如果p[0]<254,prevlensize需要1个字节来保存,反之则需要5个字节
#define ZIP_DECODE_PREVLENSIZE(ptr, prevlensize) do {                          \
    if ((ptr)[0] < ZIP_BIG_PREVLEN) {                                          \
        (prevlensize) = 1;                                                     \
    } else {                                                                   \
        (prevlensize) = 5;                                                     \
    }                                                                          \
} while(0)int zipPrevLenByteDiff(unsigned char *p, unsigned int len) {
    unsigned int prevlensize;
    ZIP_DECODE_PREVLENSIZE(p, prevlensize);
    //返回两者的差值
    return zipStorePrevEntryLength(NULL, len) - prevlensize;
}

这里引用来自segmentfault @LNMPRG源码研究的解释:

如上函数计算nextdiff,可以看出,根据插入位置p当前保存prev_entry_len字段的字节数和即将插入的entry需要的字节数相减得出nextdiff.值有三种类型

  • 0: 空间相等
  • 4:需要更多空间
  • -4:空间富余

bug修复过程首先判断nextdiff等于-4,即p位置的prev_entry_len为5个字节,而当前要插入的entry的长度只需要1个字节去保存.然后判断reqlen < 4.看到此处可能读者会有疑惑,既然prev_entry_len长度已经为5个字节了,那么新插入的值prev_entry_len+encoding+content字段肯定会大于5字节,为什么会出现小于4的情况呢? 这种情况确实比较费解,通过下文的构造示例我们能够看出,在连锁更新的时候,为了防止大量的重新分配空间的动作,如果一个entry的长度只需要1个字节就能够保存,但是连锁更新时如果原先已经为prev_entry_len分配了5个字节,则不会进行缩容操作. 把bug修复代码反向修改回来,编译之后执行如下命令可以导致Redis crash(注意前边是命令编号,下文通过该编号解释Redis中ziplist内存的变化情况):

使用6条命令往一个list中分别插入'one','two',252个'A',250个'A','three','a'六个元素.此时内存占用情况如下: 11.png

每个小矩形框表示占用内存字节数,大矩形框表示一个个entry,每个entry有三项,分别为prev_entry_len,encoding和content字段

接着执行第7条命令,内存占用情况如图,表示如下:

22.png 删除了第3个entry,此时第4个entry的前一个entry长度由255字节变为5字节(第2个entry此时为第4个entry的前一个entry),所以prev_entry_len字段由占用5个字节变为占用1个字节.参见图中黄框部分.

注意此时会发生连锁更新,因为蓝框部分的prev_entry_len由257字节变为253,也可以更新为1个字节.但Redis中在连锁更新的情况下为了避免频繁的realloc操作,这种情况下不进行缩容.

接着执行第8条命令,插入绿框中的数据(见图第3列所示),此时蓝筐中的prev_entry_len是5个字节,绿框中的数据只占用2字节,当将prev_entry_len更新为1字节后,prev_entry_len多余的4字节可以完整的容纳绿框中的数据. 即虽然插入了数据,但realloc之后反而缩小了占用的内存,从而导致ziplist中的数据损坏.

修复这个bug的代码也就很容易理解了,即图中第3列蓝框的prev_entry_len仍然保留为5个字节.

可以进一步构造另一种情况,即第6步构造为rpush list 10,则此时不会造成redis crash,而是会丢失10这个元素.读者可以画出内存占用图自行分析

连锁更新节点__ziplistCascadeUpdate

相比于旧版的连锁更新函数,新版本的函数中,先记录了总共要扩容多少字节,然后在最后才进行一个整体的扩容,而旧版本中,是在while循环中,每有一个节点需要扩容,则立刻扩容,时间复杂度会更高。

unsigned char *__ziplistCascadeUpdate(unsigned char *zl, unsigned char *p) {
    zlentry cur;
    size_t prevlen, prevlensize, prevoffset; /* Informat of the last changed entry. */
    size_t firstentrylen; /* Used to handle insert at head. */
    size_t rawlen, curlen = intrev32ifbe(ZIPLIST_BYTES(zl));
    size_t extra = 0, cnt = 0, offset;
    size_t delta = 4; /* Extra bytes needed to update a entry's prevlen (5-1). */
    unsigned char *tail = zl + intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl));
​
    /* Empty ziplist */
    if (p[0] == ZIP_END) return zl;
​
    zipEntry(p, &cur); /* no need for "safe" variant since the input pointer was validated by the function that returned it. */
    firstentrylen = prevlen = cur.headersize + cur.len;
    prevlensize = zipStorePrevEntryLength(NULL, prevlen);
    prevoffset = p - zl;
    p += prevlen;
​
    /* 以p为指针 迭代整个ziplist看是否需要更新 */
    while (p[0] != ZIP_END) {
        //安全方法解析zipentry的信息
        assert(zipEntrySafe(zl, curlen, p, &cur, 0));
​
        /* 如果节点本身不用更新,那就停止连锁更新了 */
        if (cur.prevrawlen == prevlen) break;
​
        /* 或者如果本身的prevlen已经足够大了 */
        if (cur.prevrawlensize >= prevlensize) {
            if (cur.prevrawlensize == prevlensize) {
                zipStorePrevEntryLength(p, prevlen);
            } else {
                /* 这将导致收缩,这是我们想要避免的。因此,在可用字节中设置“prevlen”。 */
                zipStorePrevEntryLengthLarge(p, prevlen);
            }
            break;
        }
​
        /* cur.prevrawlen means cur is the former head entry. */
        assert(cur.prevrawlen == 0 || cur.prevrawlen + delta == prevlen);
​
        /* 更新上一个节点的信息并前进游标。 */
        rawlen = cur.headersize + cur.len;
        prevlen = rawlen + delta; // delta = 4
        // 看更新过后的上一节点大小是否超过255,如果超过那么prevlensize就会变为5
        prevlensize = zipStorePrevEntryLength(NULL, prevlen);
        //上一个节点的偏移量就是上一节点的指针减去ziplist指针
        prevoffset = p - zl;
        //p指针向后跳过这个节点的长度,也就指向了下一个节点
        p += rawlen;
        //extra就是需要额外多少字节的大小
        extra += delta;
        //cnt计数变量,记录连锁更新发生了几个节点
        cnt++;
    }
​
    /* 如果额外需要0字节的大小,说明不用改内存大小等信息了 */
    if (extra == 0) return zl;
​
    /* 先把尾部节点偏移量给改了,这里分了3种情况 */
    if (tail == zl + prevoffset) {
        /* 当我们需要更新的最后一个节点同时也是尾部节点时,更新尾部偏移量时,不用考虑尾部节点的扩容(所以下面减去了delta)。*/
        if (extra - delta != 0) {   //但是如果尾部节点是唯一需要更新的节点时,尾部偏移量是不用改变的。
            ZIPLIST_TAIL_OFFSET(zl) =
                intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+extra-delta);
        }
    } else {
        /* 如果我们更新的最后一个条目不是尾部,则更新尾部偏移量。 */
        ZIPLIST_TAIL_OFFSET(zl) =
            intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+extra);
    }
​
    /* 现在p指向原始ziplist中第一个未更改的节点,要将数据移动到新的ziplist。 */
    //先保存旧ziplist中,p节点的偏移量
    offset = p - zl;
    //将新生成的ziplist赋值给zl
    zl = ziplistResize(zl, curlen + extra);
    //然后再通过偏移量,找到刚刚的p节点的位置
    p = zl + offset;
    //把p指向的节点更新成新的大小
    memmove(p + extra, p, curlen - offset - 1);
    //p指向下一个节点
    p += extra;
​
    /* 从头到尾迭代所有需要更新的节点。 
    需要注意,此时p已经指向了尾部了! */
    while (cnt) {   //这是之前那个计数变量
        //用原来的数据新建一个entry,主要是从尾向头遍历的
        zipEntry(zl + prevoffset, &cur); /* no need for "safe" variant since we already iterated on all these entries above. */
        rawlen = cur.headersize + cur.len;
        /* 通过尾插法把新建的那个entry插到尾部,并且判断它的prevlen字段是什么 */
        memmove(p - (rawlen - cur.prevrawlensize), 
                zl + prevoffset + cur.prevrawlensize, 
                rawlen - cur.prevrawlensize);
        //p从后往前遍历,所以p减去这个节点的偏移量
        p -= (rawlen + delta);
        if (cur.prevrawlen == 0) {
            /* cur前面已经是头部节点了,所以用firstentrylen来构建cur的prevlen */
            zipStorePrevEntryLength(p, firstentrylen);
        } else {
            /* 给prevlen增加4个字节 */
            zipStorePrevEntryLength(p, cur.prevrawlen+delta);
        }
        /* 通过减少偏移量来向前遍历 */
        prevoffset -= cur.prevrawlen;
        cnt--;
    }
    return zl;
}

查找节点ziplistFind

unsigned char *ziplistFind(unsigned char *zl, unsigned char *p, unsigned char *vstr, unsigned int vlen, unsigned int skip) {
    int skipcnt = 0;
    unsigned char vencoding = 0;
    long long vll = 0;
    size_t zlbytes = ziplistBlobLen(zl);
    
    // 遍历ziplist,直到遇到结束字符(255),skip为查找前跳过skip个节点
    while (p[0] != ZIP_END) {   // ZIP_END = Bx255
        struct zlentry e;
        unsigned char *q;
​
        //新版本中新增的安全检查
        assert(zipEntrySafe(zl, zlbytes, p, &e, 1));
        q = p + e.prevrawlensize + e.lensize;
​
        if (skipcnt == 0) {
            /* 比较当前entry和待查找entry */
            if (ZIP_IS_STR(e.encoding)) {   // 数据类型是str,判断字符串相等复杂度为O(n),所以遍历+字符串比较,复杂度就为O(n^2)了
                if (e.len == vlen && memcmp(q, vstr, vlen) == 0) {
                    return p;
                }
            } 
            else {
                /* 找出搜索的字段是否可以编码。
                请注意,我们仅在第一次执行此操作时,一旦完成,
                Vencodeding设置为非零,vll设置为整数值。 */
                if (vencoding == 0) {
                    if (!zipTryEncoding(vstr, vlen, &vll, &vencoding)) {
                        /* 如果enrty不能被encode(字段有错,不能编码为有效的整数),
                        就把设置vencoding设置为UCHAR_MAX(10进制的255),
                         这样下次就不用再尝试encode了 */
                        vencoding = UCHAR_MAX;
                    }
                    /* Must be non-zero by now */
                    assert(vencoding);
                }
​
                /* 将当前条目与指定条目进行比较,仅当Vencodeding!=UCHAR_MAX,
                因为如果字段不能进行编码,那么它就不能是有效的整数。 */
                if (vencoding != UCHAR_MAX) {
                    long long ll = zipLoadInteger(q, e.encoding);
                    if (ll == vll) {
                        return p;
                    }
                }
            }
​
            /* 重置步长 */
            skipcnt = skip;
        } else {
            /* 没到步长大小,跳过 */
            skipcnt--;
        }
​
        /* Move to next entry */
        p = q + e.len;
    }
​
    return NULL;
}

删除节点ziplistDelete,ziplistDeleteRange

有一点需要注意,由于删除节点会出现节点大小的改变,所以有可能会发生连锁更新!

/* Delete a single entry from the ziplist, pointed to by *p.
 * Also update *p in place, to be able to iterate over the
 * ziplist, while deleting entries. */
unsigned char *ziplistDelete(unsigned char *zl, unsigned char **p) {
    size_t offset = *p-zl;
    //*__ziplistDelete的第三个参数unsigned int num是1,只删除1个节点
    zl = __ziplistDelete(zl,*p,1);
​
    /* Store pointer to current element in p, because ziplistDelete will
     * do a realloc which might result in a different "zl"-pointer.
     * When the delete direction is back to front, we might delete the last
     * entry and end up with "p" pointing to ZIP_END, so check this. */
    *p = zl+offset;
    return zl;
}
​
/* Delete a range of entries from the ziplist. */
unsigned char *ziplistDeleteRange(unsigned char *zl, int index, unsigned int num) {
    //通过ziplistIndex 找到节点
    unsigned char *p = ziplistIndex(zl,index);
    //如果找到的节点就是空了,那么就直接返回ziplist头指针,如果不是就进行删除
    //*__ziplistDelete的第三个参数unsigned int num是这里的num,删除num个节点
    return (p == NULL) ? zl : __ziplistDelete(zl,p,num);
}
​
/* Delete "num" entries, starting at "p". Returns pointer to the ziplist. */
unsigned char *__ziplistDelete(unsigned char *zl, unsigned char *p, unsigned int num) {
    unsigned int i, totlen, deleted = 0;
    size_t offset;
    int nextdiff = 0;
    zlentry first, tail;
    //先求ziplist总字节数,然后转为小端存储
    size_t zlbytes = intrev32ifbe(ZIPLIST_BYTES(zl));
​
    zipEntry(p, &first); /* 由于上面2个函数已经确保了指针p是安全的,不用再验证指针安全性了,直接把first.p指过来 */
    
    for (i = 0; p[0] != ZIP_END && i < num; i++) {
        // 先只是跳过要删除的节点并且把deleted计数器++,不确定是否有num个节点来删除,所以deleted并不绝对等于num
        p += zipRawEntryLengthSafe(zl, zlbytes, p);
        deleted++;
    }
    // first.p指向第一个要被删除节点的首地址
    // p指向的是最后一个删除节点的下一节点的首地址, totlen大于0表示需要删除节点
    assert(p >= first.p);
    totlen = p-first.p; /* 计算有多少个字节需要被删除 */
    if (totlen > 0) {
        uint32_t set_tail;
        if (p[0] != ZIP_END) {
            // 获取p节点 【前一节点长度信息】 字段与首个被删除节点 【前一个节点长度信息】 字段的差值
            // zipPrevLenByteDiff 的返回值有三种可能:
            // 1)新旧两个节点的【前一个节点长度信息】字段长度 相等,返回 0
            // 2)新节点【前一个节点长度信息】字段长度 > 旧节点【前一个节点长度信息】字段长度,返回 5 - 1 = 4
            // 3)旧节点【前一个节点长度信息】字段长度 > 新节点【前一个节点长度信息】字段长度,返回 1 - 5 = -4
            //  此段注释来源:稀土掘金@Knight0xffff
​
            nextdiff = zipPrevLenByteDiff(p,first.prevrawlen);
            /*          ↑ 函数代码如下:
            int zipPrevLenByteDiff(unsigned char *p, unsigned int len) {
              unsigned int prevlensize;
              ZIP_DECODE_PREVLENSIZE(p, prevlensize);
               return zipStorePrevEntryLength(NULL, len) - prevlensize;
            }
            */
            
            /* Note that there is always space when p jumps backward: if
             * the new previous entry is large, one of the deleted elements
             * had a 5 bytes prevlen header, so there is for sure at least
             * 5 bytes free and we need just 4. */
            p -= nextdiff;
            assert(p >= first.p && p<zl+zlbytes-1);
            // 将首个被删除节点【前一节点长度信息】写入p指针指向的节点
            zipStorePrevEntryLength(p,first.prevrawlen);
​
            // 更新尾节点偏移量
            set_tail = intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))-totlen;
​
            /* 当尾部包含多个条目时,我们还需要考虑“nextdiff”。
            否则,prevlen大小的更改不会对*tail*偏移产生影响。 */
            assert(zipEntrySafe(zl, zlbytes, p, &tail, 1));
            /* 如果p节点不是尾节点, 则尾节点偏移量需要加上nextdiff的变更量
            因为尾节点偏移量是指列表首地址到尾节点首地址的距离
            p节点的 【前一节点长度信息】 字段的长度变化只影响它字段之后的信息地址。
            p节点为尾节点时,为节点首地址在【前一节点长度信息】字段前边,所以不受影响。*/
            // 此段注释来源:稀土掘金@Knight0xffff
            if (p[tail.headersize+tail.len] != ZIP_END) {
                set_tail = set_tail + nextdiff;
            }
            size_t bytes_to_move = zlbytes-(p-zl)-1;
            memmove(first.p,p,bytes_to_move);
        } else {
            // 一直删除到尾节点,不需要变更中间节点,只需要调整下尾节点偏移量
            set_tail = (first.p-zl)-first.prevrawlen;
        }
​
        /* Resize the ziplist */
        offset = first.p-zl;
        zlbytes -= totlen - nextdiff;
        zl = ziplistResize(zl, zlbytes);
        p = zl+offset;
​
        /* Update record count */
        ZIPLIST_INCR_LENGTH(zl,-deleted);
​
        /* Set the tail offset computed above */
        assert(set_tail <= zlbytes - ZIPLIST_END_SIZE);
        ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(set_tail);
​
        // 如果最后一个被删除节点的下一节点的【前一个节点长度信息】字段长度 需要变更,则可能会触发连锁更新
        if (nextdiff != 0)
            //连锁更新,见上文
            zl = __ziplistCascadeUpdate(zl,p);
    }
    return zl;
}

\