一、简单字符串
Redis自己构建了一种名为简单动态字符串(simple dynamic string ,SDS) 的类型,并将SDS作为Redis的默认字符串表示
每个sds.h / sdshdr 结构表示一个SDS值:
struct sdshdr {
// 记录buf数组中已使用的字节的数量
// 等于SDS锁保存的字符串的长度
int len;
// 记录buf数组中未使用字节的数量
int free;
// 字节数组,用于保存字符串
char buf[];
}
- buf相当于C中的字符串,一般被称为字节数组,为一个char类型的数组
- len记录了buf的长度:因为C字符串并不记录自身的长度信息,所以为了获取一个C字符串的长度,程序需要遍历整个字符串,复杂度为O(N),SDS的len属性中记录SDS本身的长度,获取长度的复杂度为O(1)。(Java数组中数组长度也保存在数组对象的对象头中)
- free属性表示这个SDS被分配但是没有使用的空间,对于字节数组操作存在两个问题
- ① 如果字符串需要拼接新的字符串,在操作之前,需要通过内存重分配来扩展数组的空间大小
- ② 如果要缩短字符串,那又需要内存的重分配来释放不使用的空间,不然会造成内存泄漏
- 对于Redis,经常用于速度要求苛刻,并且数据频繁修改的场合,但是内存重分配涉及复杂算法,并且可能执行系统调用,是一个耗时的操作,所以通过free字段采取一些优化:
- ① 空间预分配:当对一个SDS进行修改,需要对SDS进行空间扩展的时候,程序不仅会为SDS分配空间,还会分配额外的未使用空间,通过空间预分配策略,减少字符串增长操作的内存重分配次数
- 如果SDS长度(也就是len属性)小于 1 MB,那么会分配和len值一样的大小
- 如果SDS长度大于 1 MB,则分配1MB的未使用空间
- ② 惰性空间释放:用于优化SDS字符串缩短问题,当SDS通过API缩短长度之后,为使用的空接被free标记,可以等待将来的使用,或者通过API手动删除空闲空间,减少了缩短字符串的内存重分配次数。
- ① 空间预分配:当对一个SDS进行修改,需要对SDS进行空间扩展的时候,程序不仅会为SDS分配空间,还会分配额外的未使用空间,通过空间预分配策略,减少字符串增长操作的内存重分配次数
二进制安全
为了确保Redis适用于各种不同的使用场景,SDS的API都是二进制安全的,所有SDS API都会以处理二进制的方式来处理SDS 存放在buf数组里的数据,程序不会对其中的数据做任何多余操作,数据写入是什么样,读出就是什么样,所以buf被称为字节数组,而不是字符数组。使得Redis不仅可以保存文本数据还可以保存任意格式的二进制数据
兼容部分C字符串函数
SDS遵循C字符串以空字符(‘\0’)结尾的惯例,是为了可以重用< string,h > 的C语言函数,简化一些操作
二、链表
链表节点的实现
每个链表节点使用一个adlist.h / listNode 结构来表示
typedef struct listNode {
// 保存前驱节点
struct listNode *prev;
// 保存后继节点
struct listNode *next;
// 保存值
void *value;
} listNode;
虽然仅仅使用多个ListNode结构就可以组成链表,但是用adlist.h / list 来持有链表的话,操作会更加方便
typedef struct list {
// 头结点
listNode *head;
// 尾节点
listNode *tail;
// 复制函数
void *(*dup)(void *ptr);
// 释放函数
void (*free)(void *ptr);
// 匹配函数
int (*match)(void *ptr, void *key);
// 链表长度
unsigned long len;
} list;
list结构为链表提供了表头指针head、表尾指针tail,以及链表长度计数器len
- dup、free和match成员则是用于实现多态链表所需的类型特定函数
- dup函数用于复制链表节点所保存的
- free函数用于释放链表节点所保存的值
- match函数则用于对比链表节点所保存的值和另一个输入值是否相等
Redis 链表实现的总结:
- 双向链表:链表节点带有前驱节点和后继节点,获取前置或者后置节点的复杂度都是O(1)
- 无环:表头节点pre指针和表尾节点next指针都指向NULL,不会形成环状
- 带头尾指针:list中的head与tail分别保存了链表的头结点和尾节点
- 带链表长度计数器:list中的len属性使得获取链表长度的时间复杂度变为了O(1)
- 多态:链表节点使用void*指针来保存节点值,并且可以通过list结构的dup、free、match三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值
三、字典
Redis的字典使用哈希表作为底层实现,一个哈希表里面可以有多个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对
1. 字典的实现
1.1 哈希表
Redis字典锁使用的哈希表由dict.h / dictht 结构定义
typedef struct dictht {
// 哈希表数组
// 类似于Java中HashMap的
//transient Node<K,V>[] table;
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表掩码,用于计算索引值
// 总是等于size - 1
unsigned long sizemask;
// 哈希表中已有的节点数
unsigned long used;
} dictht;
- table是一个数组,数组中每个元素都是一个指向dictEntry的指针,每个dictEntry结构保存着一个键值对
- size属性记录了哈希表的大小,也即是table数组的大小
- used属性则记录了哈希表目前已有节点(键值对)的数量
- sizemask属性的值总是等于size - 1,这个属性和哈希值一起决定了一个键应该被放到table数组的哪个索引上面
1.2 哈希表节点
哈希表节点使用dictEntry结构表示,每个dictEntry结构都保存着一个键值对
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
// 指向下一个哈希节点,形成链表
struct dictEntry *next;
} dictEntry;
Java HashMap 的Node 结构
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
}
- key 属性保存着键值对中的键
- v属性保存键值对中的值,值可以是一个指针,或者是一个uint64_t 整数或者int64_t 整数
- next属性是指向下一个哈希表节点的指针,用拉链发解决哈希冲突
1.3 字典
Redis中的字典由dict.h / dict 结构表示
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash 索引
// 当rehash不在进行时,值为-1
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;
- type属性是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数
- privdata属性则保存了需要传给那些类型特定函数的可选参数(类似于Java中的泛型)
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时使用
- rehashidx属性是除了ht[1]以外,另一个与rehash有关的属性,它记录了rehash目前的进度,如果没有rehash,那么它的值为-1
一个普通状态下(没有进行rehash)的字典
2. 字典算法
2.1 哈希算法
当要将一个新的键值对添加到字典里面时,程序需要先根据键值对的键计算出哈希值和索引值,然后再根据索引值,将包含新键值对的哈希表节点放到哈希表数组的指定索引上面(和Java HashMap一致)
# 使用字典设置的哈希函数,计算键key的哈希值
hash = dict -> type -> hashFunction(key);
# 使用哈希标的sizemask属性和哈希值,计算出索引值
# 根据情况不同,ht【x】可以是ht【0】 或者ht【1】
index = hash & -> ht[x].sizemask;
Redis底层使用MurmurHash2算法来计算键的哈希值,这种算法的优点在于,即使输入的键是有规律的,算法仍能给出一个很好地随机分布性,并且算法的计算速度也非常快
uint32_t MurmurHash2 ( const void * key, int len, uint32_t seed )
{
// 'm' and 'r' are mixing constants generated offline.
// They're not really 'magic', they just happen to work well.
const uint32_t m = 0x5bd1e995;
const int r = 24;
// Initialize the hash to a 'random' value
uint32_t h = seed ^ len;
// Mix 4 bytes at a time into the hash
const unsigned char * data = (const unsigned char *)key;
while(len >= 4)
{
uint32_t k = *(uint32_t*)data;
k *= m;
k ^= k >> r;
k *= m;
h *= m;
h ^= k;
data += 4;
len -= 4;
}
// Handle the last few bytes of the input array
switch(len)
{
case 3: h ^= data[2] << 16;
case 2: h ^= data[1] << 8;
case 1: h ^= data[0];
h *= m;
};
// Do a few final mixes of the hash to ensure the last few
// bytes are well-incorporated.
h ^= h >> 13;
h *= m;
h ^= h >> 15;
return h;
}
2.2 解决键冲突
当有两个或以上数量的键被分配到了哈希表数组的同一个索引上面时,我们称这些键发生了冲突(collision)。
Redis的哈希表使用链地址法(separate chaining)来解决键冲突(和Java 7 中的HashMap类似),每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来,这就解决了键冲突的问题。
因为dictEntry节点组成的链表没有指向链表表尾的指针,所以为了速度考虑程序总是将新节点添加到链表表头位置。
一个包含两个键值对的哈希表
使用链表解决k1 和 k2 的冲突
2.3 rehash
随着操作的不断执行,哈希表保存的键值对会逐渐地增多或者减少,为了让哈希表的负载因子(load_factor)维持在一个合理的范围之内(可以减少出现哈希冲突的几率),当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩。
扩展和收缩哈希表的工作可以通过执行rehash(重新散列)操作来完成,Redis对字典的哈希表执行rehash的步骤如下:
- 为字典的ht[1] 属性哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量(也就是dictht.used属性的值)
- 如果执行的是扩展操作,那么ht[1] 的大小为第一个大小等于ht[0].used * 2 的 2 的n次幂(为什么要是2的n次幂呢?)
- 因为2的n次幂二进制表示为 1000…000 (1后面n个0), sizemask的值就是该值减一,则为n个1(111…111),当算取哈希桶下标时,通过 hash & sizemask,这时异或操作就相当于取模操作,减少哈希冲突,并且提高运算速度
- 如果执行的是收缩操作,那么ht[1] 的那么ht[1]的大小为第一个小于ht[0].used的 2n
- Java HashMap 没有收缩的操作,而Redis中有
- 如果执行的是扩展操作,那么ht[1] 的大小为第一个大小等于ht[0].used * 2 的 2 的n次幂(为什么要是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做准备(这整的和标记复制算法一样,就是没有对象死亡)
哈希表的扩展与收缩
当以下条件中的任意一个被满足时,程序会自动开始对哈希表执行扩展操作
- 服务器目前没有在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1
- 服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5
- 哈希表的负载因子通过公式
# 负载因子 = 哈希表已保存节点数量 / 哈希表的大小
load_factor = ht[0].used / ht[0].size
根据BGSAVE命令或BGREWRITEAOF命令是否正在执行,服务器执行扩展操作所需的负载因子并不相同,这是因为在执行BGSAVE命令或BGREWRITEAOF命令的过程中,Redis需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制(copy-on-write)技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,从而尽可能地避免在子进程存在期间进行哈希表扩展操作,这可以避免不必要的内存写入操作,最大限度地节约内存。
- 另一方面,当哈希表的负载因子小于0.1时,程序自动开始对哈希表执行收缩操作。
2.4 渐进式 rehash
扩展或收缩哈希表需要将ht[0]里面的所有键值对rehash到ht[1]里面,但是,这个rehash动作并不是一次性、集中式地完成的,而是分多次、渐进式地完成的。这样做主要因为在数据量较大时,如果一次性,集中式地完成,庞大的计算量可能会导致服务器在一段时间内停止服务。
渐进式rehash的详细步骤
- 为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表(上文已经介绍了分配空间的策略)
- 在字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作正式开始
- 在rehash进行期间,每次对字典执行增删改查操作时,程序执行指定操作的同时,会将ht[0] 哈希表位于rehashidx 索引上的键值对rehash到ht[1] 中,当该索引的键值对rehash完成之后, rehashidx属性值会 + 1
- 当所有键值对都rehash完成后,rehashidx属性的值又会被设置成 - 1,表示rehash工作完成
渐进式rehash执行期间的哈希表操作
- 因为在进行渐进式rehash的过程中,字典会同时使用ht[0]和ht[1]两个哈希表,所以在渐进式rehash进行期间,字典的删除(delete)、查找(find)、更新(update)等操作会在两个哈希表上进行
- 例如,要在字典里面查找一个键的话,程序会先在ht[0]里面进行查找,如果没找到的话,就会继续到ht[1]里面进行查找
- 另外,在渐进式rehash执行期间,新添加到字典的键值对一律会被保存到ht[1]里面,而ht[0]则不再进行任何添加操作,这一措施保证了ht[0]包含的键值对数量会只减不增,并随着rehash操作的执行而最终变成空表