zoukankan      html  css  js  c++  java
  • redis系列之------字典

    前言

    字典, 又称符号表(symbol table)、关联数组(associative array)或者映射(map), 是一种用于保存键值对(key-value pair)的抽象数据结构。

    在字典中, 一个键(key)可以和一个值(value)进行关联(或者说将键映射为值), 这些关联的键和值就被称为键值对。

    字典中的每个键都是独一无二的, 程序可以在字典中根据键查找与之关联的值, 或者通过键来更新值, 又或者根据键来删除整个键值对, 等等。

    字典经常作为一种数据结构内置在很多高级编程语言里面, 但 Redis 所使用的 C 语言并没有内置这种数据结构, 因此 Redis 构建了自己的字典实现。

    字典在 Redis 中的应用相当广泛, 比如 Redis 的数据库就是使用字典来作为底层实现的, 对数据库的增、删、查、改操作也是构建在对字典的操作之上的。

    因此,了解字典对我们了解Redis数据库有很大的帮助。同时可以跟Java的HashMap进行对比,看看孰好孰坏。

    字典的定义

     1 typedef struct dict {
     2 
     3     // 类型特定函数
     4     dictType *type;
     5 
     6     // 私有数据
     7     void *privdata;
     8 
     9     // 哈希表
    10     dictht ht[2];
    11 
    12     // rehash 索引
    13     // 当 rehash 不在进行时,值为 -1
    14     int rehashidx; /* rehashing not in progress if rehashidx == -1 */
    15 
    16 } dict;

    主要看ht,和rehashidx两个参数。

    ht 属性是一个包含两个项的数组, 数组中的每个项都是一个 dictht 哈希表, 一般情况下, 字典只使用 ht[0] 哈希表, ht[1] 哈希表只会在对 ht[0] 哈希表进行 rehash 时使用。

    除了 ht[1] 之外, 另一个和 rehash 有关的属性就是 rehashidx : 它记录了 rehash 目前的进度, 如果目前没有在进行 rehash , 那么它的值为 -1 。

     1 typedef struct dictht {
     2 
     3     // 哈希表数组
     4     dictEntry **table;
     5 
     6     // 哈希表大小
     7     unsigned long size;
     8 
     9     // 哈希表大小掩码,用于计算索引值
    10     // 总是等于 size - 1
    11     unsigned long sizemask;
    12 
    13     // 该哈希表已有节点的数量
    14     unsigned long used;
    15 
    16 } dictht;

    table 属性是一个数组, 数组中的每个元素都是一个指向 dict.h/dictEntry 结构的指针, 每个 dictEntry 结构保存着一个键值对。

    size 属性记录了哈希表的大小, 也即是 table 数组的大小

    sizemask 属性的值总是等于 size-1 , 这个属性和哈希值一起决定一个键应该被放到 table 数组的哪个索引上面。(不是很清楚,为什么要单独定义一个mask,而不直接size-1);

    而 used 属性则记录了哈希表目前已有节点(键值对)的数量。

     1 typedef struct dictEntry {
     2 
     3     //
     4     void *key;
     5 
     6     //
     7     union {
     8         void *val;
     9         uint64_t u64;
    10         int64_t s64;
    11     } v;
    12 
    13     // 指向下个哈希表节点,形成链表
    14     struct dictEntry *next;
    15 
    16 } dictEntry;

    key 属性保存着键值对中的键, 而 v 属性则保存着键值对中的值, 其中键值对的值可以是一个指针, 或者是一个 uint64_t 整数, 又或者是一个 int64_t 整数。

    next 属性是指向另一个哈希表节点的指针, 这个指针可以将多个哈希值相同的键值对连接在一次, 以此来解决键冲突(collision)的问题。

    可以明显的看出来,redis是通过链表来解决hash冲突的。

    因此,redis的字典大概如下:

                                                                       

                                       

    Rehash

    随着操作的不断执行, 哈希表保存的键值对会逐渐地增多或者减少, 为了让哈希表的负载因子(load factor)维持在一个合理的范围之内, 当哈希表保存的键值对数量太多或者太少时, 程序需要对哈希表的大小进行相应的扩展或者收缩。

    也就是我们常说的,扩容,再次hash。

    Redis rehash过程:

    • 为字典的 ht[1] 哈希表分配空间。一般为原字典的两倍,即 ht[0] * 2;
    • 将保存在 ht[0] 中的所有键值对 rehash 到 ht[1] 上面
    • 当 ht[0] 包含的所有键值对都迁移到了 ht[1] 之后 (ht[0] 变为空表), 释放 ht[0] , 将 ht[1] 设置为 ht[0] , 并在 ht[1] 新创建一个空白哈希表, 为下一次 rehash 做准备。

    但其实rehash是非常的耗时间的。假设ht[0]非常的大呢? 40W,400W,甚至4000W呢?

    一次rehash甚至可能导致redis宕机,所以出现了渐进式hash。

    渐进式Rehash

    这个 rehash 动作并不是一次性、集中式地完成的, 而是分多次、渐进式地完成的。为了避免 rehash 对服务器性能造成影响, 服务器不是一次性将 ht[0] 里面的所有键值对全部 rehash 到 ht[1] , 而是分多次、渐进式地将 ht[0] 里面的键值对慢慢地 rehash 到 ht[1] 。

    • 为 ht[1] 分配空间, 让字典同时持有 ht[0] 和 ht[1] 两个哈希表。
    • 在字典中维持一个索引计数器变量 rehashidx , 并将它的值设置为 0 , 表示 rehash 工作正式开始。
    • 在 rehash 进行期间, 每次对字典执行添加、删除、查找或者更新操作时, 程序除了执行指定的操作以外, 还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1] , 当 rehash 工作完成之后, 程序将 rehashidx 属性的值增一。
    • 随着字典操作的不断执行, 最终在某个时间点上, ht[0] 的所有键值对都会被 rehash 至 ht[1] , 这时程序将 rehashidx 属性的值设为 -1 , 表示 rehash 操作已完成。

    扩容代码大致如下:

     1 int dictRehash(dict *d, int n) {
     2     int empty_visits = n*10; /* Max number of empty buckets to visit. */
     3 
     4     // 判断是否正在扩容
     5     if (!dictIsRehashing(d)) return 0;
     6 
     7     while(n-- && d->ht[0].used != 0) {
     8         dictEntry *de, *nextde;
     9 
    10         /* Note that rehashidx can't overflow as we are sure there are more
    11          * elements because ht[0].used != 0 */
    12         assert(d->ht[0].size > (unsigned long)d->rehashidx);
    13 
    14         // 找到一个不为空的桶,进行迁移
    15         while(d->ht[0].table[d->rehashidx] == NULL) {
    16             d->rehashidx++;
    17             if (--empty_visits == 0) return 1;
    18         }
    19         // 找到这个桶第一个指针节点
    20         de = d->ht[0].table[d->rehashidx];
    21         // 将这个桶中的所有的key节点转移到新的数组中。while循环链表
    22         while(de) {
    23             uint64_t h;
    24 
    25             nextde = de->next;
    26             /* Get the index in the new hash table */
    27             h = dictHashKey(d, de->key) & d->ht[1].sizemask;
    28             de->next = d->ht[1].table[h];
    29             d->ht[1].table[h] = de;
    30             d->ht[0].used--;
    31             d->ht[1].used++;
    32             de = nextde;
    33         }
    34         // 旧的桶节点置为null,并且rehashidx+1
    35         d->ht[0].table[d->rehashidx] = NULL;
    36         d->rehashidx++;
    37     }
    38 
    39     /* Check if we already rehashed the whole table... */
    40     if (d->ht[0].used == 0) {
    41         zfree(d->ht[0].table);
    42         d->ht[0] = d->ht[1];
    43         _dictReset(&d->ht[1]);
    44         d->rehashidx = -1;
    45         return 0;
    46     }
    47 
    48     /* More to rehash... */
    49     return 1;
    50 }

    在进行渐进式 rehash 的过程中, 字典会同时使用 ht[0] 和 ht[1] 两个哈希表, 所以在渐进式 rehash 进行期间, 字典的删除(delete)、查找(find)、更新(update)等操作会在两个哈希表上进行: 比如说, 要在字典里面查找一个键的话, 程序会先在 ht[0]里面进行查找, 如果没找到的话, 就会继续到 ht[1] 里面进行查找, 诸如此类。

    另外, 在渐进式 rehash 执行期间, 新添加到字典的键值对一律会被保存到 ht[1] 里面, 而 ht[0] 则不再进行任何添加操作: 这一措施保证了 ht[0] 包含的键值对数量会只减不增, 并随着 rehash 操作的执行而最终变成空表。

    所遇到问提

    问题一:

    要在字典里面查找一个键的话, 程序会先在 ht[0]里面进行查找, 如果没找到的话, 就会继续到 ht[1] 里面进行查找, 诸如此类。

    这一点其实我比较有疑惑?为何要先去ht[0]中查找,找不到再去ht[1]中查找,这样岂不是效率低下,查找了两张表。不能根据hash值和rehashidx进行比较判断么?只查一张表不行么?

    为此我翻阅了不少资料:

    这是redis官方其他人的pull request:https://github.com/antirez/redis/pull/5692    暂时还没有merge

    同时这是我和一位大佬的讨论:https://github.com/Junnplus/blog/issues/35   最终得出结论

    还是看源码:(源码真是一个好东西)

     1 for (table = 0; table <= 1; table++) {
     2     // 找到key的index
     3     idx = h & d->ht[table].sizemask;
     4     // 拿到key值对应的value
     5     he = d->ht[table].table[idx];
     6     while(he) {
     7         if (key==he->key || dictCompareKeys(d, key, he->key))
     8             return he;
     9         he = he->next;
    10     }
    11     if (!dictIsRehashing(d)) return NULL;
    12 }

    其实只有两种情况

    • key在ht[0],还没有迁移
    • key在ht[1],已经迁移了。

    针对第一种情况,他在第一层的循环已经找到了key值,并且返回(第八行),不再继续后面操作,因此不存在效率问题。

    第二种情况,看第五行,he此时为null,根本不会循环链表。然后第二次循环才能找到key。而第一次是做了一次hash,复杂度为O(1)。效率几乎是没有损失,因此也不存在效率问题。

    综上:我得出的结论是。可以拿idx和rehashidx进行对比,同时也可以像redis这样写,不会损失效率。redis可能为了代码的简洁以及统一,不想写那么多的判断条件,因此没有比较idx和rehashidx。

    当我认为提前结束会更好,毕竟不用后续判断了,也比较清楚。类似这个request:https://github.com/antirez/redis/pull/5692/files

    问题二:

    假如在redis准备进行rehash的时候,他需要为ht[1]申请一块内存,这块内存可是ht[0]的两倍哦,那次是计算机内存不存会如何?

    梳理一下哈希表大小和内存申请大小的对应关系:

                                                                             

    正常状态下,redis为ht[1]分配完内存后,会持续一段时间进行rehash。rehash完毕后,就会释放ht[0]内存了。类似如下图:

    内存先上升,后下降。

    但此时内存不存的话,Redis会进行大量的Key驱逐,也就是会淘汰掉很久未使用的key,跟LRU有点类似。

    那么此时可能就会影响到了业务,这是非常大的问题呢。

    那么针对在Redis满容驱逐状态下,如何避免因Rehash而导致Redis抖动的这种问题。

    • 我们在Redis Rehash源码实现的逻辑上,加上了一个判断条件,如果现有的剩余内存不够触发Rehash操作所需申请的内存大小,即不进行Resize操作;
    • 通过提前运营进行规避,比如容量预估时将Rehash占用的内存考虑在内,或者通过监控定时扩容。

    参考文献:

    《redis设计与实现》  http://redisbook.com/preview/dict/incremental_rehashing.html

    《美团针对Redis Rehash机制的探索和实践》  https://tech.meituan.com/2018/07/27/redis-rehash-practice-optimization.html

    《Redis源码分析》  https://github.com/Junnplus/blog/issues/35

  • 相关阅读:
    arcgis要素转json
    GeoJSON.js:221 Uncaught Error: Invalid GeoJSON object.
    深入学习SAP UI5框架代码系列之一:UI5 Module的懒加载机制
    SAP Spartacus简介
    金庸逝世两周年纪念日:一个失意程序员的呓语
    一个用于SAP UI5学习的脚手架应用,没有任何后台API的依赖
    通过最简单的button控件,深入学习SAP UI5框架代码系列之零
    如何使用SAP UI5 SDK网站查询指定控件的属性如何使用
    一行代码将SAP CDS view数据以ALV的方式输出
    演讲预告:一个月的住院经历,我悟到了哪些和程序员职场发展相关的心得
  • 原文地址:https://www.cnblogs.com/alannever/p/14140763.html
Copyright © 2011-2022 走看看