基于带中文注释的redis3.0源码阅读,github地址:https://github.com/huangz1990/redis-3.0-annotated。redis的基础数据结构dict的源码位于src/dict.c。
数据结构
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash 索引
// 当 rehash 不在进行时,值为 -1
int rehashidx; /* rehashing not in progress if rehashidx == -1 */
// 目前正在运行的安全迭代器的数量
int iterators; /* number of iterators currently running */
} dict;
redis的字典由两个dictht(哈希表)组成,两个哈希表是为了字典的渐进式扩容,这个机制会在下面介绍。
再来看看哈希表的结构
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
// 总是等于 size - 1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
} dictht;
redis的哈希表又由dictEntry组成,每个dictEntry就是一个键值对。哈希表解决冲突的方法是链表法。
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
渐进式扩容
关于字典,需要先介绍其渐进式扩容机制和两个哈希表的作用,因为其他操作都与这两个哈希表有关,能更好地理解其他操作。
由于redis是一个单线程的程序,所以希望在字典扩容的时候不要占用太多时间,因此使用渐进式扩容的方法。渐进式扩容就是,一次只迁移一个entry的数据。
redis扩容步骤
1、获取新哈希表大小,大小一般是2的n次方,给新哈希表分配内存
2、把rehashidx设置为0表示正在进行rehash,rehashidx表示下一个需要迁移数据的地方
3、到这里后,redis对字典的增删改查操作都会调用一次单步rehash(dictRehash函数)
int dictRehash(dict *d, int n) {
// 只可以在 rehash 进行中时执行
if (!dictIsRehashing(d)) return 0;
// 进行 N 步迁移
// T = O(N)
while(n--) {
dictEntry *de, *nextde;
// 如果 0 号哈希表为空,那么表示 rehash 执行完毕
// T = O(1)
if (d->ht[0].used == 0) {
// 释放 0 号哈希表
zfree(d->ht[0].table);
// 将原来的 1 号哈希表设置为新的 0 号哈希表
d->ht[0] = d->ht[1];
// 重置旧的 1 号哈希表
_dictReset(&d->ht[1]);
// 关闭 rehash 标识
d->rehashidx = -1;
// 返回 0 ,向调用者表示 rehash 已经完成
return 0;
}
// 确保 rehashidx 没有越界
assert(d->ht[0].size > (unsigned)d->rehashidx);
// 略过数组中为空的索引,找到下一个非空索引
while(d->ht[0].table[d->rehashidx] == NULL) d->rehashidx++;
// de指向该索引的链表表头节点
de = d->ht[0].table[d->rehashidx];
// 将链表中的所有节点迁移到新哈希表
// T = O(1)
while(de) {
unsigned int h;
// 保存下个节点的指针
nextde = de->next;
// 计算新哈希表的哈希值,以及节点插入的索引位置
h = dictHashKey(d, de->key) & d->ht[1].sizemask;
// 插入节点到新哈希表的表头
de->next = d->ht[1].table[h];
d->ht[1].table[h] = de;
// 更新计数器
d->ht[0].used--;
d->ht[1].used++;
// 继续处理下个节点
de = nextde;
}
// 将刚迁移完的哈希表索引的指针设为空
d->ht[0].table[d->rehashidx] = NULL;
// 更新 rehash 索引
d->rehashidx++;
}
return 1;
}
该函数的作用是把字典d中的ht[0]中的数据迁移到ht[1]去,n是迁移的步数(entry数)
虽然该函数有一个while(n–)的循环,但实际上调用的时候n都是等于1,也就是只迁移一个entry的数据。
增
接下来看看,redis的其他操作
增加的操作在dictEntry *dictAddRaw(dict *d, void *key)函数中,该函数给定字典d和键key,获取字典中的dictEntry(插入数据的位置)。
dictEntry *dictAddRaw(dict *d, void *key)
{
int index;
dictEntry *entry;
dictht *ht;
// 如果条件允许的话,进行单步 rehash
// T = O(1)
if (dictIsRehashing(d)) _dictRehashStep(d);
// 计算键在哈希表中的索引值
// 如果值为 -1 ,那么表示键已经存在
// T = O(N)
if ((index = _dictKeyIndex(d, key)) == -1)
return NULL;
// T = O(1)
// 如果字典正在 rehash ,那么将新键添加到 1 号哈希表
// 否则,将新键添加到 0 号哈希表
ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];
// 为新节点分配空间
entry = zmalloc(sizeof(*entry));
// 将新节点插入到链表表头
entry->next = ht->table[index];
ht->table[index] = entry;
// 更新哈希表已使用节点数量
ht->used++;
// 设置新节点的键
// T = O(1)
dictSetKey(d, entry, key);
return entry;
}
static void _dictRehashStep(dict *d) {
if (d->iterators == 0) dictRehash(d,1);
}
可以看到,在插入操作之前,调用了一次_dictRehashStep函数,进行一次数据迁移。在进行rehash的时候把新的数据插入到ht[1]
查找和删除
在查找和删除时,如果字典正在进行rehash,则会到两个hashtable里面去找。下面是删除的函数,查找的也类似,就不上了。
static int dictGenericDelete(dict *d, const void *key, int nofree)
{
unsigned int h, idx;
dictEntry *he, *prevHe;
int table;
// 字典(的哈希表)为空
if (d->ht[0].size == 0) return DICT_ERR; /* d->ht[0].table is NULL */
// 进行单步 rehash ,T = O(1)
if (dictIsRehashing(d)) _dictRehashStep(d);
// 计算哈希值
h = dictHashKey(d, key);
// 遍历哈希表
// T = O(1)
// 这里遍历了ht[0]和ht[1]
for (table = 0; table <= 1; table++) {
// 计算索引值
idx = h & d->ht[table].sizemask;
// 指向该索引上的链表
he = d->ht[table].table[idx];
prevHe = NULL;
// 遍历链表上的所有节点
// T = O(1)
while(he) {
if (dictCompareKeys(d, key, he->key)) {
// 超找目标节点
// 从链表中删除
if (prevHe)
prevHe->next = he->next;
else
d->ht[table].table[idx] = he->next;
// 释放调用键和值的释放函数?
if (!nofree) {
dictFreeKey(d, he);
dictFreeVal(d, he);
}
// 释放节点本身
zfree(he);
// 更新已使用节点数量
d->ht[table].used--;
// 返回已找到信号
return DICT_OK;
}
prevHe = he;
he = he->next;
}
// 如果执行到这里,说明在 0 号哈希表中找不到给定键
// 那么根据字典是否正在进行 rehash ,决定要不要查找 1 号哈希表
if (!dictIsRehashing(d)) break;
}
// 没找到
return DICT_ERR; /* not found */
}
dictScan
在dict中还有一个dictScan函数,是用于遍历dict中的所有元素。由于redis是单线程,所以为了避免长时间的操作,dictScan使用了游标的方式实现。游标从0开始,每次遍历都会返回下一个游标,一直遍历到游标为0则整个dict遍历完毕。
dictScan这个函数,看起来只在redis的scan命令中使用到。而scan命令是用于查询redis中所有的key的,和key类似,不过key会一次返回所有key,若存在很多key,那么时间会花费得更多。
dictScan的实现比较复杂,这里先用表格展示游标的变化过程。
假设hashtable大小为16(1<<4),二进制表示(10000),哈希值掩码(1111),游标的变化过程为
初始值 | 0 | 0 | 0 | 0 |
---|---|---|---|---|
第一次遍历返回值 | 1 | 0 | 0 | 0 |
第二次 | 0 | 1 | 0 | 0 |
第三次 | 1 | 1 | 0 | 0 |
第四次 | 0 | 0 | 1 | 0 |
第五次 | 1 | 0 | 1 | 0 |
… | ||||
最后一次 | 1 | 1 | 1 | 1 |
结束 | 0 | 0 | 0 | 0 |
游标的变化过程是从左边开始递增的(正常二进制递增从右边开始,而dictScan的游标则是反过来的)
在rehash的情况下,会同时遍历两个hasttable。假设新hashtable大小为64(1<<6),二进制表示(1000000),掩码(111111),
dictScan的游标也是按照大小为16来遍历的,不过会在遍历每一个游标时,对ht[1]额外遍历所有高位的游标。
举个例子,假设正在遍历游标0100,那么会在遍历的过程中同时遍历ht[1]所有xx0100,即010100,100100,110100,高位的遍历顺序就是正常的二进制递增顺序(从右开始递增)。
接下来对着代码看一遍
unsigned long dictScan(dict *d,
unsigned long v,
dictScanFunction *fn,
void *privdata)
{
dictht *t0, *t1;
const dictEntry *de;
unsigned long m0, m1;
// v就是我们所说的游标,初始值为0,fn是回调函数,用于执行一些逻辑
// 跳过空字典
if (dictSize(d) == 0) return 0;
// 迭代只有一个哈希表的字典
if (!dictIsRehashing(d)) {
// 指向哈希表
t0 = &(d->ht[0]);
// 记录 mask
m0 = t0->sizemask;
// 指向哈希桶
de = t0->table[v & m0];
// 遍历桶中的所有节点
while (de) {
fn(privdata, de);
de = de->next;
}
// 迭代有两个哈希表的字典
} else {
// 指向两个哈希表
t0 = &d->ht[0];
t1 = &d->ht[1];
// 确保 t0 比 t1 要小
if (t0->size > t1->size) {
t0 = &d->ht[1];
t1 = &d->ht[0];
}
// 记录掩码,结合上面的例子,m0=1111,m1=111111(二进制)
m0 = t0->sizemask;
m1 = t1->sizemask;
// 指向桶,并迭代桶中的所有节点
de = t0->table[v & m0];
while (de) {
fn(privdata, de);
de = de->next;
}
do {
// 指向桶,并迭代桶中的所有节点
de = t1->table[v & m1];
while (de) {
fn(privdata, de);
de = de->next;
}
// 这里是rehash时遍历某个游标的同时遍历该游标的高位
// 该逻辑的变化过程对应上面例子的xx0100的变化过程
v = (((v | m0) + 1) & ~m0) | (v & m0);
// m0^m1=001111,v&(m0^m1)==0表示xx0100从00 0100到01 0100... 00 0100
// 即v&(m0^m1)==0时表示当前游标的高位已经遍历完了
} while (v & (m0 ^ m1));
}
// 这里是游标变化的逻辑
v |= ~m0;
// rev是二进制反转函数,输入0001会返回1000,0011返回1100。。。
v = rev(v);
// 先反转再加一实现了二进制从左边开始递增
v++;
v = rev(v);
return v;
}
关于游标的变化方式
代码中也给了解释,是为了防止扩容是漏遍历某些数据,同时该遍历方法也有可能会出现重复遍历的情况。
举个例子,假设当前hashtable大小为16(1<<4),当前游标为1100,那么已遍历游标为0000,1000,0100
1、假设在遍历了0100游标后,字典缩容了,大小变成了8(1<<3),那么在游标为0100,1100的数据会迁移到100
2、遍历1100游标时会遍历ht[0]的0100,1100和ht[1]的100,在ht[0]的0100的数据会被重复遍历
3、如果游标按正常的递增方式遍历,那么已遍历的游标就是0000,0001,0010,0011,0100,…,1011。1110还没遍历,如果在遍历之前,1110的数据被迁移到0110,那么将有数据被漏掉(0110已经遍历过)
3.1、而redis这种游标的变化方式可以避免缩容时的这个问题(我脑补了一下,感觉扩容应该两种变化方式都没有问题,可以自己模拟一下)
总结一下就是dictScan可以遍历一开始就在dict中(开始遍历后插入的数据不算,那这样开始后删掉的数据应该也拿不到)的所有数据,可能会有重复(缩容情况),但不会遗漏。
总结
由于redis是单线程程序,里面很多的耗时操作都采用了分步执行(渐进式扩容,dictScan)的方法,这种设计值得借鉴一下。