我们搞技术要培养见微知著的能力,用逻辑分析和构造来代替经验主义。例如“背压”(back-pressure)的话题就可以从很简单的模型出发,通过一步步的推导来完善解决方案。

生产者-消费者模型

这个很简单的模型就是进程内的生产者-消费者(producer-consumer)模型。生产者和消费者在不同线程。生产者和消费者通过有界队列连接,生产者往队列写入消息,消费者从队列拿取消息用于计算。若生产者速率过快,队列会写满,生产者暂停写入;若消费者速率过快,队列会排空,消费者暂停拿取。这就是简洁的压力反馈机制。
pc.jpg

背压指的是下游速率较慢时反馈给上游的压力,希望上游也减慢速率,但是实际上也要考虑下游速率较快而压力不足的情况。

拉和推

进一步分析这一模型。如果我们关注下游拿取数据的方式,这其实是拉模式(pull mode)。下游主动从队列拉取数据,若没有就等待(线程等待队列的信号量),而上游则是在主动往队列推送数据,这其实是推模式(push mode),若推不动就等待(线程等待队列的信号量)。现在上下游在同一个进程内,共享一个队列,拉模式和推模式同时存在。如果是分布式系统,上下游在不同的进程(以及不同的机器),那又如何呢?分布式系统一般会在拉模式和推模式之间二选一,两种模式的压力主导方不同。

在拉模式中:队列在上游,上游的生产者往队列写数据,写满就等待;下游不需要队列,消费者直接向上游请求数据,上游从队列取出数据作为响应返回给下游,这时上游的队列肯定是不满的,生产者就可以继续写入数据。这里有一个问题,下游请求上游时处于等待状态,它的计算能力被浪费了。更高效的方式是下游超量拉取一些数据,放在下游的内部队列里慢慢处理,同时下游可以请求上游并且等待响应。所以实际上,下游也是有队列的。但这里的重点是,用于跨进程压力反馈的是上游的队列。
pqc.jpg

在推模式中:队列在下游,生产者直接往下游发送数据,下游若队列已满而不能继续接受数据,就需要通知上游。一种方式是请求-响应(request-response)模型:上游发送带有数据的请求给下游,并且等待下游返回一个“成功”的响应,若不能处理就返回相应的“错误”响应,上游减慢或暂停推送,然后再试探何时能恢复推送。这种方式显然不是很快,因此也有发射后不用管(fire-and-forget)模型:上游只管发送数据,下游需要从另一个通道发送反馈消息给上游。但是若上游不及时处理反馈消息,还继续发送数据,而下游的队列已满,就只能发生网络丢包了,上游根据丢包率来调整推送速率。实际上,若请求-响应模型每次批量地把多个数据放在一个请求里,只需为多个数据等待一个响应,而发射后不用管模型其实也是在收到多个数据后发回一个相当于响应的反馈消息,两者又有什么区别呢?区别只有“谁”来决定批量的大小,请求-响应模型是由上游决定批量的大小,发射后不用管模型是由下游决定批量的大小。总之这里的重点是,用于跨进程压力反馈的是下游的队列。

拉模式的主要好处是,直接对上游的队列提供反馈,信息更及时。下游总是可以去请求上游,可以通过长轮询(long-polling)来满足实时性,若没有数据就等待在那(Kafka和SQS就是这么干的)。推模式的上游需要很大努力来知道下游的状态,无论下游是满还是空,上游都要慢一拍才知道,还可能有丢包的浪费。每次主动给上游发送请求才能取得数据,这个请求是额外的开销,但只要每次多批量请求一些数据,请求的开销也不算多大,能接受。

来自TCP协议的启发

既然说到丢包,就说说TCP协议中的背压吧,学名Flow Control。TCP恰好是推模式,它用滑动窗口(sliding window)来控制推送速率。上游一边推送数据包一边接受ACK包,如果滑动窗口的大小是10,就最多能有10个已发出但未被ACK的数据包。每当有ACK,就空出了一个窗口空位,能再推送一个数据包。这个滑动窗口相当于队列。若有数据包未被ACK,即丢包,可以进行有限次数的重发。未被ACK的数据包要暂存在上游的一个缓冲区里以备重发之需,称之为send buffer。下游收到数据还需要等待程序来处理,要把数据暂存在一个buffer里,称之为receive buffer。TCP的背压机制(用buffer)和应用层的背压机制(用queue)往往是同时存在的,如下图:
pnc.jpg

滑动窗口只是一个基础机制。为了达到更高效率,TCP还有拥塞控制(Congestion Control)机制,根据丢包率和一些算法来预测和调整推送速率。我们编写分布式系统时也可以想一些预测性的办法来提高效率,而不是只靠最基本的压力反馈机制。在一个多级数据流水线中,有上游、中游和下游,压力一级级向上传导是有延迟的。

  • 推模式可以利用水位线(watermark)来提高效率:下游在队列用量达到某个水位线时,把队列的余量信息反馈给上游,让上游提前调整速率而不是等到下游队列写满才做反应。
  • 拉模式也可以利用水位线,当一个上游对应多个下游时(fan-out traffic),水位线很有帮助。如果上游把数据集拆成多个逻辑分区(比如哈希分区),每个下游处理一个分区,那么简单的实现就是上游给每个分区配备一个队列,各有独立的背压,一个下游的慢只影响相应分区的队列,不影响其他分区。但是这些队列可能旱的旱涝的涝,不平衡,因此这么做不高效。一种高效的方式是每个队列小一些,再加一个共享队列,让写满的队列能临时征用共享队列。Flink的Credit-based Flow Control有异曲同工的做法,但它是基于推模式的,队列在下游(https://mp.weixin.qq.com/s/1fL470sRbu-TqSnvhn6Jng

总结

从基本的生产者-消费者模型出发,可以一步步推导出像数据流水线这样的分布式系统的背压机制。我们还知道了拉模式和推模式,对于大多数应用场景,我比较推荐拉模式。为什么?因为拉模式的压力反馈更及时,实现也简单(推模式一定要利用水位线才能高效率),利用长轮询也能达到足够的实时性。


sorra
841 声望78 粉丝