论文原址:
发现问题->证明问题严重性->提出可能的解决方案->实现解决方案->证明解决方案的有效性
开始
简介
一种热点可感知的内存 KV 存储结构。
场景
- 在某些时刻,缓存系统迎来巨大的访问量 (双11秒杀)
- 可能存在访问倾斜,大多数访问集中在极少数数据上(微博热点事件)
背景问题与挑战
-
对于集群级别的热点监测倍受重视
-
对于单机存储的热点问题也有一些优化
对于内存KV 存储的热点问题被忽视,针对已有的的散列数据结构的问题,说明解决热点问题的挑战和设计准则。
内存KV存储数据结构中,散列表是最常用的。散列表的主要作用是分离链表。
2.png
如图,在最坏的情况下,热点数据在链表尾部,访问时需要读取更多的内存,造成大量开销。
已有的解决方法:
- 利用CPU cache,但是cache容量太小
- rehash,但会成倍增加内存使用
- Java HashMap,链表转红黑树
挑战:
- 热点是动态变化的,如何检测,如何转移
- 内存 KVS 对时延要求是很高的( 无锁的数据结构尤为重要)
设计
- 基于一个有序的环哈希索引结构,通过移动头部指针快速访问热点。
- 内部全面采用无锁结构设计(CAS),支持大并发下的读、更新等操作(RCU、 Hazard Pointers等 )。针对其的特定操作:热点移位检测、头部指针移动、有序环重新哈希。
- 提供一个轻量级策略来检测运行时的热点转移。
设计实现
有序环
1.jpg
冲突链表被有序环代替:
- 原有链表的尾部会指向原有的头部
- 环的头指针可以改变
- 环的项是有序排列的
使用环的优势:
-
单向链表
- 必须从头节点开始,到尾节点终止。移动头指针的情况下,会导致一些节点无法被访问
- 移动热点不方便,需要把中间节点移动到头节点,移动链表中的节点很复杂且难以做到无锁并发
-
环
- Head 可以指向任意节点,即从任意节点开始遍历,可以遍历完整个链表。
有序的优势:
-
无序
- 环形链表,没有终结点,如果查找值不存在,不知何时停止
-
有序
- 可以根据前后项的关系判断是否终结本次查询
有序的前后项关系:
- 前驱节点 < 待查找节点 <后驱节点 miss
- 前驱节点 > 后驱节点 && 待查找节点 < 后驱节点 miss
- 前驱节点 > 后驱节点 && 待查找节点 > 前驱节点 miss
- 待查找节点 == 节点K hit
3.jpg
热点识别转移
对于热点数据,可以将头节点移到热点项,以降低热点数据访问的性能开销。
问题:如何检测热点项。
论文中名称: 即头指针是Hot Item, 其它都是Cold Item,它们的访问分别是Hot Access和Cold Access。
为何需要转移
- 避免多次遍历,希望把热点数据放在冲突链前面
- 热点经常变化,需要不断移动节点
随机移动策略:头指针周期移动,指向一个的热点项,这个决定不依赖任何历史元数据。
-
实现
-
每个线程维护一个变量,记录执行了多少次请求
-
每隔R个请求,线程决定是否要移动头指针
- 若第R个请求是hot access,头指针不移动
- 若第R个请求是cold access,头指针移动到第R个请求所在的项(clod access)上
-
-
优缺点:效率高,不需要采样、计算,响应速度快,效果可能差
- 在热点集中时非常有效,头指针会趋向于指向热点数据,否则可能会频繁摇摆
统计采样策略:依赖计数历史元数据判断热点项。
-
优缺点: 效率低、效果可能更好
-
索引格式
4.jpg
-
头指针head包括:
- active:作为控制统计采样的标识
- total_counter:当前环总共的访问次数
- address:环的头地址
-
环上每一项的next
- rehash:作为控制rehash的标识
- occupied:用于并发控制,保证并发访问的正确性
- counter:该项的访问次数
- address:下一项的地址
-
利用x86-64架构和系统特性,地址使用48位节省处理的16位用于统计采样。
-
-
统计采样:为了降低开销,且保证识别的准确性,和随机移动一样,周期性地调整。
-
每个线程维护一个变量,记录执行了多少次请求
-
每隔R个请求,线程决定是否要移动头指针
-
若第R个请求是hot access,头指针不移动,且不开启采样
-
否则,则需要移动头指针,开启统计采样,采样个数也是R
- 打开head.active(CAS)
- 后续的请求会被记录到head.total_count和对应的next.count(CAS)
-
-
-
热点调整: 基于上一步的统计采样,就可以决定哪一个是头节点
- 关闭head.active(CAS)
- 遍历环
- 公式:
- 其中,N:total count 、n:counter、(i-t):从t节点访问到i节点的内存访问次数、n/N:每一项的访问频率。
公式:当头结点方位t节点的时候,要访问环中所有的节点中的代价,代价最小值为头结点。
故本质不是直接将头结点设置为访问次数最大的节点,因为在环中的热点不止有一个。
-
使用CAS设置新的头指针
-
重置所有的计数器
-
写入密集型的热点:RCU机制
- 项的数据 Less than 8 bytes -- CAS,Read读热点、Update被视作一样的操作。
- 项的数据 More than 8 bytes -- RCU,针对update的优化点,在进行更新操作时使用RCU并且需要修改前一项的next指针,要想获取修改项的前驱项需要遍历整个环,因此修改一个Hot Item也会让其前驱变为Hot Item。在RCU下,更新的是前一项的计数器。
- 在RCU下,更新的是前一项的计数器,头指针会趋向于指向写入项的前一项,在写密集型的热点时,可以直接定位到热点的前一项,更新时就不需要遍历链表。
热点继承
头节点如果被 RCU 更新或者删除,头节点需要指向其它项。但是不能随便指向其它项,因为很可能新的一项是cold item,然后频繁触发热点识别,降低性能。 防止冷启动。
解决方式:
-
若环只有一项,直接CAS更新头指针即可,指向空指针就OK。
-
若环有多个项,利用已有的热点信息(即头指针的位置)处理:
- RCU更新:指向 new update item(在日常的经验中一个数据被更新很可能会被再多次访问,比如读取)
- 删除: 指向被删除项的下一个节点,point to next item。因为被删除的节点是热节点,则此时指向它的下一个节点,其收益并不会减少太多。
并发操作
HotRing的并发控制的基础是CAS。
Read
因为没有对环形链表结构修改,所以不存在并发问题,可以直接不加锁访问
Insert
并发插入时数据丢失问题
当需要并发向B节点与E节点插入C、D时,会导致其中一个节点丢失。
步骤:
- 创建新项
- 新项的next指针指向前一项next指针所指地址
- 修改前一项的next指向新项(CAS)
第三步可通过CAS保证线程安全。若前一项next字段发生竞争,CAS会失败,此时操作需要重试(重试后2步)。
Update
上述介绍过,当更新的数据不超过8字节:使用 CAS,不需要其它操作。
当更新的数据超过8字节:使用RCU更新,需要分3种情况。
-
RCU-update & Insert 并发引发的数据丢失
并发执行更新B和插入C,修改前一项的next需要CAS,两个操作都会成功,但是结果不能成环。
-
RCU-update & RCU-update
并发执行更新B和D,CAS都会成功,但是最后结果也会导致无法成环,且B’的next是一个丢弃数据指针。
-
RCU-update & Delete 并发引发的数据丢失
并发执行删除B和更新D,CAS也都会成功,但是最后结果也会导致无法成环,且A的next是一个丢弃数据指针。
由上问题可知,只有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类似
头指针移动
需要解决问题:
- 头指针要指向新热节点,新热节点被RCU 更新 或者 删除,头指针指向旧的新热节点上
- 头指针指向的头节点需要进行update/delete 引起的转移
使用occupy字段
-
当要移动头指针时,CAS设置新的头节点的occupy为1,保证其不被更新/删除
-
当头节点要被更新时:
- 移动前,更新时会设置新版本的头节点occupy为1
- 移动完成,重置occupy为0
-
当头节点要被删除时:
- 除了设置当前被删除的头节点occupy为1,还得设置下一项的occupy为1,因为下一项是新的头节点,需要保证其不被更新/删除
无锁rehash
rehash原因:同一个链上热点数量过多,导致性能降低
HotRing支持无锁的rehash操作。HotRing使用访问开销(即item操作平均内存访问次数)来触发rehash。
3步:
- 初始化
- 分割
- 删除
初始化
首先创建rehash线程,初始化一个是原2倍大小的散列表。
11.png
如图所示,在old Table中的一个旧头指针,相应地在new Table 中两个新的头指针。1个环会被拆成2个环。回到上述索引格式Metadata中,自然散列值k的bit数拓展到k+1(在计算机语言中乘2就是左移一位),则tag的bit数减少一位变为tag-1。根据这一位的值(即rehash bit),决定原有环中的项在哪一个新环上(和JDK HashMap类似)
HotRing根据标记范围划分数据。假设哈希值有n位,标签范围为
两个新的头指针分别管理来自
的条目。每个重散列项具有与数据项相同的格式,但没有存储有效的KV对。
12.png
在初始化阶段,两个子重hash项的标记设置不同。如图所示,相应的rehash项将标签设置为0和T/2,分别为。
分割
13.jpg
在拆分阶段, 后台线程创建一个rehashNode,包含两个子Node,作为2个新环的头(作为假的头)。它的格式和data item一样,但是tag值分别是0和T/2,代表不同的rehash bit。
线程遍历原有的环,根据rehash bit,将项插入到不同的新环上,插入完毕后就可用了(可访问,可写且可读)。
在rehash中,rehash结束前的请求(来自Old Table)通过识别索引格式的rehash节点。正确地访问到new table的数据,而不会影响并发读写。rehash结束后的请求直接访问new table。
删除
线程将创建的rehash item删除。
在此之前,需要保证旧表上的访问要都完成(类似于RCU的grace period同步原语)。所有旧表访问结束后,线程会删除旧表和rehash node。
故只有rehash线程会阻塞,其它线程是不会阻塞的。
例子
在一个head中,其hash值为hash(key)=137=10001001
故n=10001001,其k=1000,tag=1001。此项为hash表的head8环中
进行rehash
则 k+1=10001,tag-1=001,rehash bit=1,此项在new table的head17环中
总结
背景:论文中通过YCSB对HotRing方案进行了压力测试,并对比了行业有名的Memcached等。可以看到HotRing方案的吞吐量远超其他方案:
14.png
15.png
配置:
- HotRing-r (random movement strategy)
- HotRing-s (sampling statistics strategy)
左图压测了链表长度对性能的影响,可以发现当链表长度从2一直递增到16的过程中。HotRing方案的性能几乎是恒定的,可以保持一个很好的性能。、右图表明hotring在数据访问呈现严重倾斜的情况下,也能保持非常好的性能。
16.png
θ是齐夫分布的参数,YCSB生成工作负载时, θ的值越高,表明测试的所使用负载的倾斜程度就越严重。
左图表明Hot Ring在read miss的情况下的性能比chaining hash的性能要好。这是因为Hot Ring中每一个桶的环是有序的,判断元素不存在不需要遍历桶中的所有元素。右图热点切换时,不同的热点选择策略稳定下来需要的时间,hotring-r在两秒内就可以达到一种稳定的状态了。
17.png
在分裂前,为了保证从旧哈希表进入的访问均已经返回,rehash的过程被阻塞一段时间,随着数据容量的不断增长,rehash操作可以维持这个稳定的性能。如下图所示:
18.png
知识拓展
RCU
read-copy update。是 Linux 中比较重要的一种同步机制。“读,拷贝更新”,更新数据的时候,需要先复制一份副本,在副本上完成修改,再一次性地替换旧数据。这是 Linux 内核实现的一种针对读多写少的共享数据的同步机制。
适合场景:
- 高频读低频写
- 对数据没有强一致性要求
cloud.tencent.com/developer/a…
无锁链表
论文地址:citeseerx.ist.psu.edu/viewdoc/dow…
巨人的肩膀
论文
keys961.github.io/2020/02/28/…
硬核课堂