跋山涉水——深入 Redis 字典遍历

本文涉及的产品
云数据库 Redis 版,社区版 2GB
推荐场景:
搭建游戏排行榜
简介: Redis 字典的遍历过程逻辑比较复杂,互联网上对这一块的分析讲解非常少。我也花了不少时间对源码的细节进行了整理,将我个人对字典遍历逻辑的理解呈现给各位读者。

Redis 字典的遍历过程逻辑比较复杂,互联网上对这一块的分析讲解非常少。我也花了不少时间对源码的细节进行了整理,将我个人对字典遍历逻辑的理解呈现给各位读者。也许读者们对字典的遍历过程有比我更好的理解,还请不吝指教。

img_68df290954b1fb9c13fffa2b2b0516b3.jpe

一边遍历一边修改

我们知道 Redis 对象树的主干是一个字典,如果对象很多,这个主干字典也会很大。当我们使用 keys 命令搜寻指定模式的 key 时,它会遍历整个主干字典。值得注意的是,在遍历的过程中,如果满足模式匹配条件的 key 被找到了,还需要判断 key 指向的对象是否已经过期。如果过期了就需要从主干字典中将该 key 删除。

void keysCommand(client *c) {

dictIterator *di; // 迭代器

dictEntry *de; // 迭代器当前的entry

sds pattern = c->argv[1]->ptr; // keys的匹配模式参数

int plen = sdslen(pattern);

int allkeys; // 是否要获取所有key,用于keys *这样的指令

unsigned long numkeys = 0;

void *replylen = addDeferredMultiBulkLength(c);

// why safe?

di = dictGetSafeIterator(c->db->dict);

allkeys = (pattern[0] == '*' && pattern[1] == '');

while((de = dictNext(di)) != NULL) {

sds key = dictGetKey(de);

robj *keyobj;

if (allkeys || stringmatchlen(pattern,plen,key,sdslen(key),0)) {

keyobj = createStringObject(key,sdslen(key));

// 判断是否过期,过期了要删除元素

if (expireIfNeeded(c->db,keyobj) == 0) {

addReplyBulk(c,keyobj);

numkeys++;

}

decrRefCount(keyobj);

}

}

dictReleaseIterator(di);

setDeferredMultiBulkLength(c,replylen,numkeys);

}

复制代码

那么,你是否想到了其中的困难之处,在遍历字典的时候还需要修改字典,会不会出现指针安全问题?

重复遍历

字典在扩容的时候要进行渐进式迁移,会存在新旧两个 hashtable。遍历需要对这两个 hashtable 依次进行,先遍历完旧的 hashtable,再继续遍历新的 hashtable。如果在遍历的过程中进行了 rehashStep,将已经遍历过的旧的 hashtable 的元素迁移到了新的 hashtable中,那么遍历会不会出现元素的重复?这也是遍历需要考虑的疑难之处,下面我们来看看 Redis 是如何解决这个问题的。

迭代器的结构

Redis 为字典的遍历提供了 2 种迭代器,一种是安全迭代器,另一种是不安全迭代器。

typedef struct dictIterator {

dict *d; // 目标字典对象

long index; // 当前遍历的槽位置,初始化为-1

int table; // ht[0] or ht[1]

int safe; // 这个属性非常关键,它表示迭代器是否安全

dictEntry *entry; // 迭代器当前指向的对象

dictEntry *nextEntry; // 迭代器下一个指向的对象

long long fingerprint; // 迭代器指纹,放置迭代过程中字典被修改

} dictIterator;

// 获取非安全迭代器,只读迭代器,允许rehashStep

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;

}

// 获取安全迭代器,允许触发过期处理,禁止rehashStep

dictIterator *dictGetSafeIterator(dict *d) {

dictIterator *i = dictGetIterator(d);

i->safe = 1;

return i;

}

迭代器的「安全」指的是在遍历过程中可以对字典进行查找和修改,不用感到担心,因为查找和修改会触发过期判断,会删除内部元素。「安全」的另一层意思是迭代过程中不会出现元素重复,为了保证不重复,就会禁止 rehashStep。

而「不安全」的迭代器是指遍历过程中字典是只读的,你不可以修改,你只能调用 dictNext 对字典进行持续遍历,不得调用任何可能触发过期判断的函数。不过好处是不影响 rehash,代价就是遍历的元素可能会出现重复。

安全迭代器在刚开始遍历时,会给字典打上一个标记,有了这个标记,rehashStep 就不会执行,遍历时元素就不会出现重复。

