Redis使用了6种简单基础数据结构(简单动态字符串、链表、字典、跳跃表、整数集合、压缩列表)分别组合实现了字符串(string)、散列(hash)、列表(list)、集合(set)和有序集合(sorted set)这五种类型的键的底层实现数据结构对象。
目录
简单动态字符串
链表
字典
跳跃表
整数集合
压缩列表
简单动态字符串
Redis 没有直接使用 C 语言传统的字符串表示(以空字符结尾的字符数组,以下简称 C 字符串), 而是自己构建了一种名为简单动态字符串(simple dynamic string,SDS)的抽象类型, 并将 SDS 用作 Redis 的默认字符串表示。
1、SDS定义
struct sdshdr {
// 记录 buf 数组中已使用字节的数量
// 等于 SDS 所保存字符串的长度
int len;
// 记录 buf 数组中未使用字节的数量
int free;
// 字节数组,用于保存字符串
char buf[];
};
![](https://img.laitimes.com/img/9ZDMuAjOiMmIsIjOiQnIsIyZuBHL0FWby9mZvwVZnFWbp1zczV2YvJHctM3cv1Ce-cnW1JkbMtmSE9EaSdlW0kFVNVzY61kaOdVT1U0VNVTWHp1dFJTWspERONTT6lVMRR1Tyk1RNJzYq10MwkWZwpFShdnRtNmb5k3YsR2VZRHbygldwIjYqVTehZXOtllesdkWsp0MMZ3bENGMShUYvwlbj5yZtlmbkN3YuQnclZnbvN2Ztl2Lc9CX6MHc0RHaiojIsJye.jpg)
-
属性的值为 , 表示这个 SDS 没有分配任何未使用空间。free
-
属性的值为len
, 表示这个 SDS 保存了一个五字节长的字符串。5
-
属性是一个buf
类型的数组, 数组的前五个字节分别保存了char
、'R'
、'e'
、'd'
、'i'
五个字符, 而最后一个字节则保存了空字符's'
。'\0'
2、SDS缓冲区
为了杜绝缓冲区溢出, SDS采取内存重分配策略:当 SDS API 需要对 SDS 进行修改时, API 会先检查 SDS 的空间是否满足修改所需的要求, 如果不满足的话, API 会自动将 SDS 的空间扩展至执行修改所需的大小, 然后才执行实际的修改操作。
但是内存重分配涉及复杂的算法, 并且可能需要执行系统调用, 所以通常是一个比较耗时的操作,SDS采用空间预分配和惰性空间释放两种策略来优化。
空间预分配
频繁的重新分配内存会导致性能的降低,所以Redis在对SDS进行空间扩展时, 程序不仅会为 SDS 分配修改所必须要的空间, 还会为 SDS 分配额外的未使用空间。
空间预分配通过 len(SDS所保存字符串的长度)和free(buf 数组中未使用字节的数量)来实现:
- 如果对 SDS 进行修改之后, SDS 的长度(也即是
属性的值)将小于
len
, 那么程序分配和
1 MB
属性同样大小的未使用空间, 这时 SDS
len
属性的值将和
len
属性的值相同。 举个例子, 如果进行修改之后, SDS 的
free
将变成
len
字节, 那么程序也会分配
13
字节的未使用空间, SDS 的
13
数组的实际长度将变成
buf
字节(额外的一字节用于保存空字符)。
13 + 13 + 1 = 27
- 如果对 SDS 进行修改之后, SDS 的长度将大于等于
, 那么程序会分配
1 MB
的未使用空间。 举个例子, 如果进行修改之后, SDS 的
1 MB
将变成
len
, 那么程序会分配
30 MB
的未使用空间, SDS 的
1 MB
数组的实际长度将为
buf
。
30 MB + 1 MB + 1 byte
在扩展 SDS 空间之前, SDS API 会先检查未使用空间是否足够, 如果足够的话, API 就会直接使用未使用空间, 而无须执行内存重分配。
惰性空间释放
惰性空间释放用于优化 SDS 的字符串缩短操作: 当 SDS 的 API 需要缩短 SDS 保存的字符串时, 程序并不立即使用内存重分配来回收缩短后多出来的字节, 而是使用
free
属性将这些字节的数量记录起来, 并等待将来使用。
SDS 并没有释放多出来的
8
字节空间, 而是将这
8
字节空间作为未使用空间free保留在了 SDS 里面, 如果将来要对 SDS 进行增长操作的话, 这些未使用空间就可能会派上用场。
这样操作将不需要执行内存重分配,提高了性能,而且 SDS 也提供了相应的 API , 让我们可以在有需要时, 真正地释放 SDS 里面的未使用空间, 所以不用担心惰性空间释放策略会造成内存浪费。
3、SDS API
函数 | 作用 | 时间复杂度 |
---|---|---|
| 创建一个包含给定 C 字符串的 SDS 。 | O(N) , 为给定 C 字符串的长度。 |
| 创建一个不包含任何内容的空 SDS 。 | O(1) |
| 释放给定的 SDS 。 | O(1) |
| 返回 SDS 的已使用空间字节数。 | 这个值可以通过读取 SDS 的 属性来直接获得, 复杂度为 O(1) 。 |
| 返回 SDS 的未使用空间字节数。 | 这个值可以通过读取 SDS 的 属性来直接获得, 复杂度为 O(1) 。 |
| 创建一个给定 SDS 的副本(copy)。 | O(N) , 为给定 SDS 的长度。 |
| 清空 SDS 保存的字符串内容。 | 因为惰性空间释放策略,复杂度为 O(1) 。 |
| 将给定 C 字符串拼接到 SDS 字符串的末尾。 | O(N) , 为被拼接 C 字符串的长度。 |
| 将给定 SDS 字符串拼接到另一个 SDS 字符串的末尾。 | O(N) , 为被拼接 SDS 字符串的长度。 |
| 将给定的 C 字符串复制到 SDS 里面, 覆盖 SDS 原有的字符串。 | O(N) , 为被复制 C 字符串的长度。 |
| 用空字符将 SDS 扩展至给定长度。 | O(N) , 为扩展新增的字节数。 |
| 保留 SDS 给定区间内的数据, 不在区间内的数据会被覆盖或清除。 | O(N) , 为被保留数据的字节数。 |
| 接受一个 SDS 和一个 C 字符串作为参数, 从 SDS 左右两端分别移除所有在 C 字符串中出现过的字符。 | O(M*N) , 为 SDS 的长度, 为给定 C 字符串的长度。 |
| 对比两个 SDS 字符串是否相同。 | O(N) , 为两个 SDS 中较短的那个 SDS 的长度。 |
4、总结
- Redis 只会使用 C 字符串作为字面量, 在大多数情况下, Redis 使用 SDS (Simple Dynamic String,简单动态字符串)作为字符串表示。
- 比起 C 字符串, SDS 具有以下优点:
- 常数复杂度获取字符串长度。
- 杜绝缓冲区溢出。
- 减少修改字符串长度时所需的内存重分配次数。
- 二进制安全。
- 兼容部分 C 字符串函数。
链表
typedef struct listNode {
// 前置节点
struct listNode *prev;
// 后置节点
struct listNode *next;
// 节点的值
void *value;
} listNode;
list对象统一对整个链表进行维护:
typedef struct list {
// 表头节点
listNode *head;
// 表尾节点
listNode *tail;
// 链表所包含的节点数量
unsigned long len;
// 节点值复制函数
void *(*dup)(void *ptr);
// 节点值释放函数
void (*free)(void *ptr);
// 节点值对比函数
int (*match)(void *ptr, void *key);
} list;
list
结构为链表提供了表头指针
head
、表尾指针
tail
, 以及链表长度计数器
len
, 而
dup
、
free
和
match
成员则是用于实现多态链表所需的类型特定函数:
-
函数用于复制链表节点所保存的值;dup
-
函数用于释放链表节点所保存的值;free
-
函数则用于对比链表节点所保存的值和另一个输入值是否相等。match
Redis 的链表实现的特性可以总结如下:
- 双端: 链表节点带有
和
prev
指针, 获取某个节点的前置节点和后置节点的复杂度都是 O(1) 。
next
- 无环: 表头节点的
指针和表尾节点的
prev
指针都指向
next
, 对链表的访问以
NULL
为终点。
NULL
- 带表头指针和表尾指针: 通过
结构的
list
指针和
head
指针, 程序获取链表的表头节点和表尾节点的复杂度为 O(1) 。
tail
- 带链表长度计数器: 程序使用
结构的
list
属性来对
len
持有的链表节点进行计数, 程序获取链表中节点数量的复杂度为 O(1)。
list
- 多态: 链表节点使用
指针来保存节点值, 并且可以通过
void*
结构的
list
、
dup
、
free
三个属性为节点值设置类型特定函数, 所以链表可以用于保存各种不同类型的值。
match
总结
- 链表被广泛用于实现 Redis 的各种功能, 比如列表键, 发布与订阅, 慢查询, 监视器, 等等。
- 每个链表节点由一个
结构来表示, 每个节点都有一个指向前置节点和后置节点的指针, 所以 Redis 的链表实现是双端链表。listNode
- 每个链表使用一个
结构来表示, 这个结构带有表头节点指针、表尾节点指针、以及链表长度等信息。list
- 因为链表表头节点的前置节点和表尾节点的后置节点都指向
, 所以 Redis 的链表实现是无环链表。NULL
- 通过为链表设置不同的类型特定函数, Redis 的链表可以用于保存各种不同类型的值。
字典
Redis 的字典使用哈希表作为底层实现, 一个哈希表里面可以有多个哈希表节点, 每个哈希表节点保存了字典中的一个键值对。
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
// 总是等于 size - 1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
} dictht;
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
table
属性是一个数组, 数组中的每个元素都是一个指向
dictEntry
结构的指针, 每个
dictEntry
结构保存着一个键值对。
size
属性记录了哈希表的大小, 也即是
table
数组的大小, 而
used
属性则记录了哈希表目前已有节点(键值对)的数量。
sizemask
属性的值总是等于
size-1
, 这个属性和哈希值一起决定一个键应该被放到
table
数组的哪个索引上面(与&操作)。
对于
哈希表节点
dictEntry中
,
key
属性保存着键值对中的键, 而
v
属性则保存着键值对中的值, 其中键值对的值可以是一个指针, 或者是一个
uint64_t
整数, 又或者是一个
int64_t
整数。
next
属性是指向另一个哈希表节点的指针, 这个指针可以将多个哈希值相同的键值对连接在一次, 以此来解决哈希冲突(collision)的问题。
Redis 中的字典由哈希表和一些字典属性组成:
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash 索引
// 当 rehash 不在进行时,值为 -1
int rehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;
type
属性和
privdata
属性是针对不同类型的键值对, 为创建多态字典而设置的:
-
属性是一个指向type
结构的指针, 每个dictType
结构保存了一簇用于操作特定类型键值对的函数, Redis 会为用途不同的字典设置不同的类型特定函数。dictType
- 而
属性则保存了需要传给那些类型特定函数的可选参数。privdata
ht
属性是一个包含两个项的数组,数组中的每个项都是一个
dictht
哈希表。一般情况下, 字典只使用
ht[0]
哈希表,
ht[1]
哈希表只会在对
ht[0]
哈希表进行 rehash 时使用。除了
ht[1]
之外, 另一个和 rehash 有关的属性就是
rehashidx
: 它记录了 rehash 目前的进度, 如果目前没有在进行 rehash , 那么它的值为
-1
。
哈希算法
# 使用字典设置的哈希函数,计算键 key 的哈希值
hash = dict->type->hashFunction(key);
# 使用哈希表的 sizemask 属性和哈希值,计算出索引值
# 根据情况不同, ht[x] 可以是 ht[0] 或者 ht[1]
index = hash & dict->ht[x].sizemask; //sizemask等于ht哈希数组长度减1
Redis 的哈希表使用链地址法(separate chaining)来解决键冲突: 每个哈希表节点都有一个
next
指针, 多个哈希表节点可以用
next
指针构成一个单向链表,节点的插入采用的是头插法,程序总是将新节点添加到链表的表头位置。
rehash
扩展和收缩哈希表的工作可以通过执行 rehash (重新散列)操作来完成, Redis 对字典的哈希表执行 rehash 的步骤如下:
- 为字典的
哈希表分配空间, 这个哈希表的空间大小取决于要执行的操作, 以及
ht[1]
当前包含的键值对数量 (也即是
ht[0]
属性的值):
ht[0].used
- 如果执行的是扩展操作, 那么
的大小为第一个大于等于
ht[1]
的 2^n (想上取2^n整);
ht[0].used * 2
- 如果执行的是收缩操作, 那么
的大小为第一个大于等于
ht[1]
的 2^n 。
ht[0].used
- 将保存在
中的所有键值对 rehash 到
ht[0]
上面: rehash 指的是重新计算键的哈希值和索引值, 然后将键值对放置到
ht[1]
哈希表的指定位置上。
ht[1]
- 当
包含的所有键值对都迁移到了
ht[0]
之后 (
ht[1]
变为空表), 释放
ht[0]
, 将
ht[0]
设置为
ht[1]
, 并在
ht[0]
新创建一个空白哈希表, 为下一次 rehash 做准备。
ht[1]
当以下条件中的任意一个被满足时, 程序会自动开始对哈希表执行扩展操作:
- 服务器目前没有在执行 BGSAVE 命令或者 BGREWRITEAOF 命令, 并且哈希表的负载因子大于等于
;1
- 服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令, 并且哈希表的负载因子大于等于
;5
其中哈希表的负载因子可以通过公式计算:
# 负载因子 = 哈希表已保存节点数量 / 哈希表大小
load_factor = ht[0].used / ht[0].size
根据 BGSAVE 命令或 BGREWRITEAOF 命令是否正在执行, 服务器执行扩展操作所需的负载因子并不相同, 这是因为在执行 BGSAVE命令或 BGREWRITEAOF 命令的过程中,服务器会提高执行扩展操作所需的负载因子, 从而尽可能地避免在子进程存在期间进行哈希表扩展操作,避免不必要的内存写入操作,最大限度地节约内存。
另一方面, 当哈希表的负载因子小于
0.1
时, 程序自动开始对哈希表执行收缩操作。
渐进式rehash
rehash 动作并不是一次性、集中式地完成的, 而是分多次、渐进式地完成的,因为一次性所有键值对全部 rehash 到
ht[1]中时,
庞大的计算量可能会导致服务器在一段时间内停止服务。
以下是哈希表渐进式 rehash 的详细步骤:
- 为
分配空间, 让字典同时持有
ht[1]
和
ht[0]
两个哈希表。
ht[1]
- 在字典中维持一个索引计数器变量
, 并将它的值设置为 , 表示 rehash 工作正式开始。
rehashidx
- 在 rehash 进行期间, 每次对字典执行添加、删除、查找或者更新操作时, 程序除了执行指定的操作以外, 还会顺带将
哈希表在
ht[0]
索引上的所有键值对 rehash 到
rehashidx
, 当 rehash 工作完成之后, 程序将
ht[1]
属性的值增一。
rehashidx
- 随着字典操作的不断执行, 最终在某个时间点上,
的所有键值对都会被 rehash 至
ht[0]
, 这时程序将
ht[1]
属性的值设为
rehashidx
, 表示 rehash 操作已完成。
-1
在进行渐进式 rehash 的过程中, 字典会同时使用
ht[0]
和
ht[1]
两个哈希表, 所以在渐进式 rehash 进行期间, 字典的删除(delete)、查找(find)、更新(update)等操作会在两个哈希表上进行: 比如说, 要在字典里面查找一个键的话, 程序会先在
ht[0]
里面进行查找, 如果没找到的话, 就会继续到
ht[1]
里面进行查找, 诸如此类。
另外, 在渐进式 rehash 执行期间, 新添加到字典的键值对一律会被保存到
ht[1]
里面, 而
ht[0]
则不再进行任何添加操作: 这一措施保证了
ht[0]
包含的键值对数量会只减不增, 并随着 rehash 操作的执行而最终变成空表。
总结
- 字典被广泛用于实现 Redis 的各种功能, 其中包括数据库和哈希键。
- Redis 中的字典使用哈希表作为底层实现, 每个字典带有两个哈希表, 一个用于平时使用, 另一个仅在进行 rehash 时使用。
- 当字典被用作数据库的底层实现, 或者哈希键的底层实现时, Redis 使用 MurmurHash2 算法来计算键的哈希值。
- 哈希表使用链地址法来解决键冲突, 被分配到同一个索引上的多个键值对会连接成一个单向链表。
- 在对哈希表进行扩展或者收缩操作时, 程序需要将现有哈希表包含的所有键值对 rehash 到新哈希表里面, 并且这个 rehash 过程并不是一次性地完成的, 而是渐进式地完成的。
跳跃表
Redis 的跳跃表由
zskiplistNode
和
zskiplist
两个结构定义, 其中
zskiplistNode
结构用于表示跳跃表节点, 而
zskiplist
结构则用于保存跳跃表节点的相关信息, 比如节点的数量, 以及指向表头节点和表尾节点的指针。
跳跃表节点
typedef struct zskiplistNode {
// 后退指针
struct zskiplistNode *backward;
// 分值
double score;
// 成员对象
robj *obj;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;
层
跳跃表节点的
level
数组可以包含多个元素, 每个元素都包含一个指向其他节点的指针, 程序可以通过这些层来加快访问其他节点的速度, 一般来说, 层的数量越多, 访问其他节点的速度就越快。
每次创建一个新跳跃表节点的时候, 程序都根据幂次定律 (越大的数出现的概率越小) 随机生成一个介于
1
和
32
之间的值作为
level
数组的大小, 这个大小就是层的“高度”。
前进指针
每个层都有一个指向表尾方向的前进指针(
level[i].forward
属性), 用于从表头向表尾方向访问节点。
跨度
层的跨度(
level[i].span
属性)用于记录两个节点之间的距离:
- 两个节点之间的跨度越大, 它们相距得就越远。
- 指向
的所有前进指针的跨度都为 , 因为它们没有连向任何节点。NULL
初看上去, 很容易以为跨度和遍历操作有关, 但实际上并不是这样 —— 遍历操作只使用前进指针就可以完成了, 跨度实际上是用来计算排位(rank)的: 在查找某个节点的过程中, 将沿途访问过的所有层的跨度累计起来, 得到的结果就是目标节点在跳跃表中的排位。
后退指针
节点的后退指针(
backward
属性)用于从表尾向表头方向访问节点: 跟可以一次跳过多个节点的前进指针不同, 因为每个节点只有一个后退指针, 所以每次只能后退至前一个节点。
分值和成员
节点的分值(
score
属性)是一个
double
类型的浮点数, 跳跃表中的所有节点都按分值从小到大来排序。
节点的成员对象(
obj
属性)是一个指针, 它指向一个字符串对象, 而字符串对象则保存着一个 SDS 值。
在同一个跳跃表中, 各个节点保存的成员对象必须是唯一的, 但是多个节点保存的分值却可以是相同的: 分值相同的节点将按照成员对象在字典序中的大小来进行排序, 成员对象较小的节点会排在前面(靠近表头的方向), 而成员对象较大的节点则会排在后面(靠近表尾的方向)。
跳跃表
与链表一样虽然仅靠多个跳跃表节点就可以组成一个跳跃表,但有一个跳跃表对象来维护这些节点可以更方便处理。
typedef struct zskiplist {
// 表头节点和表尾节点
struct zskiplistNode *header, *tail;
// 表中节点的数量
unsigned long length;
// 表中层数最大的节点的层数(不包括表头节点)
int level;
} zskiplist;
header
和
tail
指针分别指向跳跃表的表头和表尾节点, 通过这两个指针, 程序定位表头节点和表尾节点的复杂度为 O(1) 。
通过使用
length
属性来记录节点的数量, 程序可以在 O(1) 复杂度内返回跳跃表的长度。
level
属性则用于在 O(1) 复杂度内获取跳跃表中层高最大的那个节点的层数量, 注意表头节点的层高并不计算在内。
总结
- 跳跃表是有序集合的底层实现之一, 除此之外它在 Redis 中没有其他应用。
- Redis 的跳跃表实现由
和zskiplist
两个结构组成, 其中zskiplistNode
用于保存跳跃表信息(比如表头节点、表尾节点、长度), 而zskiplist
则用于表示跳跃表节点。zskiplistNode
- 每个跳跃表节点的层高都是
至1
之间的随机数。32
- 在同一个跳跃表中, 多个节点可以包含相同的分值, 但每个节点的成员对象必须是唯一的。
- 跳跃表中的节点按照分值大小进行排序, 当分值相同时, 节点按照成员对象的大小进行排序。
整数集合
整数集合(intset)是集合键的底层实现之一: 当一个集合只包含整数值元素, 并且这个集合的元素数量不多时, Redis 就会使用整数集合作为集合键的底层实现。
整数集合是 Redis 用于保存整数值的集合抽象数据结构, 它可以保存类型为
int16_t
、
int32_t
或者
int64_t
的整数值, 并且保证集合中不会出现重复元素。
typedef struct intset {
// 编码方式
uint32_t encoding;
// 集合包含的元素数量
uint32_t length;
// 保存元素的数组
int8_t contents[];
} intset;
contents
数组是整数集合的底层实现: 整数集合的每个元素都是
contents
数组的一个数组项(item), 各个项在数组中按值的大小从小到大有序地排列, 并且数组中不包含任何重复项。
length
属性记录了整数集合包含的元素数量, 也即是
contents
数组的长度。
虽然
intset
结构将
contents
属性声明为
int8_t
类型的数组, 但实际上
contents
数组并不保存任何
int8_t
类型的值 ——
contents
数组的真正类型取决于
encoding
属性的值:
- 如果
属性的值为encoding
, 那么INTSET_ENC_INT16
就是一个contents
类型的数组, 数组里的每个项都是一个int16_t
类型的整数值 (最小值为int16_t
,最大值为-32,768
)。32,767
- 如果
属性的值为encoding
, 那么INTSET_ENC_INT32
就是一个contents
类型的数组, 数组里的每个项都是一个int32_t
类型的整数值 (最小值为int32_t
,最大值为-2,147,483,648
)。2,147,483,647
- 如果
属性的值为encoding
, 那么INTSET_ENC_INT64
就是一个contents
类型的数组, 数组里的每个项都是一个int64_t
类型的整数值 (最小值为int64_t
,最大值为-9,223,372,036,854,775,808
)。9,223,372,036,854,775,807
升级
每当我们要将一个新元素添加到整数集合里面, 并且新元素的类型比整数集合现有所有元素的类型都要长时, 整数集合需要先进行升级(upgrade), 然后才能将新元素添加到整数集合里面。
升级整数集合并添加新元素共分为三步进行:
- 根据新元素的类型, 扩展整数集合底层数组的空间大小, 并为新元素分配空间。
- 将底层数组现有的所有元素都转换成与新元素相同的类型, 并将类型转换后的元素放置到正确的位上, 而且在放置元素的过程中, 需要继续维持底层数组的有序性质不变。
- 将新元素添加到底层数组里面。
升级之后新元素的摆放位置
因为引发升级的新元素的长度总是比整数集合现有所有元素的长度都大, 所以这个新元素的值要么就大于所有现有元素, 要么就小于所有现有元素:
- 在新元素小于所有现有元素的情况下, 新元素会被放置在底层数组的最开头(索引 );
- 在新元素大于所有现有元素的情况下, 新元素会被放置在底层数组的最末尾(索引
)。length-1
整数集合的升级策略提升了整数集合的灵活性,且尽可能地节约内存。
另外,整数集合不支持降级操作, 一旦对数组进行了升级, 编码就会一直保持升级后的状态。
总结
- 整数集合是集合键的底层实现之一。
- 整数集合的底层实现为数组, 这个数组以有序、无重复的方式保存集合元素, 在有需要时, 程序会根据新添加元素的类型, 改变这个数组的类型。
- 升级操作为整数集合带来了操作上的灵活性, 并且尽可能地节约了内存。
- 整数集合只支持升级操作, 不支持降级操作。
压缩列表
压缩列表(ziplist)是列表键和哈希键的底层实现之一。压缩列表是 Redis 为了节约内存而开发的, 由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构。
一个压缩列表可以包含任意多个节点(entry), 每个节点可以保存一个字节数组或者一个整数值。
属性 | 类型 | 长度 | 用途 |
---|---|---|---|
| | 字节 | 记录整个压缩列表占用的内存字节数:在对压缩列表进行内存重分配, 或者计算 的位置时使用。 |
| | 字节 | 记录压缩列表表尾节点距离压缩列表的起始地址有多少字节: 通过这个偏移量,程序无须遍历整个压缩列表就可以确定表尾节点的地址。 |
| | 字节 | 记录了压缩列表包含的节点数量: 当这个属性的值小于 ( )时, 这个属性的值就是压缩列表包含节点的数量; 当这个值等于 时, 节点的真实数量需要遍历整个压缩列表才能计算得出。 |
| 列表节点 | 不定 | 压缩列表包含的各个节点,节点的长度由节点保存的内容决定。 |
| | 字节 | 特殊值 (十进制 ),用于标记压缩列表的末端。 |
节点构成
每个压缩列表节点可以保存一个字节数组或者一个整数值,每个节点都由
previous_entry_length
、
encoding
、
content
三个部分组成。
previous_entry_length
节点的
previous_entry_length
属性以字节为单位, 记录了压缩列表中前一个节点的长度。
previous_entry_length
属性的长度可以是
1
字节或者
5
字节:
- 如果前一节点的长度小于
字节, 那么254
属性的长度为previous_entry_length
字节: 前一节点的长度就保存在这一个字节里面。1
- 如果前一节点的长度大于等于
字节, 那么254
属性的长度为previous_entry_length
字节: 其中属性的第一字节会被设置为5
(十进制值0xFE
), 而之后的四个字节则用于保存前一节点的长度。254
因为节点的
previous_entry_length
属性记录了前一个节点的长度, 所以程序可以通过指针运算, 根据当前节点的起始地址来计算出前一个节点的起始地址,压缩列表的从表尾向表头遍历操作就是使用这一原理实现。
encoding
节点的
encoding
属性记录了节点的
content
属性所保存数据的类型以及长度:
- 一字节、两字节或者五字节长, 值的最高位为
、00
或者01
的是字节数组编码: 这种编码表示节点的10
属性保存着字节数组, 数组的长度由编码除去最高两位之后的其他位记录;content
- 一字节长, 值的最高位以
开头的是整数编码: 这种编码表示节点的11
属性保存着整数值, 整数值的类型和长度由编码除去最高两位之后的其他位记录;content
content
节点的
content
属性负责保存节点的值, 节点值可以是一个字节数组或者整数, 值的类型和长度由节点的
encoding
属性决定。
- 编码的最高两位
表示节点保存的是一个字节数组;00
- 编码的后六位
记录了字节数组的长度001011
;11
-
属性保存着节点的值content
。"hello world"
连锁更新
由于每个节点的
previous_entry_length
属性都记录了前一个节点的长度,且
previous_entry_length
的长度可以是1字节或5字节,那么存在这样一种情况:在一个压缩列表中,有多个连续的长度介于
250
字节到
253
字节之间的节点
e1
至
eN
。
因为
e1
至
eN
的所有节点的长度都小于
254
字节, 所以记录这些节点的长度只需要
1
字节长的
previous_entry_length
属性, 换句话说,
e1
至
eN
的所有节点的
previous_entry_length
属性都是
1
字节长的。
这时, 如果我们将一个长度大于等于
254
字节的新节点
new
设置为压缩列表的表头节点, 那么
new
将成为
e1
的前置节点,又因
e1
的
previous_entry_length
属性仅长
1
字节, 它没办法保存新节点
new
的长度,所以程序将对压缩列表执行空间重分配操作, 并将
e1
节点的
previous_entry_length
属性从原来的
1
字节长扩展为
5
字节长。
这会导致,
e1的长度超过253字节
(
e1
原本的长度介于
250
字节至
253
字节之间),e2为了记录下
e1
的长度,也会发生同样的问题,最终为了让每个节点的
previous_entry_length
属性都符合压缩列表对节点的要求,程序需要不断地对压缩列表执行空间重分配操作,直到
eN
为止。Redis 将这种在特殊情况下产生的连续多次空间扩展操作称之为“连锁更新”(cascade update)。
因为连锁更新在最坏情况下需要对压缩列表执行
N
次空间重分配操作, 而每次空间重分配的最坏复杂度为 O(N) , 所以连锁更新的最坏复杂度为 O(N^2) 。
要注意的是, 尽管连锁更新的复杂度较高, 但它真正造成性能问题的几率是很低的:
- 首先, 压缩列表里要恰好有多个连续的、长度介于
字节至250
字节之间的节点, 连锁更新才有可能被引发, 在实际中, 这种情况并不多见;253
- 其次, 即使出现连锁更新, 但只要被更新的节点数量不多, 就不会对性能造成任何影响: 比如说, 对三五个节点进行连锁更新是绝对不会影响性能的;
总结
- 压缩列表是一种为节约内存而开发的顺序型数据结构。
- 压缩列表被用作列表键和哈希键的底层实现之一。
- 压缩列表可以包含多个节点,每个节点可以保存一个字节数组或者整数值。
- 添加新节点到压缩列表, 或者从压缩列表中删除节点, 可能会引发连锁更新操作, 但这种操作出现的几率并不高。
参考文章:
《Redis设计与实现》
http://redisbook.com/