这是我参与「第五届青训营 」伴学笔记创作活动的第 十六 天
SkipList(跳表)
SkipList是 sortSet 的实现结构之一;
SkipList是一种时间复杂度为O(log(n))的数据结构,其功能与性能相近于堆和红黑树。
🤔既然与堆和红黑树性能相当,为什么需要设计 SkipList ?
📎这需要从 SkipList 的前身有序链表说起,我们知道有序链表的链式结构只能从头到尾地遍历(非常耗时)。就以下图的单链表为例,查找的元素5恰好位于链表尾部,我们只得一个一个元素的遍历所有元素才能查询到目标元素。
🤔❓能不能尽量的减少查询的时间呢,如何去设计这种结构呢?
📎想减少单链表遍历时间,就需要想办法遍历更少的元素,怎么办呢🤔。。。通过观察不难发现单链表最大的问题在于只能从头到尾式遍历,如果我们直接访问到尾部这样子不就让查询的性能直接提升一倍吗,是的这就演变出了双端链表。
但是双端链表并没有真正地提升查询时间复杂度,单链表与双端链表都是时间复杂度为O(N)。还有其他的优化途径吗🤔。。。从单链表到双端链表,仅仅是加入了一个可访问的尾部的区别,实际的性能却放了一倍。那如果我们再加多几个可以访问的节点呢❓直接在加一个指针指向中间节点怎么样🤔❓好像可以,但是这个指针放哪里呢❓除了首尾节点会有一个指针外,中间节点可加不了指针了欸😲
📎欸嘿,这时候就要靠SkipList了。让我们康康它是怎么设计的吧👻
-
首先要解决的问题是中间节点指针存放在哪?
- 引入
指针数组,不同层级指针跨度也不同
- 引入
-
指针数组层数怎么控制呢?如果每个元素的指针数组层数一样高,那不就退化成了链表了吗?
随机层数,Redis控制 SkipList 的层数在 1~32 之间。- 下图是随机层数的跳表插入过程:
SkipList 结构源码分析:
typedef struct zskiplist {
//SkipList 结构头尾节点
struct zskiplistNode *header, *tail;
//节点长度
unsigned long length;
//随机层级
int level;
} zskiplist;
//可以看到skiplist的构成还包括skiplistnode
/* ZSETs use a specialized version of Skiplists */
typedef struct zskiplistNode {
//节点存储的值
sds ele;
//ZSet的得分,node按照得分进行排序
double score;
//前一节点的指针
struct zskiplistNode *backward;
//skiplist层级结构数组
struct zskiplistLevel {
//下一节点指针
struct zskiplistNode *forward;
//指针跨度
unsigned long span;
} level[];
} zskiplistNode;
从源码不难看到SkipList组成基本与上文一致,将其结构可视化如下图:
SkipList、堆与红黑树
- 三者都实现了对数据的插入、删除、查找与有序输出操作
- 三者的各个操作时间复杂度基本都是O(log(N) 级别的
- SkipList 支持按照
区间输出,这是堆和红黑树所不具备的 - SkipLsit 能够通过设置层级参数,按需平衡空间与时间差异
ZipList(压缩链表)
ZipList是一种特殊的“双端链表”结构,但是不同于普通的链表实现,在Redis中ZipList是一块特殊编码的连续内存块;
ZipList结构示意图:
| 属性 | 类型 | 长度 | 用途 |
|---|---|---|---|
| zlbytes | uint32_t | 4Byte | 记录整个压缩列表占用的内存字节数 |
| zltail | uint32_t | 4Byte | 记录压缩列表表尾节点距离压缩列表的起始地址有多少字节,通过这个偏移量,可以确定表尾节点的地址。 |
| zllen | uint16_t | 2Byte | 记录了压缩列表包含的节点数量。 最大值为UINT16_MAX (65534),如果超过这个值,此处会记录为65535,但节点的真实数量需要遍历整个压缩列表才能计算得出。 |
| entry | 列表节点 | indef | 压缩列表包含的各个节点,节点的长度由节点保存的内容决定。 |
| zlend | uint8_t | 1Byte | 特殊值 0xFF (十进制 255 ),用于标记压缩列表的末端。 |
/* Create a new empty ziplist. */
unsigned char *ziplistNew(void) {
//The size of a ziplist header add The size of the "end of ziplist" entry
unsigned int bytes = ZIPLIST_HEADER_SIZE+ZIPLIST_END_SIZE;
//Allocate memory or panic
unsigned char *zl = zmalloc(bytes);
//total bytes a ziplist is composed of
ZIPLIST_BYTES(zl) = intrev32ifbe(bytes);
//the offset of the last item inside the ziplist.
ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(ZIPLIST_HEADER_SIZE);
//the length of a ziplist, or UINT16_MAX if the length cannot be determined without scanning the whole ziplist
ZIPLIST_LENGTH(zl) = 0;
//Special "end of ziplist" entry
zl[bytes-1] = ZIP_END;
return zl;
}
ZipList-Entry
🤔ziplist是一种特殊的“双端链表”结构,但是ziplist却并不使用指针的方式去记录前后节点,redis将内存浪费控制得令人发指,他将记录前后节点得两个指针(32Byte)用下述得结构代替:
| 属性 | 类型 | 长度 | 作用 |
|---|---|---|---|
| previous_entry_length | uint16_t | 1|5 Byte | 记录前一节点长度。 如果前一节点的长度小于254B,则采用1B来保存这个长度值; 如果前一节点的长度大于254B,则采用5B来保存,第一个Byte为0xfe,后四个Byte才是真实值 |
| encoding | int | 1|2|5 Byte | 记录contents编码类型及其长度 |
| contents | indef | indef | 实际数据保存位置 |
⚠️ZipList中所有存储长度的数值均采用小端字节序,即低位字节在前,高位字节在后。
⚠️⚠️例如:数值0x1234,采用小端字节序后实际存储值为:0x3412。
ZipList-Entry结构源码:
static inline void zipEntry(unsigned char *p, zlentry *e) {
//前一节点长度
ZIP_DECODE_PREVLEN(p, e->prevrawlensize, e->prevrawlen);
//entry编码类型
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;
}
Encoding编码
ZipListEntry中的encoding编码分为字符串和整数两种:
- 字符串:如果encoding是以“00”、“01”或者“10”开头,则证明content是字符串
| 编码 | 编码长度 | 字符串大小 |
|---|---|---|
| |00pppppp| | 1 bytes | <= 63 bytes |
| |01pppppp|qqqqqqqq| | 2 bytes | <= 16383 bytes |
| |10000000|qqqqqqqq|rrrrrrrr|ssssssss|tttttttt| | 5 bytes | <= 4294967295 bytes |
- 整数:如果encoding是以“11”开始,则证明content是整数,且encoding固定只占用1个字节 | 编码 | 编码长度 | 整数类型 | | -------- | ------------ | ---------------------------------------------------------- | | 11000000 | 1 | int16_t(2 bytes) | | 11010000 | 1 | int32_t(4 bytes) | | 11100000 | 1 | int64_t(8 bytes) | | 11110000 | 1 | 24位有符整数(3 bytes) | | 11111110 | 1 | 8位有符整数(1 bytes) | | 1111xxxx | 1 | 直接在xxxx位置保存数值,范围从0001~1101,减1后结果为实际值 |
连锁更新问题
ZipList为节省内存空间,为记录前一节点的长度使用了1B或者5B的长度值,同时编码长度也分1B、2B和5B,在特定场景下极容易因为一个长度较大的Entry添加到链表头部而导致后续的节点都需要更新前一节点的长度值和编码格式。
现在,假设我们有N个连续的、长度为250~253字节之间的entry,因此entry的previous_entry_length属性用1个字节即可表示,如图所示:
ZipList这种特殊情况下产生的连续多次空间扩展操作称之为连锁更新(Cascade Update)。新增、删除都可能导致连锁更新的发生。
总结
- 压缩列表的可以看做一种连续内存空间的"双向链表";
- 列表的节点之间不是通过指针连接,而是记录上一节点和本节点长度来寻址,内存占用较低;
- 如果列表数据过多,导致链表过长,可能影响查询性能;
- 增或删较大数据时有可能发生连续更新问题;
- 由于压缩列表申请的是连续内存,这也意味着压缩列表并不适用于较大数据量场景。