Kafka 消费幂等性失守导致用户被重复扣款 ¥8.7w 的复盘:6 种幂等方案对比 + Outbox + CDC 落地

大促当晚 Kafka rebalance 触发重复消费,12 位用户被重复扣款,投诉炸了客服。11 天复盘揭开 3 个真凶:auto-commit 在 rebalance 时是定时炸弹、max.poll.interval.ms 超时、支付回调本身不幂等。本文 6 种幂等方案对比、Outbox + Debezium CDC 实战、灰度上线策略、压测脚本、7 条事件驱动纪律。

2026 年 3 月某个周五凌晨 02:47,我被支付风控组的电话叫醒——大促开始 3 小时,客服后台已经接到 12 单"扣款两次"的投诉,涉及金额 8.7 万,部分用户已经在小红书发帖。我打开监控面板看了一眼,所有 Kafka 指标都是绿色的,消费延迟 < 100ms,broker CPU 30%,没有 rebalance,没有 lag。"看起来一切正常"——这是我们事后复盘时,工程师们用得最多的一句话,也是最让人后背发凉的一句话。

这是一次典型的"Kafka exactly-once 神话破灭"事故。后面 11 天,我们把整套订单履约链上的 17 个消费者全部翻了一遍,重新理解了"幂等性"在分布式系统里到底意味着什么,落地了"幂等表 + Outbox Pattern + CDC"三件套,把同类事故的复发概率从 0.003% 压到了 0。这篇是完整复盘,8000 字左右,你看完应该能避开我们踩的所有坑。

服务背景:这套架构在事故前是怎么跑的

简单交代下规模和技术栈,后面所有结论都建立在这套上下文上:

维度 数值
业务 某 SaaS 电商订单履约系统,主要做 B 端商户的订单生成、库存扣减、支付回调、履约推送
规模 日均订单 40 万,峰值 5000 TPS,大促可冲到 1.2 万 TPS
消息中间件 Kafka 3.6.1,3 broker + 3 zookeeper,核心 topic 12 partition,replication.factor=3,min.insync.replicas=2
消费端 Spring Boot 3.2 + spring-kafka 3.1.1,17 个独立 consumer group,Java 21
存储 MySQL 8.0(RR 隔离级别) + Redis 7.2 + ElasticSearch 8.10
支付 对接 4 家第三方网关(微信/支付宝/某银联/PingPong)
事故前消费配置 enable.auto.commit=true,auto.commit.interval.ms=5000,max.poll.records=500

这套架构在事故前已经稳定跑了 14 个月,没有出过任何"重复扣款"投诉。所有人都觉得 Kafka 3.x 的 exactly-once 已经把我们保护得死死的,直到那个周五凌晨。

事故时间线:从客服电话到根因定位的 11 天

时刻 事件
03-13 23:30 大促 V 神节预热开启,流量平稳爬升
03-14 00:00 主会场开抢,瞬时 QPS 冲到 1.1 万,Kafka 消费延迟 80ms
03-14 02:15 支付回调 consumer 所在的某台 Pod 因为 GC 暂停 14.3 秒被 Kafka 踢出 group,触发 rebalance
03-14 02:17 rebalance 完成,被重新分配的 partition 从上次 offset 重消费了约 4200 条消息
03-14 02:30 客服后台开始接到第 1 单"扣款两次"投诉,运营怀疑是用户误操作
03-14 02:47 10 分钟内累计 9 单投诉,风控组判断异常,呼叫值班
03-14 03:10 我登录系统,临时把 enable.auto.commit 改成 false 后立即回滚——发现一行代码改不了根因,先恢复服务
03-14 03:25 临时上线兜底脚本:扫描最近 1 小时支付流水,对同 user_id + 同金额 + 间隔 < 60s 的记录人工冻结
03-14 09:00 大促告一段落,启动完整复盘
03-15 ~ 03-17 排查所有 consumer,发现 17 个里只有 4 个真正实现了幂等
03-18 ~ 03-22 设计统一幂等方案:幂等表 + Outbox + CDC 三件套
03-23 ~ 03-24 预发环境压测,模拟 rebalance / OOM kill / 网络抖动,验证不再重复处理
03-25 分批灰度上线,全部 consumer 改造完成

第一反应:Kafka 不是 exactly-once 吗?

