微软技术探究之FASTER
本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。
本作品 (李兆龙 博文, 由 李兆龙 创作),由 李兆龙 确认,转载请注明版权。
引言
微软在2018 SIGMOD Transactions and Indexing Session 中发表了一篇名为《FASTER: A Concurrent Key-Value Store with In-Place Updates》的paper,文章提出了一种在更新密集,访问模式多表现出时间局部性,允许工作集大于内存,访问操作多为点读,Blind Update以及Read Modify Write场景下的表现极佳的KV引擎,号称单机QPS可以达到1.6亿。
上面这句话其实已经说的非常清楚了,就是在这些约束下Faster很牛逼,但是上面三种操作其实限制了很多的使用场景。比如它就没法代替LSM-Tree,BW-Tree这样的存储结构,因为这些结构都是支持范围操作的,但是换来的就是上述三种操作QPS潜力有限。Redis这样的KV系统以其丰富的接口类型让FASTER望其项背,但工作集局限于内存这点注定了其高昂的TCO(就算目前云上有不少的混合存储产品,成本依旧高昂),且性能和Faster不在一个数量级。
严格的约束带来的是强大的性能,最近我们团队刚刚上线了称为Cache Node的类计算节点(定位其实就类似于DAX,本质上就是加速用户的热点读写操作,虽说我在[2]这篇文章中Diss过这种模式的坏处,但显然其带来的好处目前来看是优于坏处的,这也告诉我们凡是切忌想当然,当然那篇文章的中心论点我仍旧不认为有什么错,只不过题目略带夸张而已),其存储引擎暂定使用内部优化过的一个FASTER实现版本,基本性能目前看来是说的过去的。
重点Topic
这篇文章处于“ Transactions and Indexing ”中不是没有道理的,在超过内存的工作集合能保持三种操作如此高的性能我认为其缓存友好,无锁,高并发,可伸缩的index设计是一大亮点;另外一种称为Epoch Protection 同步框架也是非常巧妙,是Faster实现扩展的重要原因;最后提出HybridLog,解决append-only log在工作集大于内存时带来的性能问题。
如下是FASTER的宏观架构:
接下来我们分别聊聊我对这三个技术点的一些理解,当然原始论文才是最好的学习资料,FASTER的代码也是开源的,不过代码量相比与成熟的存储引擎来说有些过小了,看起来更像是一个prototype,当然用来学习是非常Nice的,我自己最近并没有时间看这个代码,所以对FASTER的理解可能并不是非常准确。
Epoch Protection
FASTER对于提升多核可扩展性遵循一个准则:不在线程间公共访问路径上执行昂贵的同步开销。FASTER在大多数时间中都独立执行操作,并在一定的时机执行延迟同步。
Epoch Basic
系统维护一个原子计数器称为 E E E,称为current epoch,任何线程都可以递增。每个线程中都维护着一个本地副本称为 E t E_t Et,所有线程中的 E t E_t Et都会定期被刷新至 E E E,每一个线程中的 E t E_t Et都被存储在一个cache-line中,这些cache-line被一个共享Epoch表保存。我们任务所有线程的 E t E_t Et都大于 c c c时认为 E p o c h C Epoch C EpochC已经被同步至所有线程,即它是安全的;此外引入一个称为 E s E_s Es的记录最大安全的Epoch,其根据共享Epoch表更新,在Epoch表更新时同步更新。
Trigger Actions
Faster增强了基本的Epoch Framework,当一个Epoch使用Trigger Actions变的安全时其可以执行任何操作。线程在本地递增Epoch为X时可以关联一个Action(其实就是一个回调),在未来Epoch X安全时这个回调由系统触发。这个行为由drain list实现,drain list是一个由<epoch, action>组成的操作对列表。其实现为一个small array,在 E s E_s Es被更新是这个array被扫描,数组中使用CAS操作保证每个Action只执行一次。注意,Epoch Protection只有在current epoch被更新才会更新 E s E_s Es以及扫描drain list,以确保扩展性。
operation
Epoch Protection共定义了四个操作,任何线程都可以发起其中任意一个:
Acquire
:为线程T创建一个Epoch,并设置 E t E_t Et为E。Refresh
:将 E t E_t Et更新为 E E E,并更新 E s E_s Es,并执行drain list中就绪的Actions。BumpEpoch
:把 E E E更新为 E + 1 E+1 E+1,并添加一个<E, Action>到drain list中。Release
:从全局共享的Epoch Table中删除T。
下面这个例子我认为可以很好的解释Epoch Protection到底做了是什么事情:
Epochs with trigger actions can be used to simplify lazy synchronization in parallel systems. Consider a canonical example, where a function active-now must be invoked when a shared variable status is updated to active. A thread updates status to active atomically and bumps the epoch with active-now as the trigger action. Not all threads will observe this change in status immediately. However, all of them are guaranteed to have observed it when they refresh their epochs (due to sequential memory consistency using memory fences). Thus, active-now will be invoked only after all threads see the status to be active and hence is safe.
带有Trigger Actions的Epoch可以简化并行系统中的同步。一个线程原子的更新自己的Epoch时立即触发Trigger Actions并不能保证所有的线程都观察到这一结果,但是当所有的线程都通过Refresh观察到更新的Epoch时就可以保证一定观察到了Trigger Actions,这个可以使用内存屏障保证。
Faster 中的 Epoch Protection 可以被看作是一个同步框架,在FASTER的很多地方都被使用。例如内存安全垃圾收集(第 4 节)、索引伸缩(附录 B)、circular buffer 维护和 page刷新(第 5 节)、共享日志页面边界维护(第 4 节)和Checkpoint(第 6.5 节),同时为用户操作(例如读取和更新)提供更快的共享内存位置的无锁访问。
这一节我基本是原封不动的翻译原文。我向来是不喜欢拷贝粘贴式的写文章的,但是原文中这部分的描述实在是惜字如金,如此有趣的系统用了寥寥小几百字,几乎是一笔带过,凭借我可能不那么职业的职业判断,实在是认为原文没有一个字是废话,不敢怠慢,全段落手打,生怕错过细节。
我认为Epoch Protection是三个Topic中理解最为困难的一个,理解了这个基本也就铁定能理解其他两个了。好了,来谈谈对Epoch Protection
的浅薄看法吧。
首先在文章的1.2有这样的描述:
This framework provides Faster threads with unrestricted access to memory under the safety of epoch protection. Throughout the paper, we highlight instances where this generalization helped simplify our scalable concurrent design
该框架依赖于epoch protection使得Faster线程可以无限制安全的访问地址。在整篇论文中,我们强调了这种泛化,有助于简化扩展和并发结构的设计。
然后在2.3中我们又可以知道epoch base这样的方案其实并不是一个新颖的东西,在BW-Tree这样的结构上已经有了一些特化的使用,而FASTER把其扩展到一个通用的框架上,所以理解epoch base是重中之重。
文中提到的其他使用epoch base的结构都称这种做法为Epoch based reclaimation
,即垃圾回收。这个问题很容易阐述,在并发数据结构中我们通常会遇到一个问题,即一块地址在被删除的时候不能直接free,原因是有可能其他线程正在访问这块地址或者获取了这块地址但还没有开始访问,如果直接free就会出现core了,问题的关键在于什么时候我们可以“安全”free掉这块地址。
有些读者可能注意到了这其实和RCU非常类似,这里“安全”的概念其实就是grace period
,不过内核的实现会更加优雅,相反这里Epoch Protection的实现就非常类似于[6]中描述的用户态RCU。
假设线程A分配到一个Epoch 称为E1,线程B分配到一个E2,已知E2大于E1,我们认为如果每个线程的操作是原子的,那么E1如果和E2操作同一块地址,E2是一定安全的,这隐含着E1的所操作的内存已经不被大于它的Epoch线程持有了。将删除操作带入E1,正常操作带入E2,那么E1一定是可以“安全”的释放这块内存的。
按照文中的例子来说,不管是内存中的垃圾回收还是circular buffer 维护和 page刷新,都隐含着其执行时必须是安全的。
我关心的是Epoch Protection Framework到底给出了怎样的保证,从文中可以看出其保证了在每个Epoch关联的Action执行时,其一定是“安全”的(内存屏障保证,先执行的一定是可见的),不会再有线程持有它所操作的地址。基于这个保证,类似于垃圾回收这样复杂的并发操作都会被简化。
但是问题在于这是否高效,在2.3的开头有这样一句话困扰了我很长时间:FASTER的准则是不在线程间公共访问路径上执行昂贵的同步开销,Faster的线程独立执行操作,大部分时间没有同步。话是这样没错,一般情况下线程会执行BumpEpoch
,然后每隔一段时间后执行refresh
,但是看起来所有的操作都成了类似串行的状态。
这里我可能还是没有理解清楚。
Hash Index
这是Faster这么快的一个重要组成部分,最吸引我的地方在于其cache-line友好的索引设计。
Faster的hash index由2k个缓存对齐的哈希桶构成,一般来说cache的大小都是64字节[8],一个64字节的buket由七个八字节的hash buket和一个溢出指针构成:
这里八字节的选择比较有意思,首先是CMPXCHG指令是支持八字节[9]的,这意味着我们可以使用CAS对哈希桶执行无锁操作,其实目前64位机器上物理地址只占了48位(地址线也只有48根),剩下16位我们可以当作其他用途(这种方案非常常见)。十六位中15位称为tag(可有可无的),一位称为Tentative,前者用作找到key对应的bucket,后者称为两阶段插入的算法避免相同的key被写入到两个桶中。
当然可以像上面那样玩的主要原因是因为为了cache-line友好把实际数据的地址存在了hash index中。
Hybridlog
文章其实是先从逻辑地址入手,再介绍Apeend only的log structed方案,最后才引入Hybridlog,但是我们不阐述前两种方案。
Hybridlog解决的问题是如何把工作集扩展至内存之外。
思路是把地址空间看作2^48次方,这部分地址空间被分为三部分:
其中Stable存在磁盘中,Read-Only一部分在磁盘中,一部分在内存中,显然Read-Only和Mutable加起来是要大于内存的。
读写的对象如果是大于Read-Only,就是就地更新,因为这部分数据一定是存在在内存中的;如果是大于Stable但是小于Read-Only,就会在Mutable中创建一个新的副本,修改hash index中的指向;如果小于Stable,这部分数据就需要发出异步IO从磁盘中读取了。
这里Read-Only的引入我认为其实就是基于Log Structed引入了一个缓存淘汰策略(类似于Second Chance FIFO),使得内存能够处理的数据大于原始方案,但相比于Append only又更快。
对于Stable区域的写操作(blind write,RMW,CRDT)也有着不同的处理方案。
关于Checkpoint也比较有趣,但是还处于初期阶段,大概就是不给予WAL的recover方案,当然是没法保留内存中数据的,但是可以保证一致性这里论文并没有写的很详细。
总结
其实刚看这篇文章的时候是抱有比较大的期望的,但是看完以后认为理论上并没有什么创新,基本是工业上做到了极致,当然于我来说也是收获匪浅的。
总结一下:
优点:
- 为了支持大于内存的设计,先引入log structed方案,这种方案使得blind write和RMW非常高效,但是因为Append Only的低效率设计了Hybridlog,这种设计其实就是在log structed上加了一层特化的内存淘汰策略。
- cache-line友好的hash index带来了极高的性能。
- 抽象出了Epoch Protection Framwork,使得这种Epoch的思想可以让更多并发系统收益,而不是以前那样用于特定的功能。
缺点:
- API过于单一,不支持多键原子修改,不支持范围操作,只支持read,blind write,RMW
- 论文中没有解决数据持久性,宕机意味着Stable之后的数据丢失。
参考: