Redis压缩列表的设计与实现
作者:逸风尊者
压缩列表(Ziplist)其应用场景主要包括以下几个方面:
1. 哈希表(Hash)
在 Redis 中,当哈希表(Hash)的键值对数量较少且每个键和值的长度较短时,Redis 会使用压缩列表作为哈希表的底层实现。这种方式可以大幅度减少内存开销,提高存储效率。
使用条件
- 键值对数量较少(默认不超过 512 个,可以通过配置参数
hash-max-ziplist-entries
调整)。 - 每个键和值的字符串长度较短(默认不超过 64 字节,可以通过配置参数
hash-max-ziplist-value
调整)。
2. 列表(List)
当 Redis 列表中的元素数量较少且每个元素的长度较短时,压缩列表会被用作列表的底层实现,以节省内存。
使用条件
- 列表的元素数量较少(默认不超过 512 个,可以通过配置参数
list-max-ziplist-entries
调整)。 - 每个元素的字符串长度较短(默认不超过 64 字节,可以通过配置参数
list-max-ziplist-value
调整)。
3. 有序集合(Sorted Set)
在有序集合中,当成员数量较少且成员和分数的长度较短时,压缩列表会被用作有序集合的底层实现之一,以提高存储效率。
使用条件
- 成员数量较少(默认不超过 128 个,可以通过配置参数
zset-max-ziplist-entries
调整)。 - 每个成员和分数的字符串长度较短(默认不超过 64 字节,可以通过配置参数
zset-max-ziplist-value
调整)。
优点与缺点
优点:
- 高效内存利用:通过紧凑的存储结构,大幅减少内存占用。
- 适合小数据量:在存储小规模数据时,能够提供良好的性能和内存效率。
缺点:
- 操作代价较高:由于压缩列表是连续存储结构,插入和删除操作可能需要移动大量数据,性能相对较低。
- 不适合大数据量:压缩列表更适合小规模的数据存储,当数据量变大时,操作效率会显著下降。
示例及实际应用
假设我们有一个包含用户属性的哈希表结构,并且用户属性数量较少,属性值也较短,这样的哈希表会被存储为压缩列表:
# 创建一个包含用户属性的哈希表 HSET user:1000 name "Alice" HSET user:1000 age "30" HSET user:1000 city "New York" # 获取用户属性 HGETALL user:1000 # 返回 ["name", "Alice", "age", "30", "city", "New York"]
当满足压缩列表的使用条件时,上述哈希表将以压缩列表的形式存储,从而节省内存开销。
同样地,对于一个短列表和一个小规模有序集合,它们也可以被存储为压缩列表:
# 创建一个短列表 LPUSH mylist "element1" LPUSH mylist "element2" LPUSH mylist "element3" # 获取列表所有元素 LRANGE mylist 0 -1 # 返回 ["element3", "element2", "element1"] # 创建一个小规模有序集合 ZADD myzset 1 "member1" ZADD myzset 2 "member2" ZADD myzset 3 "member3" # 获取有序集合所有成员 ZRANGE myzset 0 -1 WITHSCORES # 返回 ["member1", "1", "member2", "2", "member3", "3"]
底层实现
1. 整体结构
压缩列表由一系列按顺序排列的节点组成,每个节点可以存储一个整数或一个字节数组。所有数据都存储在连续的内存块中。整体结构如下:
- zlbytes:4 字节,记录整个压缩列表占用的内存字节数。
- zltail:4 字节,指向压缩列表尾部节点的偏移量,便于快速定位最后一个节点。
- zllen:2 字节,记录压缩列表中的节点数量。如果超过 65535,则需要遍历整个列表来计算节点数量。
- entryX:一个或多个节点,每个节点存储一个实际数据。
- zlend:1 字节,特殊标志符,值固定为 0xFF,标记压缩列表的结束。
2. 节点结构
每个节点由以下部分组成:
- previous_entry_length:存储前一个节点的长度,以便支持反向遍历。当前一个节点长度小于 254 字节时,该字段占 1 字节;否则,该字段占 5 字节。
- encoding:编码类型和长度信息,用于标识当前节点的数据类型及其长度。该字段的长度取决于实际编码方式。
- content:实际存储的数据,可以是整数或字节数组。
3. 编码方式
压缩列表支持多种编码方式,根据数据类型和长度选择最合适的编码方式。常见的编码方式包括:
字符串编码:
- 长度小于或等于 63 字节:6 位表示长度
- 长度小于或等于 16383 字节:14 位表示长度
- 长度大于 16383 字节:32 位表示长度
整数编码:
- 1 字节整数:表示范围为 [-128, 127]
- 3 字节整数:表示范围为 [-2^23, 2^23-1]
- 5 字节整数:表示范围为 [-2^39, 2^39-1]
4. 操作
压缩列表支持多种操作,包括插入、删除、查找等。这些操作通过调整节点结构和更新相关元数据来实现。
插入
插入一个新节点,需要找到插入位置,调整后续节点的 previous_entry_length
字段,并可能触发内存重分配。例如:
unsigned char *ziplistInsert(unsigned char *zl, unsigned char *p, unsigned char *s, unsigned int slen) { // step 1: 找到插入位置 // step 2: 扩展内存以容纳新节点 // step 3: 调整其他节点的 previous_entry_length 字段 // step 4: 将新节点写入压缩列表 }
删除
删除一个节点,需要调整前后节点的连接关系,并可能触发内存收缩。例如:
unsigned char *ziplistDelete(unsigned char *zl, unsigned char **p) { // step 1: 找到要删除的节点 // step 2: 释放节点占用的空间 // step 3: 调整其他节点的 previous_entry_length 字段 // step 4: 缩减内存 }
查找
遍历压缩列表,查找满足条件的节点。例如:
unsigned char *ziplistFind(unsigned char *zl, unsigned char *s, unsigned int slen, unsigned int skip) { // step 1: 遍历压缩列表 // step 2: 比较节点内容 // step 3: 返回匹配的节点 }
扩容机制
压缩列表(Ziplist)的主要设计目标之一是节省内存。为了保持数据紧凑存储,压缩列表使用连续的内存块。因此,当需要插入新的数据时,如果现有内存块空间不足,就需要进行扩容操作。
1. 扩容触发条件
当需要在压缩列表中插入新节点,但当前内存空间不足以容纳该新节点时,会触发扩容操作。这是为了确保压缩列表能够继续正常存储新增的数据。
2. 扩容步骤
步骤1:计算新长度
首先,根据当前压缩列表的总长度和新节点的大小,计算出扩容后所需的总长度。
size_t curlen = intrev32ifbe(ZIPLIST_BYTES(zl)); // 当前压缩列表的总长度 size_t reqlen = zipRawEntryLength(p); // 新节点所需的长度 size_t newlen = curlen + reqlen; // 新的总长度
步骤2:分配新内存
根据计算出的新长度,为压缩列表分配一个更大的内存块。这个过程通常使用 zrealloc
函数,该函数不仅会增加所需的最小容量,还会多分配一些额外的空间,以减少频繁的内存重分配操作,从而提高性能。
if (newlen > curlen) { zl = zrealloc(zl, newlen); // 分配新的内存块 ZIPLIST_BYTES(zl) = intrev32ifbe(newlen); // 更新 zlbytes 字段 }
步骤3:复制旧数据
在分配了新的内存块之后,将旧的压缩列表数据复制到新的内存块中。这是由 zrealloc
自动处理的,程序员无需手动干预。
步骤4:更新元数据
扩容后,需要更新压缩列表的元数据字段,包括 zlbytes
、zltail
等,以反映新的内存布局。
ZIPLIST_BYTES(zl) = intrev32ifbe(newlen); // 更新 zlbytes // 如果插入点在尾部,需要更新 zltail if (tail_pos == curlen) { ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(new_tail_offset); }
步骤5:插入新节点
在更新完元数据之后,在适当的位置插入新节点,并调整相关的 previous_entry_length
和其他必要的字段。
unsigned char *ziplistInsert(unsigned char *zl, unsigned char *p, unsigned char *s, unsigned int slen) { // ... 前面部分略 ... // 插入新节点 memmove(p + reqlen, p, curlen - (p - zl)); zipSetEntry(zl, p, s, slen); // 更新 zlend 标志 ZIPLIST_END(zl) = 0xFF; return zl; }
3. 内存分配策略
Redis 使用 Jemalloc 或者系统默认的内存分配器来进行内存管理。内存扩展时,通常会分配比实际需求更多的内存,以减少未来再次扩容的次数。这种策略称为“缓冲区增长”,可以显著提升性能,避免频繁的内存分配和数据拷贝。
4. 内存收缩
除了扩容,压缩列表还可能进行收缩操作。当删除大量节点或者节点内容缩小时,压缩列表可能会释放不再需要的内存空间。这通常发生在内存非常紧张或者节点删除操作非常频繁的情况下。
收缩示例
假设删除操作导致压缩列表变得非常稀疏,可以通过重新分配较小的内存空间来实现收缩:
unsigned char *ziplistShrink(unsigned char *zl) { size_t curlen = intrev32ifbe(ZIPLIST_BYTES(zl)); size_t newlen = calculateNewLengthAfterDeletion(zl); if (newlen < curlen) { zl = zrealloc(zl, newlen); ZIPLIST_BYTES(zl) = intrev32ifbe(newlen); } return zl; }
以上就是Redis压缩列表的设计与实现的详细内容,更多关于Redis压缩列表的资料请关注脚本之家其它相关文章!