先复习下分布式事务比较流行的几种方案。

考虑两个操作A和B,不放在一个事务内,怎么保证原子性(要么同时发生,要么同时不做/回滚)

方案一:可靠消息

通过消息队列,操作A执行成功,同时发送对应事件,操作B通过消费该事件来进行,即收到该事件,执行对应动作。

首先,MQ必须保证不丢消息。

RocketMQ的可靠消息实现:发送prepare消息 => 执行本地事务 => 发送ack消息,MQ定期询问没有ack的消息状态,所以需要实现一个回查事务是否成功的接口。

本地消息表的实现是:本地事务中,加入写本地消息表。事务执行成功,消息表自然也有一条对应记录,需要有一个调度任务,去发送消息,并且仅当发送明确成功时,才标记消息。

消费者消费消息,执行B操作。

如果消费者消费失败,一种是消费者实现幂等,可以重复消费,直到成功。

如果消费者不幂等,即只能消费一次,可以考虑消费者的ack和回查接口(RocketMQ实现)。

如果是不可重试的失败,需要业务上给出补偿方案,例如通知A操作执行者执行相应补偿。

方案二:tcc

tcc更多的是业务上的设计来保证最终一致性,不依赖数据库层面的事务或者某个特殊能力的组件。

将一个业务操作划分为try、commit/cancel三个动作。commit和cancel只会发生一个,即try阶段执行后,就能决定是cancel或者commit。

cancel和commit需要幂等,并且失败作补偿,或者走异常预案去处理。

场景一:调第三方接口+更新本地

举个例子,退款请求,第三方提供退款接口和查询接口,查询接口有一定同步延时,即可能出现退款接口返回成功后,一段时间内查询接口没有查到。

一种简单方案:

  1. 本地标记prepare
  2. 调用退款 => 根据结果执行本地事务,更新某些字段,同时更新prepare为acked
  3. 定期轮询处于prepare阶段的本地事务,调用第三方的查询接口,根据结果来作相应处理(包括标记prepare)。如果第三方没结果,可能是由于第三方的同步延迟,需要重试查询,直到有明确的结论(确实没有调用退款,或者调用了退款)。

定期轮询时,需要有一定的时间差,比如5分钟前prepare的。

场景二:水平分库后跨db事务

举个例子,将一个db库拆分成两个,两个库中的表都一样。PM提了个需求,要实现一个批量原子操作,有可能会同时操作这两个库。

方案一:
改造业务,避免出现跨库的操作。缺点可能是,业务改造成本大,有可能时间来不及。

方案二:
嵌套事务:两个库的操作外层嵌套两个库的事务(例如@Transactional或者python的atomic(db))。

这么做的原因是,两个事务的顺序是:开启库1事务 => 开启库2事务 => 执行跨库操作 => 提交库2事务 => 提交库1事务
回滚是:开启库1事务 => 开启库2事务 => 执行跨库操作 => 回滚库2事务 => 回滚库1事务

即只要两个库的提交操作和回滚操作之间没有出现异常,就能保证跨库的原子性。

如果两个库的提交操作和回滚操作之间出现问题(例如进程被强制杀掉/断电/数据库挂掉/超时等),就会出现不一致。

总结

  1. 业务上和产品设计,要有完善和明确的异常流程来兜底各种异常,出现问题可以走预案,而不是让研发或者运维去救火。
  2. 日志和监控能够cover住各种分支,出了问题,能快速定位。
  3. 最终一致性,并不仅仅是技术层面,需要结合业务层面去定义,甚至是业务层面用流程去cover住,避免技术去思考分布式事务的问题。