bigywater.tech
分布式事务 · 10

Saga 的隔离性缺失:脏读与补偿冲突

Saga 的每一步都是已经提交的本地事务,所以别的事务能读到它的中间状态,补偿也可能和别人的更新交错。这篇把脏读、补偿覆盖并发更新、中间状态造成的丢单三种异常逐个复现,再试语义锁和相对增量两种对策。

506 / 1007
审批服务读到 Saga 的中间状态而错误拒绝贷款的次数 / 总读取次数
90 / 200
补偿“恢复成扣款前的值”时,覆盖掉并发利息更新的次数(相对增量是 0)
27 / 27
库存预留窗口里到达的买家全部被拒(窗口外的都买到了)

脏读

实测 · 本机执行输出
  30 个 saga 依次跑完,每个 saga 里“已扣款、还没补偿”的窗口 100 ms,共 6.3 秒;审批服务一共读了 1007 次,读到过的余额值 [200, 1000]
  其中读到中间状态 200 并因此“拒绝”贷款的有 506 次(50%)—— 这些拒绝都是错的:每个 saga 最后都被补偿了,账户余额从来没有真的低过 1000
  最终余额: 1000

每一步都已提交,中间状态对所有人可见。审批服务一半的读取看到被扣掉的 800,于是拒绝了本来能批准的贷款。

补偿覆盖别人的更新

实测 · 本机执行输出
  补偿写法            | 试验次数 | 终态不是 1005 的次数(丢了利息) | 比例 
  ----------------+------+---------------------+----
  写法 A:恢复成扣款前读到的值 | 200  | 90                  | 45%
  写法 B:加回扣掉的 100  | 200  | 0                   | 0% 
  利息更新落在“扣款之后、补偿之前”的窗口里时,A 把它覆盖掉了(丢失更新);B 是相对增量,先后顺序不影响结果

补偿写“恢复成旧值”会抹掉扣款与补偿之间别人写入的更新;写“加回扣掉的数”则与别的更新可交换。

中间状态造成丢单

实测 · 本机执行输出
  60 次试验,买家到达时刻在 0 到 0.4 秒之间均匀随机;预留窗口是 0.1 到 0.3 秒
  买家被拒绝(库存不足)的有 27 次;其中到达时刻落在预留窗口里的 27 次;到达时落在窗口里的买家共 27 个
  被拒绝的买家全部落在预留窗口里
  窗口里到达的买家被拒了,可是 0.2 秒后 saga 补偿把这 1 件放回去了:这些订单本来是卖得出去的(丢单)。晚于 0.3 秒到的买家买到了

库存被预留的那段时间,买家被拒;预留释放后这些单本可成交。

语义锁

实测 · 本机执行输出
  同样的 30 个 saga:读 balance + pending 的审批服务读了 1006 次,其中 505 次读到了 saga 进行中的状态(pending > 0),错误拒绝 0 次
  “等待型”读者(看到 pending > 0 就等到 saga 收尾再决定):等待了 30 次,每次平均等 102 ms,最长 106 ms;等待换来的是它永远只看到已收尾的状态

在 pending 列里标出“正在被 saga 处理”的量,读者用 balance + pending 判断总资产就不被中间状态误导;等待型读者则等到 saga 收尾。代价是读者要理解 pending。

对策对比

实测 · 本机执行输出
  做法                    | 审批被错误拒绝(第一节的 saga 窗口,次) | 丢失的更新(第二节,200 次里)
  ----------------------+-------------------------+------------------
  无防护:读 balance + 绝对值补偿 | 506                     | 90               
  只改补偿:相对增量             | 506                     | 0                
  相对增量 + 语义锁(pending 列) | 0                       | 0                
  (第一列的数字是第一节“只读 balance”的错误拒绝次数,第三行是第四节读 balance + pending 的结果;丢失更新数第一行来自写法 A,后两行来自写法 B)

相对增量解决丢失更新,语义锁解决脏读误判,两者各管一类。没有通用开关。

分步演示

Saga 的隔离性
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / MySQL 26.7.0 与 PostgreSQL 16.15(Homebrew,每个实验自己 initdb、自己起实例,端口 9150 以上,只监听 127.0.0.1)/ Python 3.14.8 + PyMySQL 1.2.3 + psycopg 3.3.6 / Redis 8.10.2 / Kafka 4.3.1 / RabbitMQ 4.3.6 / etcd 3.7.2(按各篇注明用到哪些);不连任何外部服务,不碰本机已有的数据库实例;故障用 kill -9 进程、让连接断开或在代码里的指定位置抛异常来制造;计时用 time.perf_counter,数字每次运行会有小幅抖动。本篇用一个 MySQL 26.7.0 实例的 bank 库,Saga 的每个步骤是 autocommit 的单条语句,窗口用真实 sleep(100 ms / 20 ms / 200 ms),读者和竞争更新是独立线程;随机到达时刻和利息写入时刻用固定随机种子
    • 没有验证:真实 Saga 框架(Seata Saga、Temporal 等)对隔离性提供了什么;可交换更新在复杂业务(多字段约束、取消订单要退多种东西)里是否都能做到;“重读值”和“版本检查”这类对策(没有实验);数字里的 50% 取决于我设的窗口与间隔(各 100 ms),不是普遍比例
    京ICP备2026067502号-1京公网安备11010502063487号