总结一下最近遇到的一些问题和对应的一些想法。
Staged Event-Driven Architecture,简称SEDA,这个词最开始是在Cassandra那边听到的。这个架构的提出是2001年的事情了,用多个线程池而非一个来处理请求。一个请求从开始到结束被拆分为多个阶段,每个阶段用一个独立线程池、并且每个阶段的线程数可以不等,在阶段之间用一个队列连接。每个阶段处理完就扔队列里,下个阶段的线程从队列拉请求去处理。每次处理完一个请求就从队列里拿下个请求来处理。到了2016年,实际上几乎任何server或者server的框架都会用类似的架构,比如一个Web服务器都有如下一些阶段:accept新的连接,根据http协议来parse成对应web服务标准的请求,调用业务实现者的业务代码处理请求、把请求的结果返回给client。不一定每个阶段都是独立的一个线程池、用队列连接,但至少在“业务实现者的业务代码处理请求”这步,是有一个线程池来做的。这样这些线程不断的跑业务实现者的写的代码,前面和后面的任务都是框架来做,可能并不在一个线程池跑。而这个阶段的线程一般也叫worker或者handler。这当然也没啥高端的,不就是线程池嘛。但是在2001年的论文中,单纯的“线程池模型”是限制死了并发请求数的,一旦当前没有任何线程可用,就直接把请求丢掉的(估计20年前的web server都是这么写的?)。所以我觉得只要有个队列来等待handler处理请求,就可算作SEDA了。
SEDA的论文在当时(多核CPU都很少、操作系统的线程实现很弱)其实其中一个想解决的问题是找到系统的瓶颈的问题,在不能开很多线程的情况下,找到消耗资源的线程,在这些阶段多开、在不消耗资源的阶段少开。同时还提到了根据每个阶段的响应时间动态调整线程池大小之类的功能。这些在现在其实都没啥大用了。多核遍地、thread可以开很多,不能说像coroutine、goroutine一样随便开,也起码不至于还得动态调整了。也正是如此,现在的web服务在业务逻辑那块也普遍不会再拆成不同的stage了,从头跑到尾就可以(按原作者在2010年的博文中总结的,就是很多拆的很细的stage应该用函数调用来连接,那其实不就是不拆了)。
那现在的模式有啥问题呢?
假设我们有100个handler来处理请求。里面的逻辑都是内存+CPU,自然没啥问题;如果是读硬盘,可能问题也不大;如果是后面有一堆无状态的rpc server需要调用(比如nginx+http server的模式),那么只要能发现有问题的后台server然后踢掉就行。但如果底下是读一个数据库,尤其是有多个节点的、有状态的分布式数据库(不同的数据分布在不同的机器上,机器挂了不能直接踢掉),就可能有如下问题:
假如这100个handler访问分布式数据库的超时是1000ms,分布式数据库有10个节点,平均的响应时间是50ms。那么理论上这个服务能抗住2000的qps。如果当前的qps是1000,但其中一个数据库的节点有问题、非常卡,所有的请求过去了都会timeout。按道理来说,只挂了1/10的数据库节点,可用性应该是90%,因为90%的数据不需要访问这个数据库。但是,1000的qps的十分之一是100,也就是每秒有100个请求会超时一秒。那就意味着100个handler几乎都被慢请求卡住了,整个系统的可用性降到几乎为0。也就是说下层微小的可用性问题会导致上层的可用性问题被放大很多倍。
这个问题的本质在于,handler是blocking模式。跑完完整的请求才会把线程让出来。那么如果其中某个地方阻塞了(锁、IO等等),那么就一直要占用。Blocking模式的代码当然是最好写的,从头到尾按照逻辑来,遇到并发访问同一个字段之类的就加锁等着,简单直接。而Cassandra目前就有个非常激进的计划,把整个server拆成non-blocking的模式,并且有可能有并发访问同一个数据的请求都分配给同一个thread/EventLoop来执行从而完全规避锁(他们是说能不需要锁,但还没开始写,所以根据文档按我目前的理解是这样实现的),每次执行一段不会阻塞的代码,遇到阻塞操作就把当前线程让出去,等对应的状态ready了再执行下一步的event事件。整个系统完全不会有任何线程被阻塞,自然可以根本解决这个问题。每个时刻每个thread都在干活,那么整个系统的线程数可以完全等于CPU数,也就是所谓的Thread Per Core架构(TPC)。而且连锁都不需要了,很多代码都不需要考虑原子性了,各种容器直接上非并发的,性能更好,对GC压力也小,操作系统也没线程切换的开销。Cassandra的目标是从SEDA转向TPC,只不过代码改动会很大,因为要把所有blocking的都拆掉,近乎重写每个模块了,是个非常长期的事情。这块我最近也在研究,感觉挺好玩的。
在服务彻底搞成TPC问题之前自然也是要解决可用性放大的问题的,或者说也没必要单纯为了解决可用性问题搞TPC。按照SEDA以及non-blocking的思路,可以先把访问数据库前、访问数据库、访问数据库后拆成三个阶段。相当于业务代码拆成异步的两个部分。这里需要数据库的client支持异步的请求而非一个线程的同步请求。如果不支持的话只能是将就下开一个大线程池(因为都是阻塞操作,所以稍微多开一些不会把系统搞死,handler因为有业务的计算等操作,RUNNING的比例会很大,开太多可能会死),“降低”放大的比例和悲剧的概率,当然也不是长久办法。好在HBase和Cassandra比较先进,在Client协议上支持一个连接并发的跑多个请求,拆异步其实比较容易,而Cassandra的Datastax半官方Java Client已经是支持异步的了。HBase的话社区在做,有非官方的实现,等官方的可能还得一会。
如果业务端连异步也来不及改(而且其实确实不好改),那么最简单粗暴的就是限制死对一个server的并发请求数的上限,达到上限直接把请求干掉,不给任何超时。一般来说如果10个server的话那么平均每个server会有10个并发的请求(实际上达不到,因为handler不完全用来等待数据库请求,还有业务逻辑,需要看总的时间占比),那么把上限设成20,可以在一个server卡的时候保证80个handler依然可用的,即使流量跑满也能保证可用性只下降到80%,在系统流量不大的情况下可以保证可用性能真正达到90%。
Client端会因为blocking的访问把下层的可用性放大,其实数据库自己也一样,因为分布式数据库无论哪种架构都需要数据存N份因此rpc是必不可少的。
比如如果是Cassandra,那么client的请求到了一个节点之后,会根据key来计算出真正拥有这个数据的N个节点,而这可能是集群中的任何几个节点。那么SEDA+异步就可以防止因为某个节点卡而消耗掉处理client请求的handler(实际上做为一个AP的系统如果W和R都小于N的话甚至只要不让client直接请求卡的节点,是不会有任何可用性问题的)。
对于HBase来说,写数据时下层的三个HDFS节点是固定的,其中一个一般是自己,另外两个整个Region Server因为只有一个WAL文件,也会是固定的两个机器。因为HDFS的pipeline类似2PC,需要三个节点都必须可用,同时写到三个节点才算成功(这样的好处是整个集群挂俩节点也没事,各种QUORUM的系统挂俩就跪了,坏处是延迟高可用性容易抖动),这个是没办法的事情。因此集群中任何一个datanode卡了都可能会导致有三个Region Server的所有write handler都卡住从而可用性被放大,直到把对应的datanode踢掉(即使用的multiWAL同时写多个WAL文件,那么从概率上也还是3/n的region被影响)。而读的时候如果数据不在本地(这属于BigTable的设计问题,也没办法)去调用其他机器,是有可能访问任何一个机器的,那么这个时候如果一个机器卡了,理论上可能整个集群所有region server的所有read handler都被堵死的,直到把对应的datanode踢掉。之所以这里可以踢掉datanode,而HBase client那边不能踢掉region server,是因为datanode踢掉任何两个,整个集群都还能用,而region server踢掉就意味着它上面的region不能用,所以只能指望master把region挪走,但如果region server卡而不死,master又不会这样做……因此HBase client更方便的方法只能是限制到某个机器的并发请求上限了。
这也是未来HBase可能需要改进的地方,比较蛋疼的问题是HDFS的client不支持异步……
对于用multi-paxos、raft写的系统来说,一个group内leader卡了本来就什么也做不了,如果follower卡了也不会影响leader的操作,所以应该不存在可用性放大问题。
总之,按照我目前的理解,SEDA的队列思想已经被普遍用在所有server框架中,并且依然适合用在blocking模式下小比例可用性问题会被放大的地方,准确的说就是“阻塞访问有状态的多节点的服务”。因此读写本地磁盘什么的一般是不需要的,除非本地有一大堆磁盘想规避某个慢盘吃光handler之类的问题,这个本质上和请求有状态rpc server是一个道理了。所以数据存本地的话在这也是有优势的,不需要用SEDA的工程性优化来解决可用性放大的问题,相对来说也就是HBase/BigTable的又一个劣势吧。
发表回复