关于redis中的SDS、List、字典
SDS(simple dynamic string)
和c语言中的字符串类似,SDS在正常维护字符串之外引入了两个变量free和len,len用于记录当前存储的字符串长度(不包括末尾的‘\0’),free表示SDS结构中剩余可以分配的内存大小。
struct sdshdr { // 记录 buf 数组中已使用字节的数量 // 等于 SDS 所保存字符串的长度 int len; // 记录 buf 数组中未使用字节的数量 int free; // 字节数组,用于保存字符串 char buf[]; };
关于缓冲区溢出的问题
与 C 字符串不同, SDS 的空间分配策略完全杜绝了发生缓冲区溢出的可能性: 当 SDS API 需要对 SDS 进行修改时, API 会先检查 SDS 的空间是否满足修改所需的要求, 如果不满足的话, API 会自动将 SDS 的空间扩展至执行修改所需的大小, 然后才执行实际的修改操作, 所以使用 SDS 既不需要手动修改 SDS 的空间大小, 也不会出现前面所说的缓冲区溢出问题。
内存分配
空间预分配
空间预分配用于优化 SDS 的字符串增长操作: 当 SDS 的 API 对一个 SDS 进行修改, 并且需要对 SDS 进行空间扩展的时候, 程序不仅会为 SDS 分配修改所必须要的空间, 还会为 SDS 分配额外的未使用空间。
其中, 额外分配的未使用空间数量由以下公式决定:
- 如果对 SDS 进行修改之后, SDS 的长度(也即是
len
属性的值)将小于1 MB
, 那么程序分配和len
属性同样大小的未使用空间, 这时 SDSlen
属性的值将和free
属性的值相同。 举个例子, 如果进行修改之后, SDS 的len
将变成13
字节, 那么程序也会分配13
字节的未使用空间, SDS 的buf
数组的实际长度将变成13 + 13 + 1 = 27
字节(额外的一字节用于保存空字符)。 - 如果对 SDS 进行修改之后, SDS 的长度将大于等于
1 MB
, 那么程序会分配1 MB
的未使用空间。 举个例子, 如果进行修改之后, SDS 的len
将变成30 MB
, 那么程序会分配1 MB
的未使用空间, SDS 的buf
数组的实际长度将为30 MB + 1 MB + 1 byte
。
通过空间预分配策略, Redis 可以减少连续执行字符串增长操作所需的内存重分配次数。
惰性空间释放
惰性空间释放用于优化 SDS 的字符串缩短操作: 当 SDS 的 API 需要缩短 SDS 保存的字符串时, 程序并不立即使用内存重分配来回收缩短后多出来的字节, 而是使用 free
属性将这些字节的数量记录起来, 并等待将来使用。
举个例子, sdstrim
函数接受一个 SDS 和一个 C 字符串作为参数, 从 SDS 左右两端分别移除所有在 C 字符串中出现过的字符。
比如对于图 2-14 所示的 SDS 值 s
来说, 执行:
sdstrim(s, "XY"); // 移除 SDS 字符串中的所有 'X' 和 'Y'
会将 SDS 修改成图 2-15 所示的样子。
注意执行 sdstrim
之后的 SDS 并没有释放多出来的 8
字节空间, 而是将这 8
字节空间作为未使用空间保留在了 SDS 里面, 如果将来要对 SDS 进行增长操作的话, 这些未使用空间就可能会派上用场。
List
每个链表节点的定义
typedef struct listNode { // 前置节点 struct listNode *prev; // 后置节点 struct listNode *next; // 节点的值 void *value; } listNode;
多个 listNode
可以通过 prev
和 next
指针组成双端链表, 如图 3-1 所示。
虽然仅仅使用多个 listNode
结构就可以组成链表, 但使用 adlist.h/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
函数则用于对比链表节点所保存的值和另一个输入值是否相等。
图 3-2 是由一个 list
结构和三个 listNode
结构组成的链表:
Redis 的链表实现的特性可以总结如下:
- 双端: 链表节点带有
prev
和next
指针, 获取某个节点的前置节点和后置节点的复杂度都是 O(1) 。 - 无环: 表头节点的
prev
指针和表尾节点的next
指针都指向NULL
, 对链表的访问以NULL
为终点。 - 带表头指针和表尾指针: 通过
list
结构的head
指针和tail
指针, 程序获取链表的表头节点和表尾节点的复杂度为 O(1) 。 - 带链表长度计数器: 程序使用
list
结构的len
属性来对list
持有的链表节点进行计数, 程序获取链表中节点数量的复杂度为 O(1) 。 - 多态: 链表节点使用
void*
指针来保存节点值, 并且可以通过list
结构的dup
、free
、match
三个属性为节点值设置类型特定函数, 所以链表可以用于保存各种不同类型的值。
哈希表
Redis 字典所使用的哈希表由 dict.h/dictht
结构定义:
typedef struct dictht { // 哈希表数组 dictEntry **table; // 哈希表大小 unsigned long size; // 哈希表大小掩码,用于计算索引值 // 总是等于 size - 1 unsigned long sizemask; // 该哈希表已有节点的数量 unsigned long used; } dictht;
哈希表节点使用 dictEntry
结构表示, 每个 dictEntry
结构都保存着一个键值对:
typedef struct dictEntry { // 键 void *key; // 值 union { void *val; uint64_t u64; int64_t s64; } v; // 指向下个哈希表节点,形成链表 struct dictEntry *next; } dictEntry;
key
属性保存着键值对中的键, 而 v
属性则保存着键值对中的值, 其中键值对的值可以是一个指针, 或者是一个 uint64_t
整数, 又或者是一个 int64_t
整数。
next
属性是指向另一个哈希表节点的指针, 这个指针可以将多个哈希值相同的键值对连接在一次, 以此来解决键冲突(collision)的问题。
Redis 中的字典由 dict.h/dict
结构表示:
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
结构的指针, 每个dictType
结构保存了一簇用于操作特定类型键值对的函数, Redis 会为用途不同的字典设置不同的类型特定函数。- 而
privdata
属性则保存了需要传给那些类型特定函数的可选参数。
typedef struct dictType { // 计算哈希值的函数 unsigned int (*hashFunction)(const void *key); // 复制键的函数 void *(*keyDup)(void *privdata, const void *key); // 复制值的函数 void *(*valDup)(void *privdata, const void *obj); // 对比键的函数 int (*keyCompare)(void *privdata, const void *key1, const void *key2); // 销毁键的函数 void (*keyDestructor)(void *privdata, void *key); // 销毁值的函数 void (*valDestructor)(void *privdata, void *obj); } dictType;
ht
属性是一个包含两个项的数组, 数组中的每个项都是一个 dictht
哈希表, 一般情况下, 字典只使用 ht[0]
哈希表, ht[1]
哈希表只会在对 ht[0]
哈希表进行 rehash 时使用。
除了 ht[1]
之外, 另一个和 rehash 有关的属性就是 rehashidx
: 它记录了 rehash 目前的进度, 如果目前没有在进行 rehash , 那么它的值为 -1
。
Rehash
扩展和收缩哈希表的工作可以通过执行 rehash (重新散列)操作来完成, Redis 对字典的哈希表执行 rehash 的步骤如下:
- 为字典的
ht[1]
哈希表分配空间, 这个哈希表的空间大小取决于要执行的操作, 以及ht[0]
当前包含的键值对数量 (也即是ht[0].used
属性的值):- 如果执行的是扩展操作, 那么
ht[1]
的大小为第一个大于等于ht[0].used * 2
的 2^n (2
的n
次方幂); - 如果执行的是收缩操作, 那么
ht[1]
的大小为第一个大于等于ht[0].used
的 2^n 。
- 如果执行的是扩展操作, 那么
- 将保存在
ht[0]
中的所有键值对 rehash 到ht[1]
上面: rehash 指的是重新计算键的哈希值和索引值, 然后将键值对放置到ht[1]
哈希表的指定位置上。 - 当
ht[0]
包含的所有键值对都迁移到了ht[1]
之后 (ht[0]
变为空表), 释放ht[0]
, 将ht[1]
设置为ht[0]
, 并在ht[1]
新创建一个空白哈希表, 为下一次 rehash 做准备。
哈希表的扩展与收缩
其中哈希表的负载因子可以通过公式:
# 负载因子 = 哈希表已保存节点数量 / 哈希表大小 load_factor = ht[0].used / ht[0].size
计算得出。
渐进式rehash
以下是哈希表渐进式 rehash 的详细步骤:
- 为
ht[1]
分配空间, 让字典同时持有ht[0]
和ht[1]
两个哈希表。 - 在字典中维持一个索引计数器变量
rehashidx
, 并将它的值设置为0
, 表示 rehash 工作正式开始。 - 在 rehash 进行期间, 每次对字典执行添加、删除、查找或者更新操作时, 程序除了执行指定的操作以外, 还会顺带将
ht[0]
哈希表在rehashidx
索引上的所有键值对 rehash 到ht[1]
, 当 rehash 工作完成之后, 程序将rehashidx
属性的值增一。 - 随着字典操作的不断执行, 最终在某个时间点上,
ht[0]
的所有键值对都会被 rehash 至ht[1]
, 这时程序将rehashidx
属性的值设为-1
, 表示 rehash 操作已完成。
渐进式 rehash 的好处在于它采取分而治之的方式, 将 rehash 键值对所需的计算工作均滩到对字典的每个添加、删除、查找和更新操作上, 从而避免了集中式 rehash 而带来的庞大计算量。
参考链接:
【1】《Redis设计与实现》第二章