事后我把当晚值班同事的群聊翻出来,前两个小时所有人都在重复同一个问题:"我们 enable.idempotence 不是 true 吗?我们 isolation.level 不是 read_committed 吗?为什么还会重复消费?"

这个误解很普遍,所以我先把 Kafka exactly-once 的边界说清楚——它只在 Kafka 内部生效:

// Producer 端的幂等性配置
Properties props = new Properties();
props.put("enable.idempotence", true);          // 同一 producer 在同一 partition 内不会重复写
props.put("acks", "all");                       // 等待 ISR 全部确认
props.put("max.in.flight.requests.per.connection", 5);
props.put("retries", Integer.MAX_VALUE);

// 这套配置只保证:即使 producer 重试,broker 上也不会有重复消息
// 但 consumer 把消息拉出来后,你的业务代码处理完写 MySQL/调支付接口
// 这一步 Kafka 完全不参与,exactly-once 帮不上忙

真正的"端到端 exactly-once"需要 Kafka Streams API 或者 KafkaTransactionManager 把外部资源绑进事务里。但我们的业务消费要调 4 家第三方支付网关,不可能塞进 XA 事务,也不可能等支付方实现 Kafka 事务感知。所以"业务层面的 exactly-once"必须靠**幂等性设计**,不是靠中间件配置。

真凶 1:auto-commit 在 rebalance 时是一颗定时炸弹

翻 Kafka 文档你会看到一句话:"enable.auto.commit=true 时,offset 会按 auto.commit.interval.ms 周期性提交"。我们当时配的是 5000ms。这意味着——consumer 拉了 500 条消息,处理到第 350 条时被 rebalance 踢出,offset 还停留在拉取前的位置。新接管的 consumer 会从那个位置重新拉,前 350 条就被消费了两次。

把它画成图你就明白了:

auto-commit 还有一个隐藏陷阱:它的"提交时机"是**下一次 poll() 调用时**,不是处理完业务后。也就是说,即使你的业务处理失败抛了异常,只要下一次 poll() 调到了,offset 就被提交了——这就从"重复消费"变成了"消息丢失"。两个问题同时存在,看起来像薛定谔。

真凶 2:max.poll.interval.ms 在大批量处理时容易超

spring-kafka 默认的 max.poll.interval.ms 是 300000ms(5 分钟)。我们配的 max.poll.records=500,每条消息要调一次支付网关确认状态(平均 200ms)。500 × 200ms = 100s,理论上够。但大促当晚有一个外部网关响应飙到 2s,500 条要 1000s——直接超过 5 分钟,consumer 被踢。被踢之后又是 rebalance,又是重复消费。

# 事故前的 application.yml(有问题)
spring:
  kafka:
    consumer:
      enable-auto-commit: true
      auto-commit-interval: 5000
      max-poll-records: 500
      properties:
        max.poll.interval.ms: 300000
        session.timeout.ms: 45000
        heartbeat.interval.ms: 3000

# 事故后修正版
spring:
  kafka:
    consumer:
      enable-auto-commit: false      # 改成手动 commit
      max-poll-records: 50           # 单次拉取减少 10 倍
      properties:
        max.poll.interval.ms: 600000 # 留出 10 分钟容忍外部网关慢响应
        session.timeout.ms: 30000
        heartbeat.interval.ms: 10000
        # 加上 partition.assignment.strategy 用 CooperativeStickyAssignor
        # 减少 rebalance 时移动的 partition 数量
        partition.assignment.strategy: org.apache.kafka.clients.consumer.CooperativeStickyAssignor

这里有个反直觉的点:很多人觉得 max.poll.records 调大可以提高吞吐,但**调大会让 rebalance 风险呈指数级放大**。事故后我们的工程师吃透了一个原则——单次 poll 处理时间应该控制在 session.timeout.ms 的 1/3 以内,这样即使有偶尔的慢请求也不会被踢。

真凶 3:支付网关回调本身就不是幂等的

这是更深一层的问题。即使 Kafka 消费没重复,支付网关的回调消息**也可能被自己重发**——微信支付的回调机制是"最多 8 次,间隔递增 4m/10m/1h/2h/6h/15h/24h",PingPong 是"接口返回非 success 就重试,最多 10 次"。我们的回调处理器没做幂等,导致同一笔交易号被处理两次,实际扣款一次,在我们系统里却生成了两笔订单 + 两次扣减库存。

这个坑的隐蔽性在于:支付网关重试是低频事件,在日常 QPS 下基本不可见,只有在大促这种"网关本身也在抖动"的场景下才集中爆发。

三个真凶串起来的因果链

6 种幂等方案的横向对比

事故后我们列了 6 种主流幂等方案,逐个评估在我们场景下的可行性。这张表是我们决策的核心依据:

方案 核心机制 实现复杂度 性能影响 适用场景
A 数据库唯一索引 用业务自然键(order_no) 或消息 ID 加 unique key,重复插入返 DuplicateKeyException 有天然业务唯一键的简单写入场景
B 幂等表 单独维护 idempotency_records 表,处理前先 INSERT 一条 message_id 记录,失败就跳过 +1 次 DB 写 消息处理含多步业务,需要原子化
C Redis SETNX SET message_id NX EX 600 加分布式锁,处理完业务再 DEL +2 次 Redis 高 QPS、对延迟敏感场景
D Kafka 事务消息 Producer initTransactions + 业务写 DB + commitTransaction 绑一起 显著 纯 Kafka 内部链路,不涉及外部 RPC
E Outbox Pattern + CDC 业务表 + outbox 表在同一事务,Debezium 把 outbox 抓出来发 Kafka +1 次 DB 写,异步 事件驱动 + 跨服务一致性
F Saga + 补偿 每一步操作配一个反向补偿,失败时倒序执行 很高 较高 长事务、需要业务级回滚

简单写场景(比如更新订单状态)我们选 A;复杂场景(支付回调要扣库存 + 写流水 + 通知履约)我们选 B + E 的组合——业务事务里同时写"幂等表"和"outbox 表",Debezium 从 outbox 把事件流出去通知下游。这样既保证"消费幂等",又保证"消息一定不丢"。

修法 1:幂等表的正确实现(看似简单,坑很多)

很多人一上来就写:

// 错误写法 1:先查后插,有竞态
public void handleMessage(String messageId, Order order) {
    if (idempotencyMapper.exists(messageId)) {
        return; // 已处理
    }
    processOrder(order);
    idempotencyMapper.insert(messageId);  // 业务和幂等记录不在同一事务,中途崩了就重复
}

// 错误写法 2:先插后处理,但插入和业务不在事务
public void handleMessage(String messageId, Order order) {
    try {
        idempotencyMapper.insert(messageId);
    } catch (DuplicateKeyException e) {
        return;
    }
    processOrder(order);  // 如果这里挂了,下次重试 idempotency 已存在,直接跳过,业务丢失
}

正确写法是把"幂等记录插入"和"业务处理"放在同一个事务里,失败一起回滚:

@Service
public class IdempotentOrderHandler {
    @Autowired private IdempotencyRecordMapper idempotencyMapper;
    @Autowired private OrderService orderService;

    @Transactional(rollbackFor = Exception.class)
    public void handle(String messageId, String bizType, OrderEvent event) {
        IdempotencyRecord record = new IdempotencyRecord();
        record.setMessageId(messageId);
        record.setBizType(bizType);
        record.setStatus("PROCESSING");
        record.setCreatedAt(LocalDateTime.now());
        try {
            idempotencyMapper.insertSelective(record);
        } catch (DuplicateKeyException e) {
            // 已经被处理过,直接返回
            log.info("duplicate message detected, skip: {}", messageId);
            return;
        }
        // 走到这里说明是第一次处理,可以放心做业务
        orderService.process(event);
        // 同事务内更新状态为 SUCCESS,失败时整条记录回滚
        idempotencyMapper.updateStatus(messageId, "SUCCESS");
    }
}

idempotency_records 表结构:

