压缩列表ziplist 是由一系列特殊编码的连续内存块组成的顺序型数据结构,即 将一系列数据与其编码信息存储在一块物理上连续的内存空间上,但是 逻辑上却是分作多个部分,其目的是在一定可控的时间复杂读条件下尽可能的减少不必要的内存开销,从而达到节省内存的效果
Redis 中 压缩列表 是 列表键和哈希键 的底层实现之一,当一个 列表键只包含少量列表项 并且每个列表项 要么是比较小的整数值或者比较短的字符串 的时候,那么就会使用 压缩列表进行表示,同理 哈希键的情况也是一样的
压缩列表 的实现原理主要体现在 编码
这个词上,所以我们后续更多以 编码 角度去解读…
压缩列表结构
一个压缩列表是一段连续的内存空间,往往包含 zlbytes( 压缩表占用空间数据 ) + zltail( 表尾数据记录 ) + zllen( 表长 ) + entry( 若干个节点 ) + zlend( 特殊标识 )
zlbytes
uint32_t 类型,4 字节,记录整个压缩列表占用的内存字节数zltail
uint32_t 类型,4 字节,记录压缩列表 起始位置节点距离尾节点地址 的距离 有多少字节zllen
uint16_t 类型,2 字节,压缩列表 节点数量,当属性值小于 uint16_t 最大数 65535 的时候代表 节点数量,如果等于这个值,真实节点数量就只能遍历整个列表才能得出了entry
节点,整个压缩列表可能存储若干个节点zlend
uint8_t 类型,1 字节,存储特殊值0xFF ( =255 ) ,用于标记压缩列表尾端
比如 一个压缩列表 示例:
zlbytes 值 0xd2 ( =210 ),表示压缩列表长度为 210字节
zltail 值 0xb3( =179 ),表示从 压缩列表起始地址指针 + 179字节 就表示最后一个节点的指针地址
zllen 值 0x5 ( =5 ),表示有 5个节点
压缩列表节点结构
压缩列表节点的结构体内容如下:
首先我们需要知道前提要点,那就是 每个节点都有可能存储的是 一个字节数组 或者 一个整数值
- 字节数组长度一般要求长度在如下三种范围内
- <= 63 ( 26 - 1 )
- <= 16 383 ( 214 - 1 )
- <= 4 294 967 295 ( 232 - 1 )
- 整数值 大小一般要求在如下三种范围内
- 4位 长度 的无符号整数
- 1 字节长度的有符号整数
- 3 字节长度的有符号整数
- int16_t 类型整数
- int32_t 类型整数
- int64_t 类型整数
然后我们一块看看 节点的 三个组成元素:
previous_entry_length
记录了前一个节点的长度,即当前节点指针地址 - previous_entry_length = 前一个节点指针地址
,压缩列表的逆向遍历就是用这一方法实现的- 如果前一个节点的长度小于 254 字节,那么 previous_entry_length 属性的长度为 1 字节,前一个节点的长度就保存在这个字节里; 例如,previous_entry_length 属性的值为 0x00000005,那么代表 前一个节点的长度为 5
- 如果前一个节点的长度大于等于 254 字节,那么 previous_entry_length 属性的长度为 5 字节,不过 其中第 1个字节为 0xFE( =254 ) ,之后 4个直接 用来保存前一个节点的 长度;例如,previous_entry_length 属性的值为 0xFE00001233,那么代表 前一个节点的长度为 0x00001233 = 4659 字节
encoding
记录了节点 content 属性所保存数据的类型以及长度- 1 字节、2 字节、5 字节 长度的情况下,而且属性值最高位分别为 00、01、10 开头的为字符数组编码,这种编码表示字节的 content 属性保存着字符数组,数组的长度由编码除去最高两位之后其他位记录;下面我们来进行示例一下各种长度的字符串的情况,下方 b、x、a 等表示占位二进制数据,_ 表示留空
00bbbbbb
共 1 字节,表示长度小于等于 63 字节的字符数组01bbbbbb xxxxxxxx
共 2 字节,表示长度小于等于 16 383 字节的字符数组10______ aaaaaaaa bbbbbbbb cccccccc dddddddd
共 5 字节,表示长度小于等于 4 294 967 295 字节的字符数组
- 1 字节长 而且 属性值最高位为 11 开头的代表是 整数编码,这种编码表示字节的 content 属性保存着整数值,整数值类型和长度由 编码除去最高两位之后其他位记录;下面我们来进行示例一下各种长度的整数的情况
11000000
共 1 字节,代表 content 保存 int16_t 类型的整数11010000
共 1 字节,代表 content 保存 int32_t 类型的整数11100000
共 1 字节,代表 content 保存 int64_t 类型的整数11110000
共 1 字节,代表 content 保存 24 位有符号整数11111110
共 1 字节,代表 content 保存 8 位有符号整数1111xxxx
共 1 字节,使用这一编码的的节点 content 不保存属性,因为 xxxx 已经能代表 0 ~ 12 的值,所以不需要保存在 content 属性里面
- 1 字节、2 字节、5 字节 长度的情况下,而且属性值最高位分别为 00、01、10 开头的为字符数组编码,这种编码表示字节的 content 属性保存着字符数组,数组的长度由编码除去最高两位之后其他位记录;下面我们来进行示例一下各种长度的字符串的情况,下方 b、x、a 等表示占位二进制数据,_ 表示留空
content
保存了节点的值内容,节点的值可以是 字符数组或者 整数,值的类型和长度 由节点的 encoding 属性确定
例如,节点的值为 字符数组 “hello wettper”,长度为 13,那么各种属性存储的是:
content 存储 “hello wettper”
encoding 存储 00001101
下一个节点的 previous_entry_length 存储 0x0000000d
而又假如,节点的值为 整数 4659,那么各种属性存储的是:
content 存储 4659
encoding 存储 11000000,代表保存 int16_t 类型整数
下一个节点的 previous_entry_length 存储 0x00000001
下面我们就 Redis 压缩列表 一些典型的 API 进行解析~~~
源码解析
由于有些方法里的代码量比较大,我们这里按照 典型的代码片段进行解析,同志们可以根据文章提示的代码位置 和 代码里面的关键词 在源码中搜素,可能数据结构一些元素 看不太懂什么意思,没关系,先混个脸熟,后面看完回头再看过来就明白了
Redis 定义了多种宏 来定义 encoding值 和 判断 encoding 类型 (为了代码显示问题,特地把 define 前面的 # 去除了)
另外还有一些获取各部分值的宏 (为了代码显示问题,特地把 define 前面的 # 去除了)
ziplist 创建函数 ziplistNew()
压缩表的插入函数主要有两个 ziplistPush()
和 ziplistInsert()
,分别是 往压缩列表表尾或表头推入新节点 和 往压缩列表指定位置插入指定节点,最终使用的处理逻辑都是 __ziplistInsert()
节点插入分为两种情况,相关的逻辑也大不相同
- 尾端插入,相对比较简单
- 记录到达 ziplist 末端所需的偏移量(因为之后的内存重分配可能会改变 ziplist 的地址,因此记录偏移量而不是保存指针)
- 根据新节点要保存的值,计算出编码这个值所需的空间大小以及编码它前一个节点的长度所需的空间大小,然后对 ziplist 进行内存重分配
- 设置新节点的各项属性: pre_entry_length 、 encoding 、 length 和 content
- 更新 ziplist 的各项属性,比如记录空间占用的 zlbytes ,到达表尾节点的偏移量 zltail ,以及记录节点数量的 zllen
- 中途插入,比起上一条,由于可能引起一系列前方节点的变化(
连锁更新
),所以要复杂很多;假设我们要将一个新节点 new 添加到节点 prev 和 next 之间- 为新节点扩大 ziplist 的空间
- 设置 new 节点的各项值
- 新的 new 节点取代原来的 prev 节点, 成为了 next 节点的新前驱节点
- 但是这个时候 next 节点的 pre_entry_length 信息还没有更新,存储的还是 prev 的长度信息,所以 next 节点 pre_entry_length 需要存储 new 节点的长度信息,但是这里就会出现一个问题,会出现三种情况:
next 的 pre_entry_length 刚好够 new长度编码 (new 和 pre 长度编码都是一样的 1 或者 5 字节)
、next 的 pre_entry_length 有 5 字节,但是 new 长度编码只需要 1 字节
、next 的 pre_entry_length 有 1 字节,但是 new 长度编码需要 5 字节
,如果是 前两种情况都还好,只需要直接存储,但是如果是 第三种 情况,则会引起连锁更新
:必须对 ziplist 进行内存重分配, 从而扩展 next 的空间,然而,因为 next 的空间长度改变了, 所以程序又必须检查 next 的后继节点 next+1 , 看它的 pre_entry_length 能否编码 next 的新长度, 如果不能的话,程序又需要继续对 next+1 进行扩容… 这就是说, 在某个/某些节点的前面添加新节点之后, 程序必须沿着路径挨个检查后续的节点,是否满足新长度的编码要求, 直到遇到一个能满足要求的节点(如果有一个能满足,则这个节点之后的其他节点也满足), 或者到达 ziplist 的末端 zlend 为止, 这种检查操作的复杂度为 O(N2)
不过对于 连锁更新
,因为只有在新添加节点的后面有连续多个长度接近 254 的节点时, 这种连锁更新才会发生, 所以可以普遍地认为, 这种连锁更新发生的概率非常小, 在一般情况下, 将添加操作看成是 O(N) 复杂度也是可以的;执行完这三种情况的其中一种后, 程序更新 ziplist 的各项属性, 至此,添加操作完成
|
|
获取压缩列表指定索引上的节点函数 ziplistIndex()
而删除节点的逻辑跟插入节点的有些相像,也会产生 连锁更新,删除节点ziplistDelete()
和 删除给定多个节点 ziplistDeleteRange()
核心逻辑函数都是 __ziplistDelete()
连锁更新 函数 __ziplistCascadeUpdate()
本文作者: wettper
本文链接: http://www.web-lovers.com/redis-source-ziplist.html
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!