4. 分片集群事务架构

在单副本集里,事务的边界由主节点和本地 oplog 决定;一旦进入分片集群,事务的边界就被拆成了三层:mongos 负责路由与协调,config server 提供分片元数据,各 shard 副本集负责本地事务执行。理解这三层如何协作,才能判断一笔事务到底“贵”在哪里。

一、mongos 不是执行者,而是路由与协调入口

客户端连接的是 mongos,而不是直接连接 shard。mongos 本身不持久化业务数据,它做三件事:解析查询、根据分片键和 config server 的路由表定位目标 shard、把结果聚合后返回。对于事务,mongos 还承担 coordinator(协调者) 角色:它决定事务涉及哪些 shard,向这些 shard 下发 prepare/commit/abort,并维护事务的全局状态。

因此,事务的第一个成本来自“路由是否精确”。如果事务的所有操作都命中同一个 shard,mongos 只需协调一个分片,走的是单分片事务路径,代价接近副本集事务;如果操作跨越多个 shard,就进入跨分片事务,协调开销、网络往返和锁持有时间都会显著上升。

二、分片键决定事务的“分片亲和性”

分片键不仅决定数据分布,也决定事务能否被限制在单个分片内。判断依据是:事务中所有写操作的过滤条件,是否都能唯一确定同一个分片。例如集合 orders 以 {customerId: "hashed"} 分片,若事务只操作 customerId = "C1001" 的订单和该客户的账户,且账户集合也以 customerId 分片,那么这两个集合的对应文档会落在同一 shard,事务就是单分片的。

反之,如果事务同时更新 customerId = "C1001" 的订单和 customerId = "C2002" 的账户,两个分片键值不同,必然跨 shard。更隐蔽的情况是:集合 A 按 customerId 分片,集合 B 按 orderId 分片,即使业务上它们相关,事务也会跨分片。因此,分片键设计要服务于事务边界,而不是只服务于查询分布。

分片集群事务架构:路由 → 协调 → 两阶段提交 客户端应用 startTransaction / commit mongos(协调者 Coordinator) 路由解析 · 分片定位 · 全局事务状态 下发 prepare / commit / abort Config Server 分片元数据 · 路由表 chunk 分布 · 分片键范围 事务请求 查路由表 返回分片位置 Shard A(副本集) 分片键范围:customerId hashed [0, 2^63) 本地事务:orders / accounts prepare → 写本地 oplog 持有本地锁至 commit/abort Shard B(副本集) 分片键范围:customerId hashed [2^63, 2^64) 本地事务:orders / accounts prepare → 写本地 oplog 持有本地锁至 commit/abort Shard C(副本集) 分片键范围:customerId hashed [2^64, 3*2^63) 本地事务:orders / accounts prepare → 写本地 oplog 持有本地锁至 commit/abort prepare prepare prepare 跨分片两阶段提交(2PC)与代价 阶段一:Prepare 各 shard 写本地 oplog 并锁定 返回“可提交 / 不可提交” 阶段二:Commit / Abort mongos 汇总投票后下发决定 各 shard 释放锁并确认 主要代价 网络往返 × 分片数 · 锁持有时间延长 协调者故障恢复 · 写冲突概率上升 单分片事务路径 所有操作命中同一 shard → 代价接近副本集 跨分片事务路径 操作命中多个 shard → 触发 2PC 协调 分片键设计目标 让事务边界与分片边界对齐

三、跨分片两阶段提交:prepare 与 commit 之间发生了什么

当 mongos 判定事务涉及多个 shard,它会启动两阶段提交。第一阶段是 prepare:mongos 向所有参与 shard 发送 prepare 请求,每个 shard 在本地副本集内执行写操作、写入 oplog,并进入“已准备但未提交”状态。此时数据对本地事务可见性受控,但全局尚未提交。第二阶段是 commit 或 abort:mongos 收集所有 shard 的投票,只要有一个 shard 返回失败,就向所有 shard 下发 abort;全部成功则下发 commit,各 shard 完成本地提交并释放锁。

关键点在于:prepare 之后、commit 之前,各 shard 的锁不会释放。这意味着跨分片事务的锁持有时间等于“最慢 shard 的 prepare 时间 + 协调决策时间 + commit 传播时间”。如果某个 shard 负载高或网络抖动,整个事务的锁窗口会被拉长,进而阻塞其他写操作。

四、分布式事务的代价:不只是“慢一点”

跨分片事务的代价可以从四个维度量化理解:

  • 网络往返放大:单分片事务只需 1 次 mongos→shard 往返;跨 3 个 shard 的 2PC 至少需要 2 轮 × 3 个 shard 的往返,加上协调决策,总往返次数约为分片数的 2~3 倍。
  • 锁竞争窗口延长:prepare 阶段各 shard 持有本地锁,直到 commit/abort 到达。若事务涉及热点文档,其他事务会在锁上排队,吞吐下降。
  • 协调者故障恢复成本:mongos 作为协调者若在 commit 阶段崩溃,恢复时需要重新读取事务状态并决定提交或回滚,期间参与 shard 的锁可能持续持有,形成“悬挂事务”。
  • 写冲突概率上升:跨分片事务往往涉及多个分片键值,不同事务可能以不同顺序访问相同 shard,增加死锁和重试概率。

工程判断:跨分片事务不是“不能用”,而是“要算清楚”。如果业务事务天然跨多个分片键,优先考虑调整分片键或拆分事务,而不是直接依赖 2PC 硬扛。

五、把事务限制在单分片的实操思路

以下示例展示如何通过分片键设计让事务落在单个 shard。假设有两个集合:accounts 和 orders,业务要求“扣款并创建订单”必须原子完成。

// 两个集合都使用 customerId 作为分片键(hashed)
sh.shardCollection("bank.accounts", { customerId: "hashed" })
sh.shardCollection("bank.orders",   { customerId: "hashed" })

// 事务中所有操作都带同一个 customerId
session.startTransaction()
db.accounts.updateOne(
  { customerId: "C1001" },
  { $inc: { balance: -100 } },
  { session }
)
db.orders.insertOne(
  { customerId: "C1001", orderId: "O9001", amount: 100 },
  { session }
)
session.commitTransaction()

由于两个集合的分片键都是 customerId,且事务中所有操作都带 customerId = "C1001",mongos 可以确定这两个操作都落在同一个 shard 上,事务走单分片路径,不触发 2PC。反之,如果 orders 按 orderId 分片,即使业务上订单属于该客户,事务也会跨分片。

六、事务协调中的关键参数与观察点

在实际运维中,可以通过以下方式观察跨分片事务的代价:

观察对象 关注指标 判断依据
mongos 日志 事务协调耗时、参与 shard 数 参与 shard 数 > 1 即跨分片,耗时随分片数上升
shard 副本集 锁等待时间、oplog 写入延迟 prepare 期间锁等待明显升高,说明事务窗口过长
config server 路由表变更频率 chunk 迁移期间事务路由可能变化,增加协调复杂度
事务重试率 TransientTransactionError 次数 重试率高说明写冲突或协调超时频繁

七、小结:架构决定代价,设计决定边界

分片集群事务的代价不是固定值,而是由“事务涉及几个 shard”决定的。mongos 负责路由和协调,config server 提供元数据,shard 执行本地事务;跨分片时 2PC 引入额外的网络往返、锁持有和故障恢复成本。最有效的优化不是调参数,而是在分片键设计阶段就让事务边界与分片边界对齐——能单分片完成的事务,就不要让它跨分片。