35-源码 2:循序渐进 —— 探索「字典」内部
课程
1
开篇:授人以鱼不若授人以渔 —— Redis 可以用来做什么?
学习时长: 5分21秒
2
基础:万丈高楼平地起 —— Redis 基础数据结构
上次学到
学习时长: 16分14秒
3
应用 1:千帆竞发 —— 分布式锁
学习时长: 7分47秒
4
应用 2:缓兵之计 —— 延时队列
学习时长: 8分9秒
5
应用 3:节衣缩食 —— 位图
学习时长: 8分52秒
6
应用 4:四两拨千斤 —— HyperLogLog
学习时长: 14分17秒
7
应用 5:层峦叠嶂 —— 布隆过滤器
学习时长: 17分54秒
8
应用 6:断尾求生 —— 简单限流
学习时长: 4分37秒
9
应用 7:一毛不拔 —— 漏斗限流
学习时长: 7分22秒
10
应用 8:近水楼台 —— GeoHash
学习时长: 7分52秒
11
应用 9:大海捞针 —— Scan
学习时长: 8分42秒
12
原理 1:鞭辟入里 —— 线程 IO 模型
学习时长: 4分1秒
13
原理 2:交头接耳 —— 通信协议
学习时长: 3分34秒
14
原理 3:未雨绸缪 —— 持久化
学习时长: 5分27秒
15
原理 4:雷厉风行 —— 管道
学习时长: 3分51秒
16
原理 5:同舟共济 —— 事务
学习时长: 6分36秒
17
原理 6:小道消息 —— PubSub
学习时长: 7分7秒
18
原理 7:开源节流 —— 小对象压缩
学习时长: 7分14秒
19
原理 8:有备无患 —— 主从同步
学习时长: 4分9秒
20
集群 1:李代桃僵 —— Sentinel
学习时长: 3分52秒
21
集群 2:分而治之 —— Codis
学习时长: 7分28秒
22
集群 3:众志成城 —— Cluster
学习时长: 8分38秒
23
拓展 1:耳听八方 —— Stream
学习时长: 13分40秒
24
拓展 2:无所不知 —— Info 指令
学习时长: 4分4秒
25
拓展 3:拾遗补漏 —— 再谈分布式锁
学习时长: 2分18秒
26
拓展 4:朝生暮死 —— 过期策略
学习时长: 2分21秒
27
拓展 5:优胜劣汰 —— LRU
学习时长: 4分34秒
28
拓展 6:平波缓进 —— 懒惰删除
学习时长: 2分13秒
29
拓展 7:妙手仁心 —— 优雅地使用 Jedis
学习时长: 6分35秒
30
拓展 8:居安思危 —— 保护 Redis
学习时长: 2分19秒
31
拓展 9:隔墙有耳 —— Redis 安全通信
学习时长: 6分34秒
32
拓展 10:法力无边 —— Redis Lua 脚本执行原理
学习时长: 9分24秒
33
拓展 11:短小精悍 —— 命令行工具的妙用
学习时长: 9分21秒
34
源码 1:丝分缕析 —— 探索「字符串」内部
学习时长: 5分20秒
35
源码 2:循序渐进 —— 探索「字典」内部
学习时长: 7分24秒
36
源码 3:挨肩迭背 —— 探索「压缩列表」内部
学习时长: 10分42秒
37
源码 4:风驰电掣 —— 探索「快速列表」内部
学习时长: 3分49秒
38
源码 5:凌波微步 —— 探索「跳跃列表」内部
学习时长: 9分57秒
39
源码 6:破旧立新 —— 探索「紧凑列表」内部
学习时长: 2分42秒
40
源码 7:金枝玉叶 —— 探索「基数树」内部
学习时长: 5分36秒
41
源码 8:精益求精 —— LFU vs LRU
学习时长: 8分4秒
42
源码 9:如履薄冰 —— 懒惰删除的巨大牺牲
学习时长: 9分53秒
43
源码 10:跋山涉水 —— 深入字典遍历
学习时长: 9分24秒
44
源码 11:见缝插针 —— 探索 HyperLogLog 内部
学习时长: 13分3秒
45
尾声:百尺竿头 —— 继续深造指南
学习时长: 2分32秒
juejin_logo copyCreated with Sketch.

源码 2:循序渐进 —— 探索「字典」内部

dict 是 Redis 服务器中出现最为频繁的复合型数据结构,除了 hash 结构的数据会用到字典外,整个 Redis 数据库的所有 key 和 value 也组成了一个全局字典,还有带过期时间的 key 集合也是一个字典。zset 集合中存储 value 和 score 值的映射关系也是通过 dict 结构实现的。

struct RedisDb {
    dict* dict; // all keys  key=>value
    dict* expires; // all expired keys key=>long(timestamp)
    ...
}

struct zset {
    dict *dict; // all values  value=>score
    zskiplist *zsl;
}

dict 内部结构

dict 结构内部包含两个 hashtable,通常情况下只有一个 hashtable 是有值的。但是在 dict 扩容缩容时,需要分配新的 hashtable,然后进行渐进式搬迁,这时候两个 hashtable 存储的分别是旧的 hashtable 和新的 hashtable。待搬迁结束后,旧的 hashtable 被删除,新的 hashtable 取而代之。

struct dict {
    ...
    dictht ht[2];
}

所以,字典数据结构的精华就落在了 hashtable 结构上了。hashtable 的结构和 Java 的 HashMap 几乎是一样的,都是通过分桶的方式解决 hash 冲突。第一维是数组,第二维是链表。数组中存储的是第二维链表的第一个元素的指针。

struct dictEntry {
    void* key;
    void* val;
    dictEntry* next; // 链接下一个 entry
}
struct dictht {
    dictEntry** table; // 二维
    long size; // 第一维数组的长度
    long used; // hash 表中的元素个数
    ...
}

渐进式rehash

大字典的扩容是比较耗时间的,需要重新申请新的数组,然后将旧字典所有链表中的元素重新挂接到新的数组下面,这是一个O(n)级别的操作,作为单线程的Redis表示很难承受这样耗时的过程。步子迈大了会扯着蛋,所以Redis使用渐进式rehash小步搬迁。虽然慢一点,但是肯定可以搬完。

dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing)
{
    long index;
    dictEntry *entry;
    dictht *ht;

    // 这里进行小步搬迁
    if (dictIsRehashing(d)) _dictRehashStep(d);

    /* Get the index of the new element, or -1 if
     * the element already exists. */
    if ((index = _dictKeyIndex(d, key, dictHashKey(d,key), existing)) == -1)
        return NULL;

    /* Allocate the memory and store the new entry.
     * Insert the element in top, with the assumption that in a database
     * system it is more likely that recently added entries are accessed
     * more frequently. */
    // 如果字典处于搬迁过程中,要将新的元素挂接到新的数组下面
    ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];
    entry = zmalloc(sizeof(*entry));
    entry->next = ht->table[index];
    ht->table[index] = entry;
    ht->used++;

    /* Set the hash entry fields. */
    dictSetKey(d, entry, key);
    return entry;
}

搬迁操作埋伏在当前字典的后续指令中(来自客户端的hset/hdel指令等),但是有可能客户端闲下来了,没有了后续指令来触发这个搬迁,那么Redis就置之不理了么?当然不会,优雅的Redis怎么可能设计的这样潦草。Redis还会在定时任务中对字典进行主动搬迁。

// 服务器定时任务
void databaseCron() {
    ...
    if (server.activerehashing) {
        for (j = 0; j < dbs_per_call; j++) {
            int work_done = incrementallyRehash(rehash_db);
            if (work_done) {
                /* If the function did some work, stop here, we'll do
                 * more at the next cron loop. */
                break;
            } else {
                /* If this db didn't need rehash, we'll try the next one. */
                rehash_db++;
                rehash_db %= server.dbnum;
            }
        }
    }
}

查找过程

插入和删除操作都依赖于查找,先必须把元素找到,才可以进行数据结构的修改操作。hashtable 的元素是在第二维的链表上,所以首先我们得想办法定位出元素在哪个链表上。

func get(key) {
    let index = hash_func(key) % size;
    let entry = table[index];
    while(entry != NULL) {
        if entry.key == target {
            return entry.value;
        }
        entry = entry.next;
    }
}

值得注意的是代码中的hash_func,它会将 key 映射为一个整数,不同的 key 会被映射成分布比较均匀散乱的整数。只有 hash 值均匀了,整个 hashtable 才是平衡的,所有的二维链表的长度就不会差距很远,查找算法的性能也就比较稳定。

hash 函数

hashtable 的性能好不好完全取决于 hash 函数的质量。hash 函数如果可以将 key 打散的比较均匀,那么这个 hash 函数就是个好函数。Redis 的字典默认的 hash 函数是 siphash。siphash 算法即使在输入 key 很小的情况下,也可以产生随机性特别好的输出,而且它的性能也非常突出。对于 Redis 这样的单线程来说,字典数据结构如此普遍,字典操作也会非常频繁,hash 函数自然也是越快越好。

hash 攻击

如果 hash 函数存在偏向性,黑客就可能利用这种偏向性对服务器进行攻击。存在偏向性的 hash 函数在特定模式下的输入会导致 hash 第二维链表长度极为不均匀,甚至所有的元素都集中到个别链表中,直接导致查找效率急剧下降,从O(1)退化到O(n)。有限的服务器计算能力将会被 hashtable 的查找效率彻底拖垮。这就是所谓 hash 攻击。

扩容条件

/* Expand the hash table if needed */
static int _dictExpandIfNeeded(dict *d)
{
    /* Incremental rehashing already in progress. Return. */
    if (dictIsRehashing(d)) return DICT_OK;

    /* If the hash table is empty expand it to the initial size. */
    if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);

    /* If we reached the 1:1 ratio, and we are allowed to resize the hash
     * table (global setting) or we should avoid it but the ratio between
     * elements/buckets is over the "safe" threshold, we resize doubling
     * the number of buckets. */
    if (d->ht[0].used >= d->ht[0].size &&
        (dict_can_resize ||
         d->ht[0].used/d->ht[0].size > dict_force_resize_ratio))
    {
        return dictExpand(d, d->ht[0].used*2);
    }
    return DICT_OK;
}

正常情况下,当 hash 表中元素的个数等于第一维数组的长度时,就会开始扩容,扩容的新数组是原数组大小的 2 倍。不过如果 Redis 正在做 bgsave,为了减少内存页的过多分离 (Copy On Write),Redis 尽量不去扩容 (dict_can_resize),但是如果 hash 表已经非常满了,元素的个数已经达到了第一维数组长度的 5 倍 (dict_force_resize_ratio),说明 hash 表已经过于拥挤了,这个时候就会强制扩容。

缩容条件

int htNeedsResize(dict *dict) {
    long long size, used;

    size = dictSlots(dict);
    used = dictSize(dict);
    return (size > DICT_HT_INITIAL_SIZE &&
            (used*100/size < HASHTABLE_MIN_FILL));
}

当 hash 表因为元素的逐渐删除变得越来越稀疏时,Redis 会对 hash 表进行缩容来减少 hash 表的第一维数组空间占用。缩容的条件是元素个数低于数组长度的 10%。缩容不会考虑 Redis 是否正在做 bgsave。

set 的结构

Redis 里面 set 的结构底层实现也是字典,只不过所有的 value 都是 NULL,其它的特性和字典一模一样。

思考

  1. 为什么缩容不用考虑 bgsave?
  2. Java 语言和 Python 语言内置的 set 容器是如何实现的?
留言
Ctrl + Enter
全部评论(57)
BinK_1783的头像
删除
未来会好到无法想象
点赞
回复
FitzBogard的头像
删除
服务端开发 @ 家里蹲
感觉光看小册,看的不是太明白,还有什么配套的doc可以看吗
1
回复
ji_haha的头像
删除
后端开发
小册这写的,真的太浅了
8
回复
春天百花开的头像
删除
��钱后续的章节态度没前面好了,很多内容讲的很浅而且章节排版也不连串,许多篇章让读者看了还可能一头雾水,然后读者再去找
资料去看,问题老钱也很少回复过,老钱洞主,加油鸭。
还有一个比较重要的建议:
内容大部分是,"原作是这样写的" ,作为一个资深程序员我觉得更多应该思考为什么这么做,而不是这样做就交工了,也许我的要求
过高了,还是感谢老钱的文章。
问题:
Redis扩容条件是什么? 即临界点
当元素个数和容量达到1:1,则开始扩容(如果正在执行bgsave、bgaof则不执行,如果达到1:5则强制执行。)
哪些指令会触发迁移?
对元素的新增、修改、删除操作都会触发扩容,但这个扩容是将对应桶的数据迁移到新hashtable中。
Redis扩容是只针对某个db还是所有db都扩容? 单个db扩容
扩容时新增、查询、修改、删除操作该如何定向?
新增:当新增元素时会将元素迁移到新的hashtable中
查询、删除、修改:先查询 ht[0] ,若存在则迁移这个key所在的桶并返回元素,若不存在则到ht[1]中查找元素。
如何扩容的?会开启异步线程执行渐进式扩容操作。
扩容结束标识?当旧hashtable中无元素时则代表迁移完成,会将ht[0] 和ht[1]的引用交换。
Redis扩容是double的,那么在扩容时Redis的内存占用率是原先的三倍吗?
不是的,因为hashtable内部数组存储的value是hash函数计算随机值,所以在扩容时只是对数组扩容了,但并没有分配对应K-V占用的内存空间。迁移时也只是将元素的引用地址做变更。
为什么缩容不用考虑 bgsave?
因为COW的特性是当内存页上的数据被修改时会复制一页做修改,如果删除操作并不会触发删除内存页的数据,操作系统回收内存机制导致的。
Java 语言和 P
展开
66
1
删除
java的set底层使用了HashMap,value为常量java对象:
// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();
add时:
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
展开
点赞
回复
沙鸥_的头像
删除
民工
这里我想问一下,在链表中查询数据,时间复杂度是O(n),这里查询有做优化吗?
点赞
1
删除
链表的特征是前后连接,又没有排序,顶多加一个头尾中三个节点
点赞
回复
L N W的头像
删除
查找过程中,字典不是有两个hashtable吗,是不是会先在第一个找,第一个找不到就到第二个找
点赞
1
删除
第一个里面才有值h[0],h[1]扩容时候会有数据,并且优先查找h[0],查不到再去h[1],扩容过程中,新来的数据直接插入到h[1]
点赞
回复
MaWenlong1的头像
删除
java
dictEntry** table;这个代表的是一维数组吧?我看注释写的是“二维”
点赞
回复
长尾巴的大灰狼的头像
删除
redis的hashmap可以指定大小,避免多次rehash吗?就像java一样。比如我已经知道这个key值我要插一百万的数据进去
点赞
回复
LHB君的头像
删除
原因应该是缩容不需要rehash吧
原本槽位0-7,缩为0-3,只需要把槽位4-7的链表分别拼接到0-3下面,只要一维数组realloc一下
1
回复
小渣同学的头像
删除
高级工程师 @ 某咸鱼国企
缩容是因为数据太少,不会影响性能,才不用考虑bgsave?
点赞
回复
贝氏小马的头像
删除
看源码bgsave的时候也不能缩容吧
1
回复
亿尾的头像
删除
侦察地税工程师
我觉得是否考虑bgsave这个问题,5000缩到1000也比100扩容到500多元素啊,不能说是元素多少的问题吧,应该看的是这个dict当前的情况。如果是扩容,那么说明当前的dict可能是个热数据,插入数据会比较频繁,那么cow出来的那部分是不是会比原数据占用大?那么插入数据越多,内存的占用就越大,那这时候再加上新分配的hashtable,占用的内存是原来多少倍不好说。那再说说缩容,假如缩容的这时是个插入频繁的热数据,那么在缩容中发现元素的个数达不到缩容的条件,是否会停止?假如是个删除修改频繁的热数据,那么cow出来的内存占用是会在可控范围内?假如是个冷数据,那么cow出来的部分是否只占用很少的内存?
展开
5
回复
graper的头像
删除
rehash为什么要用指令触发和跑定时任务? 为什么不直接跑定时任务啊?
点赞
1
删除
是想吧整个过程更加分散在命令的执行中吧,更少的影响单个命令的响应。
点赞
回复
N_J_N的头像
删除
缩容是,不会马上释放内存,按文章前文中说的惰性释放内存内容,避免redis卡顿,也同样避免了cow,所以缩容不必考虑bgsave
2
回复
LzeroFong的头像
删除
服务器定时任务那里,对字典主动搬迁,似乎是针对整一个redis db的K-V。对于hash类型的值,是只能在每次指令操作时 触发?
点赞
回复
鱼乐就是我的头像
删除
redis设计与实现中讲到MurMurHash2为字典的默认hash函数
点赞
1
删除
SipHash 哈希算法是在 Redis 4.0 才开始使用的,
3.0-4.0 使用的是 MurmurHash2 哈希算法,
3.0 之前是 DJBX33A 哈希算法;
1
回复
北落师门重名了55433的头像
删除
如果一次rehash没有完全结束的时候又发生一次rehash那么存在几个hashtable?
1
2
删除
一般的程序会在做这种操作时定义一个变量,rehash会首先判断这个变量,如果发现正在做rehash操作,当前的rehash是不会执行的
1
回复
删除
这个情况不会发生吧,要触发下一次rehash就意味着又有开始扩容那么多数据插入了,而这个时候依靠渐进式hash,所有的key已经迁移完成。不存在这一次未完成,又发生下一次。
点赞
回复
amazing0218的头像
删除
大数据开发工程师 @ 腾讯音乐
在Java里面set的只是接口,具体实现有多种,自然使用的数据结构也不一样,比如HashSet,TreeSet等
1
回复
(+曦+)的头像
删除
扩容必须要分配多余的空间,意味着数据分成两份,但是缩容可以再原来的基础上缩小。数据不会被分成两份自然不容考虑cow
点赞
3
删除
cow什么意思
点赞
回复
删除
copy on write
cow什么意思
点赞
回复
查看更多回复
肉面肥龙的头像
删除
后台 @ 一间公司
我的疑问是,如果出现了缩容,会缩为多大,是否会在缩容后,因为一个新的key增加而导致马上出现一个扩容,因为“正常情况下,当 hash 表中元素的个数等于第一维数组的长度时,就会开始扩容,扩容的新数组是原数组大小的 2 倍。”
点赞
3
删除
缩容后再新增一个key,一般不会出现立即扩容。原因如下:初始容量2N,当低于10% * 2N 时缩容,缩容后容量为N,此时要在扩容,要满足10% * 2N + 1 = N,求得N=1.25,但一般N >2 吧。
点赞
回复
删除
缩容时空间大小为 能包含 used 的 2^N
点赞
回复
查看更多回复

查看全部 57 条回复