日常的开发中,涉及到缓存的应用场景, 大多数情况会用到redis, 本系列文章将介绍redis的内部构造以及运行机制。
本文主要介绍redis中的三种数据结构: 简单动态字符串、链表和字典。
1. 简单动态字符串
1.1. SDS的定义
redis中并没有直接使用c传统字符串表示, 而是定义了一种名为简单动态字符串(simple dynamic string)的抽象类型, 并将SDS作为redis内部默认字符串表示。 redis中只会将c类型字符串作为字符串字面量应用于无需修改的场景, 如日志记录等。 对于其他场景, 均使用SDS来表示字符串的值。
SDS定义如下:
struct sdshdr {
//记录buf数组中已经使用的字节数量
//等于sds保存的字符串长度
int len;
//记录buf数组中未使用的字节数量
int free;
//字节数组, 用于保存字符串
char buf[];
}
下图为SDS表示的字符串示例:
可以看到, SDS实际遵循了c字符串的惯例, 最后一个字符是空字符(\0), 同时, 保存空字符的1字节空间是不计入len长度的。这样的好处在于, SDS可以直接使用C字符串函数库中的函数。
1.2. SDS与C字符串的区别
1.2.1 获取字符串长度
SDS直接读取len,复杂度O(1); C字符串要遍历整个字符串, 复杂度0(n)
1.2.2. 避免缓冲区溢出
对C字符串进行拼接操作时, 若操作前没有分配足够的存储空间, 那么拼接操作有可能发生缓冲区溢出的情况, 从而影响其他相邻的字符串, 产生不可预知的问题; 而SDS在拼接操作前, 会先通过free空间判断buff中剩余空间大小, 若不足以容纳新字符串, 则会进行扩容操作, 从而避免C字符串遇到的问题。
1.2.3. 减少字符串修改过程中的内存分配次数
由于C字符串并不记录自身长度, 故对于包含了N个字符的C字符串而言, 其底层的字符数组长度总是N+1。因此每次扩展(append)和缩减(trim)均涉及内存空间的分配。 否则, 扩展操作会出现字符串溢出; 缩减操作会造成内存泄漏。
而SDS通过len字段解除了字符串长度和数组长度的关联,buf长度不一定就是字符串长度+1, 其还包含了未使用的字节, 其个数用free来表示,那么buf长度=len+free+1。SDS通过以下两种措施,来提高执行性能:
1). 空间预分配
用于优化字符串扩展操作, 当SDS的API对一个SDS进行扩展修改时, 程序不仅会为SDS分配修改所必须的空间, 还会分配额外的未使用空间供后续扩展使用, 具体算法如下:
a. 对于长度小于1M的字符串, 程序将分配和len属性相同大小的未使用空间, 例如字符串扩展长度为10, 那么会分配10个字节的空闲空间, buf总长为10+10+1=21个字节,如下图:
b. 若SDS长度超过1M, 则每次都会分配1M的未使用空间。
2). 惰性释放空间
而在做字符串缩减类的操作时, SDS也并不急于将不再使用的空间释放, 而是更新free后, 将其作为未使用空间备用。当然SDS的API提供了相关函数来真正释放未使用空间。
1.2.4. 二进制安全
C字符串中只能存储符合编码规范的字符, 除字符串末尾外, 不允许空字符(\0)的存在。 而SDS是可以保存任何字符的, 因此天然就可以支持保存二进制数据。这就使得redis既可以保存字符串数据, 也可以保存任意格式的二进制数据。
1.2.5. 兼容部分C字符串函数
由于SDS沿用了以空字符结尾的管理,因此在一些场景下, 可以使用C字符串函数库中的一些函数。
2. 链表
redis中的链表应用很广, 我们常用的列表键底层使用的就是链表。 简单来说, redis中使用了双向无环链表, 分别存储了头、尾指针, 可以支持多种数据结构, 定义如下:
链表节点定义:
typedef struct listNode {
//前驱节点
struct listNode *prev;
//后继节点
struct listNode *next;
//节点值
void *value;
} listNode;
链表结构定义:
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;
下图展示了链表的示例结构
3. 字典
redis自己设计了其使用的字典结构, 并广泛应用。 redis数据项就是基于字典处理的, 若set test_key 'test_value', test_key即字典的键, test_value为字典的值; 另外, 哈希键的底层实现,也应用了字典结构。
3.1. 字典的实现
redis的字典(dict)通过哈希表(dictht)实现, 一个哈希表里可以有多个哈希节点(dictEntry), 哈希表节点就保存了字典中的一个键值对, 哈希表(dictht)通过数组+链表的形式组织哈希节点。
3.1.1. dictEntry
typedef struct dictEntry {
//键
void *key;
//值
union {
void *val;
unit64_tu64;
int64_ts64;
}v;
struct dictEntry *next;
} dictEntry;
可以看到哈希节点中存储了下一个节点的指针, 其作用是在发生哈希碰撞时, 会将key的hash值相同的节点组成一个单向链表。
3.1.2. dictht
typedef struct dictht {
//哈希节点数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表掩码, 用于计算索引值, 等于size-1
unsigned long sizemask;
//哈希表中已有节点数量
unsigned long used;
} dictht;
哈希表的结构示例如下, 其包含了一个size为4的空哈希表:
下图展示了key的hash值相同的两个节点在哈希表中的结构:
3.1.3. dict
typedef struct dict {
//类型特定函数,其中包含了计算hash值的函数、负值键/值函数等
dictType *type;
//私有数据
void *privdata;
//哈希表
dictht ht[2];
//rehash索引, 当rehash操作不在进行时, 其值为-1
int trehashidx;
} dict;
typedef struct dictType {
//计算hash值的函数
unsigned int (*hashFunction)(counst 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是长度为2的dictht数组, 一般情况只会用到ht[0], 但是在rehash操作时, ht[1]也会被应用到, 后续会进行详细阐述。 rehashidx字段也是在rehash操作时被用到的。
3.2. 哈希算法
当一个键值对要放入哈希表时, 会调用dict->type->hashFunction()方法获取hash值, 然后再通过dict->ht[x]->sizemask与前面获取到的hash做按位与操作(&), 从而获取到当前节点要放到dict->ht[x]->table数据组中的具体索引位置。假如要将键值对k0/v0添加到一个空字典中, 过程如下:
1). hash = dict->dictType->hashFunction(k0);计算出k0的hash值,假设是8;
2). index = hash & dict->ht[0].sizemask = 8 & 3 = 0, 结果如下图:
3.3. 键冲突
有两个或以上数量的键被分配到了哈希表的同一个索引上, 被称为键冲突。 redis的哈希表通过链表方式来解决冲突。举个例子, 假设键值对k2/v2要执行放入字典的操作, 而k2通过hash计算出的索引值所在的数组位置上, 已经存在了哈希节点, 即发生了键冲突。 此时, 使用dictEntry的next指针,将k2/v2键值对节点与已存在键值对节点链接起来解决冲入。 由于dictEntry只有next节点, 会生成一个单链表, 因此新加入的节点总是放到链表头。如下图展示了解决键冲突的过程:
3.4. rehash
随着操作不断进行, 哈希表保存的键值对会增多或减少, 当负载因子达到某一个阈值时, 需要对哈希表进行扩容或缩容。这时候就需要进行rehash操作了。 扩容后的哈希表容量为第一个大于等于ht[0].uesed*2的2的n次方的数字; 缩容后容量为大于等于ht[0].uesed的2的n次方的数字。其整个过程简单描述为: 1. 先给ht[1]生成扩容后大小的dictht对象; 2. 循环计算ht[0]中存储的键值对在ht[1]中的索引位置, 并将其放入ht[1]中; 3. 当所有键值对都迁移到了ht[1]后,释放ht[0], 并将ht[0]指向ht[1], 给ht[1]创新一个新的空哈希表。
而redis出于性能考虑, 将上面描述的rehash过程分步执行, 因而引入了渐进式rehash。 这种渐进式rehash应用了分而治之的思想, 将rehash过程分散到了在每次对字典执行添加、删除、查找或更新操作时, 从而保证性能, 其过程如下:
1). 判断dict是否正在rehashing(rehashidx不为-1),只有是,才能继续往下进行,否则已经结束哈希过程,直接返回。
2). 执行完具体的操作后, 将ht[0]表中rehasidx索引上所有键值对rehash到ht[1]上, 完成后ht[0]表中对应的指针设置为空, rehashidx++,
3). 最后判断一下ht[0]是否全部迁移完毕,若是,则回收空间,重置ht[0],重置rehashidx为-1,否则用返回值告诉调用方,dict内仍然有数据未迁移。
下图展示了整个过程:
最后,需要说明的一点: 在渐进式rehash执行期间, 查询、更新、删除操作会先试图在ht[0]上进行, 若不存在再操作ht[1]; 而插入操作一律会被保存到ht[1];
参考:
《Redis设计与实现》