List数据结构
List是一个有序(按加入的时序排序)的数据结构,Redis采用quicklist(双端链表) 和 ziplist 作为List的底层实现。可以通过设置每个ziplist的最大容量,quicklist的数据压缩范围,提升数据存取效率
list-max-ziplist-size -2 // 单个ziplist节点最大能存储 8kb ,超过则进行分裂,将数据存储在新的ziplist节点中 对应redis.conf的配置 8KB 不推荐数据设置太大 压缩效率低且占空间
list-compress-depth 1 // 0 代表所有节点,都不进行压缩,1, 代表从头节点往后走一个,尾节点往前走一个不用压缩,其他的全部压缩,2,3,4 … 以此类推
在版本3.2之前,Redis 列表list使用两种数据结构作为底层实现:
压缩列表ziplist
双向链表linkedlist
因为双向链表占用的内存比压缩列表要多, 所以当创建新的列表键时, 列表会优先考虑使用压缩列表, 并且在有需要的时候, 才从压缩列表实现转换到双向链表实现。
压缩列表转化成双向链表条件
创建新列表时 redis 默认使用 redis_encoding_ziplist 编码, 当以下任意一个条件被满足时, 列表会被转换成 redis_encoding_linkedlist 编码:
试图往列表新添加一个字符串值,且这个字符串的长度超过 server.list_max_ziplist_value (默认值为 64 )。
ziplist 包含的节点超过 server.list_max_ziplist_entries (默认值为 512 )。
注意:这两个条件是可以修改的,在 redis.conf 中:
list-max-ziplist-value 64 列表对象保存的所有字符串元素的长度都小于64字节;
list-max-ziplist-entries 512 列表元素保存的元素数量小于512个;
另外对于使用 ziplist 编码的列表对象,当以上两个条件中任何一个不能满足时,对象的编码转换操作就会执行,原本保存在压缩列表里面的所有列表元素都会被转移并保存到双端链表里面,对象的编码也从 ziplist 变为 linkedlist 。
双向链表linkedlist
当链表entry数据超过512、或单个value 长度超过64,底层就会转化成linkedlist编码;
linkedlist是标准的双向链表,Node节点包含prev和next指针,可以进行双向遍历;
还保存了 head 和 tail 两个指针,因此,对链表的表头和表尾进行插入的复杂度都为 (1) —— 这是高效实现 LPUSH 、 RPOP、 RPOP ,LPUSH 等命令的关键。linkedlist比较简单,我们重点来分析ziplist。
typedef struct listNode {
struct listNode *prev; //前置节点
struct listNode *next; //后置节点
void *value; //节点的值
}listNode
多个 listNode 可以通过 prev 和 next 指针组成双端链表,结构如下
另外提供了操作链表的list结构
typedef struct list{
listNode *head; //表头节点
listNode *tail; //表尾节点
unsigned long len; //链表所包含的节点数量
void (*dup) (void *ptr); //节点值复制函数
void (*free) (void *ptr); //节点值释放函数
int (*match) (void *ptr, void *key); //节点值对比函数
}list;
list结构为链表提供了表头指针 head ,表尾指针 tail 以及链表长度计数器 len ,dup、free、match 成员则是用于实现多态链表所需的类型特定函数。
Redis链表实现的特性
-
双端:链表节点带有 prev 和 next 指针,获取某个节点的前置节点和后置节点复杂度都是O(1)。
-
无环:表头节点的 prev 指针和表尾节点的 next 指针都指向 NULL,对链表的访问以NULL为终点。
-
带表头指针和表尾指针:通过list结构的 head 和 tail 指针,程序获取链表的表头节点和表尾结点的复杂度都是O(1)。
-
带链表长度计数器:程序使用 list 结构的 len属性对 list持有的链表节点进行计数,程序获取链表中节点数量的复杂度为O(1)。
-
多态:链表节点使用 void* 指针来保存节点值,并且通过 list 结构的 dup、 free、match 三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。
压缩列表ziplist
压缩列表 ziplist 是为 Redis 节约内存而开发的。ziplist 是由一系列特殊编码的内存块构成的列表(像内存连续的数组,但每个元素长度不同), 一个 ziplist 可以包含多个节点(entry)。
ziplist 将表中每一项存放在前后连续的地址空间内,每一项因占用的空间不同,而采用变长编码。
当元素个数较少时,Redis 用 ziplist 来存储数据,当元素个数超过某个值时,链表键中会把 ziplist 转化为 linkedlist,字典键中会把 ziplist 转化为 hashtable。由于内存是连续分配的,所以遍历速度很快。
在3.2之后,ziplist被quicklist替代。但是仍然是zset底层实现之一。
ziplist 是一个特殊的双向链表, 特殊之处在于:没有维护双向指针:prev next;而是存储上一个 entry的长度和 当前entry的长度,通过长度推算下一个元素在什么地方。牺牲读取的性能,获得高效的存储空间,因为(简短字符串的情况)存储指针比存储entry长度 更费内存。这是典型的“时间换空间”。
ziplist使用局限性:;字段、值比较小,才会用ziplist。
压缩列表(zipList)实现的列表对象
area |<---- ziplist header ---->|<----------- entries ------------->|<-end->|
size 4 bytes 4 bytes 2 bytes ? ? ? ? 1 byte
+---------+--------+-------+--------+--------+--------+--------+-------+
component | zlbytes | zltail | zllen | entry1 | entry2 | ... | entryN | zlend |
+---------+--------+-------+--------+--------+--------+--------+-------+
^ ^ ^
address | | |
ZIPLIST_ENTRY_HEAD | ZIPLIST_ENTRY_END
|
ZIPLIST_ENTRY_TAIL
- zlbytes: 32bit,表示ziplist占用的字节总数,对ziplist占用的内存字节数,对ziplist进行内存重分配,或者计算末端时使用
- zltail: 32bit,表示ziplist表中最后一项(entry)在ziplist中的偏移字节数。通过zltail我们可以很方便地找到最后一项,从而可以在ziplist尾端快速地执行push或pop操作
- zlen: 16bit, 表示ziplist中数据项(entry)的个数。当这个值小于UNIT16_MAX(65535)时,这个值就是ziplist中的节点数量,当这个值等于UNIT16_MAX时,节点的数量需要通过遍历整个ziplist才可以计算出来
- entry: 表示真正存放数据的数据项,长度不定,真正存储节点的区域,每一项都连续存储的,构成entry1、entry2、….entryN。
- zlend: ziplist最后1个字节,是一个结束标记,标记ziplist末端,值固定等于255(二进制值 1111 1111 UNIT8_MAX)。
- prerawlen: 前一个entry的数据长度。
- len: entry中数据的长度
- data: 真实数据存储
由于每一项、占用的空间可能都不同,只能在添加entry的时候确定,也就是每个entry占用的实际长度只有在节点添加后才确定,因此entry采用变长编码。
ziplist每一个存储节点、都是一个 zlentry,就是上文我们所说的entry;zlentry的源码定义:
typedef struct zlentry { // 压缩列表节点
unsigned int prevrawlensize, prevrawlen; // prevrawlen是前一个节点的长度,prevrawlensize是指prevrawlen的大小,有1字节和5字节两种
unsigned int lensize, len; // len为当前节点长度 lensize为编码len所需的字节大小
unsigned int headersize; // 当前节点的header大小
unsigned char encoding; // 节点的编码方式
unsigned char *p; // 指向节点的指针
} zlentry;
void zipEntry(unsigned char *p, zlentry *e) { // 根据节点指针返回一个enrty
ZIP_DECODE_PREVLEN(p, e->prevrawlensize, e->prevrawlen); // 获取prevlen的值和长度
ZIP_DECODE_LENGTH(p + e->prevrawlensize, e->encoding, e->lensize, e->len); // 获取当前节点的编码方式、长度等
e->headersize = e->prevrawlensize + e->lensize; // 头大小
e->p = p;
}
可见,每一个存储节点 zlentry,都包含: 1:prevrawlen 前一个 entry的长度 2:lensize 当前entry的长度
如何通过一个节点向前跳转到另一个节点? 用指向当前节点的指针 e , 减去 前一个 entry的长度, 得出的结果就是指向前一个节点的地址 p
完整的zlentry由以下3各部分组成:
- prevrawlen:记录前一个节点所占有的内存字节数,通过该值,我们可以从当前节点计算前一个节点的地址,可以用来实现表尾向表头节点遍历;
- len/encoding:记录了当前节点content占有的内存字节数及其存储类型,用来解析content用;
- content:保存了当前节点的值,节点的值可以是一个字节数组或者整数,值的类型和长度由节点的 encoding 属性决定。
最关键的是prevrawlen和len/encoding,content只是实际存储数值的比特位。
prevrawlen是变长编码,有两种表示方法:
- 如果前一节点的长度小于 254 字节,则使用1字节(uint8_t)来存储prevrawlen;
- 如果前一节点的长度大于等于 254 字节,那么将第 1 个字节的值设为 254 ,然后用接下来的 4 个字节保存实际长度。
ziplist连锁更新问题
因为在ziplist中,每个zlentry都存储着前一个节点所占的字节数,而这个数值又是变长编码的。假设存在一个压缩列表,其包含e1、e2、e3、e4……,e1节点的大小为253字节,那么e2.prevrawlen的大小为1字节,如果此时在e2与e1之间插入了一个新节点e_new,e_new编码后的整体长度(包含e1的长度)为254字节,此时e2.prevrawlen就需要扩充为5字节;如果e2的整体长度变化又引起了e3.prevrawlen的存储长度变化,那么e3也需要扩…….如此递归直到表尾节点或者某一个节点的prevrawlen本身长度可以容纳前一个节点的变化。其中每一次扩充都需要进行空间再分配操作。删除节点亦是如此,只要引起了操作节点之后的节点的prevrawlen的变化,都可能引起连锁更新。
连锁更新在最坏情况下需要进行N次空间再分配,而每次空间再分配的最坏时间复杂度为O(N),因此连锁更新的总体时间复杂度是O(N^2)。
即使涉及连锁更新的时间复杂度这么高,但它能引起的性能问题的概率是极低的:需要列表中存在大量的节点长度接近254的zlentry。
由于ziplist连锁更新的问题,也使得ziplist的优缺点极其明显;也使得后续Redis采取折中,替换了ziplist。
ziplist的主要优点是节省内存,但它上面的查找操作只能按顺序查找(可以是从前往后、也可以从后往前)。
ziplist将数据按照一定规则编码在一块连续的内存区域,目的是节省内存,这种结构并不擅长做修改操作。一旦数据发生改动,就会引发内存realloc,可能导致内存拷贝。
Redis3.2+ list的新实现quickList
Redis中的列表list,在版本3.2之前,列表底层的编码是ziplist和linkedlist实现的,但是在版本3.2之后,重新引入 quicklist,列表的底层都由quicklist实现。
关于3.2版本之前的ziplist,linkedlist两种存储方式的优缺点:
- 双向链表linkedlist便于在表的两端进行push和pop操作,在插入节点上复杂度很低,但是它的内存开销比较大。首先,它在每个节点上除了要保存数据之外,还要额外保存两个指针;其次,双向链表的各个节点是单独的内存块,地址不连续,节点多了容易产生内存碎片。
- ziplist存储在一段连续的内存上,所以存储效率很高。但是,它不利于修改操作,插入和删除操作需要频繁的申请和释放内存。特别是当ziplist长度很长的时候,一次realloc可能会导致大批量的数据拷贝。
而quickList,是ziplist和linkedlist二者的结合;quickList将二者的优点结合起来。quickList是一个ziplist组成的双向链表。链表中的每一个节点都以压缩列表ziplist的结构保存着数据,而ziplist有多个entry节点,保存着数据。相当于一个quicklist节点保存的是一片数据,而不再是一个数据。
本质上来说,quicklist里面保存着一个一个小的ziplist。结构如下:
quicklist宏观上是一个双向链表,因此,它具有一个双向链表的优点,进行插入或删除操作时非常方便,虽然复杂度为O(n),但是不需要内存的复制,提高了效率,而且访问两端元素复杂度为O(1)。
quicklist微观上是一片片entry节点,每一片entry节点内存连续且顺序存储,可以通过二分查找以 log2(n)log2(n) 的复杂度进行定位。
总体来说,quicklist给人的感觉和B树每个节点的存储方式相似。
/* quicklistNode is a 32 byte struct describing a ziplist for a quicklist.
* We use bit fields keep the quicklistNode at 32 bytes.
* count: 16 bits, max 65536 (max zl bytes is 65k, so max count actually < 32k).
* encoding: 2 bits, RAW=1, LZF=2.
* container: 2 bits, NONE=1, ZIPLIST=2.
* recompress: 1 bit, bool, true if node is temporarry decompressed for usage.
* attempted_compress: 1 bit, boolean, used for verifying during testing.
* extra: 12 bits, free for future use; pads out the remainder of 32 bits
* /
typedef struct quicklistNode {
struct quicklistNode *prev; //上一个node节点
struct quicklistNode *next; //下一个node节点
unsigned char *zl; //数据指针,当前节点的数据没有压缩,那么它指向一个ziplist指针;否则它指向一个quicklistZF结构
unsigned int sz; //zl指向的ziplist实际占用的内存大小,需要注意的是,如果ziplist被压缩了,那么这个sz的值仍然是压缩前的ziplist的大小
unsigned int count : 16; //zipList里面包含的数据项个数
unsigned int encoding : 2;//ziplist是否压缩,1--ziplist 2--quicklistZF /* RAW==1 or LZF==2 */
unsigned int container : 2;//存储类型,目前使用固定值2,表示使用zipList /* NONE==1 or ZIPLIST==2 */
unsigned int recompress : 1; /* was this node previous compressed? */
unsigned int attempted_compress : 1; /* node can't compress; too small */
unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;
/* quicklistLZF is a 4+N byte struct holding 'sz' followed by 'compressed'.
* 'sz' is byte length of 'compressed' field.
* 'compressed' is LZF data with total (compressed) length 'sz'
* NOTE: uncompressed length is stored in quicklistNode->sz.
* When quicklistNode->zl is compressed, node->zl points to a quicklistLZF
*/
typedef struct quicklistLZF {
unsigned int sz; /* LZF size in bytes*/
char compressed[];
} quicklistLZF;
/* quicklist is a 32 byte struct (on 64-bit systems) describing a quicklist.
1. 'count' is the number of total entries.
2. 'len' is the number of quicklist nodes.
3. 'compress' is: -1 if compression disabled, otherwise it's the number
4. of quicklistNodes to leave uncompressed at ends of quicklist.
5. 'fill' is the user-requested (or default) fill factor.
*/
typedef struct quicklist {
quicklistNode *head; //头结点
quicklistNode *tail; //尾节点
unsigned long count; //ziplist中的entry节点计数器 /* total count of all entries in all ziplists */
unsigned int len; //quicklist中quicklistNode节点计数器 /* number of quicklistNodes */
int fill : 16; //保存ziplist大大小,配置文件设定 /* fill factor for individual nodes */
unsigned int compress : 16; //保存压缩程度值,配置文件设定,0表示不压缩 /* depth of end nodes not to compress;0=off */
} quicklist;
在quicklist表头结构中,有两个成员是fill和compress,其中” : “是位域运算符,表示fill占int类型32位中的16位,compress也占16位。
fill成员对应的配置:list-max-ziplist-size -2
当数字为负数,表示以下含义:
-1 每个quicklistNode节点的ziplist字节大小不能超过4kb。(建议)
-2 每个quicklistNode节点的ziplist字节大小不能超过8kb。(默认配置)
-3 每个quicklistNode节点的ziplist字节大小不能超过16kb。(一般不建议)
-4 每个quicklistNode节点的ziplist字节大小不能超过32kb。(不建议)
-5 每个quicklistNode节点的ziplist字节大小不能超过64kb。(正常工作量不建议)
当数字为正数,表示:ziplist结构所最多包含的entry个数。最大值为 215215。
compress成员对应的配置:list-compress-depth 0
后面的数字有以下含义:
0 表示不压缩。(默认)
1 表示quicklist列表的两端各有1个节点不压缩,中间的节点压缩。
2 表示quicklist列表的两端各有2个节点不压缩,中间的节点压缩。
3 表示quicklist列表的两端各有3个节点不压缩,中间的节点压缩。
以此类推,最大为 216216。
fill和compress的配置文件是redis.conf。
- list是如何实现阻塞队列的?
阻塞队列,就像ArrayBlockingQueue那样,消费端取数据时,如果列表为空,就阻塞。Redis是如何实现的呢?blpop 、 brpop 和 brpoplpush这个几个阻塞命令的实现原理:
只有当这些命令被用于空列表时, 它们才会阻塞客户端。阻塞一个客户端需要执行以下步骤:
- 将客户端的状态设为“正在阻塞”,并记录阻塞这个客户端的各个键,以及阻塞的最长时限(timeout)等数据。
- 将客户端的信息记录到 server.db[i]->blocking_keys 中(其中 i 为客户端所使用的数据库号码)。
- 继续维持客户端和服务器之间的网络连接,但不再向客户端传送任何信息,造成客户端阻塞。
步骤 2 是将来解除阻塞的关键, server.db[i]->blocking_keys 是一个字典, 字典的键是那些造成客户端阻塞的键, 而字典的值是一个链表, 链表里保存了所有因为这个键而被阻塞的客户端 (被同一个键所阻塞的客户端可能不止一个):
- ziplist和linkedlist 编码下的list,列表操作命令底层实现不同
lpush、rpush、lpop、rpop等进行列表操作,这些命令在不同编码的列表对象下的实现方法是不同的。
对linkedlist来说,这些命令都是基于prev、next、head、tail指针完成的,双向链表基于指针,实现比较容易。
而ziplist则是 通过当前entry的指针,加减位移的偏移量(前一个entry的长度),进行跳跃节点。