Redis源码(1)基本数据结构1:https://developer.aliyun.com/article/1508210
字典Dict
源码文件dict.h 和 dict.c。
Redis中字典使用哈希表实现。同时,为了实现渐进式Rehash的操作,每一个字典都有两个hash表(新/旧)。
结构定义
Redis中字典使用哈希表实现。同时,为了实现渐进式Rehash的操作,每一个字典都有两个hash表(新/旧)。
// 字典定义 typedef struct dict { // 类型特定函数 // 这一指针指向的结构体中存储了hash表常用的函数指针 dictType *type; // 私有数据:保存了需要传给那些类型特定函数的可选参数(见后文) void *privdata; // 哈希表 dictht ht[2]; // 该值表示rehash进行到的下标索引位置 // 当 rehash 不在进行时,值为 -1 // 开始时值为0 // 正在进行中时,值处于0到size之间 int rehashidx; int iterators; // 目前正在运行的安全迭代器的数量 } dict;
其中的hash表定义如下:
//每个字典都使用两个哈希表,从而实现渐进式 rehash 。 typedef struct dictht { // 哈希表数组(存储的是指向节点指针数组的指针) // 都使用指针的方式可以节省空间 dictEntry **table; // 哈希表大小 unsigned long size; // 哈希表大小掩码,用于计算索引值 // 总是等于 size - 1 unsigned long sizemask; // 该哈希表已有节点的数量 unsigned long used; } dictht;
hash表的节点(Entry)定义如下,Redis是采用开链法来处理hash冲突的:
//哈希表节点 typedef struct dictEntry { // 键 void *key; // 值 union { void *val; uint64_t u64; int64_t s64; } v; // 指向下个哈希表节点,形成链表 struct dictEntry *next; } dictEntry;
一个hash表的实例见下:
PS:在redis的hash表结构中,如果出现hash冲突,新的key-value是在旧的key-value前面的,这么做也很好理解:如果插入到链表最后,那么还需要一个遍历链表的操作,O(N)的复杂度。
一个普通状态下的字典实例见下:
接下来我们看看其中的重要API,比如计算hash值、处理hash冲突或者是rehash。
接口API
总览
函数 | 作用 | 时间复杂度 |
dictCreate | 创建一个新的字典。 |
dictAdd | 将给定的键值对添加到字典里面。 | |
dictReplace | 将给定的键值对添加到字典里面, 如果键已经存在于字典,那么用新值取代原有的值。 |
dictFetchValue | 返回给定键的值。 | |
dictGetRandomKey | 从字典中随机返回一个键值对。 |
dictDelete | 从字典中删除给定键所对应的键值对。 | |
dictRelease | 释放给定字典,以及字典中包含的所有键值对。 | , N 为字典包含的键值对数量。 |
**dictAddRaw:**新增键值对
dictEntry *dictAddRaw(dict *d, void *key) { int index; dictEntry *entry; dictht *ht; // 如果条件允许的话,进行单步 rehash // T = O(1) if (dictIsRehashing(d)) _dictRehashStep(d); // 计算键在哈希表中的索引值 // 如果值为 -1 ,那么表示键已经存在 if ((index = _dictKeyIndex(d, key)) == -1) return NULL; // 如果字典正在 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; }
在其中有个函数:dictkeyindex()——计算该字典中可以插入该键值对的index,如果标志符号dict_can_resize为正,那么会在hash表的size和used比率大于1(即负载因子)时(没有执行BGSAVE)进行rehash或者大于5(执行BGSAVE)时进行强制rehash。
计算hash值时是这样的:
index = hash & dict->ht[0].sizemask
即是利用计算出的hash值跟sizemask相与,这个hash算法被称为MurmurHash2(目前有3了,但是Redis没用),这种算法的优点在于,即使输入的键是有规律的,算法仍能给出一个很好的随机分布性,并且算法的计算速度也非常快。
- 为什么负载因子一个是1一个是5?
根据 BGSAVE 命令或 BGREWRITEAOF 命令是否正在执行,服务器执行扩展操作所需的负载因子并不相同,这是因为在执行BGSAVE命令或BGREWRITEAOF 命令的过程中,Redis 需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制(copy-on-write)技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,**从而尽可能地避免在子进程存在期间进行哈希表扩展操作,**这可以避免不必要的内存写入操作,最大限度地节约内存。
// 指示字典是否启用 rehash 的标识 static int dict_can_resize = 1; // 强制 rehash 的比率(强制不可被上面的标志所阻止) static unsigned int dict_force_resize_ratio = 5;
rehash的具体操作见接下来的这个API。
Rehash
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); // 略过数组中为空的索引,找到下一个非空索引 // hash表的entry中第一个就是void* key,所以可以直接访问其是否为空 // 来判断该出是否存在键值对 while(d->ht[0].table[d->rehashidx] == NULL) d->rehashidx++; // 指向该索引的链表表头节点 de = d->ht[0].table[d->rehashidx]; // 将链表中的所有节点迁移到新哈希表 // T = O(1) while(de) { unsigned int h; // 保存下个节点的指针 nextde = de->next; /* Get the index in the new hash table */ // 计算新哈希表的哈希值,以及节点插入的索引位置 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; }
通过上述源码我们可以得出:
Redis的hash表采用渐进式分步hash的方法
rehash的过程中字典的**两个hash表同时存在,并且在迭代、更新、删除键的时候都需要考虑这两个hash表,**值得注意的是:字典的删除(delete)、查找(find)、更新(update)等操作会在两个哈希表上进行,但插入只会在第二个表中,rehash完毕之后会重置第一个表。
在rehash时,如果扩展操作,那么ht[1]的大小为第一个大于等于 ht[0].used*2的2n**;如果收缩操作(**负载因子小于0.1**),**那么ht[1]的大小为第一个大于等于ht[0].used的2n。
rehsh时,相关API会经常进行单步rehash,数据库操作主要是调用int dictRehashMilliseconds(dict *d, int ms) **,**即在指定时间内执行rehash,时间到了就返回已迭代到的index。
迭代器
跟链表一样,Redis字典中也存在迭代器,主要是为了实现遍历一个字典。
其定义如下:
安全迭代器
dictIterator *dictGetSafeIterator(dict *d) { dictIterator *i = dictGetIterator(d); // 设置安全迭代器标识 i->safe = 1; return i; }
非安全迭代器
dictIterator *dictGetIterator(dict *d) { dictIterator *iter = zmalloc(sizeof(*iter)); iter->d = d; iter->table = 0; iter->index = -1; iter->safe = 0; iter->entry = NULL; iter->nextEntry = NULL; return iter; }
两种迭代器都共用同一个函数接口:
dictEntry *dictNext(dictIterator *iter)
该接口返回迭代器指向的当前节点。在如果是安全迭代器调用该函数,会更新字典的iterator计数器(安全迭代器);如果是非安全迭代器调用该函数,会计算此时字典的fingerprint,以确定用户没有违规操作。
有关迭代器的重点函数是:****dictScan()。详见源码注释。
跳跃表skiplist
源码文件:t_zset.c 中所有以 zsl 开头的函数。
跳跃表是一种可以对有序链表进行近似二分查找的数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。
跳跃表支持平均O(logN) 、最坏O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理节点。
Redis在两个地方用到了跳跃表,一个是实现有序集合,另一个是在集群节点中用作内部数据结构(后文会将,见Redis多机数据库)。
在大部分情况下,跳跃表的效率可以和平衡树相媲美,并且因为跳跃表的实现比平衡树要来得更为简单,所以有不少程序都使用跳跃表来代替平衡树。
通过下面这个图,来看看跳表这个数据结构:
跳表其实是空间换时间的结构,因为每隔一定的点都需要建立一个链表索引。
它的查询过程如下:
即先同层查找,而后往下一层找,直到最后的找到节点,类似一个二分的过程。
另外,我们想要为跳表插入或者删除数据,我们首先需要找到插入或者删除的位置,然后执行插入或删除操作,前边我们已经知道了,跳表的查询的时间复杂度为 O(logn),因为找到位置之后插入和删除的时间复杂度很低,为 O(1),所以最终插入和删除的时间复杂度也为 O(longn)。
想进一步了解可参考:
https://zhuanlan.zhihu.com/p/68516038
Redis源码(1)基本数据结构3:https://developer.aliyun.com/article/1508223