盘古是一个分布式文件系统,在整个阿里巴巴云计算平台——“飞天”中,它是最早被开发出的服务,因此用中国古代神话中开天辟地的盘古为其命名,希冀能创建出一个全新的“云世界”。在“飞天”平台中,它是负责数据存储的基石性系统,其上承载了一系列的云服务(如图1所示)。盘古的设计目标是将大量通用机器的存储资源聚合在一起,为用户提供大规模、高可用、高吞吐量和良好扩展性的存储服务。盘古的上层服务中,既有要求高吞吐量,期待I/O能力随集群规模线性增长的“开放存储”;又有要求低时延的“弹性计算”,而作为底层平台核心模块的盘古必须二者兼顾,同时具备高吞吐量和低时延。
在内部架构上盘古采用Master/ChunkSer ver结构(如图2所示),Master管理元数据,多Master之间采用Primar y-Secondaries模式,基于PAXOS协议来保障服务高可用;ChunkServer负责实际数据读写,通过冗余副本提供数据安全;Client对外提供类POSIX的专有API,系统地提供丰富的文件形式,满足离线场景对高吞吐量的要求,在线场景下对低延迟的要求,以及虚拟机等特殊场景下随机访问的要求。
自5K项目以来,集群规模快速扩张到5000个节点,规模引发的相关问题纷至沓来。首当其冲的就是盘古Master IOPS问题,因为更大的集群意味着更多文件和更多访问,显然上层应用对存储亿级文件和十亿级文件集群的IOPS是有显著区别的。同时更大规模的集群让快速发展的上层应用看到了更多可能性,导致更多业务上云,存储更多数据,也间接导致了对IOPS的更高需求。此前盘古Master较低的IOPS已经限制了上层业务的快速扩张,业务高峰期时有告警,亟待提升。另外一个规模相关问题就是盘古Master冷启动速度,更多的文件和Chunk数导致更长的冷启动时间,影响集群可用性。
要解决上述问题,性能优化势在必行。但对于盘古这样复杂的大型系统,不可能毕其功于一役,需要在不同的阶段解决不同的性能瓶颈。优化通常会伴随整个生命周期,因此优化工作本身也需要进行经验、工具等方面的积累,为后续持续优化提供方便。因此在这次规模问题优化中,我们积极建设了自己的锁Profile工具,并依此解决了多个锁导致的性能问题;在解决主要的锁瓶颈后,我们进行了架构上的优化,包括Pipeline优化和Group Commit优化,取得了良好效果;最后我们通过对细节的不断深入,反复尝试,较好地解决了由规模导致冷启动时间过长的问题。
工具篇
在盘古这样复杂的大型系统上,锁是优化过程经常遇到的问题。优化的首要任务是找出具体瓶颈,切忌猜测和盲目修改代码。先用压测工具对盘古Master加压,通过Top、Vmstat等系统工具发现CPU负载不到物理核的一半,Context Switch高达几十万,初步怀疑是锁竞争导致。同事也觉得某些路径下的锁还有优化空间,但到底是哪些锁竞争厉害?哪些锁持有的时间长?哪些锁等待时间长?具体又是哪些操作需要这些锁?由于缺少切实可靠的数据支撑,不能盲目下手,而坚持数据说话是一个工程师应有的品质。
盘古Master提供了众多的读写接口,内部的不同模块使用了大量的锁,我们需要准确得知是哪类操作导致哪个锁竞争严重,此前常用的一些Profile工具难以满足需求,因此我们有必要打造自己的“手术刀”——锁分析工具,方便后续工作。首先为了区分不同的锁,我们需要对代码中所有的锁进行统一命名,并将命名记录到锁实现内,同时为了区分不同的操作,还需要对所有的操作进行一个唯一的类型编号。在某个Worker线程从RPC中读取到具体请求时,将类型编号写入到该Worker线程私有数据中,随后在该RPC请求的处理过程中,不同锁的拿锁操作,都归类于该操作类型。
在每个锁内部,维护一个Vector数据,LockPerfRecord记录锁的Profile信息,具体定义如下:
每个操作对应Vector中的一个元素,线程私有数据中记录的操作编号即Vector下标。整体来看形成了表1中的稀疏二维数组,空记录表示该操作未使用对应锁。
具体实现上,Acquire Counts采用原子变量,时间测量上采用Rdtsc。谈到Rdtsc有不少人色变,认为有CPU变频、多CPU之间不一致等问题,但在长时间粒度(分钟级)和大量调用(亿次)的压测背景下,这些可能存在的影响不会干扰最终结果,多次实验结果也证实了这一点。其他实现细节,如定时定量(每n分钟或者每发起x次操作)发起不影响主流程的Perf Dump就不再赘述。整个代码在百行左右,对平台的侵入也很小,仅需要为每个锁添加一个初始化命名,在RPC处理函数的入口设定操作编号即可。
利器在手,按图索骥,对存在性能问题的锁进行各种优化。例如使用多个锁来替换原来全局唯一锁,减少冲突概率;减少加锁范围;使用无锁的数据结构,或者使用更轻量级的锁来优化。整个优化过程极富趣味性,经常是解决一个锁瓶颈后,发现瓶颈又转移到另外一个锁上,在工具的Profile结果中有非常直观的展示。先后优化了Client Session、Placement等模块中锁的使用,取得了显著的效果,CPU基本可以跑满,ContextSwitch大幅度下降,整个过程酣战淋漓。
架构篇
在锁竞争问题解决到一定程度后,继续进行锁的优化就很难在IOPS上取得较大收益。此时结合业务逻辑,我们发现可以从架构上做出部分调整,以提升整体的IOPS。
读写分离(快慢分离)
整个盘古Master对外接口众多,根据是否需要在Primary和Secondaries之间同步Operation Log来分成读和写两大类。所有读操作都不需要同步Operation Log,所有的写操作基于数据一致性必须同步。考虑到Primary和Secondaries随时都可能发生切换,要保证数据一致,Client端发送的每一个写请求,Primary必须在同步Operation Log完全成功后才能返回Client。显而易见,写比读要慢得多,如果让同一个线程池来同时服务读和写,将会导致怎样的结果呢?一个形象的隐喻是多车道的高速公路,想象一下如果一条高速公路上不按照速度来划分多车道,而是随心所欲地混跑,20码和120码的车跑在同一条车道上,整体的吞吐量无论如何也不会高。参照高速公路设计,进行快慢分离,耗时低的读操作占用一个线程池,耗时高的写操作使用另外一个线程池,双方互不干扰,进行这样一个简单的切分后,读IOPS得到显著提升,而写操作并未受影响。
Pipeline
读写分离后,读的IOPS性能得到极大提升,但写操作依然有待提升。写操作的基本流程如图3所示。
在Master端,写操作最大的时间开销在同步Operation Log到Secondaries。这里的关键缺陷在于同步Operation Log期间,工作线程只能被动地等待一段相当长的时间,这个过程包括将数据同步到Secondaries上,并由Secondaries将这个数据同步写入到磁盘,由于涉及到同步写物理磁盘(而非写Page Cache),这个时间是毫秒量级。所以写操作的IOPS肯定不会高。定位问题后,在结构上稍做调整如图4所示。
类似中断处理程序,整个RPC的处理流程分成了上半部和下半部,上半部由Worker线程处理Request,将Operation Log提交到Oplog Server,不再阻塞等待同步Oplog成功,而是接着处理下一个Request。下半部的工作包括填充Response,将Response写入到RPC Server ,下半部由另外一个线程池承担,通过Oplog同步成功的消息触发。这样Worker线程源源不断地处理新的Request,写操作IOPS显著提升。由于只有同步Oplog完全成功才会返回Response,所以数据一致性与此前的实现相同。
Group Commit
经过Pipeline优化后,写操作性能显著提升,但我们对结果依然不太满意,想进一步提升IOPS,为上层客户提供一个更好的结果。继续Profile,发现新实现下同步Oplog吞吐量较低,主要原因是每个写请求都导致一次主备间同步Oplog,而且这条Oplog在Primary和Secondaries上都需要同步写到磁盘。压力较高时,将有大量的同步RPC和小片数据同步写磁盘,导致吞吐量低。通常分布式系统会使用Group Commit技术来优化这个问题,将时间上接近的Oplog组成一个Group,整个Group一次提交,吞吐量可以明显提升。但传统的Group Commit会带来Latency的明显增加,需要在吞吐量和Latency之间做权衡。鱼与熊掌如何兼得?我们对Group Commit过程进行了适当优化,较好地解决了这个问题。
将组Group和同步Group分离,前者由一个Serialize线程承担,后者由Sync线程完成。Serialize线程作为生产者,Sync线程作为消费者,两者通过一个队列来共享数据,当Serialize线程发现队列中大于M个Group等待被同步的时候,将暂停组新的Group;而当Sync线程发现队列中等待的Group小于N个的时候,将唤醒Serialize线程组新的Group,由于Serialize线程经过了一段时间的等待,积累了一批数据,此时可以组成更大的Group,同时又不会造成Latency的提升。当整个系统负载低的时候,队列为空,Serialize无需等待,Latency很低;当系统负载较高的时候,队列中有堆积,此时暂停Serialize,也不会增加额外的Latency,而且随后可以组成较大的Group,获得高吞吐量收益。通过对Serialize和Sync操作的压力测试,可以确定最优化的M、N值。
细节篇
专注细节,做深做透对一个大型复杂系统而言尤为重要。在整个优化过程中,我们遇到很多富有趣味性的细节问题,经过深入的挖掘后,取得了良好的成果。其中具有代表性的一个例子就是Sniff的深入优化。在5K项目前,盘古Master冷启动时间以小时计,5K后由于规模扩张,Chunk数剧增,冷启动时间会更长。冷启动时,需要做Sniff操作,获取所有ChunkServer上Chunk的信息(Chunk的数量在十亿数量级),将这些信息汇聚到几个Map结构中,在多线程环境下Map结构的插入和更新必须锁保护。通过锁工具Profile也证实瓶颈就在锁保护的Map上。所以缩短启动时间就转化为如何对锁保护的Map进行读写性能优化这样一个十分具体的细节问题。为了减少锁竞争,调整结构如图5所示。
引入一个无锁队列,所有的工作线程将数据更新到无锁队列,随后由单一线程从无锁队列中批量更新到Map,调整后Sniff期间Context Switch从40万降低到4万左右,耗时缩减到半小时,效果显著。但我们依然觉得过长,继续深挖,发现此时CAS操作异常频繁,而且单一的更新线程已经占满一个核,继续优化感觉无从下手了,此时回过头来研究业务特点,看看能否根据业务特点来减少某些约束。功夫不负有心人,最终我们发现了一些非常有趣的细节,即在Sniff阶段,我们基本不读取这些Map,只在Sniff完成后,第一个写请求的处理过程中需要读取Map,这意味着只要Sniff完成后Map结果保证正确即可,而在Sniff过程中,Map更新不及时导致的不准确是可接受的。根据这个细节,我们让每个工作线程在TSD(线程私有数据)中缓存一个同类型的Map,形成图6中的结构。
每个工作线程直接将Sniff数据更新到线程私有的Map中,这个过程不需要锁保护,当TSD中汇聚的数据超过一定规模,或者数据沉淀了一定时间后再提交到无锁队列中,这样此前每个数据进一次无锁队列变成了批量数据进一次队列,效率明显提升。当然这里也产生了一个新的问题,Map进入无锁队列是由前端Sniff数据驱动的,当Sniff完成时,前端再无数据驱动,TSD中可能滞留了一批“沉没”数据无法提交到队列中,影响最终结果的准确性,这里我们引入一个超时线程来解决这个问题。最终优化完成后,Sniff在数分钟内完成。
总结与展望
经过这一轮优化,Master IOPS有数倍提升,冷启动时间大幅缩减,取得了良好效果。优化过程中我们形成了如下一些共识,希望能对后续工作有所指导。
■ 坚持用数据来确认瓶颈。一个点是不是瓶颈,是否需要优化,这是一个根本的方向性问题。可以大胆猜想,但一定要用数据来确认具体的瓶颈,否则方向性问题搞错了,会导致后续在非关键路径上浪费大量资源。
■ 密切结合业务逻辑。很多时候系统的优化不是一个简单的计算机科学领域问题,而是与业务逻辑高度相关,结合业务逻辑特点进行优化,往往会事半功倍。
■ 追求极致。在达到预期目标后,再问问自己是否达到了理论极限?是否还有潜力可挖?有时候多走一步,性能可能会有质的飞跃。
性能优化不仅仅是一项工作,更是一种锲而不舍、精益求精的态度,优化没有终点,我们一直在路上!