2

SEDA 架构反思

 1 year ago
source link: https://www.zenlife.tk/seda-retrospective.md
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

SEDA 架构反思

2022-09-27

SEDA(Staged Event-Driven Architecture) 是一个很经典的架构,其实一开始我不知道它叫这个名字,但是在实际日常使用中,已经接触过许多了。 它是把业务生命期,拆分成了许多的 stage,然后 stage 和 stage 之间,通过 queue 来连接,一个 stage 处理完,放到另一个 stage 的 queue 里面等待处理,每个 stage 的执行有自己的 thread pool,从 queue 获取任务后执行。

举个例子,tikv 的写入流程,看看请求从进入,到处理结束的过程。

  • 首先消息进来要经过 grpc 那层;
  • 接下来跟事务处理相关的,scheduler 层处理事务冲突,两阶段提交等,直到将请求转化成 key-value;
  • 接着是走 raft 引擎层,raft 引擎那里面,又涉及到比较多的内部 stage,需要经历 raft 多副本复制,本地持久化 log 这样的过程。
  • 最后还要 apply log 持久化到存储层。存储引擎那边,apply log 涉及到 rocksdb 的操作。

按 stage 拆分,每个 stage 都有对应的 thread pool,中间的消息处理有 queue 的过程,这个就是经典的 SEDA 架构。

这个架构的第一个痛点就是"玄学调优"。因为有很多的 stage,就对应有很多的 thread pool,每个 thread pool 都有线程数量的参数调节,比如说看这个文档:

  • gRPC 线程池
  • Scheduler 线程池
  • Raftstore 线程池
  • StoreWriter 线程池
  • Apply 线程池
  • RocksDB 线程池

这么多线程池,这么多参数,用户咋知道哪个配置多少线程啊?问调优专家?研发自己也不知道啊!只能通过观察队列长度判断,哪个 stage 的队列排队过长,就是慢在哪一步了。然而这些参数往往是不好动态调的,又要重启服务。并且业务负载不一样,可能成为瓶颈的 stage 还不一样,所以这些参数没有多少自适应的能力,说得好听叫"经验参数",说得直白点就叫"玄学调优"。需要玄学调优说明系统设计得不好。

设计得好的系统,应该有某种背压机制,或者动态地根据 workload 调整消费者线程数量。线程池这种属于 CPU 资源,比如某个 stage 相对较闲,某个 stage 的 CPU 到瓶颈了,那么总的 CPU 资源应该倾斜到瓶颈的那个 stage 上面,这样将有利于消除瓶颈,提升整体性能。然而 thread 数据固定死的情况下,是做不到这种动态调节的。

第二个痛点是"长尾延迟"问题。stage 很多,每个 stage 都有它各自的 P99 波动,理想状态是各个 stage 都很稳,然而,在极限压力的时候,各个 stage 都不稳,P99 的波动会很大,组合起来之后,对最终的整体波动影响更多,长尾延迟最终变得不可控。 理论上是,系统稳定之后,只在 stage 某一级形成排队,但是真实系统是动态的过程,哪怕前面输入是稳定的,系统内部还会有一些后台任务。还是以 tikv 为例,可能执行到一定时间,就有后台 rocksdb compaction 的过程,又或者 raft snapshot 的处理,再比如 scheduler 锁冲突,继而造成延迟,它就是有概率的事情。系统稳定下来这个假设是不成立的,永远都在动态变化中。

调优的时候,可以观察消息在各个 stage 中的处理耗时,然后分析是什么事件影响了延迟。但是长尾这个事情真的很难解决,因为"不稳*不稳 = 非常不稳"。总之用户的体感就是:不稳。

第三个痛点,大小任务相互影响。

这里面有一个 bach 和 streaming 的概念。倾向 batch 处理的任务,吞吐好,但是延迟方面会升高;而倾向 streaming 的任务,实时性更好,吞吐上却不如 batch。好,我们看这个架构下,它只有一个简单的队列,缺乏优先级调节的手段。遇到大小任务交错的时候,会出现什么问题呢? 大的任务的处理会更耗时,当它被跟小任务同等对待时,它会阻塞队列中,排在它后面的更多的小的任务。于是从整体角度,系统的平均请求处理延迟上涨了。用户很直观的体感就是,跟大的 workload 一起跑的时候,一些很简单的请求延迟也是明显受影响的。

最后,这个架构还有一个问题,"系统资源使用不满"。经常有用户压测时有疑问,为什么 CPU 打不满呀?因为上下文切换开销。抛开负载或者非 CPU 瓶颈的因素,这个架构的线程数是可以远大于 CPU 核数。比如说觉得这个 stage 慢,那么就给它多配一些 thread,但是实际上可以使用的总资源量就那么多,某处的增加对应就表示其它地方的减少,总资源量依然不变,只是上下文切换变多了,耗损增加,有效利用率变低。

如何让 CPU/IO 等等资源都被有效合理地利用,是比较复杂的问题。如果用得不好,就是一会儿 CPU 忙,一会儿 IO 又忙,总体的利用率看起来都是打不满,但是呢,总的系统吞吐又上不去。

说了几个 SEAD 的问题,再来设想一些可能更好的设计点。

第一个点是,TPC (thread per core)。我发现无论是 Go 的 runtime,还是云风的 skynet 这种,其实 thread 的数量都是根据机器的核数固定的。增加 thead 这种粒度,对 CPU 的使用并不友好,只会增加上下文切换的开销。而在上层提供 goroutine / 协程 / actor 这类的概念则会更加友好。 如果没有这类东西的时候,应该是一个事件驱动+回调的模型,或者用 future 之类的尽量简化一下心智负担,但总体上相当于是人肉的状态机了,把业务拆成很多的函数串起来,以函数的片段的形式,放给各个 thread 去驱动。

我们可以设想一下 SEDA 的线程池,怎么样演化成 TPC 的过程。首先,由于每个 stage 由于有各自的 thread pool 于是有了我们说的 "玄学调参" 的问题,那么我们想用固定 core 数量的 thread,统一成一个池子。 然后这个统一线程池需要能处理所有类型的任务。以前每种池子只处理一种消息,如果改成一个池子能处理所有类型的消息,那就变成了这样:

func run() {
	switch msg.type {
		case TaskA:
			runXXX()
		case TaskB:
			runYYY()
		case TaskC:
			runZZZ()
		...
	}
}

这种写法肯定是很蛋疼的,每多一种消息就要改这里的代码。 怎么解决呢 – 把消息变成自解释的。消息的类型,消息的数据,消息的处理函数,三者打包成一个东西,就是变成闭包了,或者叫回调函数。这样一个池子就可以驱动所有的任务,只要实现一个 run() 的方法。 一个请求的处理要经过很多的 stage,所以一次的 run() 并不行,需要有切成很多小片段的 run(),没有语言层面的支持就是回调函数了(回调地狱),如果这里做得比较顺滑,那就等于重新发明了协程。

接着说第二个点,全异步 io。如果是 TPC 的,那同步 io 一旦运行到 io 的位置,thread 就跟着阻塞了,这样是不行的。io wait 是 TPC 的敌人。 Go 的 runtime 里面,不算做得特别的彻底,它的基础的网络 io 是全异步的,但是 file io 类的还是有同步模型。不过由于有 M-P-G 多层,这个不是啥大问题。M = thread 然后如果 thread 进入睡眠之后,可以动态增减 M,保持总体的 P 等于核数就行,还是充分利用计算资源的。

最后,调度一定要有。CPU 调度,IO 调度,所有资源类的。因为上下文切换这个事情不可控,压力大的时候,有可能切出去之后,很久不被切回来。而重要任务它是 block 其它任务,增加整体延迟。Go 的 runtime 在这一块就是缺失的。想要解决大小任务的相互影响,降低延迟,业务都应该根据自己场景实现一套调度机制。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK