闲暇之余,通读了《Redis 设计与实现》,个人比较喜欢第一版,小记几笔,以便查阅,如果单纯为了使用,请移步:《命令查询手册》,共勉~
整数集合
Intset是集合键的底层实现之一,用于有序、无重复的保存多个整数值,如果一个集合满足:
- 只保存整数元素;
- 元素的数量不多;
那么Redis就会使用intset来保存集合,我们先来看看intset的定义:
typedef struct intset {
// 保存元素所使用的类型的长度
uint32_t encoding;
// 元素个数
uint32_t length;
// 保存元素的数组
int8_t contents[];
} intset;
这个int8_t很有迷惑性,实际上,这个类型声明只是作为一个占位符使用,Redis会根据元素的值,自动选择采用什么长度的整数类型来保存元素,比如:在一个intset里,最长元素可以用int16_6来保存,那么这个intset的所有元素都以int16_t类型保存,如果这时插入了一个新元素且这个元素无法用int16_t类型保存,而需要更大的int32_t来保存,那么这个intset就会自动进行“升级”,将所有的元素从int16_t转换为int32_t,再将新元素插入到集合中。在对contents中的元素进行读取或写入时,程序并不是直接使用contents来对元素进行索引,而是根据encoding的值,对contents进行类型转换和指针运算,计算出元素在内存中的正确位置。在添加新元素,进行内存分配时,分配的空间也由encoding的值决定。
一般来说,在做intsetAdd时,只需要保证数组中没有重复元素且数组中的元素从小到大排列即可,不过一旦intset编码需要“升级”时,事情似乎变得复杂了,在升级前,我们需要先明确两点:
- 在C语言中,从长度较短的带符号整数到长度较长的带符号整数之间的转换时无损的,并不会修改元素中原有的值;
- 集合编码元素的值,由元素中长度最大的那个值决定;
如果需要升级,Redis便会将升级集合和添加新元素的任务转交给intsetUpgradeAndAdd来完成,有以下几步:
- 检测新元素需要什么类型来保存;
- 将encoding设置为新的编码类型,并根据新编码类型,对整个contents数组进行内存重分配;
- 调整contents数组内原有元素在内存中的排列方式,从旧编码调整为新编码;
- 将新元素添加到集合中。
整个过程,第三部最为复杂,书中举了个例子来方便理解它:
-
将 encoding 属性设置为 INTSET_ENC_INT32 。
-
根据 encoding 属性的值,对 contents 数组进行内存重分配。
重分配完成之后, contents 在内存中的排列如下:
bit 0 15 31 47 63 95 127
value | 1 | 2 | 3 | ? | ? | ? |
contents 数组现在共有可容纳 4 个 int32_t 值的空间。
因为原来的 3 个 int16_t 值还“挤在” contents 前面的 48 个位里, 所以程序需要移动它们并转换类型, 让它们适应集合的新编码方式。
首先是移动 3 :
bit 0 15 31 47 63 95 127
value | 1 | 2 | 3 | ? | 3 | ? |
| ^
| |
+-------------+
int16_t -> int32_t
接着移动 2 :
bit 0 15 31 47 63 95 127
value | 1 | 2 | 2 | 3 | ? |
| ^
| |
+-------+
int16_t -> int32_t
最后,移动 1 :
bit 0 15 31 47 63 95 127
value | 1 | 2 | 3 | ? |
| ^
V |
int16_t -> int32_t
最后,将新值 65535 添加到数组:
bit 0 15 31 47 63 95 127
value | 1 | 2 | 3 | 65535 |
^
|
add
将 intset->length 设置为 4 。
至此,集合的升级和添加操作完成,现在的 intset 结构如下:
intset->encoding = INTSET_ENC_INT32;
intset->length = 4;
intset->contents = [1, 2, 3, 65535];
在升级时,需要对元素进行“类型转换”和“移动”操作,但移动不仅出现在升级中,对contents进行增删操作一样需要移动intset中的元素,所以我们可以看到工具手册中集合的操作,其时间复杂度并都不低于O(N),关于Redis的集合对象,会在后文中说明。
压缩列表
Ziplist是Redis自己实现的一种包含多个节点(entry)类似于数组数据存储结构,每个节点可以保存一个长度受限的字符数组或整数,与数组不同的是,它允许存储大小不同的数据。既然能称为压缩列表,那么其本身的设计目的之一肯定是为了节省内存,也因为这种特性,哈希键、列表键和有序集合键初始化的底层实现均采用了ziplist,让我们来先看看压缩列表的组成结构:
area |<---- ziplist header ---->|<----------- entries ------------->|<-end->|
size 4 bytes 4 bytes 2 bytes ? ? ? ? 1 byte
+---------+--------+-------+--------+--------+--------+--------+-------+
component | zlbytes | zltail | zllen | entry1 | entry2 | ... | entryN | zlend |
+---------+--------+-------+--------+--------+--------+--------+-------+
^ ^ ^
address | | |
ZIPLIST_ENTRY_HEAD | ZIPLIST_ENTRY_END
|
ZIPLIST_ENTRY_TAIL
因为数组为了保证空间的连续性,存储的大小相对固定,所以存储时肯定会浪费部分存储空间,所以即为了保证连续性,又为了节省空间,Redis考虑对数组进行压缩,但是这种压缩就导致一个问题,在遍历元素时,因为不知道元素的大小,所以无法计算出下一个节点的具体位置,所以Redis需要考虑为每个节点增加一个length属性,同时为了兼顾一些其他操作做出了优化,其列表结构如下:
- zlbytes:uint32_t类型,长度4byte,用于记录整个压缩列表占用的内存字节数,当压缩列表需要进行内存重分配或者计算zlend的位置时被使用;
- zltail:unit32_t类型,长度4byte,用于记录表尾节点距离压缩列表起始地址有多少字节,可以通过这个寄点直接确定表位节点的地址;
- zllen:unit16_t类型,长度2byte,用于记录压缩列表包含的节点数量,但是当这个值等于uint16_max(65535)时,就需要遍历整个压缩列表才能计算出节点的真实数量;
- entry[]:节点列表,压缩列表中的节点,其长度由节点中保存的内容决定,每个节点的构成如下:
area |<----------------- entry ------------->|
+------------------+--------------------+
component | pre_entry_length | encoding | content |
+------------------+----------+---------+
previous_entry_length属性记录了压缩列表中前一个节点的长度,如果前一个节点的长度小于254字节,那么该属性的长度就为1字节,如果大于或等于254字节,那么其长度就为5字节。content用于保存节点的值,可以是一个字节数组或整数,其类型和长度由节点encoding决定。
- zlend:uint8_t类型,长度1byte,是一个特殊值0xFF(十进制中的255),用于标记压缩列表的末端。