List
Redis和它的键们(2)—List
2.1 数据结构
在介绍列表键之前,我们先引入Redis对两个经典表结构的实现,Redis诞生之初,实际是有两种表类型的数据结构,一个是双向链表,一个是压缩列表。在我们经典的程序数据结构中对应着不同的两个类型,分别是链表和数组,他们二者二者擅长插入和利用连续的空间以节省占地。
相比普通的List的而言,Redis的List除了支持随机访问,还具有更多队列的特性,比如它支持左右POP元素(PUSH),它将会引出我们关心的关于Redis的一个重要的新结构
- 一个列表最多存储Integer.MAX_VALUE个元素(没错,这是Java里的表达,最大的那个32位无符号整数)
以下条件必须全部满足,List才会以双向链表的形式存在
- 每个元素都小于64byte
- 元素数目不大于512个
- 如果不满足,List会转化为压缩链表
2.2 压缩列表
双向链表并没有什么特别的,它是和我们学习过的双向链表没什么不同
但是压缩链表和我们所认识的连续空间数组不太一样,它的特别之处就是它每个元素都是单独编码的,这样的设计可以极大的帮助它节省内存
- zlbytes:整个表占用的字节数
- ztail:记录压缩列表「尾部」节点距离起始地址由多少字节,也就是列表尾的偏移量
- zllen:记录压缩列表包含的节点数量
- zlend:标记链表结束的符号,固定为0xFF
上图我们还可以看见它其中每个元素的结构,它可以为元素单独编码,这样精确到每个元素的编码是非常节约内存的,比如说,表达65535需要16个bit,但是表达15仅仅需要4个bit(1110)(当然,Redis并非用的是如此简单的方式,我们接下来可以专门开一期Redis的各种编码方式),这样紧凑的结构显然是经过精心设计的接下来我们来解读一下其主要结构
prevLen存储着前一个节点的字节长度,这样它就可以完成从后往前的查找
- 当prevLen≤255时,prevLen部分需要占用1字节
- 当prevLen>255时,prevLen部分需要占用2字节
- encoding表示data部分的编码方式和长度
- data是数据的本体
上面介绍的是压缩链表的编码方式(图源Redis设计与源码)
如果是整数,就会采用上图所示的1个byte进行编码,如果是字符串类型,见下表
[redis设计与实现的解释]
看完上面的介绍,我们可以很清晰的发现:
- 压缩列表的实现注重于空间的节约
- 压缩链表的查询效率比较低下
当然,我们无法直接调用压缩列表,其作为list的一种实现,list所提供的接口都是很符合压缩列表特性的,但是仍有一点需要我们注意,那就是前文提到的prevLen长度,这个长度会因为其他节点的长度改变而改变自身的长度,相信你已经发现,压缩列表具有一种叫做连锁更新的隐患,这甚至触及到了redis单线程的命门也是文章探讨的主线,那就是阻塞。
2.3 连锁更新
当一个节点被修改了,导致其长度超过了255字节,那么对应的,其接下来的节点也必须修改以适应此变更,也就是prevLen从1字节变为2字节,如果不巧的是,此entry长度恰好为255字节,修改后,继续引发后面的entry的prevLen发生改变。如此往复,整个压缩列表的内存空间以O(N²)的复杂度重新分配了一次。
更加糟糕的是,这些工作都必须由主线程来完成,阻塞主线程的几率就被大大增加了。
不过,从实际情况来考虑,这种想法确实是略显杞人忧天了,它必须满足几个苛刻的条件,才会使主线程的阻塞称为一个不可忽视的开销
- 必须对列表的某个元素做增加其长度的修改,并且这个修改必须使其长度从255以下到255以上
- 被修改的节点的后继节点的长度必须全部为255byte
- 由255byte的节点构成的后继节点串必须足够长
很显然,除非精心设计,这样的情况并不容易发生,但即便如此,Redis对这种设计缺陷还是给出了自己的解决方案
2.4 连锁更新的解决:quicklist和listpack
2.4.1 quicklist
在Redis3.2中为了解决连锁更新的问题,引入了quicklist
我们可以发现,它通过增大压缩列表粒度来控制每次连锁更新的规模,这是聪明的做法,笔者觉得到目前为止,虽然连锁更新的问题并未完全解决,但其危害已经到了可以预测的规避的级别上了。
typedef struct quicklistNode {
//前一个quicklistNode
struct quicklistNode *prev; //前一个quicklistNode
//下一个quicklistNode
struct quicklistNode *next; //后一个quicklistNode
//quicklistNode指向的压缩列表
unsigned char *zl;
//压缩列表的的字节大小
unsigned int sz;
//压缩列表的元素个数
unsigned int count : 16; //ziplist中的元素个数
....
} quicklistNode;
但redis自身并不满足,并在接下来的版本中用listpack彻底解决了这个问题
2.4.1 listpack
listpack是redis在5.0推出的用来彻底取代压缩列表的结构,其改动也非常简单
主要的改动就是把entry的结构改为了自己的长度,这样在修改元素的时候,其不会影响到别的链表的长度,只需要O(N)的开销便可
prevLen不是用来从后往前遍历吗,这样还怎么找到前一个节点?
这也就是listpack把len放在节点尾部的原因:当我需要从后往前遍历时,第一个找到的是len长度,这可以帮我定位到此节点的头部,再通过向前寻位的方式,找到前一个链表的len,如此我便可以读取前驱节点的encoding,进而解读data中的数据了
到此,连锁更新彻底成为了redis历史问题了,我们也可以发现,redis的设计者对于它的追求充满着偏执,正是这种偏执成就了redis的精巧居奇
对压缩列表的看法
这种结构的优缺点都极为明显,那就是成也压缩,败也压缩,压缩为Redis所运行的珍贵的内存争取到了宝贵的发挥空间,但是其过于紧凑的设计让每个entry都动弹不得——稍有不慎,你将面临的就是至少O(N)甚至O(N²)的开销,Redis单线程的设计对于这些开销是很敏感的,这也告诉我们,规模巨大或者需要频繁更新的结构并不适合用列表的方式去存储(即便List提供了这些接口),而比较矛盾的是,在数据规模比较大的时候List才会从善于删改的双向链表改为压缩列表。
2.5 List的接口
List内部经历的设计非常复杂,但其提供的接口非常简单,总结而言,有以下五类
增
- Rpush:O(1)
- LPush:O(1)
- linsert:O(N) 将元素插入到value元素的after(or:before)
删
- Lpop:O(1)
- Rpop:O(1)
- lrem:O(N) 从左往右(+)删除|count|个值为value的元素
- ltrim:O(N) 删除指定范围的元素
查
- lrange:O(N) 返回指定范围的元素们
- lindex:O(N) 查询指定元素的index
- llen:O(1)
改
- lset:O(1) 修改指定位置的元素为value
阻塞
- blpop:O(1)
- brpop:O(1)
2.6 List in Action
消息队列
Redis的锁粒度只锁队头队尾,于是生产者可以无阻塞的向其中插入消息,消费者阻塞地在队尾抢夺消息,我们还可以在多个客户端和实例上部署此队列,保证消息的负载均衡和可用性
为何我不用专业的消息队列如Kafka、RabbitMQ?
如无必要,勿增实体。这是需求决定的,这也是设计的哲学,如果你的需求仅仅也就是用来异步或者解耦,用Redis的消息队列已经绰绰有余,而当你的业务到达了一定量级,在考虑吞吐以及治理方面的需求时,再去引入专业的消息队列也为时不晚
行文至此,不禁感叹Redis数据结构的设计精巧而全面,分布式锁、去重、消息队列以及后文我们还要提到的排行榜,每每让我臣服于其强大的魅力
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。