本地消息表實現最終一致性

背景

传统的单体应用不会横跨多个数据库,可以通过单机事务保证一致性。然而在海量数据的场景下,我需要对数据库做拆分,即分库分表,而Cobar、MyCat这类分库分表中间并不提供分布式事务的特性,并且基于二阶段提交的分布式事务性能较差,对于大多数业务场景来说,并不需要强一致,只需要保证最终一致性即可。

实践

下面我们举个下订单的场景,总共有3个实体,商品、用户、订单,我们按照user_id来sharding。所以相同user_id的用户和订单在同一个物理库下,而商品表中不存在user_id,所以商品表在不同的物理库下。

下订单的场景,主要涉及到两个事务操作,扣减库存和生成订单,因为两个操作涉及不同的数据库,所以无法保证强一致性。

我们可以通过本地消息表,来实现最终一致性,具体流程如下图:

本地消息表实现最终一致性

  • 调用外部服务,生成全局唯一的交易流水号trans_id。
  • 事务一:1) 扣减库存 2) 根据流水单号,生成对应商品的冻结记录。消息表主要由商品ID、交易流水号、冻结数、消息状态这四个字段构成,因为消息表和商品表在同一个物理库下,所以TX1中的操作1和操作2是可以构成事务操作的。冻结记录的状态有三种:已冻结、释放已售出、释放未售出。冻结记录的初始状态为已冻结。
  • 事务一如果成功,则进行事务二;如果事务一失败,则直接返回。
  • 事务二:根据交易流水号trans_id生成订单,订单的状态有三种:未支付、已支付、超时,订单的初始状态为未支付。
  • 若订单创建成功,则进行后续的支付流程。
  • 如果事务二失败,由于网络抖动超时等原因,不一定是真的生成订单失败,即 在事务二失败的情况下,可能生成了订单,也可能确实没有生成订单。
  • 定时任务一:设置一个每隔15分钟的定时任务(即一个订单必须在15分钟内完成支付),从订单表里捞出最近半小时内的所有订单,对每一个订单做如下处理:若订单超时未支付,开启事务SELECT FOR UPDATE 锁住该订单,即用悲观锁阻止用户对订单进行支付等操作,然后通过订单的trans_id去冻结表更新对应冻结记录的状态,置为释放未售出,并回滚商品数量,回滚商品的操作完成后,将订单状态置为超时,若事务中调用的回滚商品数量的服务失败,则可以发出报警人工处理,或通过更长时间的定时任务去处理;若订单为已支付,则将冻结表中记录的状态置为释放已售出。
  • 定时任务二:因为存在事务一成功,而事务二的订单确实没有创建成功的情况,这样会冻结一部分商品的数量,所以可以捞取出 创建超过10分钟 状态为已冻结的所有冻结记录,根据每个冻结记录的trans_id去订单表查询,若不存在对应的订单,则将冻结记录的状态更新为释放未售出,并回滚商品数量。
  • 另一个需要注意的点,在定时任务一中,对于超时未支付的订单,会先回滚冻结表,然后将订单状态置为超时,但这最后一步将订单置为超时可能会失败,这样会出现不一致的状态,即订单状态为未支付,而冻结记录的状态为释放未售出。所以,在支付的时候需要做一个前置校验,检查冻结记录的状态是否为已冻结,若不是,则拒绝支付。

变种

在上面这种模型的基础上,还有一种变种,如下图:

本地消息表实现最终一致性

即在TX2失败的情况下,跳转到TX3。

  • 根据trans_id查询订单,若订单不存在,则直接将冻结记录置为释放未售出,并回滚库存;若订单存在,则说明TX2因为网络抖动等原因而失败,其实订单创建成功,则进行正常的支付流程。
  • 需要注意的是:根据trans_id查询订单的时候,一定要开启事务,这样才会强制走主库,若不开启事务,则会走备库,因为MySQL主从同步延迟的问题,备库很可能无法查询到订单,从而回滚库存,这显然是错误的。

变种的优点

将定时任务的压力均匀地分配到每一次调用中,提高数据库的可用性。

总结

在不需要强一致性的业务场景下,都可以通过定时任务+幂等操作来实现最终一致性。


分享到:


相關文章: