【Redis源码系列】Redis6.0数据结构详解--ziplist篇
highlight: tomorrow-night theme: fancy
前言
上篇文章我们研究了Redis SDS数据结构的实现原理, 并深入分析了其针对性的优化手段。本篇我们研究一下另一个数据结构ziplist。
数据结构
整体布局
ziplist没有结构体定义, 官方文档描述其为: 一种特殊结构的双向链表。其特殊之处在, 没有使用双向指针(prev, next)去连接前后的元素, 而是通过计算元素中特定编码的字长偏移量来访问不同的元素, 借助源码src/ziplist.c
中的注释描述:
我们可以了解到典型的压缩列表布局为:
由如下部分组成:
- zlbytes: uint32_t
类型, 表示整个ziplist所占用内存的字节数, 在执行内存分配时会被使用。
- zltail: uint32_t
类型, 即上图到达zlend节点的偏移量, 通过此偏移量可以快速定位到zlend节点。
- zllen: uint16_t
类型, ziplist 中节点的数量。 当这个值小于 UINT16_MAX
(65535
)时,这个值就是 ziplist 中节点的数量; 当这个值等于 UINT16_MAX
时,节点的数量需要遍历整个 ziplist 才能计算得出。
- entry: ziplist 所保存的节点,各个节点的长度根据内容而定。
- zlend: uint8_t
类型, 255
的二进制值 1111 1111
(UINT8_MAX
) ,用于标记 ziplist 的末端。
entry组成
entry的逻辑定义如下:
其并非实际存储结构, 只是辅助结构, 可以通过一些宏定义更方便的展开, 简化为更容易理解的内存布局如下:
pre_entry_length
pre_entry_length记录了前一个节点的长度, 可占用 1 或者 5个字节, 语句 #define ZIP_BIG_PREVLEN 254
定义当前一个节点的字长小于254时, 使用一个字节保存其值, 超过254, 则第一个字节被设置为254, 剩下字节保存其实际长度。可以通过次记录作指针偏移量计算跳转到上一个节点,如上图, 当前指针位置为 curptr,则 preptr = curptr-pre_entry_length,这样可以快速定位到上一个节点。参见源码如下:
len
len字长有 encoding & len 两部分组成, 长度可能为 1,2,5个字节。其中encoding占用两个字节, 其值有如下两种情况: - 00,01,10: 表示存储的是字符数组 - 11: 表示存储的是整数 其值在不同情况子长下的存储示例为:
| 字节长 | 编码 | content值 |
| :---: | :--- | :--- |
| 1 byte | 00bbbbbb
| 长度小于等于 63 字节的字符数组 |
| 2 byte | 01bbbbbb xxxxxxxx
| 长度小于等于 16383 字节的字符数组 |
| 5 byte | 10____ aaaaaaaa bbbbbbbb cccccccc dddddddd
| 长度小于等于 16383 字节的字符数组 |
| 1 byte | 11000000
| int16_t
类型的整数 |
| 1 byte | 11010000
| int32_t
类型的整数 |
| 1 byte | 11100000
| int64_t
类型的整数 |
| 1 byte | 11110000
| 24 bit 有符号整数 |
| 1 byte | 11111110
| 8 bit 有符号整数 |
| 1 byte | 1111xxxx
| 4 bit 无符号整数,介于 0
至 12
之间 |
content
通过以上的结构分析我们已经能够了解entry布局的基本组成, 关于content的内容我们从一个实例入手来进行了解, 老规矩, 有请万能的 hello world
登场:
一个存储hello world的ziplist节点如图所示, 其中
encoding
占用两位, 00 标识内容类型为字符数组, length
占用6位, 为二进制的11, 表示 content 字节长度, encoding & length整体占用一个字节。
数据操作
创建ziplist
创建的方法比较简单, 可以看到ziplist
本质上是一个字节数组, 初始化内存为: ZIPLIST_HEADER_SIZE
+ZIPLIST_END_SIZE
=11个字节, 宏定义如下:
- ZIPLIST_HEADER_SIZE: (sizeof(uint32_t)*2+sizeof(uint16_t))
共10个字节。
- ZIPLIST_END_SIZE: (sizeof(uint8_t))
一个字节, 表示尾部节点。
创建完成后结构如下:
插入元素
__ziplistInsert
方法上游有ziplistInsert
和ziplistPush
方法调用核心都是执行__ziplistInsert
方法,源码分析如下:
查找元素
基于我们如上的分析, 可以比较清除的理解ziplist的查找操作, 源码解析如下:
连锁更新
前面我们分析过entry
的previous_entry_length
属性保存了前一个节点的长度, 为 1 或 5 个字节长度,当前一个节点长度在 254 以下时,使用一个字节保存其长度, 长度在 254 以上时, 使用5个字节保存其长度, 事项如下实例: 在ziplist中连续保存着长度为 250 - 253 长度的节点 e1,e2...eN, 此时每个节点的previous_entry_length
属性长度都为 1, 当我们在头结点插入一个长度超过254的entry
后, 第二个节点e1
需要修改自己的属性previous_entry_length
, 增加4个字节, 依此类推, 直到后面的每一个节点执行类似的操作, 我们将这种情况命名为: 连锁更新, 在源码src/ziplist.c
的__ziplistCascadeUpdate
方法实现了这样的操作逻辑。源码解析如下:
基于源码有如下思考, 在连锁更新的最坏的情况下需要执行N
次空间重新分配操作, 而每次空间分配最坏复杂度为O(N)
, 所以连锁更新最坏的复杂度为O(N^2)
,看起来是一个非常危险的存在, 尽管如此, 实际使用起来要达到最坏情况条件相对比较苛刻, 要满足所有节点长度介于250
-253
之间。同时在实际情况中, 更新的节点仍在小范围内, 不会对性能造成明显可见的影响。
此类操作可类比到PHP7的zend_hash
源码实现, PHP7使用链表来处理hash冲突, 同时使用开源的time33
算法做hash值计算, 当我们使用恶意数据构造zend_hash
的数据结构, 就是得原本连续存储的bucket数组转化为基于bucket的链表, 我们很容易实现这样的入侵:
```php
<?php
echo "开始添加元素" . PHP_EOL;
$a = ["cooper" => "cooper_key"];
$size = pow(2, 16);
$startTime = microtime(true);
$array = array();
for ($key = 0, $maxKey = ($size - 1) * $size; $key <= $maxKey; $key += $size) {
$array[$key] = 0;
}
$endTime = microtime(true);
echo '插入 ', $size, ' 个恶意的元素需要 ', $endTime - $startTime, ' 秒', "\n";
$startTime = microtime(true);
$array = array();
for ($key = 0, $maxKey = $size - 1; $key <= $maxKey; ++$key) {
$array[$key] = 0;
}
$endTime = microtime(true);
echo '插入 ', $size, ' 个普通元素需要 ', $endTime - $startTime, ' 秒', "\n";
执行结果如下:
开始添加元素
插入 65536 个恶意的元素需要 8.8367640972137 秒
插入 65536 个普通元素需要 0.0045688152313232 秒
```
尽管如此, 我们仍然大规模的使用PHP做web服务的实现, 不过我们需要注意防范此类的攻击。
总结
本篇我们分析了redis ziplist的源码实现, 可以看到ziplist本质内存结构是一个字节数组, 作者通过相对复杂的指针偏移规则做结构的逻辑模拟, 这样带来的好处是极大的提高的了内存的使用率, 整体实现几乎是不浪费任何一个可存储的字节, 也体现了作者对于设计一个内存型数据库的极致追求。 关于ziplist个人认为需要重点理解其编码的实现规则, 以及这些规则背后存在的意义。同时对于基于此规则引起的可能存在的复杂度较高的操作我们也做了详细的解析, 并且举一反三类比PHP源码的实现做了分解。复杂事物背后很难有100%完美的解法, 使用空间换取时间也是很多优秀作品的共同选择, 同时能够将复杂逻辑做严谨的设计与实现, 也使得我们可以站在巨人的肩膀上更好的充实自己。