1.存储类型
存储有序的字符串(从左到右),元素可以重复。可以充当队列和栈的角色。在早期的版本中,数据量较小时用 ziplist 存储,达到临界值时转换为 linkedlist 进行存储,分别对应 OBJ_ENCODING_ZIPLIST 和 OBJ_ENCODING_LINKEDLIST 。3.2 版本之后,统一用 quicklist 来存储。quicklist 存储了一个双向链表,每个节点都是一个 ziplist。
2.zipList
2.1 存储结构
ziplist在上一篇文章中其实我们已经涉及到了,也做了简单的介绍,但是哈希对象中的ziplist和列表对象中的ziplist的不同之处在于哈希对象是key-value形式的,所以其ziplist中页表现为key-value,key和value紧挨在一起,ziplist的存在就是为了提高存储效率。ziplist会将列表中每一个元素存放在前后连续的地址空间中,整体占用一大块内存,以减少内存碎片的产生。因为ziplist占用了一块连续的内存空间,对他的追加操作会引发内存的realloc,导致ziplist的内存位置发生变化,因此往ziplist中插入数据时会产生一个新的ziplist。
接下来看一下前面提到的哈希中的ziplist。ziplist 是一个经过特殊编码的双向链表,它不存储指向上一个链表节点和指向下一个链表节点的指针,而是存储上一个节点长度和当前节点长度,通过牺牲部分读写性能,来换取高效的内存空间利用率,是一种时间换空间的思想。只用在字段个数少,字段值小的场景里面。一个ziplist可以包含任意多个entry,而每一个entry又可以保存一个字节数组或者一个整数值。ziplist的组成结构如下:
<zlbytes> <zltail> <zllen> <entry> <entry> ... <entry> <zlend>
属性 | 类型 | 长度 | 说明 |
---|---|---|---|
zlbytes | uint32_t | 4字节 | 记录压缩列表占用内存字节数(包括本身做占用的4个字节) |
zltail | uint32_t | 4字节 | 记录压缩列表尾节点距离压缩列表的起始地址有多少个字节(通过这个值可以计算出尾节点的地址) |
zllen | uint16_t | 2字节 | 记录压缩列表中包含的节点数量,当列表值超过可以存储的最大值(65535)时,次值固定存储216-1(65535),因此此时需要遍历整个压缩列表才能计算出真实节点数 |
entry | 列表结点 | - | 压缩列表中的各个节点,长度由存储的实际数据决定 |
zlend | uint8_t | 1字节 | 特殊字符0xFF(十进制255),用来标记压缩列表的末端(其他正常的节点没有被标记为255的,因为255用来标识末尾,后面可以看到,正常节点都是标记为254) |
2.2 节点
ziplist 中的每个 entry 都以包含两段信息的元数据作为前缀,每一个 entry 的组成结构为:
<prevlen> <encoding> <entry-data>
prevlen属性存储了前一个entry的长度,以便能够从后到前遍历列表。 prevlen 的长度可能是1字节也可能是5字节:
- 当链表的前一个entry占用字节数小于254,此时prevlen只用1个字节进行表示。
- 当链表的前一个entry占用字节数大于等于254,此时prevlen用5个字节来表示,其中第1个字节的值是254(相当于是一个标记,代表后面跟了一个更大的值),后面4个字节才是真正存储前一个entry的占用字节数。
1个字节完全能存储255,之所以只取到254是因为zlend就是固定的255,所以255这个数要用来判断是否是ziplist的结尾。
encoding属性存储了当前entry所保存数据的类型以及长度。encoding长度为1字节,2字节或者5字节长。前面我们提到,每一个entry中可以保存字节数组和整数,而encoding属性的第1个字节就是用来确定当前entry存储的是整数还是字节数组。当存储整数时,第1个字节的前两位总是11,而存储字节数组时,则可能是00、01和10。
- 当存储整数时,第1个字节的前2位固定为11,其他位则用来记录整数值的类型或者整数值
编码 | 长度 | entry保存的数据 |
---|---|---|
11000000 | 1字节 | int16_t类型整数 |
11010000 | 1字节 | int32_t类型整数 |
11100000 | 1字节 | int64_t类型整数 |
11110000 | 1字节 | 24位有符号整数 |
11111110 | 1字节 | 8位有符号整数 |
1111xxxx | 1字节 | xxxx代表区间0001-1101,存储了一个介于0-12之间的整数,此时无需entry-data属性 |
xxxx四位编码范围是0000-1111,但是0000,1111和1110已经被占用了,所以实际上的范围是0001-1101,此时能保存数据1-13,再减去1之后范围就是0-12。至于为什么要减去1个人的理解是从使用概率上来说0是很常用的一个数字,所以才会选择统一减去1来存储一个0-12的区间而不是直接存储1-13的区间。
- 当存储字节数组时,第1个字节的前2位为00、01或者10,其他位则用来记录字节数组的长度 | 编码 | 长度 | entry保存的数据 | | —- | —- | —- | | 00pppppp | 1字节 | 长度小于等于63字节(6位)的字节数组 | | 01pppppp qqqqqqqq | 2字节 | 长度小于等于16383字节(14位)的字节数组 | | 10000000 qqqqqqqq rrrrrrrr ssssssss tttttttt | 5字节 | 长度小于等于232-1字节(32位)的字节数组,其中第1个字节的后6位设置为0,暂时没有用到,后面的32位(4个字节)存储了数据 |
entry-data:具体数据。当存储小整数时,encoding就是数据本身,此时没有entry-data部分,没有entry-data部分之后的ziplist结构如下:
<prevlen> <encoding>
压缩列表中entry的数据结构定义如下(源码ziplist.c内),当然这个代码注释写了实际存储并没有用到这个结构,这个结构只是用来接收数据,所以了解一下就可以了。
typedef struct zlentry {
unsigned int prevrawlensize;//存储prevrawlen所占用的字节数
unsigned int prevrawlen;//存储上一个链表节点需要的字节数
unsigned int lensize;//存储len所占用的字节数
unsigned int len;//存储链表当前节点的字节数
unsigned int headersize;//当前链表节点的头部大小(prevrawlensize + lensize)即非数据域的大小
unsigned char encoding;//编码方式
unsigned char *p;//指向当前节点的起始位置(因为列表内的数据也是一个字符串对象)
} zlentry;
2.3 数据示例
我们通过一个ziplist存储整数为例子来分析一下。
[0f 00 00 00] [0c 00 00 00] [02 00] [00 f3] [02 f6] [ff]
| | | | | |
zlbytes zltail zllen "2" "5" end
- 第一组4个字节代表zlbytes,0f转成二进制就是1111也就是15,也就是这整个ziplist长度是15个字节。
- 第二组4个字节zltail,0c转成二进制就是1100也就是12,就是说[02 f6]这个尾节点距离起始位置有12个字节。
- 第三组2个字节就是记录了当前ziplist中entry的数量,02转成二进制就是10,也就是说当前ziplist有2个节点
- [00 f3]就是第一个entry,00表示0,因为这是第1个节点,所以前一个节点长度为0,f3转成二进制就是11110011,刚好对应了编码1111xxxx,所以后面四位就是存储了一个0-12的整数。0011转成十进制就是3,减去1得到2,所以第一个entry存储的数据就是2。后面[02 f6]一样的算法可以得出就是5。
- 最后一组1个字节[ff]转成二进制就是11111111,代表这是整个ziplist的结尾。
假如这时候又添加了一个Hello World到列表中,那么就会新增一个entry:
[02] [0b] [48 65 6c 6c 6f 20 57 6f 72 6c 64]
- 第一个字节02转成十进制就是2表示前一个节点长度是2.
- 第2个字节0b转成二进制为00001011,以00开头,符合编码00pppppp,而除掉最开始的两位00计算之后得到十进制11,这就说明后面字节数组的长度是11.
- 第三部分的11个字节就是存储了Hello World的字节数组
2.4 插入流程&连锁更新
在ziplist.c
文件的ziplistPush
函数是 ziplist 插入数据的函数之一,实际上是去调用 ziplist.c的__ziplistInsert
函数。ziplist的添加流程不是很复杂,我们大概的梳理一下:
- 计算出待插入位置的 entry 的前一个节点的长度
- 看看能不能将要插入的数据转成整数编码
- 将待插入数据占用的总字节数赋给 reqlen
- 将待插入位置原来的 entry 的 prevrawlen 字段的变化也加到reqlen上
- 计算前一个节点的内存变化
- 根据计算出来的插入新节点后的 ziplist 大小重新调整压缩列表的大小,
- 如果计算出来前一个节点的长度变化!=0,会再次进行连锁更新操作
- 将待插入位置的 entry 及其后面的所有 entry 都往后移动,并将组装好的新的 entry 放在待插入位置
unsigned char *ziplistPush(unsigned char *zl, unsigned char *s, unsigned int slen, int where) {
unsigned char *p;
p = (where == ZIPLIST_HEAD) ? ZIPLIST_ENTRY_HEAD(zl) : ZIPLIST_ENTRY_END(zl);
return __ziplistInsert(zl,p,s,slen);
}
压缩列表这里涉及到了一个连锁更新的概念,我大概唠叨一下:
在压缩列表中当节点的前驱节点长度如果小于 254 字节,那么这个entry的prev_entry_length 属性的占用的空间为1字节,当前驱节点的长度大于或等于254字节,那么prev_entry_length属性占用的空间增长为5字节。这样就会有一个问题:
假如压缩列表有 N 个连续的节点的长度在 250-253 字节之间,那么这个时候它们记录前一个节点的长度的 prev_entry_length
都只需要 1 个字节。此时节点A前添加了一个新节点B,它的长度大于或等于254字节。这时 节点A 的 prev_entry_length
属性需要记录节点B的长度,但是由于节点A的 prev_entry_length
属性只有1个字节,没法记录节点B的长度,就需要将其 prev_entry_length
扩展为5个字节。这就会使得原本长度为250到253字节的节点A变为大于或等于254字节,使得节点B也需要扩展 prev_entry_length
属性为5字节,从而引发 节点B的下一个节点直到节点N的prev_entry_length
属性的改变。
这就是Redis压缩列表的连锁更新,事实上连锁更新这种会造成性能降低的情况很少见,因此对Redis的性能影响很小。
3.quicklist
3.1 存储结构
快速列表是一个双向链表,链表中的每个节点都使用压缩列表存储数据。在老版本的redis中,List的底层数据结构是 ziplist 和 linkedlist。当List中元素的长度比较小或者数量比较少的时候,采用ziplist 存储;当列表对象中元素的长度比较大或者数量比较多的时候,使用双向列表 linkedlist 来存储。但是这样的设计有些问题:
双向链表方便在列表的两端进行push和pop操作,但是它的内存开销比较大。
- 它的每个节点上除了要保存数据之外,还要保存两个指针
2. 双向链表的各个节点是单独的内存块,地址不连续,节点增多了容易产生内存碎片
压缩列表由于是一整块连续内存,所以存储效率很高。但是它每次增删数据都会引发一次内存的 realloc,当压缩列表存储的数据很多的时候,一次realloc 可能会导致大量的数据拷贝,对性能影响很大。所以综合了双向链表和压缩列表的优点,redis作者造出了能够在时间效率和空间效率间取了一个折中的快速列表。
typedef struct quicklist {
/* 指向双向列表的表头 */
quicklistNode *head;
/* 指向双向列表的表尾 */
quicklistNode *tail;
/* 所有的 ziplist 中一共存了多少个元素 */
unsigned long count;
/* 双向链表的长度,node 的数量 */
unsigned long len;
/* fill factor for individual nodes */
int fill : QL_FILL_BITS;
/* 压缩深度,0:不压缩; */
unsigned int compress : QL_COMP_BITS;
unsigned int bookmark_count: QL_BM_BITS;
/*bookmakrs是realloc这个结构使用的一个可选特性,这样在不使用时就不会消耗内存。*/
quicklistBookmark bookmarks[];
} quicklist;
redis.conf 相关参数:
参数 | 含义 |
---|---|
list-max-ziplist-size(fill) | 正数表示单个 ziplist 最多所包含的 entry 个数。 负数代表单个 ziplist 的大小,默认 8k。 -1:4KB;-2:8KB;-3:16KB;-4:32KB;-5:64KB |
list-compress-depth(compress) | 压缩深度(首尾两端不被压缩的 quicklistNode 节点个数),默认是 0。 1:首尾的 ziplist 不压缩;2:首尾第一第二个 ziplist 不压缩,以此类推 |
快速列表中包含了压缩列表和链表,每个节点的大小就是一个需要考虑的点。如果单个节点的压缩列表中存储的数据太多,会影响插入效率;如果单个节点存储的数据太少,又会跟普通链表一样造成空间浪费。这样一个节点上压缩列表的合理长度不好确定也不好写死,开发人员可以考虑自身的应用场景通过 list-max-ziplist-size参数来设置。
list的设计目标是存放很长的列表数据,当列表很长时占用的内存空间必然会相应增多。但是当列表很长的时候,list中访问频率最高的是两端的数据,中间的数据访问频率较低,如果对使用频率较低的数据进行压缩,在需要使用时再进行解压缩,那就可以进一步减少数据存储的空间。所以,redis 提供参数 list-compress-depth 来进行数据压缩配置,该值最终存放在 quicklist-> compress 属性中。
quicklistNode 中的*zl 指向一个 ziplist,一个 ziplist 可以存放多个元素。ziplist 的结构上面已经说过了,不再重复。
typedef struct quicklistNode {
struct quicklistNode *prev; /* 前一个节点 */
struct quicklistNode *next; /* 后一个节点 */
unsigned char *zl; /* 指向实际的 ziplist */
unsigned int sz; /* 当前 ziplist 占用多少字节 */
unsigned int count : 16; /* 当前 ziplist 中存储了多少个元素,占 16bit(下同),最大 65536 个 */
unsigned int encoding : 2; /* 是否采用了 LZF 压缩算法压缩节点,1:RAW 2:LZF */
unsigned int container : 2; /* 2:ziplist,未来可能支持其他结构存储 */
unsigned int recompress : 1; /* 当前 ziplist 是不是已经被解压出来作临时使用 */
unsigned int attempted_compress : 1; /* 测试用 */
unsigned int extra : 10; /* 预留给未来使用 */
} quicklistNode;
3.2 插入操作
快速列表的插入操作,其实不是很复杂,他会判断是指定节点后面插入
还是指定节点前面插入
,然后判断可不可能是这个快速列表还没初始化,这个时候要进行初始化操作,最后还要尝试对快速列表进行压缩。其实它判断头插还是尾插也是在为上层的头插和尾插赋能而已。源码如下:
/*
* 如果“after”为1,请在“old_node”后插入“new_node”。
* 如果“after”为0,请在“old_node”之前插入“new_node”。
* 注意:‘new _ node’是*总是*未压缩的,
* 所以如果我们将其分配给head或tail,我们不需要对其进行解压缩。
*/
REDIS_STATIC void __quicklistInsertNode(quicklist *quicklist,
quicklistNode *old_node,
quicklistNode *new_node, int after) {
if (after) {//指定节点后面插入
new_node->prev = old_node;
if (old_node) {
new_node->next = old_node->next;
if (old_node->next)
old_node->next->prev = new_node;
old_node->next = new_node;
}
if (quicklist->tail == old_node)
quicklist->tail = new_node;
} else { //指定节点前面插入
new_node->next = old_node;
if (old_node) {
new_node->prev = old_node->prev;
if (old_node->prev)
old_node->prev->next = new_node;
old_node->prev = new_node;
}
if (quicklist->head == old_node)
quicklist->head = new_node;
}
/* 如果是第一个添加的元素,还得先初始化头节点和尾结点 */
if (quicklist->len == 0) {
quicklist->head = quicklist->tail = new_node;
}
if (old_node) //如果是尾插,还要尝试对快速列表进行压缩操作
quicklistCompress(quicklist, old_node);
quicklist->len++;
}
聊完了list所使用的数据结构,现在我们再来看一下它的存储过程。
4.存储过程
通过断点追踪,列表命令的处理入口在 t_list.c文件的lpushCommand(),主要逻辑是调用 pushGenericCommand() 函数。这个函数比较简短,主要完成的操作如下;
- 调用 lookupKeyWrite() 函数去数据库中查找是否存在目标 key 的数据
- 如果不存在就调用 createQuicklistObject() 函数新建一个存储结构为 quicklist 的 redisObject 对象,通过函数 dbAdd() 将其存储到数据库中,再调用listTypePush() 函数将 value 值插入到列表中
void pushGenericCommand(client *c, int where, int xx) {
robj *lobj = lookupKeyWrite(c->db, c->argv[1]);//去数据库中查找是否存在目标 key 的数据
if (checkType(c,lobj,OBJ_LIST)) return;
if (!lobj) {//不存在
...
lobj = createQuicklistObject();//新建一个存储结构为 quicklist 的 redisObject 对象
quicklistSetOptions(lobj->ptr, server.list_max_ziplist_size,server.list_compress_depth);
dbAdd(c->db,c->argv[1],lobj);//存储到数据库
}
for (j = 2; j < c->argc; j++) {
listTypePush(lobj,c->argv[j],where);//将 value 值插入到列表中
server.dirty++;
}
...
}
4.1 创建对象
我们再来看一下快速列表对象的创建流程也就是createQuicklistObject函数:这个方法的实现在object.c文件中。
- 调用 quicklistCreate() 函数创建 quicklist 结构
- 调用创建redis 对象的通用函数 createObject() 新建一个 redisObject 对象并返回
robj *createQuicklistObject(void) {
quicklist *l = quicklistCreate();//创建 quicklist 结构
robj *o = createObject(OBJ_LIST,l);//新建一个 redisObject 对象
o->encoding = OBJ_ENCODING_QUICKLIST;
return o;
}
我们再继续追一下quicklistCreate()
。
- 申请内存
- 初始化头尾结点和其他属性
quicklist *quicklistCreate(void) {
struct quicklist *quicklist;
quicklist = zmalloc(sizeof(*quicklist));
//此处省略对必要的属性给默认值
}
从这里也可以看出来快速列表是一个带有头尾结点的双向链表结构。
至此,对象创建的流程就分析完了,我们再来看一下value是如何添加到快速列表的。
4.2 添加操作
插入的主要流程:
- 判断列表对象的编码类型是否为 OBJ_ENCODING_QUICKLIST,如果不是就报错,根据 where 参数判断数据插入 quicklist 的位置
- getDecodedObject() 将需要插入的数据转成 sds 存储的 String 对象,并调用 sdslen() 函数获取数据的长度
quicklistPush() 函数根据入参 where 选择调用的插入函数
void listTypePush(robj *subject, robj *value, int where) {
//判断列表对象的编码类型是否为 OBJ_ENCODING_QUICKLIST
if (subject->encoding == OBJ_ENCODING_QUICKLIST) {
//计算插入位置
int pos = (where == LIST_HEAD) ? QUICKLIST_HEAD : QUICKLIST_TAIL;
value = getDecodedObject(value);//将需要插入的数据转成 sds 存储的 String 对象
size_t len = sdslen(value->ptr);//获取转换后的value长度
quicklistPush(subject->ptr, value->ptr, len, pos);//选择需要调用的插入函数
decrRefCount(value);
} else {
serverPanic("Unknown list encoding");
}
}
对于插入函数的选择就分为头插和尾插。我们来看一下头插的过程:
判断 quicklist 的 head 头节点是否可以插入
- 如果可以
- 调用ziplist 的函数将数据插入到该节点的 ziplist 中
- 更新节点保存的数据的长度
- 如果不行
- 新建一个 quicklist 节点
- 调用ziplist 的函数将数据插入到该节点的 ziplist 中
- 更新节点保存的数据的长度
- 将新建节点作为头节点插入到 quicklist
- 更新 quicklist 和 quicklist 头节点的 count,count 用于记录存储的元素的总数
- 插入成功返回1,已经存在返回0
int quicklistPushHead(quicklist *quicklist, void *value, size_t sz) {
quicklistNode *orig_head = quicklist->head;
if (likely(
//判断 quicklist 的 head 头节点是否可以插入
_quicklistNodeAllowInsert(quicklist->head, quicklist->fill, sz))) {
//调用ziplist 的函数将数据插入到该节点的 ziplist 中
quicklist->head->zl = ziplistPush(quicklist->head->zl, value, sz, ZIPLIST_HEAD);
quicklistNodeUpdateSz(quicklist->head);//更新节点保存的数据的长度
} else { //头节点不能插入
quicklistNode *node = quicklistCreateNode();//新建一个 quicklist 节点
//调用ziplist 的函数将数据插入到该节点的 ziplist 中
node->zl = ziplistPush(ziplistNew(), value, sz, ZIPLIST_HEAD);
quicklistNodeUpdateSz(node);//更新节点保存的数据的长度
//将新建节点作为头节点插入到 quicklist
_quicklistInsertNodeBefore(quicklist, quicklist->head, node);
}
//更新 quicklist 和 quicklist 头节点的 count
//count 用于记录存储的元素的总数
quicklist->count++;
quicklist->head->count++;
return (orig_head != quicklist->head);
}
至此,list的插入过程和整个存储流程就分析完了,接下来我们看一下list数据结构的适用场景。
5.使用场景
5.1 用户消息时间线
因为 List 是有序的,可以用来做用户时间线
5.2 消息 队列
List 提供了两个阻塞的弹出操作:BLPOP/BRPOP,可以设置超时时间。
BLPOP:BLPOP key1 timeout 移出并获取列表的第一个元素, 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止。
BRPOP:BRPOP key1 timeout 移出并获取列表的最后一个元素, 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止。
队列:先进先出:rpush blpop,左头右尾,右边进入队列,左边出队列。
栈:先进后出:rpush brpop