这篇文章以两个典型的实际案例为基础,聊一聊分布式系统如何实现幂等性。
案例一:转账系统
在之前的文章,有多次提到转账系统这个案例,由于这个案例太典型了,很多大学教授数据库事务的时候就是用的这个案例。
对于一个单体应用版的转账系统,我们可以直接利用数据库的事务来保证整个转账操作的ACID。但是,随着用户量级的增加,单个数据库的瓶颈也随之出现,于是就出现了分库分表的设计,即:一部分用户信息存储在一个数据库,另一部分存储在另一个数据库。基于这样的设计,单个数据库的事务肯定就不可用了,我们需要采用跨数据库的分布式事务,比如基于XA协议的分布式事务,但是这种方式有一些自身的问题,并且有应用场景的局限性。所以,一般来说实际场景都是采用基于BASE的最终一致性解决方案。
如下则是一个简单的最终一致性方案设计:
Step 1:Application收到用户发出的一个转账请求之后,首先执行转出方的逻辑,如下:
begin transaction记账单 (包括:转账请求uuid 转账状态in progress)扣钱(转出方余额减少)commit/rollback
这段逻辑包含在一个transaction里面,由于只牵扯到一个数据库,可以利用单个数据库的事务保证。
Step 2:一个background job不断的抓取in progress的记账单,然后发送event(通知收款方收钱)到Kafka,发送成功之后,把账单状态改成success。
这段逻辑就是outbox pattern的实现,关于outbox pattern的具体介绍,可以参考我的另外一篇文章(空谈发件箱模式(outbox pattern))。
Step 3:转入方实现有个listener一直监听这个event,当监听到这个event时,执行如下逻辑:
begin transaction记账单(包括:转账请求uuid 转账状态success)加钱(转入方余额增加)commit/rollback
转入方的逻辑处理也是在一个transaction里面,可以通过单个数据库的事务保证。
但是,上面的设计可能有多个地方会出现event消息重发的情况,比如:background job发送event成功,但是修改账单状态失败;或者,转入方逻辑commit到数据库成功,但是发送ack给Kafka出问题,等等。那么,如何处理这样的重复消费消息的情况呢?因为如果处理不当,就可能会导致数据不一致。其实,这本质上就是一个幂等性问题,保证收到重复消息和收到一次消息的处理结果是一致的,就是幂等的。
对于上面的设计,要保证幂等性,可以在账单表中存一个request uuid,利用这个uuid达到去重的效果,具体是:转入方在收到重复转账event消息时,根据request uuid先去数据库里面检查有没有这个ID存在,有的话则表示这个转账已经处理过了,直接把这个event忽略掉;没有的话则表示需要处理这个event,执行转账。总体来讲,这样的处理逻辑就是幂等的。
当然,实际的转账系统还需要考虑各种错误情况,比如:转入方处理失败的话,可以发送一个反向的event,转出方把之前的扣钱revert回来。
案例二:数据迁移
在之前的文章,也有多次提到数据迁移这个案例。这个案例说的是需要把数据从老的数据库迁移到新的数据库,并且需要保证服务不停止(zero downtime),即不影响用户的正常使用。
对于老数据,可以直接使用一个background job不断的迁移;关键是对于新数据,应该如何“迁移”?一种办法是:双写,即在往老数据库写的同时也往新数据库写,这样来保证新数据在两边都有。
同时往两个数据库写,如何保证两边全成功全失败呢?这又是分布式事务的问题,当时提到了一种方案:best effort 1pc,使用的是Spring提供的ChainedTransactionManager。但是,这种方式在极限情况下也会出现不一致的情况,比如:数据库在特定的时间节点宕机。
下面介绍另外一种基于event方式的双写:在把数据往老数据库写之后,接着把数据本身作为event payload发到Kafka。(这里可以利用outbox pattern来保证at least once delivery)然后,新加一段逻辑,监听这个event,收到这个event之后,把数据写入到新的数据库。
同样的,在监听event这里,需要额外handle下面的情况以保证幂等性:
- 收到重复插入数据event(这个情况和上面转账的案例类似) 对于这种情况,如何实现幂等性处理? 类似的,可以依赖一个唯一的主键,先根据主键判断数据存不存在。
- 消息顺序变化 消息顺序产生变化,可能的情况有: - retry queue,两次连续更新同一条数据的event,第一个event处理失败放进retry queue,而第二个event处理成功。 - 流量切到新的数据库上时,Kafka里面还有更新数据的event,此时已经有更新数据的请求进来。 对于这种情况,如何保证幂等性呢? 关键点是老的event需要被忽略掉。实现层面可以依赖于一个时间戳,不管是迁移数据本身,或者是event对象本身,如果新的event已经处理,则老的event忽略;如果数据已经被更新,则老的event忽略。
上面提到的双写需要再额外增加一个event数据库表,如果可以,也可以采用cdc的方式,这种方式常常用于数据库的复制、备份等场景,利用这种方式,则不需要额外写一张表,而依赖数据库的事务日志,具体可以参考我的另一篇文章(空谈发件箱模式(outbox pattern))。
写在最后的话
通过上面两个案例,我们可以看到:在很多场景,都可以用最终一致性方案替代强一致性来实现分布式事务,这样应用系统可以更加容易地实现高可用(high availability)、可伸缩性(scaling)等特性;但同时也需要非常仔细地从具体业务角度处理幂等性等问题。