CREATE TABLE idempotency_records (
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    message_id VARCHAR(128) NOT NULL,
    biz_type VARCHAR(64) NOT NULL,
    status VARCHAR(32) NOT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    UNIQUE KEY uk_message_id_biz (message_id, biz_type),
    KEY idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 注意:message_id 单独 unique 不够,因为同一条消息可能被多个 bizType 消费
-- 加上 biz_type 是为了让不同消费者不互相干扰

表会越来越大,我们的清理策略是:每天凌晨跑一个定时任务,删除 14 天前 status=SUCCESS 的记录;FAILED 状态保留 90 天供排障。

修法 2:Outbox Pattern + Debezium CDC

幂等表只解决"重复消费"问题,但**事件驱动架构还有另一个对偶问题:消息丢失**。如果你在业务事务里直接 send Kafka,事务可能提交成功但消息发送失败,导致下游错过事件。Outbox Pattern 的核心思想是把"消息发送"也变成数据库事务的一部分。

@Service
public class OrderEventPublisher {
    @Autowired private OrderMapper orderMapper;
    @Autowired private OutboxMapper outboxMapper;

    @Transactional(rollbackFor = Exception.class)
    public void createOrderWithEvent(Order order) {
        orderMapper.insert(order);
        OutboxRecord outbox = new OutboxRecord();
        outbox.setEventType("ORDER_CREATED");
        outbox.setAggregateId(order.getOrderNo());
        outbox.setPayload(JSON.toJSONString(order));
        outbox.setCreatedAt(LocalDateTime.now());
        outboxMapper.insert(outbox);
        // 这一刻订单和待发事件都落在 DB 同一事务里,要么都成,要么都失败
        // 不依赖 Kafka 是否在线
    }
}

Debezium 监听 outbox 表的 binlog,把每一条新记录转成 Kafka 消息发出去。Debezium 自己保证 at-least-once + offset 持久化,即使 Debezium 重启,也会从上次 binlog 位置继续消费。下游消费者通过"幂等表"去重,整条链路就达成了"端到端 exactly-once 语义"——虽然中间发了多次,但业务只生效一次。

Debezium connector 的核心配置:

{
  "name": "order-outbox-connector",
  "config": {
    "connector.class": "io.debezium.connector.mysql.MySqlConnector",
    "database.hostname": "mysql-master.internal",
    "database.server.id": "10001",
    "database.server.name": "order_db",
    "table.include.list": "order_db.outbox_records",
    "transforms": "outbox",
    "transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
    "transforms.outbox.route.by.field": "event_type",
    "transforms.outbox.route.topic.replacement": "outbox.event.${routedByValue}",
    "transforms.outbox.table.field.event.id": "event_id",
    "transforms.outbox.table.field.event.payload": "payload"
  }
}

修法 3:消费端的"半同步 commit"模式

spring-kafka 默认 AckMode 是 BATCH,我们改成 MANUAL_IMMEDIATE,让业务代码处理完一条 commit 一条。这样即使中途崩,最多重复消费"当前正在处理的那一条",其他已经 commit 的不会重复:

@Configuration
public class KafkaConsumerConfig {
    @Bean
    public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory(
            ConsumerFactory consumerFactory) {
        ConcurrentKafkaListenerContainerFactory factory =
                new ConcurrentKafkaListenerContainerFactory<>();
        factory.setConsumerFactory(consumerFactory);
        factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL_IMMEDIATE);
        // 消费者并发设为 partition 数,避免 partition 闲置
        factory.setConcurrency(12);
        // 异常时进入死信队列
        factory.setCommonErrorHandler(new DefaultErrorHandler(
                new DeadLetterPublishingRecoverer(kafkaTemplate),
                new FixedBackOff(2000L, 3)  // 重试 3 次,每次间隔 2s
        ));
        return factory;
    }
}

@KafkaListener(topics = "order.payment.callback", groupId = "payment-handler")
public void onMessage(ConsumerRecord record, Acknowledgment ack) {
    try {
        idempotentOrderHandler.handle(record.key(), "PAYMENT_CALLBACK",
                JSON.parseObject(record.value(), OrderEvent.class));
        ack.acknowledge();  // 业务成功才 commit
    } catch (Exception e) {
        log.error("payment callback failed, will retry", e);
        // 不 ack,Kafka 在 max.poll.interval.ms 内会重新分配
        throw e;
    }
}

什么场景该用什么幂等方案——决策树

我们在事故后立的 7 条事件驱动架构纪律

  1. 所有消费者必须显式声明幂等策略。代码 review 时不写"幂等方案: A/B/C/D/E/F"注释的 PR 不合并。我们在代码模板里加了 @IdempotentConsumer 注解强制声明。
  2. 禁用 enable.auto.commit。新项目默认 false,老项目逐步迁移,迁移完前必须有兜底脚本扫重复。
  3. max.poll.records 默认 50。要调大必须有压测数据证明 P99 处理时间 < session.timeout.ms / 3。
  4. 事件 ID 必须全局唯一且语义化。比如 "order.created.{orderNo}" 而不是 UUID,这样排障时能直接关联业务。
  5. 所有外部回调(支付/物流/短信)默认都不幂等,必须自己加一层。不要相信第三方的"我们只回调一次"。
  6. 死信队列必须有告警 + 自动巡检。我们配的是 1 分钟内堆积 > 10 条触发 P2,小时维度堆积 > 100 条触发 P1。
  7. 每个核心 topic 必须有"一致性巡检"任务。每 5 分钟扫一次"上游事件数 vs 下游处理数"差值,差超过 0.1% 就报警。

事件驱动架构的 8 个配置陷阱速查

陷阱 表现 规避
enable.auto.commit=true rebalance 重复消费 改 false + 手动 ack
max.poll.records 过大 处理超时被踢 50 起步,压测后调
session.timeout.ms 太小 偶发慢响应触发 rebalance 30s 起步,GC 长的设到 45s
没配 CooperativeStickyAssignor rebalance 时全部 partition 重分配 显式配置增量协作策略
retries=0 或没配重试 偶发网络错就丢消息 retries=Integer.MAX_VALUE + 死信队列
没设 min.insync.replicas broker 宕机时消息丢失 2(replication.factor=3 时)
没监控 lag 消费者卡住没人知道 Prometheus kafka_consumergroup_lag 告警
幂等表没清理 表越来越大拖慢 INSERT 定时按 created_at 分批 DELETE

监控体系:5 个必看指标

# prometheus.yml 中的核心告警
groups:
  - name: kafka_consumer
    rules:
      - alert: KafkaConsumerLagHigh
        expr: kafka_consumergroup_lag > 1000
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "{{ $labels.group }} 消费滞后 > 1000"

      - alert: KafkaRebalanceFrequent
        expr: rate(kafka_consumer_coordinator_rebalance_total[5m]) > 0.1
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "{{ $labels.group }} 5 分钟内 rebalance > 3 次"

      - alert: IdempotencyDuplicateDetected
        expr: rate(idempotency_duplicate_total[1m]) > 10
        for: 1m
        labels:
          severity: warning
        annotations:
          summary: "{{ $labels.biz_type }} 1 分钟内检测到 > 10 条重复消息"

      - alert: DeadLetterQueueBacklog
        expr: kafka_topic_partition_current_offset{topic=~".*-dlq"} - kafka_topic_partition_committed_offset{topic=~".*-dlq"} > 50
        for: 3m
        labels:
          severity: critical
        annotations:
          summary: "死信队列 {{ $labels.topic }} 堆积 > 50"

      - alert: OutboxRecordStuck
        expr: max(outbox_oldest_record_age_seconds) > 60
        for: 1m
        labels:
          severity: warning
        annotations:
          summary: "Outbox 有记录 > 60s 未被 CDC 抓走"

这 5 个指标我们至今每天看,有任何一条触发都意味着事件链路出问题了。事故后我们还加了一个"业务对账"指标——每 5 分钟跑一次 SQL,对比 outbox 表的 ORDER_CREATED 事件数 vs orders 表的新增数,差值超过 0.01% 触发告警。

事故复发概率压到 0 的两个隐藏招数

除了上面的"标准方案",还有两招我们认为是真正把可靠性推到生产级的关键:

第一招:幂等表加"版本号"。某些业务允许"同一事件被处理多次,但只生效一次最新版本"。比如订单状态从 PENDING → PAID → SHIPPED,如果先收到 SHIPPED 再收到 PAID(消息乱序),不应该让状态回退。我们在 idempotency_records 里加了 version 字段,每次只接受 version 大于当前的更新:

UPDATE orders
SET status = 'SHIPPED', version = 3, updated_at = NOW()
WHERE order_no = 'O20260314001'
  AND version < 3;
-- 如果 affected_rows = 0,说明已经有更新版本的处理,本次跳过

第二招:Kafka 消费侧用 partition key 做"业务串行化"。订单履约的所有事件必须用 order_no 做 partition key,这样同一订单的所有事件落到同一 partition,被同一个 consumer 顺序处理,避免了"PAID 和 SHIPPED 被不同 consumer 并发处理导致的乱序"。

// Producer 端
kafkaTemplate.send(MessageBuilder
    .withPayload(orderEvent)
    .setHeader(KafkaHeaders.TOPIC, "order.events")
    .setHeader(KafkaHeaders.KEY, orderEvent.getOrderNo())  // 关键:用 orderNo 做 key
    .build());

// Consumer 端不需要做任何特殊处理,Kafka 自动保证同 key 同 partition 同 consumer
// 但要避免"同 consumer 多线程处理"破坏顺序——AckMode 必须用 MANUAL_IMMEDIATE,不要用 batch

跨语言消费者的幂等方案对比

语言 常用 Kafka 客户端 幂等推荐方案 注意点
Java/Kotlin spring-kafka @KafkaListener + @Transactional 幂等表 AckMode MANUAL_IMMEDIATE
Go segmentio/kafka-go 或 IBM/sarama 事务里 INSERT idempotency + 业务,sql.Tx 包裹 必须显式 commitMessages
Python confluent-kafka-python SQLAlchemy session 里 add idempotency + 业务 poll() 后必须显式 commit()
Node.js kafkajs Knex/Prisma 事务里写幂等表 + 业务 eachMessage 里手动 heartbeat
Rust rdkafka sqlx::Transaction 里写幂等表 + 业务 需要自己实现 BalancedConsumer 的 commit

核心思路是一致的:**业务和幂等记录必须在同一个数据库事务里**,语言只是表达方式不同。

压测:怎么验证你的幂等真的有效

预发上线前我们做了 4 类故障注入,每一类必须验证"重复处理率 = 0":

  1. Pod kill -9:消息处理一半被强杀,重启后必须不能重复处理。
  2. GC pause 模拟:用 -XX:+UnlockExperimentalVMOptions -XX:+ParallelOldGC 调出长 GC,触发 rebalance,验证不重复。
  3. 网络分区:tc qdisc add dev eth0 root netem loss 50% 给 broker 加 50% 丢包,验证 producer 重试不写入重复数据。
  4. 支付网关重复回调:用 wiremock 模拟 4 家支付网关全部把同一条回调发 5 次,验证只生效一次。
# 第 1 类故障注入脚本示例
#!/bin/bash
# kill_consumer_during_processing.sh
for i in {1..20}; do
  echo "round $i"
  kafka-console-producer --topic order.events --broker-list localhost:9092 < test_event_$i.json
  sleep 3
  POD=$(kubectl get pods -l app=order-consumer -o jsonpath='{.items[0].metadata.name}')
  kubectl exec $POD -- kill -9 1
  sleep 30
  # 验证 idempotency_records 里这条 message_id 的处理次数 = 1
  mysql -e "SELECT message_id, COUNT(*) FROM idempotency_records WHERE message_id='evt_$i' GROUP BY message_id"
done

事件溯源(Event Sourcing)适合替代我们的方案吗

复盘期间有同事提议直接上 Event Sourcing——所有状态变更都存为不可变事件,任何时候都可以从事件流重放出当前状态。理论上 ES 天然解决幂等性问题(事件追加是幂等的),但我们最终没采用,原因有三:

第一,ES 会带来巨大的概念冲击。我们 50 多个 Java 工程师全部要重新学聚合根、事件流、CQRS、快照,落地周期至少半年。第二,事件回放的延迟在大数据量下会很高,我们订单表 14 个月 5 亿条记录,真要重建一次状态可能要跑一天。第三,ES 和外部系统的协调更复杂——支付网关不会因为我们用 ES 就配合我们 replay 历史事件。综合下来,"幂等表 + Outbox + CDC"是性价比最高的方案。

但 ES 有它合适的场景,比如金融账户、审计系统、需要完整审计追溯的业务,这些场景下"事件本身就是真理"的范式比关系表存储更合适。我们计划在新的"商户对账"模块里试点 ES。

分布式事务的边界:为什么我们最终没用 Seata

有人会问:"为什么不直接用 Seata AT/TCC,把订单 + 支付 + 库存绑一起?"我们试过,但有几个硬伤:

  1. AT 模式依赖全局锁,在大促 1.2 万 TPS 下锁竞争严重,P99 从 80ms 飙到 600ms。
  2. TCC 模式要求所有参与者都实现 Try/Confirm/Cancel 三阶段接口,但支付网关不可能为我们改接口。
  3. SAGA 模式的补偿事务很难写——"已经发货的订单"怎么补偿?物流公司不会因为我们调用 Cancel 就把货退回来。
  4. Seata 自己也会引入新的可用性风险,事务协调器宕机时所有事务都被卡住。

所以我们的选择是:接受"最终一致性",用"幂等 + 重试 + 死信队列 + 对账"补偿。这个权衡在大多数互联网业务里都是对的,只有少数强一致场景(银行核心账务、证券交易)才真正需要分布式事务。

性能影响:幂等改造带来的额外成本

指标 事故前 事故后 变化
单条消息处理 P50 18ms 22ms +22%
单条消息处理 P99 180ms 240ms +33%
峰值吞吐 5000 TPS 4600 TPS -8%
idempotency_records 表 IOPS 0 +5000 新增
重复处理率 偶发 0 消除
客诉数(月) 12 0 -100%

付出 22% 的延迟换来重复处理率归零,我们认为非常值。事故后 8 个月内,我们没有再接到一例"重复扣款"类投诉。

灰度上线策略:17 个 consumer 怎么不停机迁移

把 17 个老 consumer 全部改成"幂等表 + 手动 commit"模式,我们花了 4 周。期间业务不能停,所以采用了"双写并轨"的灰度策略:

  1. 第 1 周:基础设施先行。先把 idempotency_records 表、outbox 表、Debezium connector、Prometheus 告警全部部署到生产,但不接入任何 consumer。这一周只验证"基础设施稳定运行"。
  2. 第 2 周:挑 2 个非核心 consumer 试点。选了"用户消息推送"和"履约状态同步"这两个挂了也不会立刻影响交易的 consumer 先改。灰度方式是"双写"——老逻辑保留,新增"幂等表 INSERT",观察 7 天看是否一致。
  3. 第 3 周:核心 consumer 改造。包括支付回调、库存扣减、订单状态机这 3 个最关键的。每改一个先在影子环境跑 24 小时,再灰度到生产 1%、10%、50%、100% 流量。
  4. 第 4 周:剩余 12 个批量改造 + 老配置清理。这一周我们做了"破坏性演练"——白天上线、晚上故意 kill -9 一波 pod,看新方案能否平稳处理。

灰度期间最痛苦的不是写代码,而是和上下游团队对齐 SLA。订单履约的下游有 8 个团队,每改一个 consumer 都要走"变更评审 + 灰度方案 + 回滚预案"流程。我们专门拉了一个"事件可靠性改造"周会,4 周内开了 16 次,每次半小时,把所有阻塞问题集中解掉。

更深一层:消息顺序保证的代价

事件驱动架构里有个很容易被忽视的事实——**只要你想要消息顺序,就必然牺牲吞吐**。Kafka 的"同 key 同 partition"机制保证了同一聚合根的消息顺序,但代价是单 partition 的吞吐天花板。我们做过一次压测,单 partition 的极限是 8000 msg/s,如果某个 hot key(比如某个爆款 SKU)的事件量超过这个数,这个 partition 就会成为瓶颈,无论 broker 还集群有多富余。

大促前我们提前识别了 top 100 的 hot SKU,给它们单独建了 "hot.order.events" topic,partition 数从 12 个加到 64 个,并且 key 不用 SKU 而用 (skuId + tenantId + bucketId) 复合 key 把流量打散。事故复盘后我们把这套机制扩展到了"top 1000 商户",每个商户的事件流单独有 hot 通道兜底。

RocketMQ/Pulsar/RabbitMQ 在幂等性上的差异

中间件 原生幂等支持 事务消息 死信队列 对幂等场景的友好度
Kafka 3.x Producer 端幂等 + 事务,Consumer 端要自己做 有,但限 Kafka 内部 需手动实现 ★★★(灵活但坑多)
RocketMQ 5.x Producer 端幂等较强,Consumer 端推荐 Message Key 去重 原生半事务消息 内置 ★★★★(企业级特性多)
Pulsar 3.x Producer 自动去重,Consumer 端 deduplication 配置即可 原生事务 内置 ★★★★★(语义最完善)
RabbitMQ 3.x 无,完全靠业务实现 有 DLX 机制 ★★(适合简单场景)

事故后我们认真评估过迁移到 Pulsar——它的 "deduplication" 配置只要设 brokerDeduplicationEnabled=true 就能在 broker 端自动去重,理论上能减掉一部分幂等表的代码。但综合迁移成本、社区生态、团队熟悉度,我们还是留在 Kafka,因为"幂等问题不是中间件能完全解决的,只是减轻一部分,业务端的设计才是根本"。

团队组织层面的两个反思

技术问题最终都是组织问题。这次事故暴露了我们组织的两个深层问题,值得任何做事件驱动架构的团队警醒:

反思 1:消费者代码缺乏统一规范。17 个 consumer 是 7 个团队在 3 年里陆续写的,每个团队对"幂等"的理解都不一样。有的用 Redis SETNX,有的用数据库唯一索引,有的根本没做幂等(觉得 Kafka exactly-once 就够了)。事故后我们成立了"事件总线 SIG",由架构师轮值,每周 review 新增/修改的 consumer 代码,3 个月内统一了所有消费者的代码模板。

反思 2:压测覆盖不到"故障注入"场景。我们日常压测只覆盖"正常路径"的吞吐和延迟,从来没压过"rebalance 频发"、"网关慢响应"、"消费者 OOM 重启"这些故障路径。事故后我们把 ChaosMesh 接入到了 CI 流水线,每个新功能上线前必须通过"4 类故障注入"测试,不通过不准合并。这一招上线后 4 个月,我们提前发现并修复了 5 个潜在的幂等漏洞,全部都是日常测试发现不了的。

给读者的可执行清单

如果你也在做事件驱动架构,无论是 Kafka、RocketMQ 还是 Pulsar,下面这份清单可以直接打印贴到工位:

  1. 每周 review 一次 consumer lag 和 rebalance 频次,任何异常立刻深挖。
  2. 每月跑一次"消息重放"演练,故意把某个 consumer 的 offset 回退 1 万条,验证业务结果幂等。
  3. 每季度做一次"消息中间件依赖审计",列出所有依赖事件驱动的业务,逐一检查其幂等方案。
  4. 每半年做一次"全链路 chaos 演练",在准生产环境模拟"broker 宕机 + 网关超时 + 消费者 OOM"组合故障。
  5. 每年做一次架构评审,评估当前方案是否还能撑下一年的业务增长,提前 6 个月布局升级。

总结:可靠性是设计出来的,不是配置出来的

事故复盘那周,我把团队拉到会议室白板前画了一张图,只有四个字:**消息发送、消息消费、业务幂等、业务回滚**。我说,事件驱动架构的可靠性不在 Kafka 的某个配置开关里,而在你对这四个环节的设计深度里。你可以把 enable.idempotence 开成 true,把 isolation.level 调成 read_committed,但如果你的消费者业务代码不是幂等的,所有这些配置都救不了你。

事故的本质不是"Kafka 不靠谱",而是"我们对 Kafka 的承诺有错误的期待"。这是分布式系统工程师最容易犯的错——把中间件的"内部保证"当成"端到端保证"。后面再设计任何事件驱动链路,我都会先问团队一个问题:"如果同一条消息被处理 3 次,你的系统会不会出错?"如果答案不是斩钉截铁的"不会",那这条链路就还不能上线。

事故是最贵的架构课,但好在你可以不用自己交学费。把这篇里 7 条纪律、6 种方案、8 个陷阱抄到你们的工程手册里,半年内你就能避开 95% 的同类坑。剩下 5% 留给你自己亲手踩,毕竟有些教训只有亲手交过学费才记得住。最后送你一句话——在分布式系统里,优雅从来不是目的,可靠才是。每多写一行幂等代码、每多加一条对账巡检、每多做一次故障演练,你在凌晨被电话叫醒的概率就少一分。这才是工程师真正的安全感来源。

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

RAG 系统检索召回率从 92% 跌到 31% 的 11 天事故复盘:9 个排查弯路 + 三连击根因 + 5 种修法

2026-5-25 18:43:20

技术教程

Tokenization BPE · 原理详解 完全指南:速查、踩坑与最佳实践

2026-5-19 0:56:15

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