introduction

互联网公司在实际生产环境中广泛应用内存中键值存储来提高数据存储的性能。学者们研究了不同场景下的热点问题,并且在一些场景中提出了有效的解决方案。然而,内存中键值存储场景下的热点问题被忽略了,但这个问题在互联网时代的变得空前重要。目前很多数据结构实现的KVS都不能感知热点项目,基于哈希表的KVS如果希望能提高热点项目的访问性能,会造成很大的开销。所以作者提出了基于有序环哈希索引结构的热点可感知KV数据结构HotRing。

  • 基于有序环哈希索引结构,通过让头节点更靠近热点数据来提高热点数据的访问速度
  • 提供轻量、运行时的热点转移检测策略
  • 支持并发且无锁

background&motivation

1.哈希索引与热点问题

在KVS中,哈希索引是最常用的结构,一般哈希索引包含一个哈希表和一个冲突链(collision chain)。
【论文阅读】HotRing: A Hotspot-Aware In-Memory Key-Value Store

tag是为了避免直接比较较长的key

哈希索引无法感知热点项目,所以热点项目是平均地分布在冲突链中,在工作负载高度倾斜的情况下,这种分布会导致整体性能的严重下降。
针对热点问题,目前有两种解决办法:

  • 使用CPU cache存储热点项目,但cache容量很小,只能存储全部容量的0.012%。
  • rehash(重新安排item在冲突链的位置),但会成倍地增加内存消耗,而性能提升有限,不划算。

2.挑战与设计原则

设计方案需要满足下面的条件:

  • 需要轻量级的热点感知策略,并且底层数据结构要支持热点转移

将冲突链表改成环,以支持热点转移且可以访问到所有项目

  • 需要支持大规模并发
  • 采用无锁结构实现删除和插入操作
  • 实现热点移动检测,头部指针移动和重新哈希等基础操作

HotRing 设计

【论文阅读】HotRing: A Hotspot-Aware In-Memory Key-Value Store

1.有序环

作者对于有序环的设计满足下面三个条件:

  • 将冲突链表首尾相接,变成环
  • 哈希表中的头指针可以指向环中任意项
  • 环中各项有序排列

这种设计便于以任意节点作为遍历操作的首项,都可以遍历全部的项目,便于感知到热点项目后,将头指针指向热点项目。但由于并发访问有可能改变哈希表中头指针指向的项目(比如头指针指向的项目被删除),所以仅仅将头指针指向作为搜索停止条件是不够的,因此作者设计了环中节点的排序规则并制定了搜索停止条件。
【论文阅读】HotRing: A Hotspot-Aware In-Memory Key-Value Store
其中:
【论文阅读】HotRing: A Hotspot-Aware In-Memory Key-Value Store

例子:

  • 项目B满足搜索终止条件1,搜索项位于环中两项之间,未命中
  • 项目D满足搜索命中条件,命中
  • 项目G满足搜索终止条件2,搜索项位于环中最小项之前,未命中
  • 项目H满足搜索终止条件3,搜索项位于环中最大项之后,未命中【论文阅读】HotRing: A Hotspot-Aware In-Memory Key-Value Store

2.hotspot identification

HotRing通过head pointer指向hot元素以减少hot元素的查找开销,接下来本文需要解决如何判断热点数据的问题,判断热点需要考虑两个因素,分别是判断精度-通过识别热点所占的比例来衡量的;以及反应时间-新的热点出现和我们成功检测到它之间的时间跨度。针对这个问题提出了两种热点数据判断的策略:1)Random Movement Strategy;2)Statistical Sampling Strategy。

2.1 Random Movement Strategy

主要思想:没有历史数据统计,每R个请求之后,如果第R个请求访问的不是当前的热数据(head pointer指向的元素),则将head pointer指向当前访问的元素。
缺点:需要workload有很好的热点,以及只能单一热点,不能都会造成head pointer的频繁切换

2.2 Statistical Sampling Strategy

2.2.1 主要思想:通过记录历史访问信息来计算热度

因为需要记录历史访问信息,为了能够不引入额外的空间,HotRing中利用了head pointer / next pointer的剩余空间,因为指示地址用48位就够了,这样还有16位剩下来。

  • 对于head pointer,HotRing将这16位分成1 bit的active位以及15位的total counter位,total counter用于记录当前这个bucket的访问次数
  • 对于next pointer, HotRing将这16位分成了1 bit Rehash位,1bit Occupied位以及14 bit的counter位,Rehash和Counter分别用于并发的Rehash和update操作。

【论文阅读】HotRing: A Hotspot-Aware In-Memory Key-Value Store
2.2.2 statistical sample:每个线程维护一个请求计数R,当第R个请求结束的时候判断是否触发Sample,如果访问的不是当前的热点数据则触发。然后设置Active位为1,这之后的每个请求都会在Total counter以及Counter中被计数。sample的次数等于bucket内item个数。

  • 每个线程维护一个变量,记录执行了多少次请求
  • 每隔R个请求,线程决定是否要移动头指针
    • 若第R个请求是hot access,头指针不移动,且不开启采样
    • 否则,则需要移动头指针,开启统计采样,采样个数也是R
      - 打开head.active(CAS)
      - 后续的请求会被记录到head.total_count和对应的next.count(CAS)

2.2.3 热点计算:选择Wt作为head point指向。
计算income,这个income代表了当某个item被选为head pointer之后访问该ring的平均内存访问次数,k为链中的item数目,ni为第i个item的访问次数,N为总的访问次数,Wt为income值,计算公式如下:

