消息队列可靠投递与幂等消费 -- 从"消息丢了"到"消息别重复"的完整工程实践

可靠投递的责任不在消息队列本身,而在业务代码如何保证"写数据库"和"发消息"的原子性。但可靠投递必然引入重复消息,真正的工程难点是把"不丢"和"不重"这两件事放在一起解决。

阅读路径

⏱️ 只有 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 上不去 + 无多消费者独立订阅。
方案一引入消息队列来解耦生产者和消费者。
sequenceDiagram participant Client participant Producer participant DB as MySQL (业务库) participant MQ as Kafka / RocketMQ participant Consumer participant IDB as MySQL (幂等库) Client->>Producer: POST /order Producer->>DB: INSERT order DB-->>Producer: OK Producer->>MQ: send(message) MQ-->>Producer: ACK Producer-->>Client: 200 OK MQ->>Consumer: deliver message Consumer->>IDB: INSERT idempotency_key ON DUPLICATE KEY UPDATE alt affected_rows = 1 Consumer->>Consumer: 执行业务逻辑 Consumer->>IDB: UPDATE status = 'DONE' Consumer->>MQ: commit offset else affected_rows = 0 Consumer->>MQ: skip, commit offset end Note over Producer,DB: ⚠️ 致命窗口:DB 写入成功后、MQ send 前 crash → 消息丢失

优点:

  • 实现最简单,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,数据库不再承受轮询压力。
flowchart TB subgraph Producer[&#34;Producer Layer (Order Service)&#34;] A[Order API] -->|@Transactional| B[(MySQL)] B --> C[Outbox Table] end subgraph CDC[&#34;CDC Layer&#34;] D[Debezium / Canal] E[OutboxPoller<br/>降级兜底<br/>delivery.mode=polling] end subgraph Broker[&#34;Message Broker&#34;] F[Kafka<br/>replication-factor=3<br/>acks=all] end subgraph Consumer[&#34;Consumer Layer&#34;] G[Consumer Group] H[IdempotencyFilter<br/>INSERT ON DUPLICATE KEY<br/>aggregate_id + event_type] I[Business Logic<br/>Fulfillment / Notification] J[DLQ Topic<br/>max.retries=3 后转死信] end A -->|INSERT order + outbox| C C -->|binlog streaming| D D -->|publish| F E -->|CDC 故障时自动切换| F F -->|deliver| G G --> H H -->|affected_rows=1| I H -->|affected_rows=0| J I -->|commit offset| F H -->|status='DONE' 返回缓存结果| G

优点:

  • 零业务侵入:业务代码只写 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)实现幂等消费。

为什么选它:

  1. MQ 不可用不阻塞业务。 Outbox + CDC 是唯一在 MQ 不可用时仍不阻塞业务写入的方案。数据库事务即原子边界,不依赖 MQ 可用性。RocketMQ 事务消息做不到 -- Broker 挂了你就写不了业务数据。

  2. MQ 成为可替换的基础设施。 从 Kafka 切换到 Pulsar/SQS/RabbitMQ,只需改 CDC 连接器配置,业务代码零感知。事务消息方案做不到这一点。

  3. 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.ymldelivery.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_countconsumer_lag 10 分钟无异常后全量

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 同时抢到同一条记录 -- SELECTUPDATE 之间有竞态窗口。

永久修复: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. 总结:一张可以带走的思维模型

三句话带走

  1. 可靠投递的原子边界是数据库事务,不是消息队列。 把消息和业务数据写进同一个 DB 事务,MQ 就可替换、可挂掉、可恢复 -- 业务不受影响。
  2. 幂等 Key 必须是确定性的业务标识(aggregate_id + event_type),不能是随机值。 幂等表存结果不存状态,crash 后重推能返回上次的准确结果。
  3. 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。

相关推荐
雪隐1 小时前
个人电脑玩AI-10让5060 Ti给你打工——部署 Odysseus:终于有个能打的"AI管家"了
人工智能·后端
copyer_xyf2 小时前
FastAPI 如何连接 MySQL
后端·python
IT_陈寒2 小时前
Vite打包时踩的坑:静态资源为啥突然404了?
前端·人工智能·后端
葫芦和十三3 小时前
图解 MongoDB 25|分片架构三件套:mongos、config server 和 shard
后端·mongodb·agent
葫芦和十三9 小时前
图解 MongoDB 26|片键设计:决定集群命运的一个决定
后端·mongodb·agent
Avan_菜菜10 小时前
使用 Docker + rclone 自建 WebDAV
后端·agent·claude
阳光是sunny12 小时前
别再被 worktree 绕晕了!AI 编程时代你必须掌握的 Git 隔离神器
前端·人工智能·后端
万少13 小时前
万少的博客 - 技术分享与解决方案
前端·javascript·后端
咖啡八杯13 小时前
GoF设计模式——备忘录模式
java·后端·spring·设计模式