2026 年 4 月某个周三晚上 22:18,运营群里反复出现一个奇怪反馈:"我们这边 A 商家有 800 件库存,系统显示 700 件,但订单明细只有 20 件占用——为什么有 80 件库存不知去向?"过去两个月类似反馈累积了 47 单,每次客服反馈"系统问题",运维手动改库存数据"恢复"。运营同事终于忍不住要求技术给说法。我去查了下,发现这 80 件库存被一些"诡异状态"的订单占用——订单状态显示"已取消",但库存表里仍记着这些订单的占用记录,从未被回收。
4 天后定位的根因是 Saga 模式实现的补偿漏洞:我们的电商下单链路涉及订单、库存、支付、物流 4 个服务,用 Saga 协调。某些"边缘失败路径"下(比如订单创建 → 扣库存成功 → 创建支付订单时支付服务网络抖动重试 → 实际支付成功但客户端收到 timeout 误判失败 → 触发回滚),补偿动作只跑了一部分(订单状态被改成 cancelled),但库存补偿没真正执行(被异步重试机制吞了)——导致库存记录永远占用,直到人工干预。这篇是完整复盘,涵盖 Saga 模式的两种实现(orchestration vs choreography)、补偿动作的 5 个失败模式、幂等性 + 状态机 + 重试的工程化设计、以及落地的《Saga 分布式事务纪律》。
背景:这个看似稳定运行的 Saga 实现
| 维度 | 数值 |
|---|---|
| 业务 | SaaS 跨境电商订单中台 |
| 下单涉及服务 | order-service / inventory-service / payment-service / logistics-service |
| 规模 | 日均下单 22 万,峰值 QPS 350 |
| Saga 模式 | Orchestration(中心化协调,由 order-service 编排) |
| 技术栈 | Java 17 + Spring Boot 3.2 + Kafka 3.6 + MySQL 8.0 |
| 事故现象 | "库存漏占用"——订单已取消但库存未释放,2 个月累积 47 单 |
| 商业影响 | 商家库存数据混乱,客服反复改数据,商家信任下降 |
| 修复后 | 0 例幽灵库存,Saga 一致性 100%,dead-letter 实时清零 |
事故时间线:从运营吐槽到根因落地的 4 天
| 时刻 | 事件 |
|---|---|
| 04-22 22:18 | 运营反馈,我开始排查 |
| 04-22 晚 | 抽样 5 单"幽灵库存占用"订单,查日志看 Saga 流程 |
| 04-23 上午 | 发现都是"订单 cancelled 但库存 reserved 状态没变"的状态不一致 |
| 04-23 下午 | 追到 Kafka topic saga-compensation,看到这些订单的"释放库存"事件确实发出去了,但被某个 consumer 标 dead-letter 后没人处理 |
| 04-23 深夜 | 翻 dead-letter,发现累积了 5000+ 条消息,从来没人 review |
| 04-24 上午 | 分析消息处理链路,发现补偿 consumer 的幂等检查有 bug,把"还没处理过"的消息错误识别为"已处理" |
| 04-24 下午 | 更深一层:发现 Saga 状态机存内存,服务重启会丢失正在执行的 Saga |
| 04-25 上午 | 设计修复:幂等检查重写 + 状态机持久化 + 补偿监控 |
| 04-25 下午 | 预发跑混沌测试,所有失败路径补偿都正确触发 |
| 04-26 ~ 04-30 | 分批上线,事后写脚本扫所有历史"幽灵库存"逐条人工修正 |
问题本质:Saga 在分布式系统里的暗坑
事后画了一张图描述这次失败模式涉及的所有环节:
这张图把 5 个独立缺陷叠加在一条失败路径上:支付超时误判、补偿走 Kafka 异步、内存幂等、Saga 状态非持久化、dead-letter 无人监控。任何一个单独存在都不致命,叠加起来就是 2 个月 47 单幽灵库存。
第一反应:"是不是 Kafka 漏消息"
看到"补偿没执行"的第一反应都是"消息丢了"。但 Kafka 的语义保证(at-least-once)下消息很少真正丢——通常是 consumer 那边出问题。我们查了 Kafka topic 的 lag 监控,没积压;然后查 dead-letter topic,发现了"线索":这些幽灵订单的补偿消息全部进了 dead-letter,但 dead-letter 没人 monitor,所以一直被忽略。
Dead-letter 是"消息处理失败了 N 次后扔到这里"——本意是给人工排查用,但如果不监控,这个机制等于消息真的丢了。我们的 dead-letter 在 2 个月里累积了 5000+ 条,没人看。
# 查看 dead-letter 积压量
kafka-consumer-groups.sh --bootstrap-server kafka-prod:9092 \
--describe --group inventory-compensation-dlq
# 输出 (脱敏):
# TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG
# saga-compensation-dlq 0 0 1721 1721
# saga-compensation-dlq 1 0 1689 1689
# saga-compensation-dlq 2 0 1611 1611
# 合计 5021 条未处理消息, 跨越 2 个月
# 看最早的消息时间
kafka-console-consumer.sh --bootstrap-server kafka-prod:9092 \
--topic saga-compensation-dlq --from-beginning --max-messages 1 \
--property print.timestamp=true
# CreateTime:1709452318000 ...
# 2024-03-03 — 早就有了,只是没人看
真凶 1:Saga 状态机存在内存,服务重启就丢
看 order-service 的 Saga 实现:
@Service
public class OrderSaga {
private final Map<String, SagaState> sagaStates = new ConcurrentHashMap<>(); // 内存
public void execute(OrderCreateRequest req) {
String sagaId = UUID.randomUUID().toString();
SagaState state = new SagaState(sagaId);
sagaStates.put(sagaId, state);
try {
state.setStep("RESERVE_INVENTORY");
inventoryClient.reserve(req.getProductId(), req.getQuantity());
state.markCompleted("RESERVE_INVENTORY");
state.setStep("CREATE_PAYMENT");
paymentClient.create(req);
state.markCompleted("CREATE_PAYMENT");
state.setStep("CREATE_LOGISTICS");
logisticsClient.create(req);
state.markCompleted("CREATE_LOGISTICS");
} catch (Exception e) {
compensate(state);
}
}
private void compensate(SagaState state) {
if (state.isCompleted("CREATE_LOGISTICS")) {
logisticsClient.cancel(state.getOrderId());
}
if (state.isCompleted("CREATE_PAYMENT")) {
paymentClient.cancel(state.getOrderId());
}
if (state.isCompleted("RESERVE_INVENTORY")) {
inventoryClient.release(state.getOrderId());
}
}
}
致命问题:SagaState 存在内存里。如果 order-service 在 Saga 执行中崩溃 / 重启:
- 内存里的 sagaStates 全丢
- 已经扣的库存 / 创建的支付订单 / 创建的物流单不知道存在
- 这些资源永远不被释放
- 客户端可能收到 502,以为下单失败,但实际上某些副作用已经发生
这不是边缘问题——任何"长事务"模式都不能依赖内存。我们的服务每周都会 rolling restart 部署新版本,K8s preStop 钩子有 30 秒优雅退出窗口,但 Saga 整体可能跑 1-3 分钟(尤其物流接口偶尔慢),这意味着每周都会"漏"一批正在 in-flight 的 Saga。
修法:状态机必须持久化
@Entity
@Table(name = "saga_state",
indexes = {
@Index(name = "idx_status_updated", columnList = "status, updated_at"),
@Index(name = "idx_order_id", columnList = "order_id")
})
public class SagaState {
@Id
private String sagaId;
@Column(name = "order_id")
private String orderId;
@Column(name = "current_step")
private String currentStep;
@Column(name = "completed_steps", columnDefinition = "JSON")
private String completedSteps;
@Enumerated(EnumType.STRING)
private Status status;
@Column(name = "retry_count")
private int retryCount;
@Column(name = "last_error", length = 2000)
private String lastError;
@Column(name = "created_at")
private OffsetDateTime createdAt;
@Column(name = "updated_at")
private OffsetDateTime updatedAt;
public enum Status {
RUNNING, COMPLETED, COMPENSATING, COMPENSATED, FAILED
}
}
@Service
public class OrderSaga {
private final SagaStateRepository sagaRepo;
private final ApplicationEventPublisher publisher;
@Transactional
public void execute(OrderCreateRequest req) {
String sagaId = UUID.randomUUID().toString();
SagaState state = SagaState.builder()
.sagaId(sagaId)
.orderId(req.getOrderId())
.status(Status.RUNNING)
.createdAt(OffsetDateTime.now())
.updatedAt(OffsetDateTime.now())
.build();
sagaRepo.save(state);
try {
executeStep(state, "RESERVE_INVENTORY",
() -> inventoryClient.reserve(req.getProductId(), req.getQuantity()));
executeStep(state, "CREATE_PAYMENT",
() -> paymentClient.create(req));
executeStep(state, "CREATE_LOGISTICS",
() -> logisticsClient.create(req));
state.setStatus(Status.COMPLETED);
sagaRepo.save(state);
} catch (Exception e) {
state.setStatus(Status.COMPENSATING);
state.setLastError(e.getMessage());
sagaRepo.save(state);
publisher.publishEvent(new SagaCompensationEvent(state.getSagaId()));
}
}
private void executeStep(SagaState state, String step, Runnable action) {
state.setCurrentStep(step);
state.setUpdatedAt(OffsetDateTime.now());
sagaRepo.save(state);
action.run();
state.addCompletedStep(step);
state.setUpdatedAt(OffsetDateTime.now());
sagaRepo.save(state);
}
}
关键:每个步骤前后都持久化状态到数据库。即使服务崩溃,重启后可以扫描 RUNNING / COMPENSATING 状态的 Saga,继续执行或继续补偿。
同时加一个后台 cron,定期扫"长时间不动"的 Saga:
@Component
@RequiredArgsConstructor
public class StuckSagaRecovery {
private final SagaStateRepository sagaRepo;
private final ApplicationEventPublisher publisher;
@Scheduled(cron = "0 */5 * * * ?")
public void recoverStuckSagas() {
OffsetDateTime threshold = OffsetDateTime.now().minusMinutes(10);
List<SagaState> stuck = sagaRepo.findByStatusInAndUpdatedAtBefore(
List.of(Status.RUNNING, Status.COMPENSATING), threshold);
for (SagaState s : stuck) {
if (s.getRetryCount() >= 10) {
log.error("Saga {} exceeded retry limit, mark FAILED for manual", s.getSagaId());
s.setStatus(Status.FAILED);
sagaRepo.save(s);
alertChannel.send("FAIL_SAGA", s);
continue;
}
s.setRetryCount(s.getRetryCount() + 1);
sagaRepo.save(s);
log.warn("Recovering stuck saga: {} retry={}", s.getSagaId(), s.getRetryCount());
publisher.publishEvent(new SagaCompensationEvent(s.getSagaId()));
}
}
}
真凶 2:补偿的幂等检查有 bug
补偿是异步的——通过 Kafka 发"释放库存"事件,inventory-service 的 consumer 处理:
// inventory-service 旧版本补偿 consumer
@Component
public class InventoryCompensationConsumer {
private final Set<String> processedSagas = ConcurrentHashMap.newKeySet(); // 又是内存
private final InventoryRepository inventoryRepo;
@KafkaListener(topics = "saga-compensation")
public void onCompensation(CompensationEvent event) {
String sagaId = event.getSagaId();
if (processedSagas.contains(sagaId)) {
log.info("Saga {} already processed, skip", sagaId);
return;
}
inventoryRepo.releaseReservation(event.getProductId(), event.getQuantity());
processedSagas.add(sagaId);
}
}
看起来"幂等",但 processedSagas 是内存集合——服务重启后空了。但消息从 Kafka 重投递时,代码逻辑会"以为之前没处理过"再处理一次?
实际更糟:Kafka 同一 sagaId 的事件可能被同时投到 2 个 consumer 实例(因为 partition 不固定或者 consumer rebalance),两个实例都"检查 processedSagas 没有,开始处理"——结果库存释放两次,库存数变成负数(我们有限制不允许负数,所以第二次操作 fail,但 fail 让消息进 dead-letter,看起来像"补偿失败")。
修法:用数据库级幂等表 + 唯一约束
CREATE TABLE saga_compensation_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
saga_id VARCHAR(64) NOT NULL,
step VARCHAR(64) NOT NULL,
product_id VARCHAR(64),
quantity INT,
executed_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
result VARCHAR(32),
UNIQUE KEY uk_saga_step (saga_id, step),
KEY idx_executed (executed_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@Component
@RequiredArgsConstructor
public class InventoryCompensationHandler {
private final CompensationLogRepository logRepo;
private final InventoryRepository inventoryRepo;
@Transactional(rollbackFor = Exception.class)
@KafkaListener(topics = "saga-compensation")
public void onCompensation(CompensationEvent event) {
// 1. 先尝试插入 log (利用 unique constraint 实现幂等)
try {
CompensationLog log = CompensationLog.builder()
.sagaId(event.getSagaId())
.step(event.getStep())
.productId(event.getProductId())
.quantity(event.getQuantity())
.executedAt(OffsetDateTime.now())
.build();
logRepo.saveAndFlush(log);
} catch (DataIntegrityViolationException e) {
log.info("Compensation already executed for saga={} step={}, skip",
event.getSagaId(), event.getStep());
return;
}
// 2. 在同一事务里执行实际补偿
int affected = inventoryRepo.releaseReservation(
event.getOrderId(), event.getProductId(), event.getQuantity());
if (affected == 0) {
// 没有可释放的预留 (可能已经释放过或从未预留), 不抛异常, 让 log 保留作为审计
log.warn("No reservation to release for saga={} order={}",
event.getSagaId(), event.getOrderId());
}
// 事务 commit, log 和实际操作一致
}
}
关键:用 (saga_id, step) 的 unique constraint 实现"严格至多一次"。即使消息被重复投递、即使多个 consumer 同时处理,数据库层面保证只有一个成功执行实际操作,其他都因为唯一约束冲突而 skip。
真凶 3:dead-letter 没人监控
这是个组织 / 流程问题,但比技术更难修。我们的 Kafka 集群有 30+ 个 dead-letter topic,大部分团队建了之后就忘了。事故时累积 5000+ 条未处理消息。
修法:dead-letter 必须告警 + 必须有处理流程
groups:
- name: kafka-dlq-alert
rules:
- alert: KafkaDLQHasMessages
expr: kafka_topic_partition_current_offset{topic=~".*-dlq"} - kafka_topic_partition_committed_offset{topic=~".*-dlq"} > 0
for: 5m
labels:
severity: warning
owner_team: "{{ $labels.owner }}"
annotations:
summary: "DLQ {{ $labels.topic }} has unprocessed messages"
runbook: "https://wiki/sop/dlq-handle"
- alert: KafkaDLQGrowing
expr: rate(kafka_topic_partition_current_offset{topic=~".*-dlq"}[5m]) > 0
for: 10m
labels:
severity: critical
annotations:
summary: "DLQ {{ $labels.topic }} is growing — investigate immediately"
- 每个 dead-letter topic 必须配 Prometheus 告警:消息数 > 0 触发 P3,持续 1 小时 P2
- 每个 dead-letter 必须有 owner,告警直接发到 owner 的 oncall channel
- 定期(每周)review:扫所有 dead-letter,清理或重投
- 建立 dead-letter 处理 SOP:常见错误的处理方式文档化
- 建立 dlq dashboard:每个 topic 当前消息数 / 最早消息时间 / owner 一目了然
排查辅助:写一个 Saga 调试控制台
事故第三天为了快速看清楚一个 Saga 的状态,临时写了个内部调试页面,后来固化为 ops 工具。功能很简单:
- 输入 sagaId 或 orderId,展示当前状态机 + 已完成步骤
- 列出关联的所有补偿事件 + 处理状态
- 显示 Jaeger trace 跳转链接
- 提供 "重新触发补偿" 按钮(只允许 SRE 角色操作,有审计日志)
- 显示该 Saga 涉及的所有外部资源(订单 / 库存 / 支付 / 物流)的实时状态
这个工具看起来很 "土",但对线上 oncall 极其友好。事后统计,事故响应平均时长从 47 分钟降到 8 分钟,大部分原因是 oncall 不再需要登 4 个服务挨个 grep 日志了。这告诉我一个教训:分布式系统里"可观测性 + 调试工具"和"系统本身的健壮性"同等重要。再健壮的系统出事都需要排查,排查工具越好,故障时长越短,影响面越小。
另外这个工具的"重新触发补偿"按钮是一个很危险的操作——它直接绕过 Saga 状态机重新发补偿事件。我们加了 3 道保护:
- 必须是 SRE Role 才能看到这个按钮(权限分级)
- 点击后弹二次确认对话框,需要输入 sagaId 后 4 位
- 每次触发写入审计表,记录操作人、时间、IP、操作前后状态
这种"必要但危险"的工具,绝对不能让 PM、客服或其他角色直接操作——给越多人权限,事故面就越大。
真凶 4:"支付 timeout 但实际成功"的隐蔽路径
这个边缘场景值得专门说。线上发生过这样一段时序:
| 时刻 ms | 动作 | 结果 |
|---|---|---|
| T+0 | order-service 调用 payment-service POST /charge | HTTP 连接建立 |
| T+200 | payment-service 处理,调用第三方支付网关 | 开始扣款 |
| T+4800 | 第三方网关确认扣款成功 | payment-service 准备返回 200 |
| T+5000 | order-service 设的 socket timeout 触发(5s) | 抛 SocketTimeoutException |
| T+5050 | payment-service 写完响应,但 socket 已关闭 | HTTP 写失败,但业务已完成 |
| T+5100 | order-service 误判失败,触发 Saga 补偿 | 调 payment cancel |
| T+5300 | payment cancel 因为状态已 PAID 而拒绝 | 补偿 fail 进 dead-letter |
| T+5400 | 用户已被扣款,但订单显示失败 | 客诉 |
本质问题:HTTP 客户端 timeout 不等于服务端没成功。order-service 设的 5 秒 socket timeout 在大部分情况下够用,但偶发的第三方网络抖动会让支付链路接近 5 秒,这时 timeout 触发但业务已完成。
修法 1:查询确认 + 反向补偿
触发补偿前必须先查询实际状态:
private void compensatePayment(SagaState state) {
// 关键: 先查实际状态, 而非盲目 cancel
PaymentStatus actual = paymentClient.query(state.getOrderId());
switch (actual) {
case NOT_FOUND:
case PENDING:
// 没扣款或还在处理, 不需要补偿
log.info("Payment not charged for {}, skip compensation", state.getOrderId());
break;
case PAID:
// 已成功扣款, 必须真正发起退款
paymentClient.refund(state.getOrderId(), state.getAmount());
break;
case CANCELLED:
case REFUNDED:
// 已经被处理, 幂等返回
log.info("Payment already in terminal state {}", actual);
break;
default:
throw new IllegalStateException("Unknown payment status: " + actual);
}
}
修法 2:超时分级 + 异步确认
支付这种长链路操作不应该走同步:
- order-service 发起支付:立即返回 "处理中",订单状态 PAYING
- payment-service 异步处理完成后,通过 Kafka 事件通知 order-service
- order-service 收到事件,状态机推进到下一步(创建物流单)
- 超过 60 秒未收到事件,主动查 payment-service 拿最终状态
这个改造让同步链路从 5 秒压到 50 毫秒(只是发起请求),Saga 整体路径不再受第三方网关波动影响。
修法整合:三件套
组合 1:持久化状态机 + 自动恢复
Saga state 必须落库,后台 cron 扫"卡住"的 saga 自动恢复。任何"重启后忘了"的设计都是 bug。
组合 2:数据库级幂等 + 补偿可重入
每个补偿动作必须用唯一约束实现幂等。补偿动作必须是可重入的——多次执行结果相同。
组合 3:dead-letter 全程监控 + owner 负责制
不能让 dead-letter 变成"消息坟墓"。
tracing 落地:跨服务 Saga 必须打 trace_id
事故初期排查最痛的点是把同一个 Saga 的所有日志关联起来。order-service / inventory-service / payment-service / logistics-service 各自有自己的日志,要还原一次 Saga 的完整流转,需要靠 sagaId 在 4 个服务的日志里 grep 反复对照。
事后接入了 OpenTelemetry,把 sagaId 作为 trace 的 baggage,所有跨服务调用自动透传:
@Configuration
public class TracingConfig {
@Bean
public OpenTelemetry openTelemetry() {
return OpenTelemetrySdk.builder()
.setTracerProvider(SdkTracerProvider.builder()
.addSpanProcessor(BatchSpanProcessor.builder(
OtlpGrpcSpanExporter.builder()
.setEndpoint("http://jaeger-collector:4317")
.build()).build())
.build())
.setPropagators(ContextPropagators.create(
TextMapPropagator.composite(
W3CTraceContextPropagator.getInstance(),
W3CBaggagePropagator.getInstance())))
.build();
}
}
// 在 Saga 入口绑定 sagaId
@Transactional
public void execute(OrderCreateRequest req) {
String sagaId = UUID.randomUUID().toString();
Span span = tracer.spanBuilder("saga.order.create").startSpan();
try (Scope ignored = Context.current()
.with(Baggage.current().toBuilder().put("saga.id", sagaId).build())
.with(span)
.makeCurrent()) {
// 后续所有 HTTP / Kafka 调用自动带 saga.id baggage
executeStep(state, "RESERVE_INVENTORY", ...);
} finally {
span.end();
}
}
接入后排查同类问题从 1 小时压到 5 分钟——直接在 Jaeger 上输入 sagaId,4 个服务的所有 span 串成一条时间线,谁慢、谁错、补偿到哪一步,一眼看清。这个投入回报率极高。
验证:混沌测试
预发设计了一组针对 Saga 的"找茬"测试,故意触发各种失败:
| 测试场景 | 修复前 | 修复后 |
|---|---|---|
| 下单中途 order-service kill | 库存永久占用 | 10 分钟内自动补偿 |
| 下单中途 inventory-service 拒绝 | 正确补偿 | 正确补偿 |
| 支付 timeout 但实际成功 | 双扣 + 库存占用 | 对账修正(补偿 idempotent) |
| 补偿消息被重复投递 | 库存负数 / dead-letter | 正确 skip 重复 |
| Kafka 短暂故障 30s | 消息进 dead-letter 不处理 | 恢复后自动重试,无人工介入 |
| 所有 4 服务同时重启 | 大量 Saga 永久卡住 | 5 分钟内自动恢复 |
| consumer 实例 rebalance 10 次 | 偶发重复扣 / 错跳 | 幂等保护,结果一致 |
| order-service GC 卡 8 秒 | Saga 状态丢失 | 状态在 DB, 恢复正常 |
对比:Saga vs TCC vs 2PC vs 本地消息表
事后梳理了一下分布式事务模式的取舍:
| 模式 | 语义 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 2PC(XA) | 强一致 | 简单(对业务) | 性能差 / 长锁 / 不支持微服务 | 单库多表 / 同 DB 集群 |
| TCC | 最终一致 | 性能好,资源预留 | 每服务实现 3 接口,侵入大 | 金融级强一致 + 短事务 |
| Saga Orchestration | 最终一致 | 中心化好追踪 | 编排者单点 / 复杂度集中 | 中等复杂业务流(电商订单) |
| Saga Choreography | 最终一致 | 去中心化 | 调用链难追踪 / 调试难 | 简单事件驱动流水 |
| 本地消息表 + 异步 | 最终一致 | 简单可靠,极低成本 | 仅适合"主操作 + 异步通知" | 核心 + 边缘场景(发通知 / 推搜索) |
| Outbox + CDC | 最终一致 | 事务日志驱动,零业务侵入 | 需 Debezium / Canal 基建 | 事件驱动架构 |
没有银弹。我们继续用 Saga Orchestration(适合中小复杂度业务),但把它做对了需要的工程量,事故前严重低估。
决策树:补偿动作怎么写才不出错
对账机制:silent inconsistency 的最后一道防线
Saga 即使做得再好,极端情况下仍可能漏。对账是"事后兜底"——每天扫一遍,发现"理论应该相等但实际不相等"的数据,自动修正或人工介入。
-- 找出"订单已 cancelled 但库存仍有占用"的幽灵记录
SELECT
o.order_id,
o.status AS order_status,
o.cancelled_at,
ir.product_id,
ir.quantity_reserved,
ir.reserved_at,
TIMESTAMPDIFF(MINUTE, o.cancelled_at, NOW()) AS minutes_since_cancel
FROM orders o
JOIN inventory_reservation ir ON o.order_id = ir.order_id
WHERE o.status = 'CANCELLED'
AND ir.released_at IS NULL
AND o.cancelled_at < NOW() - INTERVAL 15 MINUTE
ORDER BY o.cancelled_at;
这条 SQL 跑了一遍就找出 47 单幽灵记录,这就是事故初始发现的真正现场。后来固化为每天 03:00 的定时任务,任何幽灵记录都自动推送到 oncall channel:
| 对账类型 | 定义 | 频率 | 处理方式 |
|---|---|---|---|
| 库存占用幽灵 | 订单 cancelled 但库存未释放 | 每日 03:00 | 自动发起补偿事件 |
| 支付状态分裂 | 订单 PAID 但 payment 服务无记录 | 每小时 | 立即告警人工 |
| Saga 卡住 | RUNNING 状态超过 1 小时 | 每 30 分钟 | 触发恢复 / 转人工 |
| dead-letter 积压 | DLQ 消息数 > 0 | 实时 | 钉钉告警 owner |
| 物流单孤儿 | 物流单存在但订单不存在 | 每日 | 自动取消物流单 |
立的《Saga 分布式事务纪律》
- Saga 状态必须持久化,任何"存内存"的 Saga 都是定时炸弹。
- 必须有"卡住 Saga 自动恢复"机制,后台 cron 扫 5-10 分钟没进展的 Saga,自动触发下一步或补偿。
- 每个补偿动作必须幂等,通过数据库唯一约束实现(saga_id + step)。
- 每个 dead-letter topic 必须有监控 + owner,消息数 > 0 立即告警。
- 每个 Saga 必须有完整审计:每一步开始 / 完成 / 失败 / 补偿都记录,可追溯。
- 必须有"对账"机制:每天对所有 RUNNING / COMPENSATING 状态的 Saga 做一致性检查,生成对账报表。
- 新业务接入 Saga 必须 review:补偿逻辑必须可逆 / 幂等 / 有上限重试。
- 每季度做混沌测试:模拟各种服务挂、消息丢、网络分区,验证 Saga 在异常下仍能最终一致。
- 补偿超过 10 次重试转人工,不允许无限重试导致雪崩。
- 跨服务超时严格控制:Saga 总链路超时 < 30 秒,单步 < 5 秒,防止 in-flight Saga 堆积。
5 个补偿动作的失败模式总结
事后整理了我们见过 + 业界文章里见过的所有补偿失败模式,共 5 类,每类都有典型案例:
| 失败模式 | 典型现象 | 触发条件 | 防御手段 |
|---|---|---|---|
| 补偿丢失 | 补偿事件发了但 consumer 没处理 | dead-letter 无人监控 | DLQ 告警 + 重试机制 |
| 补偿重复 | 同一补偿被执行多次,资源被多次释放 | Kafka 重投递 + 内存幂等 | 数据库唯一约束 |
| 补偿顺序错乱 | step3 已补偿但 step2 没补 | 异步补偿无排序 | 按 step 倒序串行补 |
| 补偿条件不匹配 | 支付已成功但补偿误判 cancel | 状态读取过时 | 先查实际状态再决策 |
| 补偿无终止 | 补偿失败被反复重试,雪崩 | 无重试上限 | max retry + 转人工 |
这 5 类不是穷举,但覆盖了我们遇到的所有真实事故。每次设计新 Saga 都会拉这张表对照一遍——基本能拦下 90% 的潜在 bug。
真凶 5:order-service 编排者自身的可用性
Saga Orchestration 的天然缺陷是编排者单点——order-service 挂了,所有 Saga 都停摆。事故后我们做了几件事提升它的可用性。
1. order-service 多副本 + Saga 接管
order-service 部署 6 个副本,Saga 表加 owner_pod 字段记录"哪个 Pod 在跑这个 Saga"。如果某 Pod 心跳超时(K8s Pod Termination),其他 Pod 通过抢占式 update 拿到所有权,继续执行:
-- 拿到孤儿 Saga 的所有权 (CAS 思想)
UPDATE saga_state
SET owner_pod = 'pod-2',
updated_at = NOW(6)
WHERE saga_id = ?
AND (owner_pod IS NULL OR owner_pod = ?)
AND status IN ('RUNNING', 'COMPENSATING')
AND updated_at < NOW() - INTERVAL 30 SECOND;
affected_rows = 1 表示拿到了,= 0 表示已被别的 Pod 抢走,本 Pod 应放弃。这套机制类似 Raft 的领导者租约,但简化到了 DB 层。
2. order-service 自身降级
编排逻辑里所有外部调用都加了熔断 + 降级。比如 logistics-service 不可用时,Saga 把 CREATE_LOGISTICS 标为"延迟执行",订单依然 COMPLETED,物流单进延迟队列待补。这避免了"一个非关键服务挂了拖垮整个下单链路"。
5 个月后的总账
把修复前后的关键指标拉了一张表,这是给老板汇报用的版本:
| 指标 | 修复前(2026-04) | 修复后(2026-09) | 改善 |
|---|---|---|---|
| 幽灵库存月发生 | 23 单 / 月 | 0 单 / 月 | -100% |
| 客服处理库存数据时长 | 4 人小时 / 周 | 0 | -100% |
| Saga 平均执行时长 | 1.8 秒 | 120 毫秒 | -93% |
| Saga 失败率 | 0.31% | 0.04% | -87% |
| dead-letter 平均滞留时长 | 52 天 | 3 分钟 | -99.99% |
| 对账发现的不一致 | 历史未知 | 月均 2 单(自动修复) | 建立基线 |
| 支付二次扣款投诉 | 3 / 季度 | 0 / 季度 | -100% |
给读者的几条自查清单
- 你们 Saga(或类似分布式事务模式)的状态存哪?如果是内存,赶紧改。
- 有没有"恢复卡住 Saga"的机制?没有的话,服务重启会漏。
- 补偿动作有没有用数据库唯一约束做幂等?用 Set / 内存判重不算。
- dead-letter topic 有没有告警?有 owner 吗?dead-letter 长期不为零的系统迟早出事。
- 有没有对账?定期跑一致性检查,发现"幽灵记录"是发现 bug 的关键。
- 用 Jaeger / Zipkin 等 tracing 工具看你的 Saga 调用链,如果跨多服务且没有统一 trace_id,排查事故会很痛。
- 跑混沌测试:kill 中途 / 模拟超时 / 模拟重复消息,看你的 Saga 能不能优雅处理。
- 有没有 "支付 timeout 但实际成功" 这种边缘场景的回归测试?这是分布式事务最经典的雷区。
这次事故让我对"分布式事务"有了更深的敬畏:它不是个"加个 Saga 框架就完事"的话题,它是一个需要持续投入的工程领域。每一种异常路径都可能让"最终一致"变成"最终不一致",而这种不一致往往是 silent 的——业务方一开始看不到,等到客户投诉才暴露,代价已经很大。
另一个心得:"dead-letter" 是个组织设计陷阱。技术上它解决了"消息处理失败时怎么办"的问题,但默认行为是"扔到一边,等人来看"——而"等人来看"在大多数团队意味着"没人看"。任何引入 dead-letter 机制的团队都必须配套"谁负责看 + 多久看一次 + 看到了怎么处理"的流程,否则就是消息坟墓。我们后来把"dead-letter 监控 + 处理"写进了团队上线 checklist,任何新建 dead-letter topic 必须同步建告警 + 指定 owner,这才把这条坑彻底堵上。
最后一条:幽灵库存不是孤立的——它是"silent inconsistency"的一个典型代表。任何"看起来不对但又没明显报错"的现象都值得彻查,因为它往往是更大问题的冰山一角。我们这次只是 47 单 / 80 件库存,但同样的根因如果发生在金额对账、积分发放、风控状态这些场景,代价可能是 10 倍 100 倍。建议每个团队定期 review 自家系统的"silent failure"——找一个"不应该发生但确实在发生"的现象,挖到底,你大概率会发现一个被忽视很久的工程缺陷。
—— 别看了 · 2026