以Kafka为例说明发布/订阅者、Actor和CSP模型的区别
柏舟 新冠4年 08-23
CSP和Actor模型都是一种无锁的并行模型,pub/sub是一种编程范式。本文主要通过内部消息传递的实现说明它们的区别。
Actor
Actor模型可以应用于共享内存架构,适合解决地理分布型的问题,同时提供良好的容错性。它主要包含:消息、信箱和运行逻辑。Actor之间是并行运行的,而Actor内部封装了状态必须依次处理消息。Actor并发的关键在于:Actor内部通过信箱缓存消息从而串行执行,所以只需要关心消息的并发传递问题。
通信顺序进程CSP
CSP模型不关注发送消息的实体,而是关注发送消息时使用的Channel。它不像Actor那样与信箱是紧耦合的,而是可以单独创建和读写,并在进程中传递。
表面上的区别
模型 | 有无队列 | 原因 |
---|---|---|
pub/sub | 可有可无 | 是一种编程范式,没有规定内部需要队列,并且consumers的语义是广播。 |
CSP | 有 | 是一种并行执行框架,语义规定需要全局的队列,consumers的语义是每个消息由一个消费者消费。 |
Actor | 可有可无 | 是一种并行执行框架,语义上没有队列。通信是点对点的。 |
pull和push模型
消息队列有两种模型:
- push模型,消息队列将消息推送到消费者。典型的是基于websocket的应用,服务器将更新推送到客户端中。
- pull模型,客户端轮询消息队列是否有更新。
Kafka使用pull模型,这是因为push模型有很明显的缺点:消息的传输速度由消息队列控制,当消费者工作缓慢时,消息推送就会变成一种DOS(denial of service attack)。但是,使用pull模型,它能够充分利用消息传输,并且消息队列能够了解消费速率。此外,Kafka的容错也变得很简单,由于Kafka是一个基于Append-Only日志的消息队列,消费者只需要记录消费的位置(offset)就可以实现容错。push模型的容错就得记录本地缓存的信息和消费情况,非常复杂。
从这个角度看,消息队列可以权衡使用这两种模型,而Actor和pub/sub模型只能使用push模型,相应地容错处理更加复杂。
Actor模型为什么不能用pull模型呢?
假如Actor模型使用pull模型,就会变成这样:
由于Actor1不知道它的消息的消费者是谁,消费是否落后,所以不得不永久地保存所有产出的消息。为了解决这个问题,最后的方案与CSP模型没有太大的区别。事实上,Actor内部的实现也可以使用CSP模型。
消息传递的Exactly-Once
分布式系统很少直接使用无队列的pub/sub模型,我认为很大的一个原因是网络环境的不可靠导致消息的丢失。网络的消息传递会有以下问题:
- 丢失(At-Most-Once):消息可能未收到。
- 重复(At-Least-Once):不知道消息是否收到,超时后重复发送消息,导致接收了至少两个消息。
Kafka的Exactly-Once
Publisher的解决方案是使用版本向量:消息队列维护一个当前更新的offset(版本向量),publisher每次上传时,会带上offset,如果与消息队列的offset不同说明publisher维护的offset状态滞后了,更新就会失败。这样就解决了重复上传问题。
Consumer的解决方案是保存消费的offset:当Consumer失效时,会从本地持久化的offset中恢复,重新拉取消息消费。但是Kafka提供的Exactly-Once语义是有限的,如果Consumer超时未提交当前消费offset,Kafka会认为Consumer失效,重新分配消息给其它Consumer,此时可能造成重复消费的问题。
注:关于Kafka的描述不一定正确,而且很多东西缺少细节,比如多个publishers推送消息如何维护offset等等。
Actor和pub/sub
Actor和pub/sub的Exactly-Once很难保证。事实上大部分RPC框架都没有做,或最多提供At-Least-Once。因为push模型既需要本地持久化消息,又需要考虑消息重复、丢失的问题,处理起来就很复杂。
总结
Actor和CSP这些解决并发的思路都是如何传递消息,而不是从如何并行算法的角度考虑的。但是,现实是不同的:
- Actor和CSP几乎不能实现同步响应模型。CSP是单向的,而Actor要实现返回消息需要传输地址,其它Actor消费完毕后通过地址回传消息,此时消息会进入队列,也就是说发送和接收消息无法在一个函数中完成。
- 现代的消息队列都支持pub/sub功能,功能上比CSP更强。
- 现代的事件总线比如Vert.x,语义上不仅包含pub/sub和Actor,还支持同步响应。