接着上一章最后抛出的疑问,我们来分析redis的hash数据结构。

1.存储类型

image.png
hash是一种包含键值对的无序散列表数据结构,value只能是字符串,不能嵌套其他类型。

同样是存储字符串,hash与string的主要区别在于:

  1. 把所有相关的值聚焦到一个key中,节省内存空间
  2. 只使用一个key,减少key冲突
  3. 当需要批量获取值的时候,只需要使用一个命令,减少内存 IO CPU的消耗

hash不适合的场景包括:

  1. field不能单独设置过期时间
  2. 没有bit操作
  3. 需要考虑数据量分布的问题(value值特别大的时候,无法分不到多个节点)

2.存储原理

Redis 的 Hash 本身也是一个 KV 的结构,类似于 Java 中的 HashMap。外层的哈希(Redis KV 的实现)只用到了 hashtable。当存储 hash 数据类型时,我们把它叫做内层的哈希。内层的哈希底层可以使用两种数据结构实现。

  1. ziplist:OBJ_ENCODING_ZIPLIST(压缩列表)
  2. hashtable:OBJ_ENCODING_HT(哈希表)

2.1 压缩列表

ziplist 是一个经过特殊编码的双向链表,它不存储指向上一个链表节点和指向下一个链表节点的指针,而是存储上一个节点长度和当前节点长度,通过牺牲部分读写性能,来换取高效的内存空间利用率,是一种时间换空间的思想。只用在字段个数少,字段值小的场景里面。

  1. The general layout of the ziplist is as follows:
  2. <zlbytes> <zltail> <zllen> <entry> <entry> ... <entry> <zlend>
  1. typedef struct zlentry {
  2. unsigned int prevrawlensize; /* 上一个链表节点占用的长度 */
  3. unsigned int prevrawlen; /* 存储上一个链表节点的长度数值所需要的字节数 */
  4. unsigned int lensize; /* 存储当前链表节点长度数值所需要的字节数 */
  5. unsigned int len; /* 当前链表节点占用的长度 */
  6. unsigned int headersize; /* 当前链表节点的头部大小(prevrawlensize + lensize),即非数据域的大小 */
  7. unsigned char encoding; /* 编码方式 */
  8. unsigned char *p; /* 压缩链表以字符串的形式保存,该指针指向当前节点起始位置 */
  9. } zlentry;

zlentry结构其实没啥用,仅仅用来作为接收数据的,实际上的使用要看源码注释。

image.png
当 hash 对象同时满足以下两个条件的时候,使用 ziplist 编码:

  1. 所有的键值对的健和值的字符串长度都小于等于 64byte(一个英文字母一个字节);

  2. 哈希对象保存的键值对数量小于 512 个。

接下来看一下源码:

首先在redis.conf配置文件中我们可以对哈希底层编码的转换阈值进行配置。

  1. # Hashes are encoded using a memory efficient data structure when they have a
  2. # small number of entries, and the biggest entry does not exceed a given
  3. # threshold. These thresholds can be configured using the following directives.
  4. # ziplist 中最多能存放的 entry 节点数量
  5. hash-max-ziplist-entries 512
  6. # ziplist 中最大能存放的值长度
  7. hash-max-ziplist-value 64
  1. if (hashTypeLength(o) > server.hash_max_ziplist_entries)
  2. hashTypeConvert(o, OBJ_ENCODING_HT);
  1. void hashTypeTryConversion(robj *o, robj **argv, int start, int end) {
  2. int i;
  3. if (o->encoding != OBJ_ENCODING_ZIPLIST) return;
  4. for (i = start; i <= end; i++) {
  5. if (sdsEncodedObject(argv[i]) &&sdslen(argv[i]->ptr) > server.hash_max_ziplist_value){
  6. hashTypeConvert(o, OBJ_ENCODING_HT);
  7. break;
  8. }
  9. }
  10. }

一个哈希对象超过配置的阈值(键和值的长度有>64byte,键值对个数>512 个)时,会转换成哈希表(hashtable)。

2.2 hashTable(dict)

在 Redis 中,hashtable 被称为字典(dictionary),它是一个数组+链表的结构。Redis 的 KV 结构是通过一个 dictEntry 来实现的。Redis 又对 dictEntry 进行了多层的封装。key 是键值对中的键,v 是键值对中的值,它是一个联合类型,方便存储各种结构;next 是链表指针,指向下一个哈希表节点,他将多个哈希值相同的键值对串联在一起,用于解决键冲突,也就是所谓的链地址法。

  1. typedef struct dictEntry {
  2. void *key; /* key 关键字定义 */
  3. union {
  4. void *val; /* value 定义 */
  5. uint64_t u64;
  6. int64_t s64;
  7. double d;
  8. } v;
  9. struct dictEntry *next; /* 指向下一个键值对节点 */
  10. } dictEntry;

dictEntry 放到了 dictht(hashtable 里面):

  1. typedef struct dictht {
  2. dictEntry **table; /* 哈希表数组 */
  3. unsigned long size; /* 哈希表大小 */
  4. unsigned long sizemask; /* 掩码大小,用于计算索引值。总是等于 size-1 */
  5. unsigned long used; /* 已有节点数 */
  6. } dictht;

dictht放到了 dict 里面:

  • type 是一个指向 dict.h/dictType 结构的指针,保存了一系列用于操作特定类型键值对的函数
  • privdata 保存了需要传给上述特定函数的可选参数
  • ht 是两个哈希表,一般情况下,只使用ht[0],只有当哈希表的键值对数量超过负载(元素过多)时,才会将键值对迁移到ht[1],这一步迁移被称为 rehash (重哈希),rehash 一会儿我会带领大家看源码
  • rehashidx 由于哈希表键值对有可能很多很多,所以 rehash 不是瞬间完成的,需要按部就班,那么 rehashidx 就记录了当前 rehash 的进度,当 rehash 完毕后,将 rehashidx 置为-1
  1. typedef struct dict {
  2. dictType *type; /* 字典类型 */
  3. void *privdata; /* 私有数据 */
  4. dictht ht[2]; /* 一个字典有两个哈希表 */
  5. long rehashidx; /* rehash 索引 */
  6. unsigned long iterators; /* 当前正在使用的迭代器数量 */
  7. } dict;

从最底层到最高层 dictEntry->dictht->dict->OBJ_ENCODING_HT

关于类型处理函数:有点类似Java中的接口或者抽象类,在dictType中做了一个声明。

  1. typedef struct dictType {
  2. //计算哈希值
  3. uint64_t (*hashFunction)(const void *key);
  4. //copy key
  5. void *(*keyDup)(void *privdata, const void *key);
  6. //copy value
  7. void *(*valDup)(void *privdata, const void *obj);
  8. // compare key
  9. int (*keyCompare)(void *privdata, const void *key1, const void *key2);
  10. // 销毁键
  11. void (*keyDestructor)(void *privdata, void *key);
  12. //销毁值
  13. void (*valDestructor)(void *privdata, void *obj);
  14. int (*expandAllowed)(size_t moreMem, double usedRatio);
  15. } dictType;

总结一下hash表的存储结构:
image.png

dicth 后面是 NULL 说明第二个 dicth 还没用到。dictEntry*后面是 NULL 说明没有 hash 到这个地址。dictEntry 后面是NULL 说明没有发生哈希冲突。

3.hash函数

看完了hash的数据结构,我们来看一看Redis中对于hash函数的实现。

再此,我在前面先唠叨下,hash函数的基本概念。hash函数主要就是输入原内容,然后经过一系列运算之后,可以计算出一个值,这个值决定了该元素在hash表中的存放位置。

  1. 同一个key不论第几次传入hash函数,输出的结果应该是一样的。如果两次调用hash函数的结果不一致,那这个键就丢了(找不到),那说明hash函数式错误的。
  2. 另外,因为实现hash表的空间是有限的,所以我们要尽量让key更加均匀的分布。

接下来我们来看一下Redis中对于hash函数的实现,其实现在siphash.c。

  1. uint64_t siphash(const uint8_t *in, const size_t inlen, const uint8_t *k) {
  2. #ifndef UNALIGNED_LE_CPU
  3. uint64_t hash;
  4. uint8_t *out = (uint8_t*) &hash;
  5. #endif
  6. //1.初始化
  7. //1.1 初始化4个向量 v0 v1 v2 v3
  8. uint64_t v0 = 0x736f6d6570736575ULL;
  9. uint64_t v1 = 0x646f72616e646f6dULL;
  10. uint64_t v2 = 0x6c7967656e657261ULL;
  11. uint64_t v3 = 0x7465646279746573ULL;
  12. //1.2 将128位的key用little-endian(较小的字节在低位)编码为64位的k0,k1
  13. uint64_t k0 = U8TO64_LE(k);
  14. uint64_t k1 = U8TO64_LE(k + 8);
  15. uint64_t m;
  16. const uint8_t *end = in + inlen - (inlen % sizeof(uint64_t));
  17. const int left = inlen & 7;
  18. uint64_t b = ((uint64_t)inlen) << 56;
  19. //1.3 用k0,k1初始化v0,v1,v2,v3
  20. v3 ^= k1;
  21. v2 ^= k0;
  22. v1 ^= k1;
  23. v0 ^= k0;
  24. // 2. 压缩
  25. for (; in != end; in += 8) {
  26. //2.1 split message
  27. //#define U8TO64_LE(p) (*((uint64_t*)(p)))
  28. m = U8TO64_LE(in);
  29. v3 ^= m;
  30. SIPROUND;
  31. v0 ^= m;
  32. }
  33. switch (left) {
  34. case 7: b |= ((uint64_t)in[6]) << 48; /* fall-thru */
  35. case 6: b |= ((uint64_t)in[5]) << 40; /* fall-thru */
  36. case 5: b |= ((uint64_t)in[4]) << 32; /* fall-thru */
  37. case 4: b |= ((uint64_t)in[3]) << 24; /* fall-thru */
  38. case 3: b |= ((uint64_t)in[2]) << 16; /* fall-thru */
  39. case 2: b |= ((uint64_t)in[1]) << 8; /* fall-thru */
  40. case 1: b |= ((uint64_t)in[0]); break;
  41. case 0: break;
  42. }
  43. v3 ^= b;
  44. SIPROUND;
  45. v0 ^= b;
  46. //结束
  47. v2 ^= 0xff;
  48. SIPROUND;
  49. SIPROUND;
  50. //得到最终结果
  51. b = v0 ^ v1 ^ v2 ^ v3;
  52. #ifndef UNALIGNED_LE_CPU
  53. U64TO8_LE(out, b);
  54. return hash;
  55. #else
  56. return b;
  57. #endif
  58. }

由此可见Redis底层使用的hash算法是SipHash 算法,在本文中不作为重点内容介绍,如果有兴趣的同学可以自行了解。当然了我们也可以在回顾下,Java中的HashMap的hash算法。

  1. static final int hash(Object key) {
  2. int h;
  3. return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
  4. }

如果key==null,hash值就是0,hash值就等于key的hash值^h无符号右移16位。异或:相同则返回0,不同返回1。这就是哈希map底层使用的扰动,为了让高16位也参与运算。防止哈希冲突。

4.哈希冲突

hash冲突一定是发生在添加元素的时候,所以我们直接去看添加元素的过程。

  1. int dictAdd(dict *d, void *key, void *val) {
  2. dictEntry *entry = dictAddRaw(d, key, NULL);
  3. if (!entry) return DICT_ERR;
  4. dictSetVal(d, entry, val);
  5. return DICT_OK;
  6. }

我们接着往下看

  1. dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing) {
  2. ...
  3. if (dictIsRehashing(d)) _dictRehashStep(d);
  4. if ((index = _dictKeyIndex(d, key, dictHashKey(d, key), existing)) == -1)
  5. return NULL;
  6. /* 根据是否rehash,选择hash表,如果在 rehash 过程中,则将元素添加到 rehash 新建的哈希表中 */
  7. ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];
  8. //分配内存,执行插入
  9. entry = zmalloc(sizeof(*entry));
  10. entry->next = ht->table[index];
  11. ht->table[index] = entry;
  12. ht->used++;
  13. /* 设置key */
  14. dictSetKey(d, entry, key);
  15. return entry;
  16. }

我们来总结下这个插入的流程:

  1. 判断当前的字典是否在进行 rehash,如果是,则执行一步 rehash,否则忽略。判断 rehash 的依据就是 rehashidx 是否为 -1
  2. 通过 _dictKeyIndex 找到一个索引,如果返回-1表明字典中已经存在相同的 key,调用**_dictKeyIndex**
  3. 根据是否在 rehash 选择对应的哈希表
  4. 分配哈希表节点 dictEntry 的内存空间,执行插入,插入操作始终在链表头插入,这样可以保证每次的插入操作的时间复杂度一定是 O(1) 的,插入完毕,used属性自增
  5. dictSetKey 是个宏,调用字典处理函数中的 keyDup 函数进行键的复制

    宏:编译前定义的,静态代码替换。

接下来我们再看来看一下索引定位的逻辑:

  1. static long _dictKeyIndex(dict *d, const void *key, uint64_t hash, dictEntry **existing) {
  2. ...
  3. /* 判断是否需要进行rehash操作 */
  4. if (_dictExpandIfNeeded(d) == DICT_ERR)
  5. return -1;
  6. //因为可能存在 rehash 的情况,所以查找的时候是遍历 dict 的 ht 数组,从两个 hash 表中查找
  7. for (table = 0; table <= 1; table++) {
  8. /* 计算索引值 */
  9. idx = hash & d->ht[table].sizemask;
  10. he = d->ht[table].table[idx];
  11. while (he) {
  12. //判断key是不是已经存在了
  13. if (key == he->key || dictCompareKeys(d, key, he->key)) {
  14. if (existing) *existing = he;
  15. return -1;
  16. }
  17. he = he->next;
  18. }
  19. //判断当前是不是正在进行rehash操作
  20. if (!dictIsRehashing(d)) break;
  21. }
  22. return idx;
  23. }
  1. 判断当前哈希表是否需要进行扩展
  2. 通过位与计算索引,即插入到哈希表的哪个槽位中
  3. 查找当前槽位中的链表里是否已经存在该键,如果存在直接返回 -1;这里的 dictCompareKeys 也是一个宏,用到了keyCompare 这个比较键的函数
  4. 如果当前没有在做 rehash,那么 ht[1] 必然是一个空表,所以不能遍历 ht[1],需要及时跳出循环

    5.rehash

上面的添加过程,我们还有一个rehash没有讲,接下来我们来看一下。rehash其实就是随着数据的不断添加,哈希表保存的键值对会不断增多(或者减少),为了让哈希表的负载因子维持在一个合理的范围之内,当哈希表保存的键值对数量太多或者太少时,需要对哈希表大小进行扩展或者收缩。其实这里和java的hashMap有点类似,也有负载因子和扩容的概念。

  • 负载因子:当前已使用结点数量除上哈希表的大小,即:load_factor = ht[0].used / ht[0].size
  • 哈希表的扩容
    • 当哈希表的负载因子大于5时,为 ht[1] 分配空间,大小为第一个大于等于 ht[0].used * 2 的 2 的幂
    • 将保存在 ht[0] 上的键值对 rehash 到 ht[1] 上,rehash 就是重新计算哈希值和索引,并且重新插入到 ht[1] 中,插入一个删除一个
    • 当 ht[0] 包含的所有键值对全部 rehash 到 ht[1] 上后,释放 ht[0] 的控件, 将 ht[1] 设置为 ht[0],并且在 ht[1] 上新创件一个空的哈希表,为下一次 rehash 做准备
  • Redis 中 实现哈希表扩展调用的是 dict.c/_dictExpandIfNeeded 函数

    1. static int _dictExpandIfNeeded(dict *d) {
    2. /* 如果当前增在进行rehash操作,直接返回。 */
    3. if (dictIsRehashing(d)) return DICT_OK;
    4. /* 如果哈希表是空的,对哈希表进行初始化操作,初始化长度为4。 */
    5. if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);
    6. /*
    7. * 如果已使用节点与字典大小的比例达到1:1 ,
    8. * 并且我们在全局设置里面允许哈希表的扩容
    9. * 或者在安全的扩容区间内
    10. * 或者或负载因子达到5。
    11. * 我们将哈希表的桶位扩容为原来的2倍
    12. */
    13. if (d->ht[0].used >= d->ht[0].size &&
    14. (dict_can_resize ||
    15. d->ht[0].used / d->ht[0].size > dict_force_resize_ratio) &&
    16. dictTypeExpandAllowed(d)) {
    17. return dictExpand(d, d->ht[0].used + 1);
    18. }
    19. return DICT_OK;
    20. }

    扩展或者收缩哈希表的时候,需要将 ht[0] 里面所有的键值对 rehash 到 ht[1] 里,当键值对数量非常多的时候,这个操作如果在一帧内完成,大量的计算很可能导致服务器宕机,所以不能一次性完成,需要渐进式的完成。

  1. 为 ht[1] 分配指定空间,让字典同时持有 ht[0] 和 ht[1] 两个哈希表
  2. 将 rehashidx 设置为0,表示正式开始 rehash,前两步是在 dict.c/_dictExpand 中实现的

    1. int _dictExpand(dict *d, unsigned long size, int *malloc_failed) {
    2. if (malloc_failed) *malloc_failed = 0; //分配内存失败
    3. if (dictIsRehashing(d) || d->ht[0].used > size) return DICT_ERR; //如果size小于哈希表中实际存储的元素个数,返回false。
    4. dictht n; //n代表的是新的hash表
    5. unsigned long realsize = _dictNextPower(size); //hash表真实的使用量
    6. if (realsize == d->ht[0].size) return DICT_ERR;//如果 realsize 小于哈希表中实际存储的元素个数,返回false。
    7. n.size = realsize; //设置新表的长度
    8. n.sizemask = realsize - 1; //设置容量掩码
    9. if (malloc_failed) { //如果内存分配失败
    10. //尝试吧新表的长度设置为实际存在的元素个数的长度
    11. n.table = ztrycalloc(realsize * sizeof(dictEntry *));
    12. *malloc_failed = n.table == NULL;
    13. if (*malloc_failed) //如果分配失败,返回错误
    14. return DICT_ERR;
    15. } else //内存分配成功的逻辑
    16. n.table = zcalloc(realsize * sizeof(dictEntry *));
    17. n.used = 0;
    18. if (d->ht[0].table == NULL) { //如果 ht[0]为空
    19. d->ht[0] = n; //将新创建的hash表指针指向ht[0]
    20. return DICT_OK;
    21. } //接下来就是ht[0]不为空的情况
    22. d->ht[1] = n;
    23. d->rehashidx = 0;
    24. return DICT_OK;
    25. }
  3. 在进行 rehash 期间,每次对字典执行 增、删、改、查操作时,程序除了执行指定的操作外,还会将 哈希表 ht[0].table中下标为 rehashidx 位置上的所有的键值对 全部迁移到 ht[1].table 上,完成后 rehashidx 自增。这一步就是 rehash 的关键一步。为了防止 ht[0] 是个稀疏表 (遍历很久遇到的都是NULL),从而导致函数阻塞时间太长,这里引入了一个 “最大空格访问数”,也即代码中的 enmty_visits,初始值为 n*10。当遇到NULL的数量超过这个初始值直接返回。这一步实现在 dict.c/dictRehash 中:

  4. 最后,当 ht[0].used 变为0时,代表所有的键值对都已经从 ht[0] 迁移到 ht[1] 了,释放 ht[0].table, 并且将 ht[0] 设置为 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. if (!dictIsRehashing(d)) return 0;
    4. while (n-- && d->ht[0].used != 0) {
    5. dictEntry *de, *nextde;
    6. /* rehashidx不能溢出,之所以我们确定有更多的元素,是因为ht[0]用过!= 0 */
    7. assert(d->ht[0].size > (unsigned long) d->rehashidx);
    8. while (d->ht[0].table[d->rehashidx] == NULL) {
    9. d->rehashidx++;
    10. if (--empty_visits == 0) return 1;
    11. }
    12. de = d->ht[0].table[d->rehashidx];
    13. /* 节点迁移 */
    14. while (de) {
    15. uint64_t h;
    16. nextde = de->next;
    17. /* 获取在新表中的索引 */
    18. h = dictHashKey(d, de->key) & d->ht[1].sizemask;
    19. de->next = d->ht[1].table[h];
    20. d->ht[1].table[h] = de;
    21. d->ht[0].used--;
    22. d->ht[1].used++;
    23. de = nextde;
    24. }
    25. d->ht[0].table[d->rehashidx] = NULL;
    26. d->rehashidx++;
    27. }
    28. /* 判断我们是不是已经rehash完了整个表 */
    29. if (d->ht[0].used == 0) {
    30. ...
    31. // 迁移完毕,rehashdix 置为 -1
    32. d->rehashidx = -1;
    33. return 0;
    34. }
    35. return 1;/* 返回1表示还有节点需要迁移 */
    36. }

    哈希表的收缩,同样是为 ht[1] 分配空间, 大小等于max( ht[0].used, DICT_HT_INITIAL_SIZE ),然后和扩展做同样的处理即可。

    1. int htNeedsResize(dict *dict) {
    2. long long size, used;
    3. size = dictSlots(dict);
    4. used = dictSize(dict);
    5. return (size > DICT_HT_INITIAL_SIZE && (used * 100 / size < HASHTABLE_MIN_FILL));
    6. }

在此,我们对rehash进行一个总结。

5.1为什么要定义两个hash表?

redis 的 hash 默认使用的是 ht[0],ht[1]不会初始化和分配空间。哈希表 dictht 是用链地址法来解决碰撞问题的。在这种情况下,哈希表的性能取决于它的大小(size 属性)和它所保存的节点的数量(used 属性)之间的比率:比率在 1:1 时(一个哈希表 ht 只存储一个节点 entry),哈希表的性能最好;如果节点数量比哈希表的大小要大很多的话(这个比例用 ratio 表示,5 表示平均一个 ht 存储 5 个 entry),那么哈希表就会退化成多个链表,哈希表本身的性能优势就不再存在。在这种情况下需要扩容。Redis 里面的这种操作叫做 rehash。

  1. 为字符 ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及 ht[0]当前包含的键值对的数量。

ht[1]的大小为第一个大于等于 ht[0].used*2。

  1. 将所有的 ht[0]上的节点 rehash 到 ht[1]上,重新计算 hash 值和索引,然后放入指定的位置。

  2. 当 ht[0]全部迁移到了 ht[1]之后,释放 ht[0]的空间,将 ht[1]设置为 ht[0]表,并创建新的 ht[1],为下次 rehash 做准备。

5.2什么时候触发扩容?

  1. static int dict_can_resize = 1;
  2. static unsigned int dict_force_resize_ratio = 5;

ratio = used / size,已使用节点与字典大小的比例。

dict_can_resize 为 1 并且 dict_force_resize_ratio 已使用节点数和字典大小之间的比率超过 1:5,触发扩容。


6.Hash的应用场景

①String

String能做的,hash也都可以做。

②存储对象类型的数据

比如对象或者一张表的数据,比 String 节省了更多 key 的空间,也更加便于集中管理。

③购物车

key:用户 id;field:商品 id;value:商品数量。

+1:hincr。-1:hdecr。删除:hdel。全选:hgetall。商品数:hlen。