背景
传统的单体应用不会横跨多个数据库,可以通过单机事务保证一致性。然而在海量数据的场景下,我需要对数据库做拆分,即分库分表,而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
主从同步延迟的问题,备库很可能无法查询到订单,从而回滚库存,这显然是错误的。
变种的优点
将定时任务的压力均匀地分配到每一次调用中,提高数据库的可用性。
总结
在不需要强一致性的业务场景下,都可以通过定时任务
+幂等操作
来实现最终一致性。
以上。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。