【论文阅读】HotRing: A Hotspot-Aware In-Memory Key-Value Store
RCU写热点的计算:RCU是指Read-Modify-Update操作,对于小于8B的value,可以通过CAS(compare and swap)原子就地更新,但是对于超过8B的value,就需要先新建一个节点,然后替换旧的节点,这个时候就需要遍历整个链表来获取head pointer的前一个元素。为了减少内存访问,HotRing增加的是head pointer的上一个元素的counter,这样head就是热点数据的前一个元素,加速了热点数据的修改。
【论文阅读】HotRing: A Hotspot-Aware In-Memory Key-Value Store
2.3 热点继承
当对头节点执行更新删除时,

  • 若环只有一项,直接CAS更新头指针即可
  • 若环有多个项,利用已有的热点信息(即头指针的位置)处理:
    • RCU更新:指向新版本的头(因为很可能会被再次访问,比如读取)
    • 删除:指向被删除项的下一个节点

3.并发操作

由于头指针的移动,并发控制会更加复杂:
3.1 读取:不需要任何的操作,操作完全无锁
3.2 插入

  • 创建新项
  • 连接新项的next指针
  • 修改前一项的next指向新项(CAS)

第三步可通过CAS保证线程安全。若前一项next字段发生竞争,CAS会失败,此时操作需要重试(重试后2步)。
3.3 更新
当更新的数据不超过8字节:使用in-place CAS,不需要其它操作。
当更新的数据超过8字节:使用RCU更新,这时候需要分3种情况:

  • RCU-update & Insert
    如下图,2个线程分别更新B和插入C,修改前一项的next需要CAS,两个操作都会成功,但是结果不能成环了:

【论文阅读】HotRing: A Hotspot-Aware In-Memory Key-Value Store

  • RCU-update & RCU-update
    如下图,2个线程分别更新B和D,和第一种情况一样,CAS都会成功,但是最后结果也会导致无法成环,且B’的next是一个野指针:

【论文阅读】HotRing: A Hotspot-Aware In-Memory Key-Value Store

  • RCU-update & Delete
    如下图,2个线程分别删除B和更新D,也有类似的问题,CAS也都会成功,但是最后结果也会导致无法成环,且A的next是一个野指针:

【论文阅读】HotRing: A Hotspot-Aware In-Memory Key-Value Store
可见只有CAS设置next指针是不够的。
这时候需要额外的字段以保证正确性,这就是next指针的occupy字段,例如:

  • RCU-update & Insert
    RCU-update前,尝试CAS修改B.next值,置B.next.occupy= 1
    Insert时,使用CAS连接前一个节点,发现B.next.occupy= 1,操作会失败,重试
    操作完成后,新版本项的occupy为0
  • RCU-update & Delete
    Delete前,尝试CAS修改待删除项B.next值,置B.next.occupy = 1
  • RCU-update时,使用CAS连接前一个节点,发现B.next.occupy = 1,操作会失败,重试
    操作完成后,新版本项的occupy为0
    RCU-update & RCU-update:和RCU-update & Delete类似

3.4 删除
RCU-update & Delete的情况上面也说了。
而Delete & Delete的情况,和RCU-update & Delete情况类似;而Delete & Insert情况,和RCU-update & Insert情况类似。

3.5 头指针移动
需要解决2个问题:

  • 处理正常操作和头指针移动的并发
  • 处理更新/删除头节点之后的头指针移动

这里依旧使用occupy字段:

  • 当要移动头指针时,CAS设置新的头节点的occupy为1,保证其不被更新/删除
  • 当头节点被更新时:
    *移动前,更新时会设置新版本的头节点occupy为1
    * 移动完成,重置occupy为0
  • 当头节点被删除时:
    * 除了设置当前被删除的头节点occupy为1,还得设置下一项的occupy为1,因为下一项是新的头节点,需要保证其不被更新/删除
    * 移动完成,重置occupy为0

无锁rehash

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

1.触发条件:因为原来根据load factor触发rehash的方式没有考虑热度,所以HotRing采用的是access overhead(读取一个item平均的内存访问次数)来触发rehash。

2.步骤

  • 初始化

创建一个rehash线程,并初始化一个二倍于旧空间的hash表,每一个旧表的head pointer对应两个新表的head pointer。旧表采用hash值的k位作为hash地址,新表取k+1位。HotRing根据tag范围对数据进行划分。假设tag最大值为T,tag范围为[0,T),则两个新的Head指针对应tag范围为[0,T/2)和[T/2,T)。同时,rehash线程创建一个rehash节点(如下右图中间节点所示,包含两个空数据的子元素节点),子元素节点分别对应两个新Head指针(Head1和Head2)。HotRing利用元素中的Rehash标志位识别rehash节点的子元素节点:
【论文阅读】HotRing: A Hotspot-Aware In-Memory Key-Value Store

  • 分割

接下来需要分割原有的环到2个新的环。
线程遍历原有的环,根据rehash bit,将项插入到不同的新环上,插入完毕后就可用了(可访问,可写且可读)。
【论文阅读】HotRing: A Hotspot-Aware In-Memory Key-Value Store

  • 删除

最后一步,线程将a)中创建的rehash node删除。
【论文阅读】HotRing: A Hotspot-Aware In-Memory Key-Value Store

相关文章: