bigywater.tech
分布式事务 · 30

事务补偿的可观测性:状态表与重试

Saga 一旦跑起来,谁知道它们卡在哪一步?worker 崩溃了怎么发现、怎么接着做?重试了几次、花了多久?什么时候该报警?这篇用 saga_log / saga_step 两张状态表,配上恢复守护进程和几条监控查询,在真实的 MySQL 上把这些问题量出来。

20 → 0
worker 悄悄消失后停在 RUNNING 的 saga 数 → 恢复守护进程接管之后(对账 0 不一致)
113
支付服务中断 3 秒时,在途 saga 数的峰值(告警 2.0 s 触发)
9.1 s
告警解除的时刻(支付 4.0 s 恢复后,守护进程每 0.4 秒最多接管 8 个,恢复比中断本身还慢)

没有状态表

实测 · 本机执行输出
  要回答“lost-1 现在卡在哪一步”,得分别去问:订单库 PENDING 、库存库 RESERVED 、支付库 0 条支付;
  订单 PENDING + 库存 RESERVED + 没有支付 → 推断“预留之后、扣款之前挂了”,但这是人在拼三份数据后的推断:没有人知道 worker 是死了还是只是在等,也不知道它重试了几次、上一次错误是什么

只能跨三个服务的库拼出一个 saga 的位置,不知道它还活着没有、重试了几次。

状态表

实测 · 本机执行输出
  1000 个 saga 在 1.0 s 里跑完(worker 的返回值):{'CANCELLED': 179, 'CONFIRMED': 801, 'WORKER_CRASHED': 20}
  从状态表里看:{'in_flight': 20, 'oldest': 0.888, 'stuck': 0, 'comp_failed': 0, 'confirmed': 801, 'cancelled': 179, 'total': 1000};saga_step 一共 4296 行,其中临时失败的尝试 341 行
  各状态的 saga 个数:CANCELLED 179,  CONFIRMED 801,  RUNNING 20
  卡住的(非终态)saga 里最老的 5 个(id、状态、当前步骤、最后的错误、多少秒没更新):[('saga-12', 'RUNNING', 'confirm', None, 0), ('saga-112', 'RUNNING', 'confirm', None, 0), ('saga-193', 'RUNNING', 'charge', None, 0), ('saga-214', 'RUNNING', 'reserve', None, 0), ('saga-225', 'RUNNING', 'charge', 'create: order_create 临时不可用', 0)]

saga_log 记当前步骤、状态、最后错误,saga_step 每次尝试一行;卡住的 saga 一眼可见。

恢复守护进程

实测 · 本机执行输出
  第一轮扫描接管并继续了 20 个 saga,第二轮 0 个
  恢复之后状态表:{'in_flight': 0, 'oldest': 0.0, 'stuck': 0, 'comp_failed': 0, 'confirmed': 821, 'cancelled': 179, 'total': 1000}
  各状态的 saga 个数:CANCELLED 179,  CONFIRMED 821
  对账:库存仍被占着、但订单不是 CONFIRMED 的 0 个;已扣款、但订单是 CANCELLED 的 0 个;状态表里 COMPENSATION_FAILED 的 0 个

扫非终态、久未更新的 saga,抢 owner 接管,从记录的步骤继续;每一步幂等才安全。

重试的账

实测 · 本机执行输出
  预留库存这一步的尝试次数分布(尝试 n 次的 saga 个数):{1: 925, 2: 66, 3: 9}
  saga     | 个数  | 创建到结束 中位数 | p99   
  ---------+-----+-----------+-------
  没有临时失败   | 709 | 4 ms      | 59 ms 
  至少一次临时失败 | 271 | 30 ms     | 101 ms
  重试(带指数退避)让有临时失败的 saga 变慢,但都走完了;saga_step 里能直接查到每个 saga 在哪一步试了几次、每次什么错

尝试次数分布和耗时都能从 saga_step 查;有临时失败的 saga 更慢但都走完。

告警

实测 · 本机执行输出
  时刻    | 在途 saga | 最老的非终态 saga 多久没更新 | 已确认 | 告警
  ------+---------+-------------------+-----+---
  0.0 s | 0       | 0.0 s             | 0   |   
  0.5 s | 0       | 0.0 s             | 62  |   
  1.0 s | 2       | 0.0 s             | 123 |   
  1.5 s | 24      | 0.2 s             | 123 |   
  2.0 s | 48      | 0.4 s             | 123 | 告警
  2.5 s | 72      | 0.9 s             | 123 | 告警
  3.0 s | 79      | 1.3 s             | 123 | 告警
  3.5 s | 96      | 1.8 s             | 123 | 告警
  4.0 s | 113     | 2.3 s             | 130 | 告警
  4.5 s | 80      | 2.2 s             | 478 | 告警
  5.0 s | 72      | 2.7 s             | 528 | 告警
  5.5 s | 64      | 3.1 s             | 536 | 告警
  6.1 s | 56      | 3.1 s             | 544 | 告警
  6.6 s | 48      | 3.5 s             | 552 | 告警
  7.1 s | 40      | 4.0 s             | 560 | 告警
  7.6 s | 32      | 4.0 s             | 568 | 告警
  8.1 s | 24      | 4.4 s             | 576 | 告警
  8.6 s | 16      | 4.8 s             | 584 | 告警
  9.1 s | 6       | 0.0 s             | 594 |   
  9.6 s | 0       | 0.0 s             | 600 |   
  支付服务在 1.0 s 到 4.0 s 中断;第一次告警出现在 2.0 s,告警在 9.1 s 解除;恢复守护进程一共接管并继续了 104 个 saga;最终状态:CONFIRMED 600(共 600 个)
  其中 saga_step 里有临时失败记录的 saga 120 个;中断期间扣款的退避重试把在途数顶高、让最老的 saga 越来越久没更新,告警在那时出现;重试用完的 saga 停在 RUNNING,由守护进程在支付恢复之后接着跑完

在途数和最老非终态年龄两个指标能在中断 1 秒内触发告警;恢复速度由守护进程的接管能力决定。

分步演示

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 里的 schema(order_db / stock_db / pay_db),编排器的状态表在 saga_db;编排器、worker、恢复守护进程、监控采样都是我写的 Python 线程,不是 Seata / Temporal 等产品;故障是用固定种子的随机数在服务调用里注入的(临时失败、余额不足、worker 做完某步后“悄悄消失”),支付中断用时间窗口模拟;告警规则的阈值(在途 40、2 秒)是我设的
    • 没有验证:真正的进程崩溃(这里 worker “消失”是线程直接返回,不是 kill -9);恢复守护进程自己崩溃后的接管(这里只有一个守护进程循环);多个编排器实例并发的 saga 抢占;saga_step 表的清理与大小;和真实监控系统(Prometheus 等)的对接;COMPENSATION_FAILED 在本篇负载里一次都没有出现,没有用它演示人工处理流程(见第 9 篇)
    京ICP备2026067502号-1京公网安备11010502063487号