3.4 分布式事务与并发控制


3.4 分布式事务与并发控制

一致性档位(3.3)解决的是"副本之间怎么看同一份数据",本节解决另一类问题:一条业务规则要同时修改多个数据单元怎么办——转账要同时动两个账户、下单要同时扣库存与建订单。这是 NoSQL 世界里最贴近业务正确性的工程话题,第 4.8 节 MongoDB 的多文档事务与本节原理一一对应。

两阶段提交:教科书答案与它的软肋

跨节点事务的经典解法是两阶段提交(2PC),流程分两步:

阶段一(准备): 协调者 -> 参与者A: 准备好了吗(写入已落盘但未提交) 协调者 -> 参与者B: 准备好了吗 参与者A -> 协调者: YES 参与者B -> 协调者: YES 阶段二(提交): 协调者 -> 参与者A: 提交 协调者 -> 参与者B: 提交

所有参与者同意才全局提交,任何一人反对则全局回滚——语义干净。但软肋也致命:协调者在两阶段之间宕机,参与者全部卡死(锁被持有,谁也不知道该提交还是回滚),必须人工介入;整个过程参与者持锁等待,并发性能差。这就是多数 NoSQL 长期不支持跨节点事务的直接原因——不是做不到,而是这个代价在它们的设计哲学里太贵。

工程界的应对是绕开 2PC,用业务流程替代原子性,两个主流模式:

TCC(Try-Confirm-Cancel):把每步操作拆成"预留资源(Try)、确认(Confirm)、取消(Cancel)"三个接口。转账案例:先冻结双方金额(Try),冻结都成功则扣减(Confirm),任一失败则解冻(Cancel)。业务侵入大,但没有全局锁,吞吐好。

Saga:把长事务拆成一组本地事务,依次执行;某一步失败则反向执行之前步骤的补偿事务。订单案例:创建订单(本地事务)→ 扣库存 → 扣款;扣款失败则补回库存、取消订单。没有隔离性保证(中间状态对外可见),需要业务容忍或用状态机约束。

图:Saga 模式的正向执行与反向补偿

图:Saga 模式的正向执行与反向补偿

幂等:分布式事务的隐形地基

无论哪种模式,重试都是家常便饭(网络超时后不知道结果,只能重试),而重试要求操作幂等——执行多次与执行一次结果相同。转账幂等的常规做法是给每笔业务一个唯一请求号,服务端记录已处理请求号,重复请求直接返回上次结果。没有幂等,一切补偿与重试逻辑都是空中楼阁。

演练:用 Saga 模式完成一次跨服务下单

背景:订单、库存、账户三个独立服务(各自用自己的数据库),要求:下单成功三处数据一致;任一环节失败全部回滚;下单接口峰值每秒两千次。

操作:第一步编排器接收下单请求,生成全局订单号,执行"创建订单(状态:处理中)"本地事务;第二步依次调用库存服务扣减(带订单号幂等键)、账户服务扣款(同一幂等键);第三步两步皆成功,把订单状态改为"已完成";第四步扣款失败(余额不足),按反向顺序执行补偿:恢复库存、订单状态改"已取消"。

结果:链路吞吐达到目标(全程无全局锁);用户看到的状态迁移是"处理中 → 已完成"或"处理中 → 已取消",中间态短暂可见;一次网络超时触发库存扣减重试,幂等键拦住了重复扣减。

解读:三个细节撑起了正确性——幂等键防重放;补偿方向与执行顺序严格相反(后做的先补偿);订单的"处理中"状态是给用户解释中间态的界面手段。注意 Saga 放弃了隔离性:扣了库存但订单最终取消的窗口期里,这件商品对外可能显示缺货,这是用 TCC 的 Try 阶段(冻结而非直接扣)可以缓解的取舍。

变式:如果业务是"同一文档内改两个字段",根本不需要分布式事务——单文档原子操作即可(第 4.8 节)。先检查能不能缩小事务范围,再考虑分布式方案,这个顺序别颠倒。

易错点

