如何在MongoDB上实现转账事务

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

转账场景

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

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

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

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

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

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

天真想法

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 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 操作中完成余额判断和余额更新:

1
2
# 更新客户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 协议阶段 );

用户发起一笔转账,系统先向事务表插入一条流水,保存一次转账事务的所有上下文:

1
2
3
4
5
6
7
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 状态才能执行该事务:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
t = db.transactions.findOneAndUpdate(
  {
    "state": "Initiated",
  },
  {
    "$set" {
      "state": "Preparing",
    },
  },
)

# process t...

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

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

事务执行流程

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

  1. 将一个发起状态的事务,修改成准备状态,成功则意味着抢到该事务的执行权;
  2. 根据事务中的付款人 ID 和转账金额,对付款人账户余额进行扣款;
    • 写操作因网络原因失败时,支持重试一次;
    • 扣款必须保证余额充足;
    • 扣款后必须保存事务 ID ,以便事务回滚时可以解除扣款;
    • 扣款前必须保证事务 ID 尚未保存,避免重试时重复扣款;
    • 条件判断必须利用写操作原子性,避免基于旧数据判断;
  3. 根据事务中的收款人 ID ,在收款人账户余额记录事务 ID
    • 记录事务 ID 是为了事务提交后,能够顺利入账,以及不会重复入账(待会解释);
    • 不管收款人现在余额几何,均可入账成功,因此无需检查额外条件;
  4. 将事务状态,从准备修改为提交,成功则意味着事务已经提交;
    • 因为其他微服务会回滚超时事务,必须通过原子操作保证:
      • 要么事务被提交,提交后则不能回滚;
      • 要么事务被回滚,回滚后则不能提交;
      • 不能因为竞争态的存在而产生不一致;
  5. 移除付款人余额记录中的事务 ID ,重试时重复执行也无妨;
  6. 根据事务中的收款人 ID 和转账余额进行入账;
    • 入账后时移除事务 ID
    • 入账前必须保证事务 ID 尚未移除,避免重试时重复入账;
  7. 将事务状态修改为成功,事务结束;
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# ① 抢到一个事务来执行(原子写操作保证只能被一个微服务抢到)
t = db.transactions.findOneAndUpdate(
  {
    "state": "Initiated",
  },
  {
    "$set" {
      "state": "Preparing",
    },
  },
);

# Preparing 准备阶段,先锁住资源,比如扣款

# ② 付款人扣款,写入事务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 已写入)。如是前者,则可直接将事务标记为失败;如是后者,则接着执行步骤③。

事务回滚过程

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

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

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

  1. 将一个准备阶段超时事务标记为回滚状态;
    • 由于事务执行微服务可能并发执行,想提交事务,因此需要利用原则操作保证一致性;
  2. 回滚对付款人的扣款,需要保证重复执行时不会多次回款;
  3. 回滚写到收款人的事务 ID ,多次执行也无妨;
  4. 将事务状态改为失败,这一步也可以直接改;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# ① 抢到一个事务来回滚
# 条件指定五分钟前,以及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 ,步骤⑦将事务状态改为失败;

由于我们借助原子写操作,保证入账和回款不会重复执行,因此这些写操作均可安全重试。这样一来,就算相关微服务执行故障,可以采取超时机制,启动新微服务继续执行即可。

并发分析

  • 事务准备阶段,只有一个服务能够执行,不会有冲突;
  • 事务在提交和回滚阶段,就算多个服务重复执行,也不会不一致;
    • 原子操作保证提交阶段不会重复入账;
    • 原子操作保证回滚阶段不会重复回款;
  • 原子操作保证,事务要么被提交,要么被回滚,最终状态不会冲突;
    • 如果被执行服务提交,回滚服务就无法回滚;
    • 如果被回滚服务回滚,提交服务就无法提交;

订阅更新,获取更多学习资料,请关注我们的公众号:

【随笔】系列文章首发于公众号【小菜学编程】,敬请关注: