添加链接
link管理
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接

4.0 版本之前, MongoDB 是不支持事务的,只能由应用程序自行保证事务性。本文以银行转账为例,讲解如何在不支持事务的 MongoDB 上实现转账事务。

客户账上余额由 accounts 表保存,表结构大致如下:

{"_id": "A", "balance": 100} // 客户A账上有100元
{"_id": "B", "balance": 100} // 客户B账上有100

如果客户 A 发起一笔转账,转 10 元给 B ,那么余额表应该是这样的(假设没有其他操作):

{"_id": "A", "balance": 90} // 客户A账上有90元
{"_id": "B", "balance": 110} // 客户B账上有110

由于转账需要同时修改两条账户记录,为保证数据一致性,我们必须保证:它们要么全都修改成功,要么全都保持初始原因,不能有中间状态。

这就是转账操作的事务性要求,那么我们应该如何实现这一点呢?

初级工程师容易想当然,经常想到啥就写啥,很多人会这样做:

# 1. 查询客户A的账上余额
accountA = db.accounts.find({"_id": "A"})
# 2. 检查余额是否足够转账
if accountA.balance < 10:
  return error
# 3. 更新客户A的账上余额,完成扣款(减10元)
db.accounts.update({"_id": "A"}, {"$inc": {"balance": -10}})
# 4. 更新客户A的账上余额,完成入账(加10元)
db.accounts.update({"_id": "B"}, {"$inc": {"balance": 10}})

相信只要学过编程,应该不难想到这个思路,但这个想法漏斗百出。为什么呢?

由于转账操作由多个读写操作组合而成,因此会给我们带来诸多挑战。

客户可能并发操作,比如一边用手机转账,一边用 ATM 取款。试想转账步骤①做完后,他刚好用 ATM 把余额全都取出,会发生什么事情?

转账例程执行完步骤①,将客户 A 的余额记录查询出来,放进内存。随后 ATM 例程执行取款操作,并将余额更新为零,但这时转账例程一无所知!

转账例程拿内存里的旧数据做余额判断,符合转账条件,继续做扣款逻辑。扣款操作则会将账户余额扣为负数,转账捎带贷款,这不就乱套了吗?

为解决这个问题,我们必须保证检查账户余额和扣款是一个原子操作。这一点很容易实现,因为 MongoDB 本身就支持单文档原子操作,我们稍后介绍。

转账事务需要修改两个账户的余额,因此写操作分成了两步。如果写操作只完成了一半,程序就发生故障退出了,这时数据库中的数据就不一致。

如上图,程序完成对 A 账户的扣款,从余额减调 10 元;但它还没来得及将扣的款项入到账户 B ,程序就挂掉了;最终,A 扣款成功,B 没有入账,系统凭空少了 10 元,帐也对不平。

在程序故障时,应用必须有可靠的机制,将数据恢复到初始状态,确保一致性。

单文档原子操作

为保证读写的一致性,MongoDB 保证单文档 update 操作是原子的。以扣款业务为例,必须保证余额不小于待扣金额,可以在同一个 update 操作中完成余额判断和余额更新:

# 更新客户A的账上余额,完成扣款
accounts.update({"_id": "A", "balance": {"$gte": 10}}, {"$inc": {"balance": -10}})

注意到,这个 update 操作除了执行 A 账户的 ID ,还指定了一个额外条件:余额 balance 不小于 10MongoDB 在执行更新操作时,会将 A 这行数据锁住,满足条件才进行更新,以此保证一致性。

事务实现方法

两阶段提交

我们利用分布式事务中的 两阶段提交2PC )协议思想,来保证转账事务的一致性。两阶段提交顾名思义就是将事务过程拆分为两个阶段:准备prepare )和 提交commit )。

两阶段提交协议需要指定一个 协调者 ,用于协调执行事务过程的各个 参与者 。协调者先通知各个参与者做 准备 ,这时各个参与者应该锁定必要的资源,并反馈是否成功,成功表示承诺事务一定能提交。

如果所有参与者都反馈成功,这时协调者将事务标记为 提交 ,并通知各个参与者做提交。由于在准备阶段,各个参与者均已锁住必要资源,因此它们都可以顺利提交。

如果有些参与者无法锁定必要资源,反馈失败,协调者则将事务标记为 回滚 ,并通知各个参与者清理之前的准备工作,比如回滚数据、释放锁等等。

协调者会记录事务的执行进度,并决定事务最终是否提交。如果决定不提交事务,它将通知参与者清理已做的准备工作,确保数据恢复到初始状态。

为更好地跟踪转账事务的执行状态,我们为每一笔转账都记一个流水,字段如下:

  • payerId ,付款人 ID
  • payeeId ,收款人 ID
  • amount ,转账金额;
  • time ,发起时间;
  • state ,状态( 2PC 协议阶段 );
  • 用户发起一笔转账,系统先向事务表插入一条流水,保存一次转账事务的所有上下文:

    db.transactions.insert({
      "payerId": "A",
      "payeeId": "B",
      "amount": 10,
      "time": "2022-09-03T18:00:00Z",
      "state": "Initiated",
    

    我们利用两阶段提交的思想来实现转账事务,因而会分为几种状态:

  • Initiated ,表示事务刚 发起
  • Preparing ,表示事务正在锁定相关资源,为提交做 准备
  • Committed ,表示事务已经决定 提交
  • Rollback ,表示事务已经决定 回滚
  • Success ,表示事务执行成功;
  • Fail ,表示事务执行失败
  • 用户提交转账请求后,系统会插一条转账流水,状态为 发起 ,事务执行由后台的微服务负责。为保证事务处理速度,微服务通常会开若干并发。

    但如果多个微服务同时执行同一个事务,可能会互相干扰,进而产生不可预料的后果。那么,如何保证一个事务只被一个微服务执行呢?

    利用 MongoDB 单文档更新原子操作,我们可以规定,微服务只有将事务从 Initiated 状态改为 Preparing 状态才能执行该事务:

    t = db.transactions.findOneAndUpdate(
        "state": "Initiated",
        "$set" {
          "state": "Preparing",
    # process t...
    

    这个 update 操作,先找到一条发起状态的事务流水,并将它的状态改为准备。MongoDB 单文档更新操作保证,在更新状态的这个过程中,该字段不会被其他程序修改。

    换句话讲,一个发起状态的事务流水,最终只能被一个微服务改成准备状态,谁改成功就归谁执行。执行模型最终变成这样:多个微服务一起抢事务流水,谁抢到就由谁执行。

    事务执行流程

    事务执行微服务将事务标记为准备状态后,将对付款人和收款人的账户余额进行若干次更新,最终完成转账逻辑。详细的操作步骤如下:

  • 将一个发起状态的事务,修改成准备状态,成功则意味着抢到该事务的执行权;
  • 根据事务中的付款人 ID 和转账金额,对付款人账户余额进行扣款;
  • 写操作因网络原因失败时,支持重试一次;
  • 扣款必须保证余额充足;
  • 扣款后必须保存事务 ID ,以便事务回滚时可以解除扣款;
  • 扣款前必须保证事务 ID 尚未保存,避免重试时重复扣款;
  • 条件判断必须利用写操作原子性,避免基于旧数据判断;
  • 根据事务中的收款人 ID ,在收款人账户余额记录事务 ID
  • 记录事务 ID 是为了事务提交后,能够顺利入账,以及不会重复入账(待会解释);
  • 不管收款人现在余额几何,均可入账成功,因此无需检查额外条件;
  • 将事务状态,从准备修改为提交,成功则意味着事务已经提交;
  • 因为其他微服务会回滚超时事务,必须通过原子操作保证:
  • 要么事务被提交,提交后则不能回滚;
  • 要么事务被回滚,回滚后则不能提交;
  • 不能因为竞争态的存在而产生不一致;
  • # ② 付款人扣款,写入事务ID确保事务回滚能退款,执行过程中不能销户 # 通过原子写操作保证,余额充足才能扣款成功,不会发生扣为负值的情况 # 注意到,如果网络发生故障,事务执行微服务可以重试该写操作,这时可能导致重复扣款 # 更新条件中指定transactions不能包含当前事务ID,利用单文档写操作原子性保证只更新一次 # 重复的写操作会报错,因为transactions不能包含当前事务ID已不成立,这时重读数据可以确认状态 # 如果扣款失败,直接将事务状态改为失败 db.accounts.findOneAndUpdate( "_id": t.payerId, "balance": { "$gte": t.amount, "transactions": { "$ne": t._id, "$inc": { "balance": -t.amount, "transactions": { "$push": t._id, # ③ 确保收款人存在,写入事务ID,保证事务提交后准确入账,同时事务过程中不能销户 # 注意到,这是转账金额不能入账,因为事务可能被回滚,入账被花光就麻烦了 db.accounts.findOneAndUpdate( "_id": t.payeeId, "$addToSet": { "transactions": t._id, # ④ 将事务标记为提交 # 特别注意,必须确保事务当前的状态是Preparing # 因为事务可能因超时被回滚微服务尝试回滚 # 指定当前状态,同样利用了MongoDB更新操作的原子性 # 保证事务要么被标记为提交,要么被标记为回滚,不能有其他状态 db.transactions.findOneAndUpdate( "_id": t._id, "state": "Preparing", "$set" { "state": "Committed", # Committed 提交状态,落实资源修改,比如入账 # ⑤ 付款人余额记录只需清理事务ID即可,重复执行也无妨 db.accounts.findOneAndUpdate( "_id": t.payerId, "$pull": { "transactions": t._id, # ⑥ 收款人除了清理事务ID,还需要完成入账 # 为保证只入账一次,条件里面写了事务ID尚未被清理 # 这样就算重试导致写操作做了两遍,也只有第一遍能成功 # 因为第一遍成功后,就不满足条件了 db.accounts.findOneAndUpdate( "_id": t.payeeId, "transactions": t._id, "$inc": { "balance": t.amount, "$pull": { "transactions": t._id, # ⑦ 最后将事务状态更新为成功 # 因为不可能会更新为其他状态了,所以可以直接更新就好 db.transactions.findOneAndUpdate( "_id": t._id, "$set" { "state": "Success",

    您可能会有疑问,账号余额不足的话怎么办?

    接口在插入转账流水前,可以先检查一遍,余额不足就拒绝转账。如果检查通过,那么在事务执行时出现余额不足的概率较小,但仍然不能忽视。

    因此,事务执行微服务在写操作出现逻辑错误时,需要重读余额判断到底是余额不足,还是重试导致多扣款(事务 ID 已写入)。如是前者,则可直接将事务标记为失败;如是后者,则接着执行步骤③。

    事务回滚过程

    事务在执行的过程中,可能发生错误。不致命的错误,比如偶发的网络错误,可以通过重试解决。但如果执行事务的微服务挂掉了,那应该怎么办呢?是否能由其他微服务继续执行呢?

    如果运行其他微服务继续执行事务,会引入额外的复杂性。考虑到微服务挂掉的概率不大,我们选择简单地让事务超时,这样实现成本更低。

    因此,我们需要引入回滚微服务,来回滚执行超时的事务:

  • 将一个准备阶段超时事务标记为回滚状态;
  • 由于事务执行微服务可能并发执行,想提交事务,因此需要利用原则操作保证一致性;
  • 回滚对付款人的扣款,需要保证重复执行时不会多次回款;
  • 回滚写到收款人的事务 ID ,多次执行也无妨;
  • 将事务状态改为失败,这一步也可以直接改;
  • # ① 抢到一个事务来回滚
    # 条件指定五分钟前,以及Preparing状态,确保还没被提交
    t = db.transactions.findOneAndUpdate(
        "state": "Preparing",
        "time": {
          "$lt": "五分钟前",
        "$set" {
          "state": "Rollback",
    # Rollback 状态
    # ② 回滚对付款人的扣款
    # 这个写操作是执行过程步骤②的逆操作
    # 同样,条件需要加上事务ID还在的判断,避免重复返回扣款
    db.accounts.findOneAndUpdate(
        "_id": t.payerId,
        "transactions":  t._id,
        "$inc": {
          "balance": t.amount,
        "$pull": {
          "transactions": t._id,
    # ③ 回滚对收款人的修改,只需清理事务ID即可
    db.accounts.findOneAndUpdate(
        "_id": t.payeeId,
        "$pull": {
          "transactions": t._id,
    # ④ 最后将事务状态更新为失败
    # 因为不可能会更新为其他状态了,所以可以直接更新就好
    db.transactions.findOneAndUpdate(
        "_id": t._id,
        "$set" {
          "state": "Fail",
    

    如果事务执行微服务在事务准备阶段故障,那么事务最终将超时,进而被回滚微服务接管。但如果微服务在事务提交阶段,或者回滚阶段故障,又该怎么办呢?

    处在这两个阶段的事务,最终状态已经完全确定,只需将写操作应用到涉及的两个账户:

  • 提交阶段:步骤⑤清理付款人事务 ID ,步骤⑥收款人入账,步骤⑦将事务状态改为成功;
  • 回滚阶段:步骤②付款人回款,步骤③清理收款人事务 ID ,步骤⑦将事务状态改为失败;
  • 由于我们借助原子写操作,保证入账和回款不会重复执行,因此这些写操作均可安全重试。这样一来,就算相关微服务执行故障,可以采取超时机制,启动新微服务继续执行即可。

  • 事务准备阶段,只有一个服务能够执行,不会有冲突;
  • 事务在提交和回滚阶段,就算多个服务重复执行,也不会不一致;
  • 原子操作保证提交阶段不会重复入账;
  • 原子操作保证回滚阶段不会重复回款;
  • 原子操作保证,事务要么被提交,要么被回滚,最终状态不会冲突;
  • 如果被执行服务提交,回滚服务就无法回滚;
  • 如果被回滚服务回滚,提交服务就无法提交;
  • 订阅更新,获取更多学习资料,请关注我们的公众号: