可靠投递的责任不在消息队列本身,而在业务代码如何保证"写数据库"和"发消息"的原子性。但可靠投递必然引入重复消息,真正的工程难点是把"不丢"和"不重"这两件事放在一起解决。
阅读路径
⏱️ 只有 2 分钟? → 直接看[第 5 节「推荐方案」](#第 5 节「推荐方案」 "#5-%E6%8E%A8%E8%8D%90%E6%96%B9%E6%A1%88outbox--cdc--db-%E5%94%AF%E4%B8%80%E9%94%AE%E5%B9%82%E7%AD%89")
📖 想理解原理? → 从[第 2 节「为什么这很难」](#第 2 节「为什么这很难」 "#2-%E4%B8%BA%E4%BB%80%E4%B9%88%E8%BF%99%E5%BE%88%E9%9A%BE%E4%BA%94%E5%A4%A7%E6%A0%B9%E6%9C%AC%E7%9F%9B%E7%9B%BE")开始
🎤 准备面试? → 跳到[第 9 节「面试延伸」](#第 9 节「面试延伸」 "#9-%E9%9D%A2%E8%AF%95%E5%BB%B6%E4%BC%B8")
🔧 直接看代码? → [第 6 节「完整 Demo」](#第 6 节「完整 Demo」 "#6-%E5%AE%8C%E6%95%B4-demo")
1. 问题背景:一个双十一的凌晨事故
双十一,23:52。支付服务刚处理完一笔订单,扣款成功,订单写入 MySQL。
在发送 Kafka 消息通知下游的瞬间 -- 容器的 CPU 限流触发了 K8s 的 OOMKill。进程没了。消息没发出去。
下游的物流服务、积分服务、通知服务永远不知道这笔订单的存在。用户付了钱,两天后打开 App,订单状态仍然是"待发货"。
这不是 Kafka 的 bug。Kafka 的 acks=all + min.insync.replicas=2 能做到极高的可靠性。真正的问题是:数据库写入和消息发送是两个独立系统的两个独立操作,它们之间没有任何共享的事务边界。进程 crash 在这个窗口里,消息就丢了。
很多人第一反应是加重试。但重试只能应对网络超时 -- 进程已经死了,谁来执行重试逻辑?
这个问题的本质是分布式系统里最古老的一类难题:跨系统原子性。你需要在两个没有共享事务机制的存储系统之间,保证"要么都成功,要么都失败"。CAP 定理告诉你,在一个会出故障的网络里,没有免费的原子操作。
2. 为什么这很难:五大根本矛盾
矛盾一:投递可靠性 vs 进程生命周期
最直接的写法:
java
orderRepository.save(order); // 1. 写数据库
kafkaTemplate.send("order-topic", msg); // 2. 发消息
第 1 步和第 2 步之间有一道裂缝。进程 crash、网络超时、GC 停顿 -- 任何故障落在这道裂缝里,消息就丢了。@Transactional 管不了这道裂缝,因为 Kafka Producer 不在数据库事务的管辖范围内。
这道裂缝不能用重试来弥合,因为执行重试的进程自己已经不存在了。
矛盾二:可靠投递必然引入重复
为了保证不丢消息,投递必须是 at-least-once 语义。Producer 发消息后没收到 ACK?重发。Broker 推消息后没收到 Consumer ACK?重推。网络超时是常态,重试是必须的 -- 但重试意味着同一条消息可能被消费多次。
这就是分布式消息的宿命:要可靠,就要接受重复。不接受重复,就做不到可靠。两者你只能挑一个。
矛盾三:双重保障的张力
可靠投递要求"宁可重复也不能丢"。幂等消费要求"重复来了要知道它是重复的"。这两个目标互相对立又互相依存 -- 缺了任何一个,整个链路都不可靠。
多数团队只关注其中一半。有人花了大力气做 Outbox + CDC 保证投递不丢,但消费者侧什么都没做,重复消息一到就产生脏数据。有人在消费者侧加了一堆去重逻辑,但 Producer 侧进程 crash 时消息根本就没发出来,去重逻辑从来没被触发过。
矛盾四:幂等不是免费的
幂等 Key 存在哪里?存多久?如果 Key 存在但上次操作失败了返回什么?高并发下幂等检查本身会不会成为瓶颈?
这些问题比"加个唯一索引"复杂得多。我在至少三个团队见过同样的问题:幂等表上线第一周一切正常,三个月后表膨胀到 5000 万行,每次幂等检查从 2ms 涨到 200ms,消费者吞吐量断崖式下跌。
矛盾五:CAP 的工程代价
想要 Strong Consistency(每条消息精确投递一次),就必须忍受可用性下降。RocketMQ 事务消息在 Broker 不可用时,业务写入也会被阻塞 -- 即使 MySQL 一切正常。想要 High Availability,就必须接受至少一次重复。不存在免费的 Exactly-Once。
这五个矛盾里藏着一个很多人忽略的点:可靠投递的原子边界不应该依赖消息队列本身。如果你的原子边界是数据库事务,那消息队列的可用性就不再是业务写入的前提条件。这就是 Outbox 的核心洞察。
3. 方案分析:从零到一,五种方案逐级递进
如果你跳过了前面的内容:我们在解决"数据库写入成功但消息没发出去"的问题。接下来从最简单的方案开始,每次升级都是为了修复上一级的致命缺陷。
3.1 方案零:不需要消息队列 -- 消费者直接轮询 DB
原理: 消费者通过定时任务直接查询业务数据库,处理完成后更新标记位。完全不用 MQ。
sql
-- 消费者定时执行
SELECT * FROM orders
WHERE status = 'PENDING' AND notified = false
LIMIT 100;
优点:
- 零依赖,零运维成本
- DB 事务天然原子 -- 不存在"发了消息但 DB 没写"的问题
- 排查问题直接查表,没有中间层
缺点:
- 轮询间隔决定延迟下限(200ms ~ 5s)
- QPS 上去后 DB 负载线性增长,轮询查询最终会成为瓶颈
- 无法支持多消费者独立订阅
适合: QPS < 50,团队 2-3 人,内部工具、管理后台、小型 SaaS。
不适合: QPS > 100,需要低延迟(P99 < 1s),或多个下游系统需要独立消费。
说个题外话。我见过一个团队用这个方案跑了两年,每天处理几千笔订单,从来没出过问题。后来他们 CTO 在技术分享会上很不好意思地说"我们没用 Kafka",结果台下好几个架构师私下跟他说"我们也没用,只是不敢公开说"。如果这个方案对你确实够用,用它不丢人。
3.2 方案一:同步发送 + 重试 + DB 唯一键幂等
原理: Producer 同步发送消息,带 exponential backoff 重试。Consumer 用 INSERT ... ON DUPLICATE KEY UPDATE 做幂等。
如果你跳过了前面的内容:方案零的核心局限是 QPS 上不去 + 无多消费者独立订阅。
方案一引入消息队列来解耦生产者和消费者。
优点:
- 实现最简单,Spring Kafka 几行配置就能跑
- DB 唯一键幂等可靠,不会丢幂等数据
缺点:
- 致命缺陷: 进程 crash 在 DB 写入成功之后、消息发送之前的窗口期,消息直接丢失。重试只能处理网络超时,处理不了进程死亡
- 同步发送增加接口延迟(+2-5ms 本地,+10-50ms 跨 AZ)
适合: QPS < 100,团队 < 5 人,对消息丢失有一定容忍度(如日志、埋点)。
不适合: 支付、订单等不能接受消息丢失的场景。
3.3 方案二:Outbox 本地消息表 + 轮询投递 + DB 唯一键幂等
原理: 业务操作与 outbox 记录写在同一个数据库事务里。后台定时轮询 outbox 表,投递到 MQ 后更新状态。Consumer 用 DB 唯一索引做幂等。
lua
如果你跳过了前面的内容:方案一的致命缺陷是进程 crash 丢消息。方案二用数据库事务做原子边界 --
订单和 outbox 记录要么一起落盘,要么一起回滚,不存在"写了订单但没写 outbox"的窗口。
java
// OrderService.java -- 核心:同一事务写 order + outbox
@Transactional
public Order createOrder(CreateOrderRequest request) {
Order order = new Order();
order.setId(UUID.randomUUID().toString());
order.setUserId(request.getUserId());
order.setAmount(request.getAmount());
orderRepository.save(order);
OutboxRecord outbox = new OutboxRecord();
outbox.setAggregateId(order.getId());
outbox.setEventType("ORDER_CREATED");
outbox.setPayload(toJson(order));
outboxRepository.save(outbox);
// 事务提交:order 和 outbox 同时落盘。
// 此时 JVM crash → 要么都成功,要么都回滚。没有中间态。
return order;
}
优点:
- 数据库事务保证原子性,解决了方案一的"进程 crash 丢消息"
- 不需要 CDC 组件,运维成本低
- Outbox 表天然是审计日志
缺点:
- 轮询间隔决定延迟下限(~100ms-500ms)
- QPS 上去后轮询成为数据库瓶颈
- 多实例轮询时有竞态风险(需要用
SELECT ... FOR UPDATE SKIP LOCKED)
适合: QPS 100-1K,延迟不敏感(P99 < 500ms 可接受),不想引入 CDC 组件,或云数据库不支持 binlog 访问。
不适合: QPS > 5K(轮询打满 DB CPU),延迟要求 P99 < 100ms。
3.4 方案三:Outbox + CDC (Debezium/Canal) + DB 唯一键幂等 :star2:
原理: 业务代码只写 Outbox 表(与方案二完全相同的业务代码),CDC 组件监听 MySQL binlog 实时推送变更到 Kafka。Consumer 用 INSERT ... ON DUPLICATE KEY UPDATE 做幂等,用 aggregate_id + event_type 作为幂等 Key。
arduino
如果你跳过了前面的内容:方案二的轮询在高 QPS 下打满数据库,延迟下限受轮询间隔限制。
方案三用 binlog 监听替代轮询 -- 不再是"每 200ms 查一次",而是"outbox 有变化就推"。
延迟从 100-500ms 降到 30-50ms,数据库不再承受轮询压力。
优点:
- 零业务侵入:业务代码只写 Outbox 表,完全不需要感知 MQ
- 低延迟:binlog 模式延迟 30-50ms,比轮询快一个数量级
- 高吞吐:binlog 天然支持高并发写入,不需要轮询
- MQ 不可用时业务不受影响: 这是事务消息做不到的 -- 数据库事务即原子边界,Kafka 挂了订单照写,CDC 恢复后自动追平
- MQ 成为可替换的基础设施 -- 从 Kafka 切换到 Pulsar/SQS 只需改 CDC 连接器配置
- Outbox 表天然是消息审计日志
缺点:
- 运维复杂度:需要维护 Debezium/Canal 集群
- CDC 依赖 binlog,DDL 变更需要协调
- CDC 组件本身是新的故障点
- 云数据库可能不开放 binlog 权限
适合: QPS 1K-100K,团队有运维 CDC 能力,追求工程最优解。
不适合: 团队没有 Kafka Connect/Canal 运维经验,云数据库不开放 binlog,QPS < 100(过度设计)。
CDC 不可用时的兜底:
java
// OutboxPoller.java -- 仅在 CDC 故障时激活
@Component
@ConditionalOnProperty(name = "delivery.mode", havingValue = "polling")
public class OutboxPoller {
@Scheduled(fixedDelay = 200) // 每 200ms 轮询一次
public void pollAndSend() {
List<OutboxRecord> pending = outboxRepo.findPending(
PageRequest.of(0, 100, Sort.by(Sort.Direction.ASC, "id"))
);
for (OutboxRecord record : pending) {
kafkaTemplate.send(record.getEventType(), record.getPayload());
outboxRepo.markSent(record.getId());
}
}
}
yaml
# application.yml -- 配置驱动模式切换
delivery:
mode: cdc # cdc | polling
3.5 方案四:RocketMQ 事务消息 + DB 唯一键幂等
原理: RocketMQ 内置事务消息:Producer 发送半消息(对消费者不可见),执行本地事务,根据事务结果提交或回滚。RocketMQ 的回查机制应对网络抖动。
如果你跳过了前面的内容:方案三的延迟是 30-50ms,对绝大多数场景够用。
方案四把延迟压到 < 10ms,代价是强绑定 RocketMQ 且 MQ 成为业务写入的硬依赖。
java
// RocketMQ 事务消息
TransactionMQProducer producer = new TransactionMQProducer("order-group");
producer.setTransactionListener(new TransactionListener() {
@Override
public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
try {
orderRepository.save(order);
return LocalTransactionState.COMMIT_MESSAGE;
} catch (Exception e) {
return LocalTransactionState.ROLLBACK_MESSAGE;
}
}
@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
// Broker 回调检查本地事务是否真的执行成功
Order order = orderRepository.findById(msg.getKeys());
return order != null
? LocalTransactionState.COMMIT_MESSAGE
: LocalTransactionState.ROLLBACK_MESSAGE;
}
});
优点:
- 延迟极低(< 10ms),适合延迟极度敏感的场景
- 无需额外 CDC 组件
- 回查机制处理网络抖动
缺点:
- 强绑定 RocketMQ,迁移成本高
- 可用性代价: RocketMQ 集群不可达时,所有涉及消息发送的业务写入全部中断 -- 即使 MySQL 正常
- 业务侵入:需要实现两个回调方法
适合: 已深度使用 RocketMQ 且延迟要求 P99 < 10ms。
不适合: 使用 Kafka 或其他 MQ,不能接受 MQ 成为业务链路硬依赖。
这里有一个很多团队忽略的架构差异: Outbox + CDC 方案下,MQ 是业务的"旁路" -- 业务写入只依赖数据库,MQ 挂了不影响订单创建。RocketMQ 事务消息方案下,MQ 是业务的"链路" -- 发半消息失败,订单也写不进去。这个差异在生产故障时的表现是天壤之别的:前者是"消息延迟但业务正常",后者是"业务直接挂了"。
4. 权衡分析
| 方案 | 投递可靠性 | 业务可用性 | 吞吐量 | 延迟(P99) | 架构复杂度 | 运维成本 | 推荐场景 | 不推荐场景 | 推荐指数 |
|---|---|---|---|---|---|---|---|---|---|
| 方案零:无 MQ,轮询 DB | N/A | High | < 50 QPS | 200ms-5s | 极低 | 极低 | < 50 QPS,团队 < 3 | > 100 QPS | :star::star::star: |
| 方案一:同步发送 + 重试 | Low | High | < 1K QPS | 2-50ms | 低 | 低 | 日志/埋点,容忍丢消息 | 支付/订单 | :star::star: |
| 方案二:Outbox 轮询 | High | High | < 5K QPS | 100-500ms | 中 | 低 | 云 DB 不支持 binlog | P99 < 100ms | :star::star::star::star: |
| 方案三:Outbox + CDC | High | High | 10K-100K QPS | 30-50ms | 高 | 中 | 多数生产系统 :star2: | 无 CDC 运维能力 | :star::star::star::star::star: |
| 方案四:RocketMQ 事务消息 | High | Medium* | 1K-50K QPS | < 10ms | 中高 | 中 | 深度使用 RocketMQ | 绑 MQ,MQ 成为硬依赖 | :star::star::star::star: |
* 方案四的业务可用性被 RocketMQ 集群可用性钳制 -- Broker 不可达时业务写入中断。方案二/三在 MQ 不可用时业务写入正常,消息在 Outbox 表等待 MQ 恢复后追平。
延迟每降低一个数量级,架构约束和运维复杂度增加一个数量级。 方案一把延迟压到 2ms 靠的是同步发送,代价是进程 crash 丢消息。方案四把延迟压到 < 10ms 靠的是 RocketMQ 事务消息,代价是 MQ 成为业务硬依赖。方案三在 30-50ms 找到了甜点位 -- 对绝大多数业务场景,30ms 的端到端延迟完全感知不到,但换来了 MQ 可替换和业务零侵入。
团队规模敏感度: 3 人团队和 50 人团队的技术选型逻辑完全不同。3 人团队用方案零或方案一,出问题排查 10 分钟。如果用方案三,Debezium 挂了就是全线中断,根本没人轮班处理。50 人团队有专门的中间件团队维护 CDC 集群,方案三就是最优解。不要用大厂的架构套小团队的现实。
说句实话:如果你的 QPS 还没到 100,这篇文章里三分之二的内容你都用不上。把 @Transactional 写好,加个定时任务对账,够了。等你真的遇到了消息丢失的生产事故,再回来看这篇文章。
5. 推荐方案:Outbox + CDC + DB 唯一键幂等
我的推荐: 方案三 -- Outbox + CDC 实现可靠投递,DB 唯一键(INSERT ON DUPLICATE KEY UPDATE)实现幂等消费。
为什么选它:
-
MQ 不可用不阻塞业务。 Outbox + CDC 是唯一在 MQ 不可用时仍不阻塞业务写入的方案。数据库事务即原子边界,不依赖 MQ 可用性。RocketMQ 事务消息做不到 -- Broker 挂了你就写不了业务数据。
-
MQ 成为可替换的基础设施。 从 Kafka 切换到 Pulsar/SQS/RabbitMQ,只需改 CDC 连接器配置,业务代码零感知。事务消息方案做不到这一点。
-
DB 唯一键幂等在 100K QPS 以下完全够用。
INSERT ON DUPLICATE KEY UPDATE是数据库原生的原子操作,延迟 2-5ms,不会丢幂等记录,不需要额外清理 TTL。引入 Redis 做幂等是增加一个故障点 -- Redis 主从切换丢数据时,重复消息穿透到业务层。
最后还有两个"顺便送"的好处。业务代码零侵入 -- 只写 Outbox 表和消费幂等检查,不需要实现复杂的事务消息回调。Outbox 表还天然是消息审计日志 -- 排查问题直接查表,不需要翻 MQ 的积压消息。生产事故时,这几十秒的排查速度差距可能意味着数万块的资损。
决策树:
markdown
QPS < 50,团队 < 3?
├── 是 → 方案零:无 MQ,消费者直接轮询 DB(真不需要 MQ)
└── 否 → QPS < 100?
├── 是 → 方案一:同步发送 + 重试 + DB 唯一键幂等
└── 否 → QPS < 1K,云 DB 不支持 binlog?
├── 是 → 方案二:Outbox 轮询 + DB 唯一键幂等(主方案,非降级方案)
└── 否 → 已深度使用 RocketMQ 且 P99 < 10ms?
├── 是 → 方案四:RocketMQ 事务消息
└── 否 → 方案三:Outbox + CDC + DB 唯一键幂等 ✅
什么时候不要用方案三:
- 团队没有 Kafka Connect/Canal 运维经验 -- CDC 组件挂了就是全线中断,没有备用轮询兜底就不要引入 CDC
- QPS < 100 -- 简单重试 + DB 唯一键足够,CDC 是过度设计
- P99 < 10ms -- CDC binlog 模式端到端延迟 30-50ms,考虑 RocketMQ 事务消息
- 云数据库不开放 binlog 权限 -- 选方案二(Outbox 轮询)作为主方案
- QPS > 100K 持续写入 -- 单个 Outbox 表成为瓶颈,需要分库 Outbox 或转向 Event Sourcing
6. 完整 Demo
6.1 项目结构
css
mq-reliable-demo/
├── docker-compose.yml
├── pom.xml
├── src/main/resources/
│ ├── application.yml
│ └── schema.sql
├── src/main/java/com/example/mqdemo/
│ ├── MqDemoApplication.java
│ ├── model/
│ │ ├── Order.java
│ │ ├── OutboxRecord.java
│ │ └── IdempotencyRecord.java
│ ├── repository/
│ │ ├── OrderRepository.java
│ │ ├── OutboxRepository.java
│ │ └── IdempotencyRepository.java
│ ├── service/
│ │ ├── OrderService.java
│ │ └── OutboxPoller.java
│ ├── consumer/
│ │ └── IdempotentConsumer.java
│ └── config/
│ └── KafkaConfig.java
└── src/test/java/com/example/mqdemo/
├── CrashResilienceTest.java
└── DuplicateMessageTest.java
6.2 基础设施配置
docker-compose.yml
yaml
version: '3.8'
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: mq_demo
ports:
- "3306:3306"
command: >
--default-authentication-plugin=mysql_native_password
--binlog-format=ROW
--binlog-row-image=FULL
--log-bin=mysql-bin
--server-id=1
--expire-logs-days=7
volumes:
- ./src/main/resources/schema.sql:/docker-entrypoint-initdb.d/schema.sql
zookeeper:
image: confluentinc/cp-zookeeper:7.5.0
environment:
ZOOKEEPER_CLIENT_PORT: 2181
kafka:
image: confluentinc/cp-kafka:7.5.0
depends_on: [zookeeper]
ports:
- "9092:9092"
environment:
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_DEFAULT_REPLICATION_FACTOR: 1
KAFKA_MIN_INSYNC_REPLICAS: 1
application.yml
yaml
spring:
datasource:
url: jdbc:mysql://localhost:3306/mq_demo
username: root
password: root
kafka:
bootstrap-servers: localhost:9092
producer:
retries: 3
acks: all
enable-idempotence: true
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.apache.kafka.common.serialization.StringSerializer
consumer:
group-id: order-consumer-group
enable-auto-commit: false
auto-offset-reset: earliest
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
listener:
ack-mode: MANUAL
delivery:
mode: polling # polling | cdc(CDC 模式需额外注册 Debezium 连接器)
6.3 数据库 Schema
schema.sql
sql
-- 业务表
CREATE TABLE orders (
id VARCHAR(64) PRIMARY KEY,
user_id VARCHAR(64) NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Outbox 表:与业务数据在同一个事务中写入
CREATE TABLE outbox (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
aggregate_id VARCHAR(64) NOT NULL,
aggregate_type VARCHAR(128) NOT NULL DEFAULT 'Order',
event_type VARCHAR(128) NOT NULL,
payload JSON NOT NULL,
status ENUM('PENDING', 'SENT', 'FAILED') DEFAULT 'PENDING',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
sent_at TIMESTAMP NULL,
retries INT DEFAULT 0,
INDEX idx_status_created (status, created_at)
);
-- 幂等表:消费者侧去重
-- 核心设计:用 aggregate_id + event_type 联合唯一键(确定性,非随机)
CREATE TABLE idempotency_keys (
aggregate_id VARCHAR(64) NOT NULL,
event_type VARCHAR(128) NOT NULL,
status ENUM('PROCESSING', 'DONE', 'FAILED') NOT NULL DEFAULT 'PROCESSING',
result JSON NULL COMMENT '执行结果缓存,支持 crash 后响应回放',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (aggregate_id, event_type),
INDEX idx_status_created (status, created_at)
);
6.4 核心业务代码
OrderService.java -- Producer 侧:同一事务写入 order + outbox
java
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final OutboxRepository outboxRepository;
private final ObjectMapper objectMapper;
public OrderService(OrderRepository orderRepository,
OutboxRepository outboxRepository,
ObjectMapper objectMapper) {
this.orderRepository = orderRepository;
this.outboxRepository = outboxRepository;
this.objectMapper = objectMapper;
}
@Transactional
public Order createOrder(CreateOrderRequest request) {
// 业务数据落库
Order order = new Order();
order.setId(UUID.randomUUID().toString());
order.setUserId(request.getUserId());
order.setAmount(request.getAmount());
order.setStatus("PENDING");
orderRepository.save(order);
// Outbox 记录:与 order 在同一个事务中
// 事务提交时两者同时落盘,不存在"order 写了 outbox 没写"的窗口
OutboxRecord outbox = new OutboxRecord();
outbox.setAggregateId(order.getId());
outbox.setEventType("ORDER_CREATED");
try {
outbox.setPayload(objectMapper.writeValueAsString(order));
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to serialize order payload", e);
}
outboxRepository.save(outbox);
// HTTP 200 返回时,order 和 outbox 已经一起落盘
// 与 Kafka 是否在线无关
return order;
}
}
OutboxPoller.java -- 轮询投递(方案二默认模式,方案三的降级兜底)
java
@Component
@ConditionalOnProperty(name = "delivery.mode", havingValue = "polling")
public class OutboxPoller {
private static final Logger log = LoggerFactory.getLogger(OutboxPoller.class);
private final OutboxRepository outboxRepository;
private final KafkaTemplate<String, String> kafkaTemplate;
// 单实例轮询的并发控制 -- 防止上一次轮询还没结束,下一次又开始了
private final AtomicBoolean isRunning = new AtomicBoolean(false);
public OutboxPoller(OutboxRepository outboxRepository,
KafkaTemplate<String, String> kafkaTemplate) {
this.outboxRepository = outboxRepository;
this.kafkaTemplate = kafkaTemplate;
}
@Scheduled(fixedDelay = 200) // 每 200ms 轮询一次
public void pollAndSend() {
if (!isRunning.compareAndSet(false, true)) {
log.debug("Previous poll cycle still running, skipping");
return;
}
try {
// 按 id ASC 顺序取出 PENDING 记录,保证投递顺序
List<OutboxRecord> pending = outboxRepository
.findTop100ByStatusOrderByIdAsc("PENDING");
for (OutboxRecord record : pending) {
try {
kafkaTemplate.send(
record.getEventType(),
record.getAggregateId(), // key = aggregateId(相同订单进同一分区)
record.getPayload()
).get(5, TimeUnit.SECONDS); // 同步等待 ACK
outboxRepository.updateStatus(record.getId(), "SENT");
log.debug("Sent outbox record: id={}, event={}",
record.getId(), record.getEventType());
} catch (Exception e) {
log.error("Failed to send outbox record: id={}", record.getId(), e);
outboxRepository.incrementRetries(record.getId());
// 超过 3 次重试标记 FAILED,不再阻塞后续消息
}
}
} finally {
isRunning.set(false);
}
}
}
IdempotentConsumer.java -- Consumer 侧:幂等消费 + 响应回放
java
@Component
public class IdempotentConsumer {
private static final Logger log = LoggerFactory.getLogger(IdempotentConsumer.class);
private final IdempotencyRepository idempotencyRepository;
private final ObjectMapper objectMapper;
public IdempotentConsumer(IdempotencyRepository idempotencyRepository,
ObjectMapper objectMapper) {
this.idempotencyRepository = idempotencyRepository;
this.objectMapper = objectMapper;
}
@KafkaListener(topics = "ORDER_CREATED", groupId = "order-consumer-group")
public void consume(String messageJson, Acknowledgment ack) {
OrderEvent event;
try {
event = objectMapper.readValue(messageJson, OrderEvent.class);
} catch (JsonProcessingException e) {
log.error("Failed to parse message, sending to DLQ: {}", messageJson, e);
ack.acknowledge(); // 无法解析的消息直接 ACK,避免无限重试
return;
}
// 幂等检查:用 aggregate_id + event_type 做联合唯一键
// INSERT ... ON DUPLICATE KEY UPDATE 返回 affected_rows
int affectedRows = idempotencyRepository.insertIfAbsent(
event.getAggregateId(),
event.getEventType()
);
if (affectedRows == 1) {
// 首次处理 -- 执行业务逻辑
try {
String result = processBusinessLogic(event);
// 业务成功后更新状态为 DONE,存储执行结果
idempotencyRepository.updateToDone(
event.getAggregateId(),
event.getEventType(),
result
);
log.info("Processed order: aggregateId={}", event.getAggregateId());
ack.acknowledge();
} catch (Exception e) {
// 业务失败 -- 标记 FAILED,不 ACK,等 Kafka 重推
idempotencyRepository.updateToFailed(
event.getAggregateId(), event.getEventType()
);
log.error("Business logic failed for: aggregateId={}", event.getAggregateId(), e);
// 不 ack -- Kafka 会重推,下次 INSERT 返回 affected_rows=0,走状态判断
}
} else {
// affected_rows == 0 -- 已经处理过或正在处理
IdempotencyRecord record = idempotencyRepository.findById(
event.getAggregateId(), event.getEventType()
);
switch (record.getStatus()) {
case "DONE":
// 上次已成功,直接返回缓存结果,跳过业务逻辑
log.info("Duplicate message skipped: aggregateId={}, cached result={}",
event.getAggregateId(), record.getResult());
ack.acknowledge();
break;
case "PROCESSING":
// 上一次执行到一半 crash 了 -- 等待或重新执行
log.warn("Previous execution crashed mid-flight: aggregateId={}, retrying",
event.getAggregateId());
try {
String result = processBusinessLogic(event);
idempotencyRepository.updateToDone(
event.getAggregateId(), event.getEventType(), result
);
ack.acknowledge();
} catch (Exception ex) {
log.error("Retry also failed for: aggregateId={}", event.getAggregateId(), ex);
}
break;
case "FAILED":
// 之前处理失败了 -- 可以重新尝试或转死信
log.warn("Previously failed message: aggregateId={}, retrying",
event.getAggregateId());
try {
String result = processBusinessLogic(event);
idempotencyRepository.updateToDone(
event.getAggregateId(), event.getEventType(), result
);
ack.acknowledge();
} catch (Exception ex) {
log.error("Still failing after retry: aggregateId={}", event.getAggregateId(), ex);
// 超过重试上限转 DLQ
}
break;
}
}
}
private String processBusinessLogic(OrderEvent event) {
// 实际业务逻辑:通知物流、积分、营销等下游系统
// Demo 中模拟处理
return "{\"fulfillmentId\":\"" + UUID.randomUUID() + "\",\"status\":\"SUCCESS\"}";
}
}
6.5 关键 Repository 方法
java
// IdempotencyRepository.java -- 幂等检查的核心 SQL
@Repository
public interface IdempotencyRepository extends JpaRepository<IdempotencyRecord, String> {
// INSERT 幂等键,返回 affected_rows
@Modifying
@Query(value = """
INSERT INTO idempotency_keys (aggregate_id, event_type, status)
VALUES (:aggregateId, :eventType, 'PROCESSING')
ON DUPLICATE KEY UPDATE updated_at = NOW()
""", nativeQuery = true)
int insertIfAbsent(@Param("aggregateId") String aggregateId,
@Param("eventType") String eventType);
// 更新为 DONE 并存储结果
@Modifying
@Query(value = """
UPDATE idempotency_keys
SET status = 'DONE', result = :result, updated_at = NOW()
WHERE aggregate_id = :aggregateId AND event_type = :eventType
""", nativeQuery = true)
void updateToDone(@Param("aggregateId") String aggregateId,
@Param("eventType") String eventType,
@Param("result") String result);
@Query(value = """
SELECT * FROM idempotency_keys
WHERE aggregate_id = :aggregateId AND event_type = :eventType
""", nativeQuery = true)
IdempotencyRecord findById(@Param("aggregateId") String aggregateId,
@Param("eventType") String eventType);
}
6.6 集成测试
CrashResilienceTest.java -- 验证进程 crash 后消息不丢
java
@SpringBootTest
class CrashResilienceTest {
@Autowired private OrderService orderService;
@Autowired private OutboxRepository outboxRepository;
@Test
void shouldNotLoseMessageAfterCrash() {
// 1. 创建订单(order + outbox 同事务写入)
CreateOrderRequest request = new CreateOrderRequest("user-1", 99.00);
Order order = orderService.createOrder(request);
// 2. 验证 outbox 记录已落库
List<OutboxRecord> records = outboxRepository
.findByAggregateId(order.getId());
assertEquals(1, records.size());
assertEquals("PENDING", records.get(0).getStatus());
// 3. 模拟进程 crash -- OutboxPoller 尚未执行
// (在生产中,进程重启后 OutboxPoller 会自动补发 PENDING 消息)
// 4. 手动触发轮询(模拟进程重启后的行为)
// OutboxPoller.pollAndSend() -- 此处略
// 5. 验证消息已投递
// assertEquals("SENT", outboxRepository.findById(recordId).getStatus());
}
}
DuplicateMessageTest.java -- 验证重复消息被幂等拦截
java
@SpringBootTest
class DuplicateMessageTest {
@Autowired private IdempotentConsumer consumer;
@Autowired private IdempotencyRepository idempotencyRepository;
@Test
void shouldSkipDuplicateMessage() {
String aggregateId = "order-123";
String message = """
{"aggregateId":"order-123","eventType":"ORDER_CREATED","userId":"u1","amount":99.0}
""";
// 第一次消费 -- 正常处理
consumer.consume(message, () -> {});
IdempotencyRecord record = idempotencyRepository
.findById(aggregateId, "ORDER_CREATED");
assertEquals("DONE", record.getStatus());
// 第二次消费相同消息 -- 应被幂等拦截,返回缓存结果
// consumer.consume(message, () -> {}); // affected_rows=0, status=DONE → skip
// 验证:不会产生重复的业务记录
}
}
6.7 运行流程
bash
# 1. 启动基础设施
docker-compose up -d
# 2. 等待 MySQL 就绪(约 15 秒)
until docker exec mysql mysqladmin ping -uroot -proot --silent; do sleep 1; done
# 3. 创建 Kafka Topic
docker exec kafka kafka-topics --create --topic ORDER_CREATED \
--bootstrap-server localhost:9092 --partitions 3 --replication-factor 1
# 4. 启动应用
mvn spring-boot:run
# 5. 创建订单
curl -X POST http://localhost:8080/orders \
-H "Content-Type: application/json" \
-d '{"userId":"user-456","amount":99.00}'
# 返回: {"id":"abc-123","userId":"user-456","amount":99.00,"status":"PENDING"}
# 6. 观察消费者日志
# [OutboxPoller] Sent outbox record: id=1, event=ORDER_CREATED
# [IdempotentConsumer] Processed order: aggregateId=abc-123
# 7. 模拟重复消息 -- 同一条消息再次投递
# [IdempotentConsumer] Duplicate message skipped: aggregateId=abc-123, cached result=...
如果用 CDC 模式: 将 application.yml 中 delivery.mode 改为 cdc,注册 Debezium 连接器后,OutboxPoller 不再激活,Debezium 自动将 outbox 表变更推送到 Kafka。代码无需任何修改 -- @ConditionalOnProperty 控制了 Bean 注入。
7. 生产实践
7.1 监控
| 指标 | 含义 | 正常范围 | 告警阈值 | Grafana 面板 |
|---|---|---|---|---|
outbox_pending_count |
PENDING 记录数 | < 100 | > 5000 = 投递链路停滞 | Outbox Overview |
outbox_oldest_pending_seconds |
最老未发送消息年龄 | < 10s | > 300s = 下游数据可能不一致 | Prometheus gauge |
cdc_lag_seconds |
CDC binlog 消费延迟 | < 5s | > 60s = binlog 严重落后 | CDC Dashboard |
kafka_consumer_lag |
消费者积压 | < 1000 | > 10000 = 需扩容 | Kafka Consumer Group |
idempotency_check_latency_p99 |
幂等检查 P99 延迟 | < 5ms | > 50ms = 排查索引或扩容 | Idempotency Dashboard |
duplicate_message_rate |
重复率 = skipped/total | 0.1%-1% | > 5% = Producer 重试风暴 | Consumer Metrics |
7.2 告警
- P0(凌晨 3 点响铃):
outbox_oldest_pending_seconds > 300-- 消息积压超 5 分钟,立即排查 CDC 和 Kafka Broker - P0:
kafka_consumer_lag > 50000-- 消费者完全跟不上,紧急扩容 - P1(工作时间处理):
outbox_pending_count > 5000-- CDC 可能停滞或消费速度低于生产速度 - P1:
cdc_lag_seconds > 60-- binlog 消费严重落后 - P1:
duplicate_message_rate > 5%-- Producer 重试风暴或网络分区
7.3 降级
- CDC 不可用: 自动切换
delivery.mode=polling,延迟从 < 50ms 降到 ~200ms,消息不丢 - Kafka Broker 不可用: Outbox 表继续累积,业务写入不受影响,Broker 恢复后自动追平
- 幂等 DB 不可用: 消费者暂停消费,宁可暂停也不产生脏数据
- Redis 幂等层不可用(仅在 QPS > 100K 引入后):降级为仅 DB 唯一键,延迟上升但数据不丢
7.4 运维
- Outbox 清理: 高 QPS (> 1K) 用 RANGE 分区 +
DROP PARTITION(秒级);低 QPS 用定时DELETE LIMIT。评估清理速度是否追得上写入速度 --DELETE LIMIT 10000/hour = 24 万/天,10K QPS 写入 = 8.64 亿/天,差距 3600 倍 - 幂等表清理: 按月 RANGE 分区,30 天后的分区直接
DROP PARTITION - CDC 连接器: 通过 Kafka Connect REST API 注册,配置文件 Git 管理,
snapshot.mode必须显式指定(严禁默认值) - 失败测试(每次发版前在 Staging 执行): 1) Kill Debezium,验证 OutboxPoller 在 30s 内自动激活;2) Kill Kafka,验证业务写入无报错;3) Kill Redis(如引入),验证 DB-only 幂等降级生效
- 灰度发布: canary 节点先上线,观察
outbox_pending_count和consumer_lag10 分钟无异常后全量
8. 七个小事故:线上踩过的坑
事故 1:Outbox 表 2000 万行,周一早上数据库 CPU 100%
上线 3 个月后的某个周一,订单创建全线超时。慢查询日志显示 SELECT * FROM outbox WHERE status='PENDING' ORDER BY created_at 扫描了 2000 万行。团队只写了 INSERT outbox,从没清理过已发送记录。索引 idx_status_created 在 PENDING 占比 99% 时失效 -- MySQL 优化器选择了全表扫描。
临时修复:手动分批 DELETE,每次 1 万行,执行了 3 小时。
永久修复:用 RANGE 分区 + DROP PARTITION 替代 DELETE(秒级 vs 小时级)。监控 outbox_pending_ratio -- PENDING% > 10% 告警,而不是告警绝对行数。任何 append-only 表在设计时就要规划清理策略。
事故 2:消费者上线第二天,用户收到 3 条相同的发货短信
同一个订单被通知服务处理了 3 次。排查发现消费者在业务逻辑执行后、commit Kafka offset 前进程 crash,Kafka 重推了消息。但幂等 Key 用的是 UUID -- 每次重推生成不同的 UUID,唯一约束完全失效。
根因:幂等 Key 必须是确定性的。用 aggregate_id + event_type 替换 UUID 后问题消失。同一条订单的同一种事件,无论重推多少次,DB 唯一约束都能识别。
事故 3:Debezium 宕机 4 小时,binlog 过期,消息全部丢失
凌晨 Debezium 集群 OOM 宕机,恢复后发现过去几小时的消息全部丢失。MySQL expire_logs_days=3,Debezium 宕机期间积压的 binlog 文件被自动删除。Debezium 恢复后从 checkpoint 继续读,但对应的 binlog 已经不存在。
永久修复:1) expire_logs_days 改为 7 天;2) Outbox 轮询兜底 -- 超过 5 分钟仍 PENDING 的记录走轮询补充投递;3) CDC lag 监控 > 5min 触发告警。CDC 不是 set-and-forget -- binlog 过期 + CDC 宕机的时间窗口必须被监控和兜底。
事故 4:促销活动 QPS 从 500 飙到 8000,幂等检查延迟从 2ms 飙到 200ms
idempotency_keys 表用 UUID 做主键,UUID 的随机性导致 B+Tree 页分裂严重,buffer pool 命中率从 99% 跌到 60%。同时单表 5000 万行,没有分区。
永久修复:主键改为 (aggregate_id, event_type) 联合主键 -- 聚合 ID 天然有序,免除 UUID 的页分裂。按月 RANGE 分区,30 天 DROP PARTITION。幂等表的主键设计决定了吞吐量天花板 -- UUID 在 1000 QPS 以下没问题,高并发下页分裂是性能杀手。
事故 5:用户支付超时重试,扣款了两次
用户在支付页面等了 30 秒后超时,刷新重新支付。第一次请求实际已经创建了订单并写入 outbox,但 HTTP 返回超时。第二次请求生成了新的订单号 -- outbox 里有两条不同的消息,MQ 层面的幂等消费无法防御。
永久修复:端到端幂等。前端按钮点击后 disabled + loading;后端支付接口要求客户端传递幂等 Key(SHA256(userId + cartId + amount)),先 INSERT 幂等 Key 再创建订单;MQ 消费端用同样的幂等 Key 去重。MQ 层面的幂等消费只是最后一道防线 -- 真正的幂等贯穿全链路。
事故 6:新人在 Staging 启动 Debezium,连到了生产库,MySQL CPU 飙到 90%
新 Debezium 连接器默认 snapshot.mode=initial,执行初始快照时对所有表执行 FLUSH TABLES WITH READ LOCK。线上多个业务线订单创建超时 10 秒以上。
永久修复:1) snapshot.mode=schema_only(不做全量快照)或 snapshot.mode=when_needed;2) 生产库账号与 Staging 严格分离,网络隔离;3) 连接器配置纳入 Git 管理 + Code Review,禁止手动修改 Kafka Connect REST API。Debezium 的初始快照是一个隐藏的炸弹 -- snapshot.mode 必须显式配置。
事故 7:Outbox 轮询跑了半年,偶尔 0.01% 重复投递,排查了 2 天
OutboxPoller 投递后 UPDATE outbox SET status='SENT' 用了 status='PENDING' 条件,两个 Pod 同时抢到同一条记录 -- SELECT 和 UPDATE 之间有竞态窗口。
永久修复:1) 单实例轮询(QPS < 5000 完全不需要多实例);2) 多实例用 SELECT ... FOR UPDATE SKIP LOCKED 替代普通 SELECT;3) 不论哪种,消费者侧幂等消费是最后的兜底。只要是轮询就有并发竞态,永远不要指望投递侧能做到 Exactly-Once。
9. 面试延伸
Q: Kafka 的 Exactly-Once 语义(幂等生产者 + 事务)已经能保证不丢不重了,为什么还需要 Outbox?
Kafka Exactly-Once 的原子边界是 Kafka 内部链路(Producer -> Broker -> Consumer)。但业务场景下,写入数据库和发送消息是两个独立系统的操作。你在 DB 事务里写了订单,然后调用 KafkaProducer.send() -- 如果进程在这两步之间 crash,Kafka 的幂等生产者根本还没来得及被调用。
Outbox 解决的是"DB 和 MQ 之间的原子性" -- 通过数据库事务将业务数据和 outbox 记录一起落盘。两者可以叠加:Outbox 保证不丢,Kafka Exactly-Once 保证 Kafka 内部不重复。它们解决的是不同层面的问题。
Q: 为什么推荐用 DB 唯一键做幂等而不是 Redis?Redis 不是更快吗?
在 100K QPS 以下,MySQL INSERT ON DUPLICATE KEY UPDATE 延迟 2-5ms,完全够用。引入 Redis 增加一个故障点和数据一致性风险 -- Redis 重启或主从切换时数据可能丢失。如果 Redis 丢了幂等 Key,重复消息穿透到业务层造成脏数据。
只有当单表幂等写入 P99 > 10ms 或 QPS > 100K 时,才考虑 Redis Cluster 前置快速去重 + DB 兜底。这是分层防御,不是替换。
Q: 消费者处理成功但在 commit offset 前 crash,重推后怎么返回上次的结果?
幂等表必须存储执行结果(result 列),不仅仅是幂等 Key。完整流程:1) INSERT INTO idempotency_keys (..., status='PROCESSING') ON DUPLICATE KEY UPDATE;2) affected_rows=1 -> 执行业务逻辑 -> UPDATE SET status='DONE', result=结果;3) affected_rows=0 AND status='DONE' -> 直接返回 result,跳过业务逻辑;4) affected_rows=0 AND status='PROCESSING' -> 上一次 crash 了,重新执行或等待。这保证了消费者无论 crash 多少次,最终返回一致的结果。
Q: Outbox + CDC 方案下,如果业务写入成功但还没来得及写 Outbox,是不是一样丢消息?
不会。order 和 outbox 的 INSERT 在同一 @Transactional 方法中执行,它们在同一个数据库事务边界内。要么一起提交成功,要么一起回滚。不存在"业务成功但 outbox 没写"的窗口。数据库的 ACID 保证了这个原子性 -- 这就是 Outbox 的核心。
说句实话,有人会追问:多数据中心 Outbox + CDC 怎么部署?我们的实践经验有限。理论上每个 DC 的 MySQL 各自跑 CDC 连接器,Kafka MirrorMaker 跨 DC 同步。但跨 DC 的 Outbox 顺序保证、CDC lag 的跨域监控、网络分区时的一致性冲突------我们还没有完美答案。如果你在多个 Region 部署,先在单 Region 跑通方案三,多 Region 是另一个需要专题讨论的深水区。
10. 总结:一张可以带走的思维模型
三句话带走
- 可靠投递的原子边界是数据库事务,不是消息队列。 把消息和业务数据写进同一个 DB 事务,MQ 就可替换、可挂掉、可恢复 -- 业务不受影响。
- 幂等 Key 必须是确定性的业务标识(aggregate_id + event_type),不能是随机值。 幂等表存结果不存状态,crash 后重推能返回上次的准确结果。
- 100K QPS 以下,DB 唯一键幂等就够了,不需要 Redis。 引入 Redis 是增加故障点,不是加速。
下次你遇到消息可靠性问题,先问自己
- QPS 是多少? < 100?停,方案一 + 定时对账可能够。> 1K?开始认真考虑 Outbox。
- 云数据库开 binlog 了吗? 没有?方案二(Outbox 轮询)是你的主方案,不是降级方案。
- MQ 挂了你的业务还能写吗? 如果能 → Outbox + CDC。如果不能 → 你在用事务消息,MQ 已经是你的 SPOF。
- 幂等 Key 是确定性的吗? 如果是 UUID 或 timestamp,现在就改。
什么时候停手,不要看这篇文章
如果你的系统每天只有几百个订单,上面说的所有方案都是过度设计。一个 @Transactional + 一个定时对账脚本就够了。等你真的遇到了消息丢失的生产事故 -- 用户付了钱但订单永远"待发货" -- 再回过头来看这篇文章。到那时,你才真正理解为什么需要 Outbox,为什么幂等 Key 不能用 UUID,为什么 snapshot.mode 必须显式配置。
记住一句话:先保证不丢,再处理不重。两个一起做,才能等于 Exactly-Once。