在 4.0 版本之前,MongoDB 是不支持事务的,只能由应用程序自行保证事务性。本文以银行转账为例,讲解如何在不支持事务的 MongoDB 上实现转账事务。
转账场景
客户账上余额由 accounts 表保存,表结构大致如下:
|
|
如果客户 A 发起一笔转账,转 10 元给 B ,那么余额表应该是这样的(假设没有其他操作):
|
|
由于转账需要同时修改两条账户记录,为保证数据一致性,我们必须保证:它们要么全都修改成功,要么全都保持初始原因,不能有中间状态。
这就是转账操作的事务性要求,那么我们应该如何实现这一点呢?
天真想法
初级工程师容易想当然,经常想到啥就写啥,很多人会这样做:
|
|
相信只要学过编程,应该不难想到这个思路,但这个想法漏斗百出。为什么呢?
事务挑战
由于转账操作由多个读写操作组合而成,因此会给我们带来诸多挑战。
并发控制
客户可能并发操作,比如一边用手机转账,一边用 ATM 取款。试想转账步骤①做完后,他刚好用 ATM 把余额全都取出,会发生什么事情?
转账例程执行完步骤①,将客户 A 的余额记录查询出来,放进内存。随后 ATM 例程执行取款操作,并将余额更新为零,但这时转账例程一无所知!
转账例程拿内存里的旧数据做余额判断,符合转账条件,继续做扣款逻辑。扣款操作则会将账户余额扣为负数,转账捎带贷款,这不就乱套了吗?
为解决这个问题,我们必须保证检查账户余额和扣款是一个原子操作。这一点很容易实现,因为 MongoDB 本身就支持单文档原子操作,我们稍后介绍。
故障恢复
转账事务需要修改两个账户的余额,因此写操作分成了两步。如果写操作只完成了一半,程序就发生故障退出了,这时数据库中的数据就不一致。
如上图,程序完成对 A 账户的扣款,从余额减调 10 元;但它还没来得及将扣的款项入到账户 B ,程序就挂掉了;最终,A 扣款成功,B 没有入账,系统凭空少了 10 元,帐也对不平。
在程序故障时,应用必须有可靠的机制,将数据恢复到初始状态,确保一致性。
单文档原子操作
为保证读写的一致性,MongoDB 保证单文档 update 操作是原子的。以扣款业务为例,必须保证余额不小于待扣金额,可以在同一个 update 操作中完成余额判断和余额更新:
|
|
注意到,这个 update 操作除了执行 A 账户的 ID ,还指定了一个额外条件:余额 balance 不小于 10 。MongoDB 在执行更新操作时,会将 A 这行数据锁住,满足条件才进行更新,以此保证一致性。
事务实现方法
两阶段提交
我们利用分布式事务中的 两阶段提交 ( 2PC )协议思想,来保证转账事务的一致性。两阶段提交顾名思义就是将事务过程拆分为两个阶段:准备 ( prepare )和 提交 ( commit )。
两阶段提交协议需要指定一个 协调者 ,用于协调执行事务过程的各个 参与者 。协调者先通知各个参与者做 准备 ,这时各个参与者应该锁定必要的资源,并反馈是否成功,成功表示承诺事务一定能提交。
如果所有参与者都反馈成功,这时协调者将事务标记为 提交 ,并通知各个参与者做提交。由于在准备阶段,各个参与者均已锁住必要资源,因此它们都可以顺利提交。
如果有些参与者无法锁定必要资源,反馈失败,协调者则将事务标记为 回滚 ,并通知各个参与者清理之前的准备工作,比如回滚数据、释放锁等等。
协调者会记录事务的执行进度,并决定事务最终是否提交。如果决定不提交事务,它将通知参与者清理已做的准备工作,确保数据恢复到初始状态。
事务流水
为更好地跟踪转账事务的执行状态,我们为每一笔转账都记一个流水,字段如下:
- payerId ,付款人 ID ;
- payeeId ,收款人 ID ;
- amount ,转账金额;
- time ,发起时间;
- state ,状态( 2PC 协议阶段 );
用户发起一笔转账,系统先向事务表插入一条流水,保存一次转账事务的所有上下文:
|
|
事务状态
我们利用两阶段提交的思想来实现转账事务,因而会分为几种状态:
- Initiated ,表示事务刚 发起 ;
- Preparing ,表示事务正在锁定相关资源,为提交做 准备 ;
- Committed ,表示事务已经决定 提交 ;
- Rollback ,表示事务已经决定 回滚 ;
- Success ,表示事务执行成功;
- Fail ,表示事务执行失败
排他执行
用户提交转账请求后,系统会插一条转账流水,状态为 发起 ,事务执行由后台的微服务负责。为保证事务处理速度,微服务通常会开若干并发。
但如果多个微服务同时执行同一个事务,可能会互相干扰,进而产生不可预料的后果。那么,如何保证一个事务只被一个微服务执行呢?
利用 MongoDB 单文档更新原子操作,我们可以规定,微服务只有将事务从 Initiated 状态改为 Preparing 状态才能执行该事务:
|
|
这个 update 操作,先找到一条发起状态的事务流水,并将它的状态改为准备。MongoDB 单文档更新操作保证,在更新状态的这个过程中,该字段不会被其他程序修改。
换句话讲,一个发起状态的事务流水,最终只能被一个微服务改成准备状态,谁改成功就归谁执行。执行模型最终变成这样:多个微服务一起抢事务流水,谁抢到就由谁执行。
事务执行流程
事务执行微服务将事务标记为准备状态后,将对付款人和收款人的账户余额进行若干次更新,最终完成转账逻辑。详细的操作步骤如下:
- 将一个发起状态的事务,修改成准备状态,成功则意味着抢到该事务的执行权;
- 根据事务中的付款人 ID 和转账金额,对付款人账户余额进行扣款;
- 写操作因网络原因失败时,支持重试一次;
- 扣款必须保证余额充足;
- 扣款后必须保存事务 ID ,以便事务回滚时可以解除扣款;
- 扣款前必须保证事务 ID 尚未保存,避免重试时重复扣款;
- 条件判断必须利用写操作原子性,避免基于旧数据判断;
- 根据事务中的收款人 ID ,在收款人账户余额记录事务 ID ;
- 记录事务 ID 是为了事务提交后,能够顺利入账,以及不会重复入账(待会解释);
- 不管收款人现在余额几何,均可入账成功,因此无需检查额外条件;
- 将事务状态,从准备修改为提交,成功则意味着事务已经提交;
- 因为其他微服务会回滚超时事务,必须通过原子操作保证:
- 要么事务被提交,提交后则不能回滚;
- 要么事务被回滚,回滚后则不能提交;
- 不能因为竞争态的存在而产生不一致;
- 因为其他微服务会回滚超时事务,必须通过原子操作保证:
- 移除付款人余额记录中的事务 ID ,重试时重复执行也无妨;
- 根据事务中的收款人 ID 和转账余额进行入账;
- 入账后时移除事务 ID ;
- 入账前必须保证事务 ID 尚未移除,避免重试时重复入账;
- 将事务状态修改为成功,事务结束;
|
|
您可能会有疑问,账号余额不足的话怎么办?
接口在插入转账流水前,可以先检查一遍,余额不足就拒绝转账。如果检查通过,那么在事务执行时出现余额不足的概率较小,但仍然不能忽视。
因此,事务执行微服务在写操作出现逻辑错误时,需要重读余额判断到底是余额不足,还是重试导致多扣款(事务 ID 已写入)。如是前者,则可直接将事务标记为失败;如是后者,则接着执行步骤③。
事务回滚过程
事务在执行的过程中,可能发生错误。不致命的错误,比如偶发的网络错误,可以通过重试解决。但如果执行事务的微服务挂掉了,那应该怎么办呢?是否能由其他微服务继续执行呢?
如果运行其他微服务继续执行事务,会引入额外的复杂性。考虑到微服务挂掉的概率不大,我们选择简单地让事务超时,这样实现成本更低。
因此,我们需要引入回滚微服务,来回滚执行超时的事务:
- 将一个准备阶段超时事务标记为回滚状态;
- 由于事务执行微服务可能并发执行,想提交事务,因此需要利用原则操作保证一致性;
- 回滚对付款人的扣款,需要保证重复执行时不会多次回款;
- 回滚写到收款人的事务 ID ,多次执行也无妨;
- 将事务状态改为失败,这一步也可以直接改;
|
|
故障恢复
如果事务执行微服务在事务准备阶段故障,那么事务最终将超时,进而被回滚微服务接管。但如果微服务在事务提交阶段,或者回滚阶段故障,又该怎么办呢?
处在这两个阶段的事务,最终状态已经完全确定,只需将写操作应用到涉及的两个账户:
- 提交阶段:步骤⑤清理付款人事务 ID ,步骤⑥收款人入账,步骤⑦将事务状态改为成功;
- 回滚阶段:步骤②付款人回款,步骤③清理收款人事务 ID ,步骤⑦将事务状态改为失败;
由于我们借助原子写操作,保证入账和回款不会重复执行,因此这些写操作均可安全重试。这样一来,就算相关微服务执行故障,可以采取超时机制,启动新微服务继续执行即可。
并发分析
- 事务准备阶段,只有一个服务能够执行,不会有冲突;
- 事务在提交和回滚阶段,就算多个服务重复执行,也不会不一致;
- 原子操作保证提交阶段不会重复入账;
- 原子操作保证回滚阶段不会重复回款;
- 原子操作保证,事务要么被提交,要么被回滚,最终状态不会冲突;
- 如果被执行服务提交,回滚服务就无法回滚;
- 如果被回滚服务回滚,提交服务就无法提交;
订阅更新,获取更多学习资料,请关注我们的公众号: