Saga 分布式事务库存幽灵占用事故复盘:2 个月 47 单灵异库存 + 状态机持久化 + 幂等 + dead-letter 监控三件套

电商订单中台 Saga Orchestration 模式实现里隐藏的 5 个真凶:状态机存内存丢失、补偿幂等用内存 Set 失效、dead-letter 累积 5000+ 条无人监控、支付 timeout 但实际成功导致双扣库存占用、编排者单点。4 天定位 + 5 个月修复,把幽灵库存从月均 23 单压到 0,Saga 失败率从 0.31% 降到 0.04%,沉淀出可复用的分布式事务工程纪律 10 条。

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 分布式事务纪律》

  1. Saga 状态必须持久化,任何"存内存"的 Saga 都是定时炸弹。
  2. 必须有"卡住 Saga 自动恢复"机制,后台 cron 扫 5-10 分钟没进展的 Saga,自动触发下一步或补偿。
  3. 每个补偿动作必须幂等,通过数据库唯一约束实现(saga_id + step)。
  4. 每个 dead-letter topic 必须有监控 + owner,消息数 > 0 立即告警。
  5. 每个 Saga 必须有完整审计:每一步开始 / 完成 / 失败 / 补偿都记录,可追溯。
  6. 必须有"对账"机制:每天对所有 RUNNING / COMPENSATING 状态的 Saga 做一致性检查,生成对账报表。
  7. 新业务接入 Saga 必须 review:补偿逻辑必须可逆 / 幂等 / 有上限重试。
  8. 每季度做混沌测试:模拟各种服务挂、消息丢、网络分区,验证 Saga 在异常下仍能最终一致。
  9. 补偿超过 10 次重试转人工,不允许无限重试导致雪崩。
  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%

给读者的几条自查清单

  1. 你们 Saga(或类似分布式事务模式)的状态存哪?如果是内存,赶紧改。
  2. 有没有"恢复卡住 Saga"的机制?没有的话,服务重启会漏。
  3. 补偿动作有没有用数据库唯一约束做幂等?用 Set / 内存判重不算。
  4. dead-letter topic 有没有告警?有 owner 吗?dead-letter 长期不为零的系统迟早出事。
  5. 有没有对账?定期跑一致性检查,发现"幽灵记录"是发现 bug 的关键。
  6. 用 Jaeger / Zipkin 等 tracing 工具看你的 Saga 调用链,如果跨多服务且没有统一 trace_id,排查事故会很痛。
  7. 跑混沌测试:kill 中途 / 模拟超时 / 模拟重复消息,看你的 Saga 能不能优雅处理。
  8. 有没有 "支付 timeout 但实际成功" 这种边缘场景的回归测试?这是分布式事务最经典的雷区。

这次事故让我对"分布式事务"有了更深的敬畏:它不是个"加个 Saga 框架就完事"的话题,它是一个需要持续投入的工程领域。每一种异常路径都可能让"最终一致"变成"最终不一致",而这种不一致往往是 silent 的——业务方一开始看不到,等到客户投诉才暴露,代价已经很大

另一个心得:"dead-letter" 是个组织设计陷阱。技术上它解决了"消息处理失败时怎么办"的问题,但默认行为是"扔到一边,等人来看"——而"等人来看"在大多数团队意味着"没人看"。任何引入 dead-letter 机制的团队都必须配套"谁负责看 + 多久看一次 + 看到了怎么处理"的流程,否则就是消息坟墓。我们后来把"dead-letter 监控 + 处理"写进了团队上线 checklist,任何新建 dead-letter topic 必须同步建告警 + 指定 owner,这才把这条坑彻底堵上。

最后一条:幽灵库存不是孤立的——它是"silent inconsistency"的一个典型代表。任何"看起来不对但又没明显报错"的现象都值得彻查,因为它往往是更大问题的冰山一角。我们这次只是 47 单 / 80 件库存,但同样的根因如果发生在金额对账、积分发放、风控状态这些场景,代价可能是 10 倍 100 倍。建议每个团队定期 review 自家系统的"silent failure"——找一个"不应该发生但确实在发生"的现象,挖到底,你大概率会发现一个被忽视很久的工程缺陷。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

客服RAG系统从demo到生产的6周复盘:召回率38%到89%的真实路径+别再迷信chunksize+被否决的方案比被采纳的更值钱

2026-5-26 17:44:58

技术教程

.NET 8 LOH 碎片化导致 ASP.NET Core 每 36 小时 Pod OOMKilled 的 8 天复盘:ArrayPool + RecyclableMemoryStream + Pipelines 三件套落地

2026-5-26 18:16:35

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索