typedef struct dict {

dictType *type;

void *privdata;

dictht ht[2];

long rehashidx;

// 这个就是标记,它表示当前加在字典上的安全迭代器的数量

unsigned long iterators;

} dict;

// 如果存在安全的迭代器,就禁止rehash

static void _dictRehashStep(dict *d) {

if (d->iterators == 0) dictRehash(d,1);

}

迭代过程

安全的迭代器在遍历过程中允许删除元素,意味着字典第一维数组下面挂接的链表中的元素可能会被摘走,元素的 next 指针就会发生变动,这是否会影响迭代过程呢?下面我们仔细研究一下迭代函数的代码逻辑。

dictEntry *dictNext(dictIterator *iter)

{

while (1) {

if (iter->entry == NULL) {

// 遍历一个新槽位下面的链表,数组的index往前移动了

dictht *ht = &iter->d->ht[iter->table];

if (iter->index == -1 && iter->table == 0) {

// 第一次遍历,刚刚进入遍历过程

// 也就是ht[0]数组的第一个元素下面的链表

if (iter->safe) {

// 给字典打安全标记,禁止字典进行rehash

iter->d->iterators++;

else {

// 记录迭代器指纹,就好比字典的md5值

// 如果遍历过程中字典有任何变动,指纹就会改变

iter->fingerprint = dictFingerprint(iter->d);

}

}

iter->index++; // index=0,正式进入第一个槽位

if (iter->index >= (long) ht->size) {

// 最后一个槽位都遍历完了

if (dictIsRehashing(iter->d) && iter->table == 0) {

// 如果处于rehash中,那就继续遍历第二个 hashtable

iter->table++;

iter->index = 0;

ht = &iter->d->ht[1];

else {

// 结束遍历

break;

}

}

// 将当前遍历的元素记录到迭代器中

iter->entry = ht->table[iter->index];

else {

// 直接将下一个元素记录为本次迭代的元素

iter->entry = iter->nextEntry;

}

if (iter->entry) {

// 将下一个元素也记录到迭代器中,这点非常关键

// 防止安全迭代过程中当前元素被过期删除后,找不到下一个需要遍历的元素

// 试想如果后面发生了rehash,当前遍历的链表被打散了,会发生什么

// 这里要使劲发挥自己的想象力来理解

// 旧的链表将一分为二,打散后重新挂接到新数组的两个槽位下

// 结果就是会导致当前链表上的元素会重复遍历

// 如果rehash的链表是index前面的链表,那么这部分链表也会被重复遍历

iter->nextEntry = iter->entry->next;

return iter->entry;

}

}

return NULL;

}

// 遍历完成后要释放迭代器,安全迭代器需要去掉字典的禁止rehash的标记

// 非安全迭代器还需要检查指纹,如果有变动,服务器就会奔溃(failfast)

void dictReleaseIterator(dictIterator *iter)

{

if (!(iter->index == -1 && iter->table == 0)) {

if (iter->safe)

iter->d->iterators--; // 去掉禁止rehash的标记

else

assert(iter->fingerprint == dictFingerprint(iter->d));

}

zfree(iter);

}

// 计算字典的指纹,就是将字典的关键字段进行按位糅合到一起

// 这样只要有任意的结构变动,指纹都会发生变化

// 如果只是某个元素的value被修改了,指纹不会发生变动

long long dictFingerprint(dict *d) {

long long integers[6], hash = 0;

int j;

integers[0] = (long) d->ht[0].table;

integers[1] = d->ht[0].size;

integers[2] = d->ht[0].used;

integers[3] = (long) d->ht[1].table;

integers[4] = d->ht[1].size;

integers[5] = d->ht[1].used;

for (j = 0; j < 6; j++) {

hash += integers[j];

hash = (~hash) + (hash << 21);

hash = hash ^ (hash >> 24);

hash = (hash + (hash << 3)) + (hash << 8);

hash = hash ^ (hash >> 14);

hash = (hash + (hash << 2)) + (hash << 4);

hash = hash ^ (hash >> 28);

hash = hash + (hash << 31);

}

return hash;

}

值得注意的是在字典扩容时进行rehash,将旧数组中的链表迁移到新的数组中。某个具体槽位下的链表只可能会迁移到新数组的两个槽位中。

hash mod 2^n = k

hash mod 2^(n+1) = k or k+2^n

复制代码

迭代器的选择

除了keys指令使用了安全迭代器,因为结果不允许重复。那还有其它的地方使用了安全迭代器么,什么情况下遍历适合使用非安全迭代器呢?

简单一点说,那就是如果遍历过程中不允许出现重复,那就使用SafeIterator,比如下面的两种情况

bgaofrewrite需要遍历所有对象转换称操作指令进行持久化,绝对不允许出现重复

bgsave也需要遍历所有对象来持久化,同样不允许出现重复

如果遍历过程中需要处理元素过期,需要对字典进行修改,那也必须使用SafeIterator,因为非安全的迭代器是只读的。

其它情况下,也就是允许遍历过程中出现个别元素重复,不需要对字典进行结构性

Java高架构师、分布式架构、高可扩展、高性能、高并发、性能优化、Spring boot、Redis、ActiveMQ、Nginx、Mycat、Netty、Jvm大型分布式项目实战学习架构师视频免费学习加群:835638062 点击链接加入群聊【Java高级架构】:https://jq.qq.com/?_wv=1027&k=5S3kL3v

相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore &nbsp; &nbsp; ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库&nbsp;ECS 实例和一台目标数据库&nbsp;RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&amp;RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
相关文章
|
25天前
|
存储 NoSQL Java
【Redis系列】那有序集合为什么要同时使用字典和跳跃表
面试官问:那有序集合为什么要同时使用字典和跳跃表来实现?我:这个设计主要是考虑了性能因素。1. 如果单纯使用字典,查询的效率很高是O(1),但执行类似ZRANGE、ZRNK时,排序性能低。每次排序需要在内存上对字典进行排序一次,同时消耗了额外的O(n)内存空间
28 1
【Redis系列】那有序集合为什么要同时使用字典和跳跃表
|
6月前
|
缓存 NoSQL 数据建模
【Redis源码】dict字典学习(三)
【Redis源码】dict字典学习(三)
46 0
|
5月前
|
缓存 NoSQL Redis
【Redis 系列】redis 学习十六,redis 字典(map) 及其核心编码结构
【Redis 系列】redis 学习十六,redis 字典(map) 及其核心编码结构
|
21天前
|
存储 NoSQL 算法
【Redis技术进阶之路】「底层源码解析」揭秘高效存储模型与数据结构底层实现(字典)(二)
【Redis技术进阶之路】「底层源码解析」揭秘高效存储模型与数据结构底层实现(字典)
34 0
|
5月前
|
NoSQL 安全 算法
redis6.0源码分析:字典扩容与渐进式rehash
redis6.0源码分析:字典扩容与渐进式rehash
81 0
|
5月前
|
存储 NoSQL Java
Redis源码剖析之字典(dict)
dict中的hashtable在出现hash冲突时采用的是开链方式,如果有多个entry落在同一个bucket中,那么他们就会串成一个单链表存储。
32 0
|
9月前
|
缓存 监控 NoSQL
【Redis 系列】redis 学习十六,redis 字典(map) 及其核心编码结构
redis 是使用 C 语言编写的,但是 C 语言是没有字典这个数据结构的,因此 C 语言自己使用结构体来自定义一个字典结构
|
10月前
|
机器学习/深度学习 NoSQL 算法
Redis的设计与实现(3)-字典
Redis 的数据库使用字典实现, 对数据库的增, 删, 查, 改也是构建在对字典的操作之上的. 字典是哈希键的底层实现之一: 当一个哈希键包含的键值对比较多, 又或者键值对中的元素都是比较长的字符串时, Redis 将会使用字典作为哈希键的底层实现.
53 2
|
10月前
|
NoSQL 算法 Serverless
【Redis基础知识 七】Redis底层数据编码之字典
【Redis基础知识 七】Redis底层数据编码之字典
58 0
|
10月前
|
存储 NoSQL Redis
Redis从入门到精通之底层数据结构字典/哈希表详解
Redis中的字典(Dictionary)是一种高效的数据结构,用于存储键值对,常用于实现哈希表(Hash Table)。在本文中,我们将深入了解Redis中的字典/哈希表,包括字典的结构和操作等。字典/哈希表适合存储大量的键值对,并需要快速地查找键对应的值的场景。在实际应用中,需要根据具体的业务场景选择合适的底层数据结构。例如,如果需要按照键的顺序进行访问,可以使用有序集合(Sorted Set)等其他数据结构。
143 1
Redis从入门到精通之底层数据结构字典/哈希表详解

热门文章

最新文章