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 条事件驱动架构纪律
- 所有消费者必须显式声明幂等策略。代码 review 时不写"幂等方案: A/B/C/D/E/F"注释的 PR 不合并。我们在代码模板里加了 @IdempotentConsumer 注解强制声明。
- 禁用 enable.auto.commit。新项目默认 false,老项目逐步迁移,迁移完前必须有兜底脚本扫重复。
- max.poll.records 默认 50。要调大必须有压测数据证明 P99 处理时间 < session.timeout.ms / 3。
- 事件 ID 必须全局唯一且语义化。比如 "order.created.{orderNo}" 而不是 UUID,这样排障时能直接关联业务。
- 所有外部回调(支付/物流/短信)默认都不幂等,必须自己加一层。不要相信第三方的"我们只回调一次"。
- 死信队列必须有告警 + 自动巡检。我们配的是 1 分钟内堆积 > 10 条触发 P2,小时维度堆积 > 100 条触发 P1。
- 每个核心 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":
- Pod kill -9:消息处理一半被强杀,重启后必须不能重复处理。
- GC pause 模拟:用 -XX:+UnlockExperimentalVMOptions -XX:+ParallelOldGC 调出长 GC,触发 rebalance,验证不重复。
- 网络分区:tc qdisc add dev eth0 root netem loss 50% 给 broker 加 50% 丢包,验证 producer 重试不写入重复数据。
- 支付网关重复回调:用 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,把订单 + 支付 + 库存绑一起?"我们试过,但有几个硬伤:
- AT 模式依赖全局锁,在大促 1.2 万 TPS 下锁竞争严重,P99 从 80ms 飙到 600ms。
- TCC 模式要求所有参与者都实现 Try/Confirm/Cancel 三阶段接口,但支付网关不可能为我们改接口。
- SAGA 模式的补偿事务很难写——"已经发货的订单"怎么补偿?物流公司不会因为我们调用 Cancel 就把货退回来。
- 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 周:基础设施先行。先把 idempotency_records 表、outbox 表、Debezium connector、Prometheus 告警全部部署到生产,但不接入任何 consumer。这一周只验证"基础设施稳定运行"。
- 第 2 周:挑 2 个非核心 consumer 试点。选了"用户消息推送"和"履约状态同步"这两个挂了也不会立刻影响交易的 consumer 先改。灰度方式是"双写"——老逻辑保留,新增"幂等表 INSERT",观察 7 天看是否一致。
- 第 3 周:核心 consumer 改造。包括支付回调、库存扣减、订单状态机这 3 个最关键的。每改一个先在影子环境跑 24 小时,再灰度到生产 1%、10%、50%、100% 流量。
- 第 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,下面这份清单可以直接打印贴到工位:
- 每周 review 一次 consumer lag 和 rebalance 频次,任何异常立刻深挖。
- 每月跑一次"消息重放"演练,故意把某个 consumer 的 offset 回退 1 万条,验证业务结果幂等。
- 每季度做一次"消息中间件依赖审计",列出所有依赖事件驱动的业务,逐一检查其幂等方案。
- 每半年做一次"全链路 chaos 演练",在准生产环境模拟"broker 宕机 + 网关超时 + 消费者 OOM"组合故障。
- 每年做一次架构评审,评估当前方案是否还能撑下一年的业务增长,提前 6 个月布局升级。
总结:可靠性是设计出来的,不是配置出来的
事故复盘那周,我把团队拉到会议室白板前画了一张图,只有四个字:**消息发送、消息消费、业务幂等、业务回滚**。我说,事件驱动架构的可靠性不在 Kafka 的某个配置开关里,而在你对这四个环节的设计深度里。你可以把 enable.idempotence 开成 true,把 isolation.level 调成 read_committed,但如果你的消费者业务代码不是幂等的,所有这些配置都救不了你。
事故的本质不是"Kafka 不靠谱",而是"我们对 Kafka 的承诺有错误的期待"。这是分布式系统工程师最容易犯的错——把中间件的"内部保证"当成"端到端保证"。后面再设计任何事件驱动链路,我都会先问团队一个问题:"如果同一条消息被处理 3 次,你的系统会不会出错?"如果答案不是斩钉截铁的"不会",那这条链路就还不能上线。
事故是最贵的架构课,但好在你可以不用自己交学费。把这篇里 7 条纪律、6 种方案、8 个陷阱抄到你们的工程手册里,半年内你就能避开 95% 的同类坑。剩下 5% 留给你自己亲手踩,毕竟有些教训只有亲手交过学费才记得住。最后送你一句话——在分布式系统里,优雅从来不是目的,可靠才是。每多写一行幂等代码、每多加一条对账巡检、每多做一次故障演练,你在凌晨被电话叫醒的概率就少一分。这才是工程师真正的安全感来源。
—— 别看了 · 2026