分布式事务实战方案汇总
1. 最终一致性
1.1 本地事务表 + 轮询补偿
交互流程
- ① commit DB事务提交阶段 本地客户端向DB进行事务提交,此时需要将业务数据和记录消息事务状态的信息表同时实现本地事务,此时标记消息事务状态为UN_SEND未发送或未完成状态,此时MQ未发送
- ② ack DB确认阶段 返回DB事务提交成功或失败状态
- ③ commit MQ事务提交阶段 客户端发起MQ发送请求
- ④ update 本地事务表更新阶段 根据MQ发送结果进行本地消息事务表状态更新,成功则更新为SEND发送成功或发送完毕
- ⑤ MQ补偿 本地消息事务表定时轮询 对未发送成功消息事务进行补偿发送,实现分布式事务的最终一致
场景:重构业务新老系统双写库同步
项目背景
这是一个重构系统新老系统同时服役切量迁移的业务场景,老系统正在线上服役为各业务方提供接口查询功能,新系统重构完成后需要对接接入,调用流量要陆续从老系统切换到新系统,最终老系统迭代下线。
分布式事务
需要解决的分布式事务问题就是,双系统的数据是异构、分散的,线上不可停量,需要陆续切换完成,因此需要事先将老库存量数据洗入新库,此过程中增量数据写入是存在的,但是最终新老库是相对一致和统一的,该场景需要解决的是数据双库的双写问题。
设计方案
系统 |
写操作 |
读操作 |
调用方 |
老系统 |
异步写(MQ) |
读(冗余查) |
历史存量渠道 |
新系统 |
同步写 |
读 |
新业务渠道/历史存量切换渠道 |
场景Q&A
- Q:如何保证双库双写?
A: 同步写辛库,MQ异步写老库
- 本地事务 + 消息事务表
业务数据持久化开启数据库本地事务,该事务中记录业务数据
和同步状态
信息 - 确保本地事务一定成功,不保证异步MQ事务
数据库事务成功后再发送写老库的MQ,保证本地事务一定完成才会触发MQ发送,这样确保本地事务一定成功,MQ可能成功也可能失败 - 重试MQ事务状态,最终一致
如果MQ事务失败,通过定时任务轮询进行重发驱动,最终一致
- Q:异步MQ写,延迟问题如何解决?
A: 增加冗余查询
增加冗余对另一库的冗余查询进行Double Check。由于调用方几乎不可能同时使用新老系统做业务,因此延迟时间取决于MQ异步消费写的速度,如果场景比较复杂要确保绝对一致可以增加该处理方式
- Q:双写过程中多个MQ如何保证顺序、防重等问题?
A: 业务时间 + 业务ID
- (1)同一个
业务ID
代表一个同一笔业务,可以依此进行业务防重处理
- (2)同一个
业务ID
的基础上增加业务时间
,可以依此保证业务数据的实时刷新
业务时间、消费时间同序
业务时间、消费时间乱序
- (3)不同
业务ID
的基础上增加业务时间
,可以依此保证不同业务数据的按照业务时间刷新
消息业务ID |
业务时间 |
消费时间 |
处理逻辑 |
ID |
T |
T |
执行 |
ID |
T |
T+1 |
防重不处理 |
消息业务ID |
业务时间 |
消费时间 |
处理逻辑 |
ID |
T |
T |
执行 |
ID |
T+1 |
T+1 |
执行(覆盖) |
消息业务ID |
业务时间 |
消费时间 |
处理逻辑 |
ID |
T |
T+1 |
抛弃(业务时间较早) |
ID |
T+1 |
T |
执行 |
消息业务ID |
业务时间 |
消费时间 |
处理逻辑 |
ID |
T+1 |
T+1 |
执行(业务时间较新) |
ID+X |
T |
T |
抛弃(业务时间较早) |
场景:第三方认证核验
项目背景
这是一个认证系统以来外部核验系统进行用户身份鉴权的场景,即认证系统记录认证发起记录,并请求到外部的核验系统发起一笔核验请求,用户在核验系统确认后核验结果返回到认证系统确认用户的真实数据状态。
分布式事务
该流程中认证系统是一个本地系统,存放用户发起的认证流水信息和核验状态,依赖外部核验系统返回该笔认证记录的核验状态,由于核验过程是异步的,用户可以选择任意时间完成或者永远不完成,需要保证每次认证流程只有一笔业务发起,而且需要根据业务时间进行核验流程的超时进行强制取消或者补偿查询对齐核验状态等,需要解决的分布式事务是认证流水、核验结果的一致性。
设计方案
数据 |
系统 |
事务 |
正向事务 |
逆向事务 |
认证流水数据 |
本地系统 |
本地事务 |
本地事务2PC保证 |
定时任务,超时关闭流程 |
核验状态数据 |
外部系统 |
分布式事务 |
联同本地事务提交 / 定时任务驱动补偿提交 |
外部系统控制 |
- 正向流程
- 补偿流程
网络异常,图片无法展示|
场景Q&A
- Q:本地事务认证流水成功了,外部核验系统提交失败了怎么办?
A:通过定时任务补偿触发二次提交,只要外部事物提交一直处于未成功,便一直会被重试提交,直到成功
- Q:外部核验系统事务完成了,本地事务认证流水提前被作废了怎么办?
A:以本地事务认证流水的结果为准。本地事务是通过定时任务进行补充提交+外部事务状态核验查询的,即时在临界点外部事务完成了,但是超过了业务处理时间已经关闭,不会再补充修改,这也是根据业务场景做的取舍,用户可以再次发起新流程进行核验
1.2 本地事务表 + 事务消息
交互流程
- ① prepare 准备阶段 本地客户端向DB、MQ发送prepare请求
- ② ack 准备确认阶段 DB、MQ作为事务参与者返回本地客户端ack确认
- ③ commit/rollback 提交/回滚阶段 根据事务参与者在准备确认阶段返回结果进行事务提交或回滚,此时一旦有事务参与者返回异常或超时未返回则进行回滚提交
- ④ callback 补偿回调阶段 当事务超时提交时,则由MQ进行回调查询数据库本地事务情况
- ⑤ commit/rollback 提交/回滚阶段 根据补偿回调阶段进行事务提交和回滚,实现分布式事务的最终一致性
场景:分库分表路由字段绑定
项目背景
该业务是在分库分表场景下,需要一个切分键字段进行数据分片路由,一般我们ToC业务的话会使用userId这样的字段进行切分。然而水平切分数据带来了数据库读写性能的同时也存在一个问题,那就是查询必须携带切分键才可以完成,因为要依赖它进行数据路由查询,比如在以userId进行数据路由切分时,如果想按照用户的身份证、姓名等进行匹配查询是无法实现的,因为我们事先是不知道userId、身份证、姓名的等值匹配关系。一般而言,我们可以通过HBase + ES的方案进行解决,即监听不同库的binlog日志,将其按照时间进行排序切分汇入HBase表中,加入要检索的索引到ES中解决分库分表下数据切片产生的聚合问题。
分布式事务
基于以上场景,除了通过HBase+ES实现之外,还可以通过切分键与非切分键进行数据绑定解决,但是由于切分键与非切分键的路由一般不一致,数据会分散到不同库,因此无法做本地事务,这是我们需要解决的分布式事务问题。
设计方案
字段 |
处理方式 |
路由键 |
存储格式 |
切分键 |
DB同步写,按照切分键路由 |
切分键 |
<切分键,非切分键A,非切分键B,非切分键C> |
非切分键 |
MQ异步写,按照非切分键路由冗余存储,保存与切分键数据关系 |
非切分键 |
<非切分键A,切分键> <非切分键B,切分键> <非切分键C,切分键> |
- 交互流程
网络异常,图片无法展示|
场景Q&A
- Q:异步MQ写有延迟,查不到切分键如何处理?
A:事务处理开始阶段通过Redis记录事务开启的分布式锁标识,当其他存在冲突的同业务在该事务的处理过程有查询时,通过判断分布式锁标识是否存在来判断事务的开启,若存在则异步等待并发起间隔查询直到事务超时,若事务超时周期内反复查询到则返回,否则根据事务处理后结果返回
- Q:本地事务与MQ事务是如何协作的?
A:相当于两个2PC事务协作。
- 1.一阶段DB、MQ同时prepare(并行)
- 2.二阶段DB先commit,成功后MQ再commit(串行)
流程 |
阶段 |
操作 |
Ack反馈 |
持久化 |
是否结束 |
分布式事务成功 |
正向流程 |
Prepare |
DB prepare MQ prepare |
Yes |
No |
No |
No |
正向流程 |
Commit |
DB commit MQ commit |
Yes |
Yes |
Yes |
Yes |
- |
- |
- |
- |
- |
- |
|
异常流程 |
Prepare |
DB 或 MQ 异常 |
No |
No |
Yes |
No |
异常流程 |
Commit |
DB提交异常 |
No |
No |
Yes |
No |
异常流程 |
Commit |
MQ提交超时 回调本地事务状态,本地事务成功则提交MQ事务 |
Yes |
Yes |
Yes |
Yes |
异常流程 |
Commit |
MQ提交超时 回调本地事务状态,本地事务失败则取消MQ事务 |
Yes |
No |
Yes |
No |
- Q:会不会存在MQ事务成功,本地事务失败?
A:不会,而且要避免这种情况发生。两个2PC事务的prepare准备阶段可以同时发起,但在commit提交阶段要先保证本地数据库事务成功之后再进行MQ事务消息的commit,也就是在commit阶段是存在依赖关系、串行化之行的
- Q:事务消息如何确保最终一致?
A:prepare阶段失败、本地事务commit阶段则均不会持久化;当prepare阶段成功、本地事务commit成功,此时MQ的commit阶段异常,则依赖MQ事务消息的超时commit机制触发回调本地事务状态接口方法来决定MQ的二阶段是commit还是cancel
1.3 TCC(Try-Commit-Cancel)
交互流程
TCC事务其实主要包含两个阶段:Try阶段、Confirm/Cancel阶段。
- ① try-尝试执行业务
完成所有业务检查(一致性)
预留必须业务资源(准隔离性) - ② confirm-确认执行业务
真正执行业务
不作任何业务检查
只使用Try阶段预留的业务资源
Confirm操作必须保证幂等性 - ③ cancel-取消执行业务
释放Try阶段预留的业务资源
Cancel操作必须保证幂等性
从TCC的逻辑模型上我们可以看到,TCC的核心思想是,Try阶段检查并预留资源,确保在Confirm阶段有资源可用,这样可以最大程度的确保Confirm阶段能够执行成功。这里的资源可以是DB
,或者MQ
,RPC
,只要是分布式环境中的事务载体就可以,而且需要这些分布式事务的参与者具备正逆向条件,比如DB、MQ的事务可以支持2PC,可以根据协调者对其进行事务提交或者取消操作,RPC比如账户服务可以支持正向增加也可以支持逆向减少,除此之外,DB、MQ要自身支持事务的ACID,这是参与分布式事务的原子性保证,RPC底层也是DB,这里可以等同理解。以上参与者具备参与分布式事务的基本条件后便可以进行整合和使用,业务流程的驱动和事务的完整性由中间协调者来操作和推进。
场景:积分商品兑换
项目背景
这是一个电商系统比较经典的下单、扣款、发货流程,在下单之前会通过一系列商品在架状态、库存数量、购买限制等有效性过滤,然后在业务系统中进行一个商品兑换的场景。
分布式事务
由于是商品兑换,对于用户和系统本身来说是这个过程一个原子性的、一键完成的操作,因此下单+动账+发货是一个现实存在的分布式事务。这里假设订单数据和动账数据在一个本地数据库事务中,持久化开启数据库本地事务,该事务中记录订单生成数据
和动账数据
信息,以及发货状态
的信息记录。这里需要解决的分布式事务是订单数据、动账数据、发货状态三种的最终一致。
设计方案
数据 |
系统 |
事务 |
正向流程 |
逆向流程 |
订单数据 |
本地系统 |
本地事务 |
本地事务2PC保证 |
定时任务,异常关单 |
动账数据 |
本地系统 |
本地事务 |
本地事务2PC保证 |
定时任务,逆向补账 |
发货状态 |
外部系统 |
分布式事务 |
联同本地事务提交 / 定时任务补偿提交 |
定时任务,驱动发货或取消 |
- 正向流程
网络异常,图片无法展示| - 补偿流程
网络异常,图片无法展示|
场景Q&A
- Q:动账扣款成功,但是发货失败怎么办?
A: 定时任务根据发货状态进行发货流程驱动
- 定时任务补偿再次发货
发货成功则分布式事务最终一致,下游发货系统进行发货防重处理 - 发货失败进入逆向流程
定时任务驱动发货最终一致理论上可以一直进行,但是发货可能有一个流程时效和库存售罄的问题,可以根据业务场景进行配置比如2天内重复调用失败或调用返回无库存则进入逆向关单退款流程使得分布式事务恢复成最开始的一致性状态
- Q:逆向流程回滚账户扣款,怎么防重?
A:账户流水表做唯一索引正逆向类型 + 业务ID
,和账户额度表进行本地事务操作,确保只能成功一笔正向/逆向业务操作
场景:广告任务结算
项目背景
当一个App有了足够多的用户体量,便开始有商家进行广告或商品的投放,平台通过包装运营活动、广告位等,将用户曝光与商家付费结合达到流量变现的目的。
分布式事务
当用户进行浏览、关注、商品购买、视频观看、App下载等多种任务,我们会根据商家配置等付费规则进行商家广告费用的扣减,同时还要为用户完成任务进行奖励结算,此时的分布式事务便是商家账户扣减与用户账户增加的数据一致性问题
设计方案
角色 |
数据 |
系统 |
操作 |
平台 |
业务流水 |
结算系统 |
扣减商户费用、增加用户余额 |
商户 |
商户余额 |
商户系统 |
扣减费用 |
用户 |
用户余额 |
用户系统 |
增加余额 |
- 交互流程
网络异常,图片无法展示| - 补偿流程
网络异常,图片无法展示|
场景Q&A
- Q:商户扣款失败或者商户扣款成功,用户结算失败怎么办?
A: 定时任务根据结算状态进行结算流程驱动,会一直轮询补偿尝试结算用户,直到成功。
- Q:商户扣款成功,用户结算失败为何不进行做业务超时的逆向流程回滚商家扣款?
A: 用户做任务时一定是做了前置校验进行平台任务发放的,也就是说用户任务只要完成必须要进行结算,这是一个不能逆向的流程。即使极端情况下商户余额空了暂时无法结算到用户也要一直重试,一旦商户余额充足则继续整个结算流程。
场景:运营活动抽奖
项目背景
抽奖是运营活动中比较常见的方式,对于用户来说需要做的是参加设定人物获取积分,攒够积分就可以开始抽奖,抽中后即等待奖品入账,一般券会立刻入账使用,而实物商品则需要耐心等待物流送到用户手上。
分布式事务
关于抽奖,涉及账户动账、库存参与次数扣减、抽奖等多个环节,该场景要解决的分布式事务是账户动账、活动库存变更、抽奖下单数据一致性。
设计方案
数据 |
系统 |
操作 |
账户余额 |
交易系统 |
扣减账户余额 |
活动库存 |
运营活动系统 |
扣减库存、用户日参&总参 |
业务流水 |
运营活动系统 |
业务ID[动账凭证],抽奖ID[抽奖凭证],活动ID |
抽奖流水 |
抽奖系统 |
抽奖下单,获取中奖结果 |
- 交互流程
网络异常,图片无法展示| - 补偿流程
网络异常,图片无法展示|
场景Q&A
- Q:动账扣减失败,补偿流程为何不驱动扣减完成抽奖?
A: 抽奖业务对于用户来说实时性要求很高,正向流程没有完成的话,没有通过补偿流程驱动的必要性了,用户体验不好容易产生问题,补偿流程只针对账户扣减成功扣没有顺利完成抽奖的情况进行账户补款即可。而且这部分补款是有延迟的,在C端展示可以优化或者忽略合并掉,保证的是账户额度无损。
2. 弱一致性
2.1 最大努力通知 + 消息重试控制
场景:数据变更同步下游业务方
项目背景
系统数据发生变更时,会对外部系统产生一定影响,外部系统需要知道这种数据变化,这便是数据状态同步的场景。一般来说数据交互可以有推(Push)、拉(Pull) 两种形式,这里先说推模式,即数据变更方负责将变化通知到数据关注方。
分布式事务
这里要保证的是数据变更在多个应用中的状态一致。
设计方案
角色 |
驱动方式 |
通信方式 |
数据变更方 |
MQ + RPC 最终一致 |
调用接口通知其他系统 |
数据关注方 |
RPC调用成功更新数据 |
提供接口接收数据变更 |
- 交互流程
网络异常,图片无法展示|
场景Q&A
这里是弱一致性的实现,没有做本地事务表和定时任务轮询对比各事务状态进行补偿操作。完全依赖于MQ的失败重试驱动,若RPC调用失败即数据同步业务方失败,MQ会一直进行重试操作,随着重试次数增加,重试间隔也会增加,这里也可以业务自行进行最大努力尝试次数的控制,超过多少次尝试仍失败则放弃,因此不能保证最终一致。
场景:数据变更广播下游业务方
项目背景
这里是数据同步的说拉模式,即数据关注方对数据变更方进行数据状态变更的监听,这种处理方式处理的主动权在于数据关注方,数据变更方只负责和保证一定通知到数据变更情况,是否能够同步成功则由监听方处理和兼容。
分布式事务
这里要保证的是数据变更在多个应用中的状态一致。
设计方案
角色 |
驱动方式 |
通信方式 |
数据变更方 |
生产MQ |
数据变更存入MQ队列 |
数据关注方 |
监听MQ |
消费MQ处理数据变更情况 |
- 交互流程
网络异常,图片无法展示|
场景Q&A
这里也是弱一致性的实现,没有做本地事务表和定时任务轮询对比各事务状态进行补偿操作。完全依赖于MQ消费方的处理,若消费方处理失败或在消息队列规定时间内没有消费完毕,则数据无法保证最终一致。
2.2 战略放弃 + 报警 + 人工修复
场景:秒杀库存回滚
项目背景
在秒杀场景中,最复杂的除了解决高并发问题外,最核心的就是活动商品的库存控制、变更问题,一般商品库存会初始化到Redis缓存中进行管理,秒杀方法会对Redis缓存库存数量进行校验、扣减操作,通过MQ异步扣减DB库存,既利用Reids原子操作进行库存数量操作,又利用缓存抗住高并发请求,起到异步削峰的作用,这是秒杀的正向流程。而逆向流程是用户秒杀到商品预占了库存,但是没有及时进行订单支付或者进行了订单取消,此时要发起对库存的恢复操作。
分布式事务
这里的分布式事务是Redis缓存库存与DB库存数量一致性问题。
设计方案
存储介质 |
库存操作方式 |
Redis |
incr 、decr累计或扣减 |
DB |
MQ异步扣减 |
- 交互流程
网络异常,图片无法展示|
场景Q&A
- Q:秒杀场景会出现哪些分布式问题?
A: 根据如上流程图,扣减缓存库存、创建订单、异步MQ发送是在一个同步的函数方法中的三个非原子的子步骤,而秒杀场景下流量洪峰会一瞬间打满线程,以上三个子步骤任何异步都会出现问题,因为都是先扣缓存库存数量,根据实践经验看,极端情况下会出现扣减缓存库存成功了,后面创建订单失败了或者异步MQ没有发出来无法削减DB库存数量,因此数据结果是缓存库存扣减的多,DB扣减的少,实际抢购卖出的少,换句话说就是出现了少卖的现象。
- Q:会不会出现超卖现象?
A: 不会。依赖于Redis单线程命令执行的保证。这里需要注意的是读、写命令不是一致,可以结合分布式锁实现,也可以通过Lua脚本实现命令的原子性执行。
这里也是一个弱一致性的实现,业务场景我们保证不超卖即可,对于极端情况出现的库存数量无效多扣减做战略性放弃,一般情况下不会影响大多业务使用,如果非要吹毛求疵达到账户金额那种强一致性,思路也很简单,可以借助定时任务轮询对比缓存与DB库存数量进行校验,这里还要考虑到其他在行流程如超市关单库存恢复,仍然在行的秒杀活动等,保证数据处理不多加不多减。
3. 总结
3.1 分布式角色
- 参与者
可以通俗的认为是DB、RPC、MQ
这些能够提供事务能力的中间件或接口服务 - 协调者
维系分布式事务各个参与者分布式状态的系统、中间件,如Zookeeper、业务系统
3.2 技术保证
- 数据库事务 数据库如MYSQL提供了2PC、XA协议,依赖于
WAL + Redo Log + 刷盘策略
保证 - MQ事务 提供了2PC协议,依赖于
Ack机制+刷盘策略
保证
3.2 强弱一致选择
- 强一致性
强一致性确保的不是事务一定成功,而是事务参与者的子事务要么全成功,要么全失败,保证子事务的最终一致。一般依赖于定时任务、补偿机制、Double Check等方式进行事务状态的校准和协调,一般设计和实现的复杂度大,参与者越多,流程越复杂,越难以维护,最终一致的延迟性和可靠性保证越难 - 弱一致性
弱一致性放弃了最终一致性的保证,通过最大努力实现而不保证最终的结果,这种场景减少和减低了开发和设计的复杂度
3.3 幂等&防重
- 业务幂等通常会定义
bizId
代表全局唯一的业务标识。在MQ重发、重复消费、乱序,RPC重复调用等场景进行业务防重兼容处理。 - 如账户余额的强一致防重处理,可以结合流水表唯一索引
正逆向类型 + 业务ID
进行拦截 - 一般大多依赖于数据库的唯一索引进行防重保证,如果担心数据库性能问题,可以前置缓存拦截处理
3.4 尽早干预&补偿一致
- 尽早干预
指的是代码逻辑上尽早对串行处理的做个子事务进行回滚或逆向操作,这样可以尽快结束分布式事务,而不需要等待相对更为延迟的定时任务或其他补偿机制来驱动,这里可以使用旁路方法或不阻塞主方法放到MQ或异步线程中进行处理,比如秒杀下单发货因为库存不足或商品下架可以立刻进行发起关单退款的逆向流程 - 补偿一致
补偿机制一般可以通过定时任务、MQ重试来进行子事务驱动整个分布式事务的完结
5. 参考
《软件架构设计:大型网站技术架构与业务架构融合之道》
用户增长运营活动系统
电商大促秒杀活动系统
用户中心认证系统