Redis 字典结构实现分析

日期: 2012-08-21 作者:huangz 来源:TechTarget中国 英文

  字典是 Redis 的核心数据结构之一,在 Redis 中,每个数据库本身也是一个字典,而且字典也是 Redis 的 Hash 类型的底层实现。

  本文通过分析 Redis 源码里的 dict.h 和 dict.c 文件,了解字典结构的详细实现,籍此加深对 Redis 的理解。

  由于字典(哈希表)是一种非常常见的数据结构,而 dict.c 中使用的 separate chaining 哈希表实现可以在任何一本算法书上找到,因此,在本文中没有对字典的查找和增删等操作做过多的着墨,而是将重点放到整个字典结构的运作流程,以及哈希表的渐增式 rehash 操作上。

  字典实现的数据结构

  dict.h 文件里定义了字典实现的数据结构,比如 dict 、 dictht 和 dictEntry 等,它们之间的关系可以用下图来描述:

  

relationship
 
点击放大

  其中, dict 结构的定义如下:

  typedef struct dict {
  dictType *type; // 为哈希表中不同类型的值所使用的一族操作函数
  void *privdata;
  dictht ht[2]; // 每个字典使用两个哈希表(用于渐增式 rehash)
  int rehashidx; // 指示 rehash 是否正在进行,如果不是则为 -1
  int iterators; // 当前正在使用的 iterator 的数量
  } dict;

  代码中的注释基本说明相关属性的作用了,需要补充的一些是:

  为了实现渐增式 rehash ,每个字典使用两个哈希表,分别为 ht[0] 和 ht[1] 。当 rehash 开始进行的时候, Redis 会逐个逐个地将 ht[0] 哈希表中的元素移动到 ht[1] 哈希表,直到 ht[0] 哈希表被清空为止。文章后面会给出 rehash 的相关细节。

  另一方面, rehashidx 则是 rehash 操作的计数器,这方面的相关细节也会后面给出。

  接下来是哈希表结构 dictht ,这个哈希表是一个典型的 separate chaining hash table 实现,它通过将哈希值相同的元素放到同一个链表中来解决冲突问题:

  typedef struct dictht {
  dictEntry **table; // 节点指针数组
  unsigned long size; // 桶的大小(最多可容纳多少节点)
  unsigned long sizemask; // mask 码,用于地址索引计算
  unsigned long used; // 已有节点数量
  } dictht;

  最后要介绍的是链表的节点结构 dictEntry :

  typedef struct dictEntry {
  void *key; // 键
  union {
  void *val;
  uint64_t u64;
  int64_t s64;
  } v; // 值(可以有几种不同类型)
  struct dictEntry *next; // 指向下一个哈希节点(形成链表)
  } dictEntry;

  dictEntry 中的 key 属性保存字典的键,而 v 属性则保存字典的值, next 保存一个指向dictEntry 自身的指针,用于构成链表,解决哈希值的冲突问题。

  创建字典

  在初步了解了字典实现所使用的结构之后,现在是时候来看看相关的函数是怎样来操作这些结构的了。让我们从创建字典开始,一步步研究字典以及哈希表的运作流程。

  使用字典的第一步就是创建字典,创建新字典执行这样一个调用链:

  