第一个大坑是补偿不幂等:补偿本身也会被重试,恢复库存执行两次就多了两件。补偿操作同样要幂等。第二个是补偿失败无兜底:补偿也可能失败,必须落一张补偿失败记录表,人工或定时任务接力,这是 Saga 的最后一道闸。第三个是并发下的 ABA 问题:预检查通过后资源被第三方改走,操作执行时条件已变——解决思路是带版本号的条件写入(乐观锁),下一节的建模技巧会用到同类手段。

四种实现路径对照

分布式事务没有银弹,只有代价不同的几种路径。选哪条,取决于业务对一致性的要求与对复杂度的承受力。

路径 一致性强度 原理 优点 代价与风险
两阶段提交(2PC) 强一致 协调者先预提交收集投票,再统一提交 语义接近本地事务,业务改动小 协调者单点;资源长期锁定;阻塞风险
TCC(Try-Confirm-Cancel) 强一致(最终) 业务层拆成预留、确认、撤销三步 锁粒度由业务控制,性能较好 每个参与方都要写三段逻辑,侵入大
Saga 最终一致 拆成若干本地事务,失败时执行补偿 无长时锁定,适合长流程 补偿逻辑复杂;中间态对外可见
消息最终一致 最终一致 本地事务 + 消息队列投递,消费方重试 实现简单、吞吐高 需幂等与对账;存在延迟

演练:下单扣库存的三种做法

需求:用户下单要同时完成"创建订单"与"扣减库存",且不能超卖。

做法一(2PC 风格):协调者先让订单库与库存库各自预提交,两处都就绪后再统一提交。语义最干净,但期间两库的行锁都要持有到提交完成,高并发下锁等待与死锁风险陡增,且协调者故障会让参与者卡在"未决"状态。

做法二(Saga 风格):先创建订单(本地事务),再调库存服务扣减(本地事务)。若扣减失败,执行补偿——把订单置为已取消。整条链路无长时锁定,但用户可能短暂看到"已下单、库存未扣"的中间态,且补偿本身也可能失败(需要重试与人工兜底)。

做法三(消息最终一致 + 预留):下单时先冻结库存(预留),消息队列异步驱动真实扣减;若超时未支付,消息触发解冻。

# 做法三的关键:幂等。消费方可能被重复投递,同一条消息多次执行必须结果一致 # 用一张去重表记录已处理的消息 ID,唯一键冲突即视为已处理 INSERT INTO msg_consumed (msg_id, biz_type) VALUES ('order-90001-deduct', 'DEDUCT'); # 若插入成功 → 执行扣减;若唯一键冲突 → 直接返回成功 UPDATE sku_stock SET available = available - 1 WHERE sku_id = 5501 AND available >= 1; # 影响行数为 0 表示库存不足,触发订单取消流程

三条路的共同点是幂等:只要涉及重试,同一操作执行多次必须等价于执行一次。这是分布式事务里最容易被忽略、也最容易在生产上出事的一条。

并发控制的另一半

事务解决了"多步操作要么全做要么全不做",并发控制解决"同时操作同一份数据怎么办"。常见手段:乐观并发(版本号或时间戳,提交时校验,冲突则重试,适合读多写少)、悲观并发(先加锁再操作,适合写冲突频繁)、以及无锁的原子操作(计数用 INCR,库存扣减用带条件的 UPDATE)。

选择依据是冲突概率:冲突少则乐观(无锁开销),冲突多则悲观(避免大量重试浪费)。判断冲突概率需要真实数据支撑——先看监控里的重试率与锁等待,再决定,不要凭感觉。

本节要点回顾

  • 2PC 语义干净但协调者单点与锁等待是硬伤,NoSQL 传统上绕开它。
  • TCC 用预留-确认-取消三接口换吞吐,Saga 用本地事务 + 反向补偿换无锁。
  • 幂等是重试与补偿的地基:唯一请求号 + 已处理记录表。
  • 补偿顺序与执行相反,补偿失败要有兜底记录。
  • 先考虑缩小事务范围(单文档原子性),再考虑分布式事务方案。

单元间的一致性讲完了。接下来两个话题是 NoSQL 设计的日常功课:数据怎么组织(3.5)、规模怎么扩展(3.6)。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U