微服务架构下,各个微服务间的通信方式是首先需要决定的事。微服务间的通信方式主要有REST、RPC和消息这三种。这三种通信方式各有优缺点,各有其适合的场景,关于它们的比较及分析今天就先不讲了。
今天主要讲的是基于消息的通信方式下,先入库在发送消息的问题。基于消息的通信方式下,各个微服务间通过消息驱动来完成业务逻辑。一个典型的例子如下:
上例中,用户服务处理用户注册请求,先入库,然后发送用户注册事件,邮件服务监听用户注册事件,然后发送欢迎邮件。
那么就上述场景而言,对于用户服务,我们的业务代码该如何写呢?为什么我说先入库再发消息,没你想得这么简单呢?下面一起来看看。
1 先入库,再发消息,最简单又直接的方式
简单又直接的入库发消息伪代码如下:
1. var content = processRequest(httpRequest);
2. var message = prepareMessage(httpRequest);
3. DB.insert(content);
4. Message.publish(message);
对于先入库,再发消息的业务逻辑,最简单直接的代码如上,那么上述代码有什么问题吗?
考虑以下场景:
(1) 数据库入库成功,发送消息失败,即步骤3成功,步骤4失败
数据可能不一致。因为步骤4失败的原因总的来说有两个,一是消息发送失败(消息总线未接收到消息),此时数据不一致,因为数据库中有数据,但消息未发送。二是消息发送成功(消息总线接收到),但回包的时候失败,对于系统来说,此时数据反而是一致的,数据入库,消息发送了。
(2) 数据库入库失败
数据可能不一致。有人可能会想数据库操作失败,数据库的事务ACID特性可以保证数据一致性。但实际这里也可能有两种情况,一是数据库操作失败,事务未提交,此时数据一致,数据库会回滚事务。二是事务已提交,数据库回包失败,数据不一致(数据库和消息总线中的数据不一致),数据库中有数据,消息却没发送。
2 最简单直接的方式并不好使,那该怎么办?
其实上述问题的本质是分布式事务问题,数据库和消息总线实际是两个资源。想要保持两个或者多个资源间的数据一致性,以及操作的原子性,这正是分布式事务要解决的问题。
让我们尝试解决此类问题。
首先要问的一个问题是,我们的系统需要强一致性吗?在上述例子中如果数据库和消息总线中的数据需要保持强一致,则在任一时刻数据库与消息总线中的数据都需要保持一致。
显然并不需要。
实际在分布式系统中,只要保持弱一致性就可以了,也就是说最终一致性,对应上例,也就是说在任一时刻,数据库与消息总线中的数据可以暂时不一致,但最终需要一致,只不过中间有一些间隔。
所以现在我们只要让系统具备最终一致性就可以了,那么如何具备最终一致性呢?
在上例中,把问题具体化,其实就是处理完请求并且入库后,必须发送消息,也就是数据库中有的数据,消息总线中也必须有。问题进一步抽象定义,即解决数据库入库和发送消息的原子性问题,这两个操作要么都成功,要么都失败。并且现在我们的系统只需要满足弱一致性就可以,所以问题可以更进一步定义为这两个操作要么最终都成功,要么最终都失败。
看到这里,有一个方案应该能够浮现出来——本地消息表。
本地消息表的伪代码如下:
1. var content = processRequest(httpRequest);
2. var message = prepareMessage(httpRequest);
3. DB.begin();
4. DB.insert(content);
5. DB.insert(message);
6. DB.commit();
7. Message.publish(message);
8. DB.delete(message);
// 定时任务,补偿发送消息,这里查询的消息注意时间存在过短的问题,避免重复发送
Executor.execute(new Task() {
public void run() {
while (true) {
var message = DB.selectMessage();
Message.publish(message);
DB.delete(message);}
}
});
该方案本质上是利用了本地数据库事务的特性,将消息和业务逻辑处理放在一个事务里持久化,利用事务特性可以保证业务处理和消息能够同时存储成功或失败,然后在发送消息。
同样,让我们考虑下述场景:
(1)数据库入库成功,消息发送失败,即步骤3~6成功,步骤7失败
数据最终一致。定时任务会补偿消息投递,当然这里也可能会存在消息重复发送的问题。
(2)数据库入库失败,即步骤3~6失败。
数据最终一致。数据库在事务提交前失败,数据一致。数据库在事务提交后,但回包前失败,数据最终一致,数据已存在,定时任务会补偿消息投递。
(3)数据库入库成功,消息发送成功,消息删除失败,即步骤3~7成功,步骤8失败。
数据最终一致。消息未删除,定时任务补偿发送消息,会导致消息重复发送。消息已删除,但数据库回包前失败,补偿任务不做处理,数据最终一致。
可以看到本地消息表除了会导致消息重复投递,几乎没有别的问题。
那么消息重复投递怎么办?
如果消息重复投递,这里只看数据库跟消息总线,其实数据是不一致的,消息总线中数据多了。但从整个系统的层面来看呢?如果消费端能够实现幂等,那么整个系统的数据还是最终一致的。所以采用本地消息表,下游消费端需要实现幂等。而且现在有的消息中间件也能够实现发送消息的幂等(比如Kafka 0.11版本以上,Broker可以通过发送的消息id进行去重,保证发送消息的幂等),即重复投递的消息在消息中间件中只会存在一份,这样系统也是没有问题的。
3 先入库,再发消息原来不简单,那么符合上述场景的业务是不是都得这么做呢?
有同学看到这里可能会觉得原来处理请求,先入库再发消息的场景原来不是这么简单呀,看来以前用错了?
那是不是此场景下所有的应用都需要按照此类方案来呢?
我这里的建议是看具体业务需求。
大流量大规模的分布式系统,从可靠性及可维护性来讲,必须这么做。至于那些用户少,规模小的应用,从故障发生的概率、发生故障后人员维护的成本来考虑,你可以不遵守上述方案。
当然,能够看到这些问题后然后选择一个适合的方案,和不知道自己在做什么完全是两码事。
先知道规则,然后再知道什么时候可以打破规则。
写在最后
对于请求处理,先入库再发消息的场景并没有看起来的这么简单。该问题实际是一个分布式事务问题,涉及到两个资源间的数据一致性,入库与发消息原子性问题。
对于大多数分布式应用,能够满足数据最终一致性就可以。
所以上述场景可以采用本地消息表的方案,本地消息表实质上是利用了本地数据库的事务特性,保证业务处理与消息存储的事务特性。
本地消息表可能会存在消息重复发送的问题,所以需要实现消费端的幂等。
先知道规则,然后再知道什么时候可以打破规则。
最后留一个问题,对于消费端来说,接收消息,然后处理入库,如何保持幂等?如果是接收消息,然后处理入库,再然后再发消息的场景呢?如果是接收消息,然后远程调用的场景呢?
如果能够回答清楚上述问题,不光光是对这些场景有很深的理解,相信你对整个分布式系统的设计与实现都有很深的理解。
后面的文章,说一说我对上述场景以及分布式系统设计与实现的理解。欢迎大家关注。
希望今天的内容对大家有所帮助,更多精彩文章欢迎关注微信公众号:WU双。