数据库

MongoDB 多文档事务:何时不应使用

多文档事务看似是安全的默认选项,但提交歧义和 PSA 可用性陷阱使其代价高昂。以下是何时应跳过它们。

本文由 AI 模型从英文原文翻译而来,措辞可能与原文有出入。 阅读英文原文

MongoDB 在 4.0 版本中增加了多文档事务,而一旦有了这个功能,大多数团队就会像在关系型数据库中那样去使用它。这种直觉通常是错误的。事务本身并不能保证写入操作的安全,它也无法免除你将操作设计为幂等的责任,而且在常见的副本集拓扑结构中,当某个节点宕机时,事务恰恰可能导致数据库拒绝写入。本文将介绍导致一个团队放弃其重度依赖事务的设计的三个陷阱,以及取而代之的单文档模式。

多文档事务究竟保证了什么

MongoDB 事务为你提供跨多个文档的“全有或全无”式写入。你打开一个会话,启动一个事务,针对该会话运行写入操作,然后提交。如果任何写入失败或你中止了操作,那么所有写入都将不可见。隔离级别为快照级别,因此并发的读取者要么能看到所有已提交的更改,要么什么都看不到。

这是一种真正的保证,并且有些问题确实需要这种保证。问题在于,这种保证的范围比表面上看起来要窄,而其成本则更高。事务可以保护你免受部分写入落入数据库的风险。它不能保护你免受你自己的客户端重新发送提交请求、无法形成多数派的副本集,或多文档写入在负载下产生的锁争用等问题的影响。这三个缺陷正是该设计的不足之处。

下面是每个人最先想到的模式。

session, err := client.StartSession()
if err != nil {
	return err
}
defer session.EndSession(ctx)

_, err = session.WithTransaction(ctx, func(sc mongo.SessionContext) (interface{}, error) {
	if err := debit(sc, from, amount); err != nil {
		return nil, err
	}
	if err := credit(sc, to, amount); err != nil {
		return nil, err
	}
	return nil, nil
})

它读起来很清晰,演示效果也很好。但故障模式只在生产环境中才会出现。

UnknownTransactionCommitResult 问题

第一个陷阱是人们在测试中从未见过的。当你调用提交时,驱动程序会将命令发送到主节点并等待确认。如果在你的写入操作持久化之后、但在你收到确认之前,网络发生中断或主节点降级,那么驱动程序就无法判断提交是否成功。它将此情况呈现为一个带有 UnknownTransactionCommitResult 标签的错误。

官方的指导意见是重试提交,而驱动程序的 WithTransaction 辅助函数会为你执行此操作。这听起来像是问题已经解决了,但我们来看看重试提交意味着什么。你正在重发一个你不知道其结果的操作。如果第一次提交实际上成功了,重试操作会去提交一个已经提交过的事务,MongoDB 会将其视为空操作 (no-op),因此这种特定情况是安全的。危险在于更高一个层级。如果整个事务是由一个本身可以被重试的请求(例如客户端超时、队列重新投递、用户双击)所驱动,那么整个借贷操作就可能作为一个全新的事务再次运行。事务边界无法阻止这种情况。它只保证每次运行都是原子性的,而不能保证该逻辑操作只运行一次。

// Retrying the commit is safe. Retrying the whole operation is not,
// unless debit() and credit() are idempotent by construction.
for {
	err := doTransfer(ctx, from, to, amount, transferID)
	if err == nil {
		break
	}
	if hasErrorLabel(err, "TransientTransactionError") {
		continue // safe to replay the transaction
	}
	if hasErrorLabel(err, "UnknownTransactionCommitResult") {
		continue // safe to retry the commit
	}
	return err
}

TransientTransactionError 标签标记了整个事务可以被安全重放的失败,而 UnknownTransactionCommitResult 标记了可以重试的提交。这两种重试循环都假定底层的工作可以安全地重复执行。这个假定是你必须构建的。事务不会免费为你提供幂等性。如果你需要转账操作只被精确地应用一次,你仍然需要一个稳定的幂等性键,通常是一个 transferID,你在事务内部记录并检查它,这样重放就会变成一个空操作 (no-op)。一旦你有了那个键,事务为你带来的大部分好处就已经由该键处理了。

w=majority 带来的 PSA 可用性陷阱

第二个陷阱是结构性的,它影响的是可用性,而非正确性。事务默认以 w=majority 的写关注点提交,你也希望如此,因为以较弱关注点提交的事务在故障转移时可能会被回滚,这就失去了意义。问题在于,在主-从-仲裁(Primary-Secondary-Arbiter)拓扑中,“多数”意味着什么。

PSA 是一种流行的三节点布局,因为仲裁节点成本低廉。它在选举中投票但不持有数据,因此你只需为两个数据承载节点付费,却仍能实现故障转移。问题在于,仲裁节点无法确认写入。在一个三成员的集合中,“多数”是两个节点,而你的三个成员中只有两个承载数据。如果任一数据承载节点宕机,你就只有一个节点可以确认多数写入,而另一个不能。对于普通插入操作,该集合仍然可写,但对于 w=majority 的写入(每个事务提交都是如此),则无法再达到多数。提交操作会阻塞或超时,直到第二个数据承载节点恢复。

拓扑 数据节点 多数 对 w=majority 写入是否容忍 1 个数据节点宕机
PSA (主-从-仲裁) 2 2
PSS (主-从-从) 3 2

因此,这个看似能容忍节点故障的拓扑,实际上只对普通写入容忍,而对事务则不然。你会在构建副本集本想应对的那种确切事故中发现这个问题。一个从节点在凌晨 3 点宕机,普通流量继续,但每个事务性写入路径都挂起了。解决方法要么是采用 PSS(这需要第三个数据承载节点的成本),要么是完全不在该路径上要求经多数确认的多文档提交。如果你在那里没有使用事务,一个 w=1 的单文档写入本可以继续提供服务。

性能和锁成本

第三个陷阱更隐蔽,表现为延迟。事务在其整个生命周期内都会持有资源。它会钉住一个快照,因此存储引擎必须在事务的生命周期内保持文档的旧版本可读,并且它会获取文档级锁,导致对相同文档的其他写入者需要排队等待。在高并发竞争下,接触热点文档的事务会相互串行化,而一个缓慢的事务会使其接触到的所有文档的写入者都变慢。

MongoDB 也对事务设置了硬性限制,促使你保持事务的短小。默认的事务生命周期是 60 秒,超过后服务器会中止它。一个事务如果产生的 oplog 变更超过 16MB,就必须在内部分割,并且成本更高。这些限制本身都不是致命的,但它们共同意味着事务不适合用来做批量工作,而一个跨越了缓慢的外部调用或大型批处理的事务是一种设计错误。健康的事务是对少数文档进行少量写入,并持续几毫秒。当你的事务不再是这种情况时,就说明事务是错误的工具,而不是一个你需要调优的工具。

替代大多数事务的模式

人们为保护不变性而采用事务,但大多数这类不变性实际上并不跨越多个文档。它们跨越的是单个文档的多个字段,或者它们是可以建模为单个文档的单一逻辑事实。MongoDB 无需任何事务即可原子地更新单个文档,而这种原子性单文档更新加上幂等的 upsert 操作,覆盖了绝大多数情况,其比例之高令人惊讶。

以转账为例,这看起来是教科书式的双文档事务。如果余额以货币映射的形式存在于一个账户文档中,那么整个操作就是一次原子性更新。如果它们必须存在于不同的文档中,则将转账本身建模为一个具有确定性 id 的文档,并让每一方根据该 id 进行幂等应用。

// One document, one atomic update. No transaction, no majority commit
// required, and it stays writable when a data node is down.
res, err := accounts.UpdateOne(ctx,
	bson.M{"_id": from, "balance": bson.M{"$gte": amount}},
	bson.M{"$inc": bson.M{"balance": -amount}},
)
if err != nil {
	return err
}
if res.ModifiedCount == 0 {
	return ErrInsufficientFunds // the guard failed atomically
}