create-dict

  dictCreate 函数创建一个新的 dict 结构,然后将它传给 _dictInit 函数:

  dict *dictCreate(dictType *type, void *privDataPtr)
  {
  dict *d = zmalloc(sizeof(*d));
  _dictInit(d,type,privDataPtr);
  return d;
  }

  _dictInit 函数为 dict 结构的各个属性设置默认值,并调用 _dictReset 函数为两个哈希表进行初始化设置:

  int _dictInit(dict *d, dictType *type, void *privDataPtr)
  {
  _dictReset(&d->ht[0]); // 初始化字典内的两个哈希表
  _dictReset(&d->ht[1]);
  d->type = type; // 设置函数指针
  d->privdata = privDataPtr;
  d->rehashidx = -1; // -1 表示没有在进行 rehash
  d->iterators = 0; // 0 表示没有迭代器在进行迭代
  return DICT_OK; // 返回成功信号
  }
  _dictReset 函数为字典的几个属性值赋值,但并不为这两个哈希表的链表数组分配空间:
  static void _dictReset(dictht *ht)
  {
  ht->table = NULL; // 未分配空间
  ht->size = 0;
  ht->sizemask = 0;
  ht->used = 0;
  }

  哈希表链表的创建流程

  每个 dict 结构都使用两个哈希表,分别是 dict->h1[0] 和 dict->ht[1] ,为了称呼方便,从现在开始,我们将它们分别叫做 0 号哈希表和 1 号哈希表。

  从上一节的介绍可以知道,创建一个新的字典并不为哈希表的链表数组分配内存,也即是dict->ht[0]->table 和 dict->ht[1]->table 都被设为 NULL 。

  只有当首次调用 dictAdd 向字典中加入元素的时候, 0 号哈希表的链表数组才会被创建,dictAdd 执行这样一个调用序列:

  

  dictAddRaw 是向字典加入元素这一动作的底层实现,为了计算新加入元素的 index 值,它会调用 _dictKeyIndex :

  dictEntry *dictAddRaw(dict *d, void *key)
  {
  // 被省略的代码…
  // 计算 key 的 index 值
  // 如果 key 已经存在,_dictKeyIndex 返回 -1
  if ((index = _dictKeyIndex(d, key)) == -1)
  return NULL;
  // 被省略的代码…
  }

  _dictKeyIndex 会在计算 index 值之前,先调用 _dictExpandIfNeeded ,检查两个哈希表是否有足够的空间容纳新元素:

  static int _dictKeyIndex(dict *d, const void *key)
  {
  // 被省略的代码…
  /* Expand the hashtable if needed */
  if (_dictExpandIfNeeded(d) == DICT_ERR)
  return -1;
  // 被省略的代码…
  }

  进行到 _dictExpandIfNeeded 这一步,一些有趣的事情就开始发生了, _dictExpandIfNeeded会检测到 0 号哈希表还没有分配任何空间,于是它调用 dictExpand ,传入DICT_HT_INITIAL_SIZE 常量,作为哈希表链表数组的初始大小(在当前版本中,DICT_HT_INITIAL_SIZE 的默认值为 4 ):

  static int _dictExpandIfNeeded(dict *d)
  {
  // 被省略的代码…
  /* If the hash table is empty expand it to the intial size. */
  if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);
  // 被省略的代码…
  }

  dictExpand 会创建一个分配了链表数组的新哈希表,然后进行判断,决定是应该将新哈希表赋值给 0 号哈希表,还是 1 号哈希表:

  int dictExpand(dict *d, unsigned long size)
  {
  // 创建带链表数组的新哈希表
  dictht n; /* the new hash table */
  unsigned long realsize = _dictNextPower(size);
  /* the size is invalid if it is smaller than the number of
  * elements already inside the hash table */
  if (dictIsRehashing(d) || d->ht[0].used > size)
  return DICT_ERR;
  /* Allocate the new hash table and initialize all pointers to NULL */
  n.size = realsize;
  n.sizemask = realsize-1;
  n.table = zcalloc(realsize*sizeof(dictEntry*));
  n.used = 0;
  /* Is this the first initialization? If so it’s not really a rehashing
  * we just set the first hash table so that it can accept keys. */
  if (d->ht[0].table == NULL) {
  d->ht[0] = n; // 将新哈希表赋值给 0 号哈希表
  return DICT_OK; // 然后返回
  }
  // 被省略的代码 …
  }

  到了这一步, 0 号哈希表已经从无到有被创建出来了。

  字典的扩展,以及 1 号哈希表的创建

  在 0 号哈希表创建之后,字典就可以支持增加、删除和查找等操作了。

  唯一的问题是,这个最初创建的 0 号哈希表非常小,它很快就会被添加进来的元素填满,这时候,字典的扩展(expand)机制就会被激活,它执行一系列动作,为字典分配更多空间,从而使得字典可以继续正常运作。

  因为字典的的底层实现是哈希表,所以对字典的扩展,实际上就是对(字典的)哈希表做扩展。这个过程可以分为两步进行:

  1) 创建一个比现有的 0 号哈希表更大的 1 号哈希表

  2) 将 0 号哈希表的所有元素移动到 1 号哈希表去

  _dictExpandIfNeeded 函数检查字典是否需要扩展,每次往字典里添加新元素之前,这个函数都会被执行:

  static int _dictExpandIfNeeded(dict *d)
  {
  // 被省略的代码…
  // 当 0 号哈希表的已用节点数大于等于它的桶数量,
  // 且以下两个条件的其中之一被满足时,执行 expand 操作:
  // 1) dict_can_resize 变量为真,正常 expand
  // 2) 已用节点数除以桶数量的比率超过变量 dict_force_resize_ratio ,强制 expand
  // (目前版本中 dict_force_resize_ratio = 5)
  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].size > d->ht[0].used) ?
  d->ht[0].size : d->ht[0].used)*2);
  }
  // 被省略的代码…
  }

  可以看到,当代码注释中所说的两种情况的其中一种被满足的时候, dictExpand 函数就会被调用: 0 号哈希表的桶数量和节点数量两个数值之间的较大者乘以 2 ,就会被作为第二个参数传入dictExpand 函数。

  这次调用 dictExpand 函数执行的是和之前创建 0 号哈希表时不同的路径 —— 这一次,程序执行的是 else case —— 它将新哈希表赋值给 1 号哈希表,并将字典的 rehashidx 属性从 -1 改为0:

 int dictExpand(dict *d, unsigned long size)
  {
  // 创建带链表数组的新哈希表
  dictht n; /* the new hash table */
  unsigned long realsize = _dictNextPower(size);
  /* the size is invalid if it is smaller than the number of
  * elements already inside the hash table */
  if (dictIsRehashing(d) || d->ht[0].used > size)
  return DICT_ERR;
  /* Allocate the new hash table and initialize all pointers to NULL */
  n.size = realsize;
  n.sizemask = realsize-1;
  n.table = zcalloc(realsize*sizeof(dictEntry*));
  n.used = 0;
  /* Is this the first initialization? If so it’s not really a rehashing
  * we just set the first hash table so that it can accept keys. */
  if (d->ht[0].table == NULL) {
  d->ht[0] = n;
  return DICT_OK;
  }
  /* Prepare a second hash table for incremental rehashing */
  // 这次执行这个动作
  d->ht[1] = n; // 赋值新哈希表到 d->ht[1]
  d->rehashidx = 0; // 将 rehashidx 设置为 0
  return DICT_OK;
  }

  渐进式 rehash ,以及平摊操作

  在前一节的最后, dictExpand 的代码中,当字典扩展完毕之后,字典会同时使用两个哈希表(d->ht[0] 和 d->ht[1] 都不为 NULL ),并且字典 rehash 属性的值为 0 。这意味着,可以开始对 0 号哈希表进行 rehash 操作了。

  Redis 对字典的 rehash 操作是通过将 0 号哈希表中的所有数据移动到 1 号哈希表来完成的,当移动完成, 0 号哈希表的数据被清空之后, 0 号哈希表的空间就会被释放,接着 Redis 会将原来的 1 号哈希表设置为新的 0 号哈希表。如果将来这个 0 号哈希表也不能满足储存需要,那么就再次执行 rehash 过程。

  需要说明的是,对字典的 rehash 并不是一次性地完成的,因为 0 号哈希表中的数据可能非常多,而一次性移动大量的数据必定对系统的性能产生严重影响。

  为此, Redis 采取了一种更平滑的 rehash 机制,Redis 文档里称之为渐增式 rehash (incremental rehashing):它将 rehash 操作平摊到 dictAddRaw 、 dictGetRandomKey 、 dictFind 和dictGenericDelete 这四个函数里面,每当上述这些函数执行的时候(或者其他函数调用它们的时候), _dictRehashStep 函数就会被执行,它每次将 1 个元素从 0 号哈希表移动到 1 号哈希表:

  

incremental rehashing functions
 
点击放大

  作为展示渐增式 rehash 的一个例子,以下是 dictFind 函数的定义:

  dictEntry *dictFind(dict *d, const void *key)
  {
  // 被省略的代码…
  // 检查字典(的哈希表)能否执行 rehash 操作
  // 如果可以的话,执行平摊 rehash 操作
  if (dictIsRehashing(d)) _dictRehashStep(d);
  // 被省略的代码…
  }

  其中 dictIsRehashing 是一个宏,它检查字典的 rehashidx 属性是否不为 -1 :

 #define dictIsRehashing(ht) ((ht)->rehashidx != -1)

  如果条件成立成立的话, _dictRehashStep 就会被执行,将一个元素从 0 号哈希表转移到 1 号哈希表:

  static void _dictRehashStep(dict *d) {
  if (d->iterators == 0) dictRehash(d,1);
  }

  _dictRehashStep 定义中的 iterators == 0 检查表示,当有迭代器在处理字典的时候,不能进行 rehash ,因为迭代器可能会修改字典中的元素,从而造成 rehash 错误。

  就这样,如同愚公移山一般, 0 号哈希表的元素被逐个逐个地移动到 1 号哈希表,最终整个 0 号哈希表被清空,当 _dictRehashStep 再调用 dictRehash 时,被清空的 0 号哈希表就会被删除,然后原来的 1 号哈希表成为新的 0 号哈希表。

  当有需要再次进行 rehash 的时候,这个循环就会再次开始。

  以下是 dictRehash 函数的完整实现,它清晰地说明了如何轮换 0 号哈希表和 1 号哈希表,以及,如何将 0 号哈希表的元素 rehash 到 1 号哈希表:

  /* Performs N steps of incremental rehashing. Returns 1 if there are still
  * keys to move from the old to the new hash table, otherwise 0 is returned.
  * Note that a rehashing step consists in moving a bucket (that may have more
  * thank one key as we use chaining) from the old to the new hash table. */
  int dictRehash(dict *d, int n) {
  if (!dictIsRehashing(d)) return 0;
  while(n–) {
  dictEntry *de, *nextde;
  // 如果 0 号哈希表为空,使用 1 号哈希表代替它
  /* Check if we already rehashed the whole table… */
  if (d->ht[0].used == 0) {
  zfree(d->ht[0].table);
  d->ht[0] = d->ht[1];
  _dictReset(&d->ht[1]);
  d->rehashidx = -1;
  return 0;
  }
  // 进行 rehash
  /* Note that rehashidx can’t overflow as we are sure there are more
  * elements because ht[0].used != 0 */
  assert(d->ht[0].size > (unsigned)d->rehashidx);
  while(d->ht[0].table[d->rehashidx] == NULL) d->rehashidx++;
  de = d->ht[0].table[d->rehashidx];
  /* Move all the keys in this bucket from the old to the new hash HT */
  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;
  d->rehashidx++;
  }
  return 1;
  }

  另外,还有一个确保 rehash 得以最终完成的重要条件,那就是 —— 当 rehashidx 不等于 -1 ,也即是 dictIsRehashing 为真时,所有新添加的元素都会直接被加到 1 号数据库,这样 0 号哈希表的大小就会只减不增,最终 rehash 总会有完成的一刻(假如新加入的元素还继续被放进 0 号哈希表,那么尽管平摊 rehash 一直在努力地进行,但说不定 rehash 还是永远也完成不了):

  dictEntry *dictAddRaw(dict *d, void *key)
  {
  // 被省略的代码…
  // 如果字典正在进行 rehash ,那么将新元素添加到 1 号哈希表,
  // 否则,使用 0 号哈希表
  ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];
  // 被省略的代码…
  }

  另外,除了 _dictRehashStep 以及 dictAddRaw 的特殊处理之外,Redis 还会在每次事件中断器运行的时候,执行一个为时一毫秒的 rehash 操作,在文件 redis.c 中的 serverCron 函数中记录了这一点。

  哈希表的大小

  在介绍完哈希表的使用流程和 rehash 机制之后,最后一个需要探索的地方就是哈希表的大小了。

  我们知道哈希表最初的大小是由 DICT_HT_INITIAL_SIZE 常量决定的,而当 rehash 开始之后,根据给定的条件,哈希表的大小就会发生变动:

  static int _dictExpandIfNeeded(dict *d)
  {
  // 被省略的代码…
  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].size > d->ht[0].used) ?
  d->ht[0].size : d->ht[0].used)*2);
  }
  // 被省略的代码…
  }

  可以看到, d->ht[0].size 和 d->ht[0].used 两个数之间的较大者乘以 2 ,会作为 size 参数的值被传入 dictExpand 函数。

  但是,尽管如此,这个数值仍然还不是哈希表的最终大小,因为在 dictExpand 里面,真正的哈希表大小需要 _dictNextPower 函数根据传入的 size 参数计算之后才能得出:

  int dictExpand(dict *d, unsigned long size)
  {
  // 被省略的代码…
  // 计算哈希表的(真正)大小
  unsigned long realsize = _dictNextPower(size);
  // 被省略的代码…
  }

  _dictNextPower 不断计算 2 的乘幂,直到遇到大于等于 size 参数的乘幂,就返回这个乘幂作为哈希表的大小:

  static unsigned long _dictNextPower(unsigned long size)
  {
  unsigned long i = DICT_HT_INITIAL_SIZE;
  if (size >= LONG_MAX) return LONG_MAX;
  while(1) {
  if (i >= size)
  return i;
  i *= 2;
  }
  }

  虽然桶的元素个数 d->ht[0].size 刚开始是固定的( DICT_HT_INITIAL_SIZE ),但是,因为我们没有办法预知 d->ht[0].used 的值,所以我们没有办法准确预估新哈希表的大小,不过,我们可以确定以下两个关于哈希表大小的性质:

  1) 哈希表的大小总是 2 的乘幂(也即是 2^N,此处 N 未知)

  2) 1 号哈希表的大小总比 0 号哈希表大

  小结

  以上就是 Redis 字典结构的实现分析了,因为边幅所限,这里展示的函数多数都只贴出了主要部分的代码,如果对所有代码的细节感兴趣,可以到我的 GITHUB 上去找带有完整注释的代码:https://github.com/huangz1990/reading_redis_source

我们一直都在努力坚持原创.......请不要一声不吭,就悄悄拿走。

我原创,你原创,我们的内容世界才会更加精彩!

【所有原创内容版权均属TechTarget,欢迎大家转发分享。但未经授权,严禁任何媒体(平面媒体、网络媒体、自媒体等)以及微信公众号复制、转载、摘编或以其他方式进行使用。】

微信公众号

TechTarget微信公众号二维码

TechTarget

官方微博

TechTarget中国官方微博二维码

TechTarget中国

电子邮件地址不会被公开。 必填项已用*标注

敬请读者发表评论,本站保留删除与本文无关和不雅评论的权力。

作者

huangz
huangz

相关推荐