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 分片,即使业务上它们相关,事务也会跨分片。因此,分片键设计要服务于事务边界,而不是只服务于查询分布。
三、跨分片两阶段提交: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 引入额外的网络往返、锁持有和故障恢复成本。最有效的优化不是调参数,而是在分片键设计阶段就让事务边界与分片边界对齐——能单分片完成的事务,就不要让它跨分片。