过滤器承载了不变量。balance >= amount 条件和 $inc 会在服务器上针对单个文档一起进行评估,因此不存在并发写入者在检查和递减之间潜入的时间窗口。两个竞相执行的取款操作不可能都通过这个关卡,因为第二个操作会看到已经被递减过的余额。这与事务在单文档情况下所能提供的安全性相同,但没有提交的模糊性,没有大多数要求,也没有跨两个文档持有的锁。

当操作确实要生成一个必须只存在一次的记录时,请使用确定性的 id 和 upsert 操作,这样重试时就会覆盖而不是重复创建。

// Idempotent by id. First call inserts, every retry overwrites identical
// data, the row count never grows. Safe to replay from any retry loop.
id := TransferID(from, to, requestID)

_, err := transfers.UpdateByID(ctx, id,
	bson.M{"$setOnInsert": bson.M{
		"from": from, "to": to, "amount": amount, "at": time.Now(),
	}},
	options.Update().SetUpsert(true),
)

这与用于幂等 upsert 的确定性 ULID 背后的思想相同。ID 是根据标识该操作的内容派生出来的,因此第二次尝试会指向同一个 ID,并成为一个空操作 (no-op)。在原子性保护更新和幂等 upsert 之间,事务从大多数路径中消失了,随之消失的还有上述的三个陷阱。这就是为什么一个优先采用事务的团队,在事故让他们明白事务的实际成本后,往往会回归到非事务性的设计。

何时才真正需要事务

这并不意味着事务永远都不是正确的选择。一个真实的适用场景是:存在一个真正的“全有或全无”不变量,它跨越了多个你无法合并成一个的文档,并且没有任何单文档防护或幂等键可以表达这种约束。一个真实的例子是,在两个独立查询的集合之间移动一个项目,此时读取者绝不能看到该项目同时存在于两个集合中,也不能看到它在两个集合中都不存在。另一个例子是记账分类账,其中位于不同文档中的借方分录和贷方分录必须同时存在或同时不存在,并且这对分录被独立查询的频率足够高,以至于你无法将它们合并到单个文档中。

测试方法很简单。问问自己,这个不变量是否真的需要两个或更多文档一起更改,以及读取者是否能以一种破坏规则的方式观察到中间状态。如果两个问题的答案都是肯定的,那么事务就是正确的工具,你应该有意识地接受其成本,这意味着选择一个 PSS 拓扑结构以使大多数提交能在节点丢失后幸存,保持事务小而快,并且仍然使外层操作具有幂等性,这样重放时就不会重复应用。如果任一问题的答案是否定的,那么你使用事务只是为了避免思考数据模型,而该模型其实有更简单的答案。

在你使用事务前的检查清单

  • 该不变性确实跨越了多个无法建模为单个文档的文档,而不仅仅是单个文档的多个字段。
  • 并发读取者确实能观察到被破坏的中间状态,因此跨文档的原子性是必需的,而不仅仅是为了整洁。
  • 外围操作本身就是幂等的,由一个稳定的 id 作为键,因为提交操作可能返回 UnknownTransactionCommitResult,并且整个请求可以在事务之上重试。
  • 副本集是 PSS 而非 PSA,因此在一个承载数据的节点宕机的情况下,w=majority 的提交仍然会成功。
  • 事务是小而短的,只持有少数文档几毫秒,其内部没有缓慢的外部调用或批量写入。

全部满足这五项,那么事务就在做其他任何方式都无法完成的工作,其成本是你选择付出的代价。如果前两项不满足,那么单文档原子更新加上幂等的 upsert 操作,就能在提供更高可用性和更少争用的同时,带来同样的不变性。默认应采用更简单的设计,而事务应该是一种例外情况,你需要逐个路径地证明其合理性。