数据库写成功,MQ 消息却丢了?4 个故障窗口拆透 Transactional Outbox

40 阅读36分钟

数据库事务与 MQ 双写一致性:4 个故障复现拆透 Transactional Outbox

数据库已经写成功,消息却永远没发出去。

这种问题平时几乎看不出来,一旦发生进程崩溃、网络超时或 Broker 抖动,就可能变成一条永远补不回来的业务事件。

凌晨收到一个告警反馈:设备已经触发报警,数据库里也能查到记录,但短信通知没有发送。

排查后发现,问题不是 MQ 挂了,也不是数据库异常,而是一段看起来完全正确的双写代码:

@Transactional
public void createAlarm(DeviceAlarm alarm) {
    alarmMapper.insert(alarm);

    kafkaTemplate.send(
        "alarm-created",
        alarm.getId().toString(),
        alarm
    );
}

看起来很稳:

  • 数据库有事务;
  • Kafka 自己也有可靠性机制;
  • Producer 可以重试;
  • Consumer 还能重新消费。

但这段代码真正危险的地方,并不是 send() 会不会抛异常,而是:

MySQL COMMIT

和:

MQ SEND

属于两个独立资源。

只要没有额外的分布式事务或协调机制,它们就不存在天然的“同时成功、同时失败”。

更麻烦的是,这类问题不能只靠看正常日志发现。

真正应该问的是:

如果系统恰好死在两个动作之间,会留下什么状态?

这篇文章不把“理论上可能”写成“线上实测”。

下面所有实验都按照:

故障注入点
→ 复现步骤
→ 预期现象
→ 能证明什么

来拆。

如果没有真正执行过,就只写“预期结果”,不伪造成生产事故复盘。

场景统一使用 IoT 告警:

设备产生严重过温告警
        ↓
写入 alarm_record
        ↓
发布 ALARM_CREATED
        ↓
通知 / 规则引擎 / 实时大屏 / 自动控制

订单、支付、库存、审批、物流等系统面对的本质问题完全一样。


一、先纠正一个最容易误判的点:insert() 成功,不等于事务已经提交

先看:

@Transactional
public void createAlarm(DeviceAlarm alarm) {
    alarmMapper.insert(alarm);

    // 这里 insert 已经返回
}

很多故障实验会直接在 insert() 后:

System.exit(1);

然后声称:

“模拟数据库已经提交、MQ 还没发送时应用宕机。”

这个实验其实不成立。

在 Spring 常见的代理事务模型里,更接近下面这个顺序:

进入事务代理
    ↓
BEGIN
    ↓
调用 createAlarm()
    ↓
执行 INSERT
    ↓
方法正常返回
    ↓
事务拦截器执行 COMMIT
    ↓
调用方拿到返回值

也就是说:

alarmMapper.insert() 返回

只能说明 SQL 已经执行。

它不能证明:

COMMIT 已经成功

如果在事务方法真正返回之前把 JVM 杀掉,未提交事务通常会由数据库回滚。

所以后面复现“DB 已提交、MQ 未发送”时,故障注入点必须放在真正的事务提交之后。

这是整篇文章里第一个必须卡准的边界。


二、第二个边界:kafkaTemplate.send() 被调用,也不等于 Broker 已经确认

Spring Kafka 的 KafkaTemplate.send() 是异步发送接口。

因此:

kafkaTemplate.send(topic, key, value);

执行完成,只能说明发送动作已经被提交给 Producer。

它不等价于:

Broker 已经持久化并返回 ACK

为了做故障复现,需要明确区分:

A. send() 已调用
B. Future 成功完成
C. Broker 已按 Producer 的 acks 配置完成确认

下面实验为了让故障位置可观察,会显式等待发送结果,并明确实验前提:

acks=all

同时假设这里使用的是非事务型 Kafka Producer,没有启用 Spring Kafka 的事务同步。

发送代码:

kafkaTemplate.send(
        "alarm-created",
        alarm.getId().toString(),
        event
).get(5, TimeUnit.SECONDS);

这样我们才能把故障点放在:

Broker 按 acks=all 完成确认之后

而不是模糊地写成“调用过 send 之后”。

Kafka 官方文档明确指出:

  • acks=0 时 Producer 根本不会等待 Broker 确认,因此 Future 完成不能作为“Broker 已确认”的证据;
  • acks=1 只等待 Leader 本地写入,Leader 在副本复制前故障仍可能丢失;
  • acks=all 是 Kafka Producer 可配置的最强确认级别。

这里的 .get() 是为了让故障边界可观察,不代表所有生产代码都应该同步阻塞发送;acks=all 也不代表跨 MySQL + Kafka 获得了一个原子事务。

还要再补一个 Kafka 集群层面的前提。

acks=all 的可靠性效果,必须和:

replication.factor
min.insync.replicas
当前 ISR

一起看。

生产环境常见组合例如:

replication.factor = 3
min.insync.replicas = 2
acks = all

需要避免一个常见误解:

min.insync.replicas = 1

并不等价于:

acks=all 自动退化成 acks=1

更准确的说法是:

如果当前 ISR 实际只剩 1 个副本,而 min.insync.replicas=1 仍允许继续写入,那么 acks=all 仍可能成功返回;但此时真实的数据冗余保护已经只剩单副本。

所以做故障实验时,至少应该同时记录:

acks
replication.factor
min.insync.replicas
当前 ISR
enable.idempotence

否则单副本测试环境和多副本生产环境,很容易得到不同的可靠性结论。


三、失败窗口 1:MQ 已确认成功,但数据库最后回滚

这是最容易复现的一种。

3.1 前提

这里假设:

MySQL:普通 Spring 本地事务
Kafka:普通 Producer 发送

没有刻意配置:

XA / 2PC
Kafka Transaction + 特殊事务同步
其他跨资源协调机制

也就是说,Kafka 发送不属于当前 MySQL 本地事务。

3.2 故障复现代码

@Transactional
public void createAlarm(DeviceAlarm alarm) throws Exception {

    alarmMapper.insert(alarm);

    AlarmCreatedEvent event = buildEvent(alarm);

    // 等到 Kafka 发送结果明确成功
    kafkaTemplate.send(
            "alarm-created",
            alarm.getId().toString(),
            event
    ).get(5, TimeUnit.SECONDS);

    // 故障注入:模拟数据库事务在最终提交前失败
    throw new IllegalStateException(
            "FAULT: MQ_ACKED_BUT_DB_ROLLBACK"
    );
}

执行顺序:

BEGIN
  ↓
INSERT alarm_record
  ↓
SEND Kafka
  ↓
Broker ACK ✓
  ↓
抛异常
  ↓
ROLLBACK

3.3 预期观察

数据库:

SELECT *
FROM alarm_record
WHERE id = ?;

预期:

0 rows

Kafka:

alarm-created 中仍能看到对应 event_id

最终状态:

MySQL:✗
Kafka:✓

下游看到的是:

ALARM_CREATED

但数据库里根本没有这条告警。

这就是典型的:

幽灵消息

如果消费者执行的是:

发送短信
生成工单
执行设备联动
更新统计

业务副作用已经开始发生。


四、失败窗口 2:数据库已经提交,但 MQ 还没发,进程直接消失

这个窗口是很多文章最容易“复现错”的地方。

不能这样模拟:

@Transactional
public void createAlarm(DeviceAlarm alarm) {
    alarmMapper.insert(alarm);

    Runtime.getRuntime().halt(137);
}

因为 JVM 被杀时,事务还未必提交。

真正的复现应该把:

数据库事务

和:

MQ 发送

明确拆到提交边界两侧。

4.1 用两个 Bean 把事务边界显式化

@Service
@RequiredArgsConstructor
public class AlarmTxService {

    private final AlarmMapper alarmMapper;

    @Transactional
    public void insertAlarm(DeviceAlarm alarm) {
        alarmMapper.insert(alarm);
    }
}

调用方:

@Service
@RequiredArgsConstructor
public class AlarmApplicationService {

    private final AlarmTxService alarmTxService;
    private final KafkaTemplate<String, Object> kafkaTemplate;

    public void createAlarm(DeviceAlarm alarm) {

        // 该方法正常返回时,本地事务已经提交
        alarmTxService.insertAlarm(alarm);

        // 故障注入点:
        // DB COMMIT 已完成,MQ SEND 还没开始
        Runtime.getRuntime().halt(137);

        kafkaTemplate.send(
                "alarm-created",
                alarm.getId().toString(),
                alarm
        );
    }
}

这里故意使用独立 Bean,也是为了避免 Spring 自调用导致 @Transactional 代理失效,把实验边界搞乱。

执行顺序:

BEGIN
  ↓
INSERT alarm_record
  ↓
COMMIT ✓
  ↓
insertAlarm() 返回
  ↓
JVM HALT
  ↓
MQ SEND 没有机会执行

4.2 预期观察

应用重启后查询:

SELECT *
FROM alarm_record
WHERE id = ?;

预期:

1 row

Kafka 中:

没有对应 ALARM_CREATED

最终:

MySQL:✓
Kafka:✗

数据库已经认可这条告警。

但所有依赖事件的系统:

短信通知
规则引擎
实时大屏
工单系统
自动联动

都可能永远不知道它发生过。

这才是真正的:

事件永久丢失窗口


五、afterCommit() 能不能解决?

知道了上一个窗口以后,一个自然想法是:

那就数据库提交之后再发 MQ。

例如:

@Transactional
public void createAlarm(DeviceAlarm alarm) {

    alarmMapper.insert(alarm);

    TransactionSynchronizationManager
        .registerSynchronization(
            new TransactionSynchronization() {

                @Override
                public void afterCommit() {
                    publishAlarmCreated(alarm);
                }
            }
        );
}

这个方案确实比“事务提交前直接发”更好地避免了幽灵消息:

DB 没提交
    ↓
afterCommit 不会执行
    ↓
不会因为 DB rollback 先发出业务事件

Spring 官方对 afterCommit() 的定义也非常明确:

它在主事务已经成功提交以后执行。

但它仍然不是数据库和 MQ 之间的原子协议。

依然存在:

DB COMMIT ✓
    ↓
进程 Crash
    ↓
afterCommit 还没执行 / MQ 还没成功

最终仍然是:

DB ✓
MQ ✗

afterCommit() 解决的是:

什么时候执行

不是:

两个系统如何原子提交

还有一个容易忽略的细节:

Spring 文档特别提醒,afterCommit() 被调用时主事务已经提交,但相关事务资源可能仍然处于可访问状态。

如果在回调里还要做新的数据库事务操作,应该明确使用新的事务边界,而不能想当然地认为它会继续提交到原事务里。


六、失败窗口 3:Broker 实际写成功,但发送方不知道

这类故障不能简单写成:

ACK 丢了
→ Producer 重试
→ Kafka 一定出现重复

因为对 Kafka 来说,这个说法缺少一个重要前提:

Producer 是否启用了幂等发送

Kafka 的幂等 Producer 会利用 Producer ID 和序列号,对同一个 Producer 会话内的部分重试进行去重。

因此更准确的边界应该这样拆。

6.1 通用消息系统 / 非幂等 Producer

可能出现:

Producer
   ↓
send(E1001)
   ↓
Broker 已写入
   ↓
ACK 返回途中网络断开
   ×
Producer 看见超时
   ↓
再次 send(E1001)

如果消息系统或 Producer 没有去重机制:

E1001
E1001

重复是完全可能的。


6.2 Kafka 开启幂等 Producer 后

Kafka 可以处理一部分由 Producer 内部重试造成的重复写入。

但是:

Kafka Producer Idempotence

不等于:

业务事件在整个系统生命周期内只会出现一次

看一个 Outbox 场景:

Publisher
   ↓
发送 E1001
   ↓
Kafka ACK ✓
   ↓
Publisher 在更新 outbox=SENT 前 Crash
   ↓
应用重启
   ↓
Outbox 仍认为 E1001 未完成
   ↓
重新发送 E1001

这次重发已经是:

应用级重新发布

甚至可能来自新的 Producer 实例 / 新的 Producer 会话。

Kafka Producer 自己的幂等机制不能替你推断:

“这个 event_id 我昨天业务上已经发成功过一次,所以今天不能再发。”

因此:

Kafka Producer 幂等解决的是 Kafka Producer 协议范围内的重试重复,不是 Outbox 业务事件跨进程、跨会话的全局去重。

这也是为什么即使 Kafka Producer 已开启幂等,消费端的:

event_id 幂等

依然有价值。


七、失败窗口 4:消费者业务已经提交,但 Offset 还没提交

假设消费者逻辑是:

收到 E1001
    ↓
更新数据库
    ↓
提交业务事务
    ↓
提交 Kafka Offset

故障恰好发生在:

业务事务 COMMIT ✓
    ↓
JVM Crash
    ↓
Offset 未提交

应用重启以后,Kafka 仍可能再次投递 E1001。

7.1 可复现的边界

为了把实验做清楚,可以关闭自动提交,并把业务事务独立出来。

@Service
@RequiredArgsConstructor
public class AlarmConsumer {

    private final NotificationTxService txService;

    @KafkaListener(
        topics = "alarm-created",
        groupId = "alarm-notification"
    )
    public void consume(
            AlarmCreatedEvent event,
            Acknowledgment ack
    ) {

        // 内部事务正常返回时,业务 DB 已经提交
        txService.createNotification(event);

        // 故障注入:
        // DB COMMIT 已成功,但 offset/ack 还没提交
        Runtime.getRuntime().halt(137);

        ack.acknowledge();
    }
}

业务事务:

@Transactional
public void createNotification(AlarmCreatedEvent event) {
    notificationMapper.insert(...);
}

7.2 预期结果

第一次消费:

notification 表:插入成功
offset:未提交

消费者重启:

同一个 Kafka record 再次到达

如果没有幂等:

notification 再插一次

如果业务是:

扣余额
扣库存
发短信
下发控制命令

后果会更明显。

所以可靠消息链路从来不只是 Producer 的问题。


八、4 个失败窗口放在一起

故障窗口DBMQ / Offset主要风险
MQ 已确认,DB 最终回滚失败消息已存在幽灵消息
DB 已提交,MQ 尚未发送时宕机成功消息不存在事件永久丢失
Broker 已接收,发送方没有获得确定结果成功可能重试取决于中间件与 Producer 幂等边界
Consumer 业务已提交,Offset/ACK 前宕机已执行消息会再次投递重复消费

到这里真正应该建立的不是“MQ 很容易丢”的印象。

而是:

可靠系统最重要的问题不是每一步能不能永远成功,而是任意一步失败以后,系统是否还留下足够的信息恢复。

image.png

九、为什么几个常见方案仍然不够

9.1 “都写在一个 @Transactional 方法里”

@Transactional
public void createAlarm(DeviceAlarm alarm) {
    alarmMapper.insert(alarm);
    kafkaTemplate.send(...);
}

在本文讨论的默认前提下:

DataSourceTransactionManager
+
非事务型 Kafka Producer

数据库事务管理器控制的是数据库资源。

代码在同一个 Java 方法里,不代表:

MySQL
+
Kafka

自动变成一个原子事务。

这里必须补一个 Spring Kafka 的技术边界。

如果 ProducerFactory 已经具备事务能力,并正确配置 Kafka Transaction / Transaction Synchronization,Spring Kafka 可以让 KafkaTemplate 的发送参与同步事务流程。

但要把几种情况分开。

当前更推荐的事务同步方式

Spring Kafka 官方 DB-first 示例中,数据库事务作为主事务,Kafka 事务作为同步事务。典型提交顺序是:

Database Transaction Commit
        ↓
Kafka Transaction Commit

如果数据库事务先提交成功,而随后 Kafka 事务提交失败,两个系统仍可能不一致,应用需要补偿。

如果业务明确需要 Kafka-first,则应该通过嵌套事务等方式显式控制顺序,而不能默认假设 Spring 会自动选择。

旧的 ChainedKafkaTransactionManager

历史项目里还可能看到:

ChainedKafkaTransactionManager

它不是“固定 Kafka 先提交”,也不是“固定 DB 先提交”。

其链式事务语义是:

按配置顺序启动事务
按相反顺序提交事务

因此最终提交顺序取决于各 TransactionManager 的排列方式。

而且 ChainedKafkaTransactionManager 已经被 Spring Kafka 官方标记为 deprecated,不应该再作为新系统的首选设计。

无论使用哪种方式,都必须明确:

Kafka Transaction 不是 MySQL XA 的一部分。Spring 的事务同步 / 链式事务,本质上仍然是在应用层协调两个独立事务,不等于 MySQL + Kafka 获得真正的分布式原子提交。

所以本文真正要否定的是:

只因为写了 @Transactional
+
调用了 kafkaTemplate.send()

就自动获得跨 MySQL / Kafka 原子性。

而不是说 Spring Kafka 完全没有事务协调能力。


9.2 “数据库提交后再发”

能避免:

DB rollback
但 MQ 已经先发布

但避免不了:

DB commit
→ JVM crash
→ MQ 还没发

9.3 “发送失败就无限重试”

首先:

发送方看到失败

并不总能推出:

Broker 一定没成功

其次,即使 Producer 层已经处理了自己的重试去重,Outbox、应用重启、人工重放等更高层次仍然可能重新发布同一个业务事件。

所以系统最终还是必须有:

event_id

以及明确的幂等边界。


9.4 “直接上 Exactly Once”

Exactly Once 必须先问:

Exactly Once 在什么边界内?

Kafka 对:

Kafka Topic
→ 处理
→ Kafka Topic

可以通过事务 Producer、Consumer Offset 与 Kafka 事务配合提供非常强的语义。

但链路一旦变成:

Kafka
→ MySQL
→ HTTP
→ 短信供应商
→ 第三方设备平台

Kafka 无法替外部系统回滚已经发生的副作用。

例如:

短信平台发送成功
    ↓
Consumer Crash
    ↓
Offset 未提交
    ↓
消息重新投递

Kafka 没有办法把上一条短信“事务回滚”。

因此大多数业务系统更现实的目标是:

不静默丢关键事件
+
允许重复
+
重复可安全处理
+
最终达到业务一致

也就是:

At-Least-Once + Idempotency + Eventual Consistency


十、Transactional Outbox:不要同时赌两个系统

问题原来是:

写业务数据库
+
发 MQ

两个资源。

Transactional Outbox 的思路不是强行让两个资源同时提交。

而是先把问题改写成:

写业务数据
+
写“必须发布的事件事实”

而这两份数据都放进同一个数据库事务。

从:

MySQL
+
Kafka

变成:

MySQL Business Table
+
MySQL Outbox Table

只要两张表属于同一个本地事务,就可以做到:

一起 COMMIT

或者:

一起 ROLLBACK

十一、Polling Outbox 的表怎么设计

如果采用数据库轮询 Publisher,可以设计:

CREATE TABLE outbox_event (
    id              BIGINT          NOT NULL AUTO_INCREMENT,
    event_id        VARCHAR(64)     NOT NULL,
    aggregate_type  VARCHAR(64)     NOT NULL,
    aggregate_id    VARCHAR(64)     NOT NULL,
    event_type      VARCHAR(64)     NOT NULL,
    payload         JSON            NOT NULL,

    status          VARCHAR(16)     NOT NULL,
    retry_count     INT             NOT NULL DEFAULT 0,
    next_retry_at   DATETIME(3)     NOT NULL,

    locked_by       VARCHAR(64)     NULL,
    locked_until    DATETIME(3)     NULL,

    created_at      DATETIME(3)     NOT NULL,
    sent_at         DATETIME(3)     NULL,
    last_error      VARCHAR(1000)   NULL,

    PRIMARY KEY (id),
    UNIQUE KEY uk_event_id (event_id),

    INDEX idx_publish_scan (
        status,
        next_retry_at,
        id
    ),

    INDEX idx_lease_recovery (
        status,
        locked_until,
        id
    )
);

字段不是为了“看起来完整”。

每一个都对应一个故障恢复能力。

event_id

全局唯一业务事件 ID。

用于:

链路追踪
消费者幂等
故障重放
人工排查

status

例如:

PENDING
PROCESSING
RETRY
SENT
DEAD

retry_count / next_retry_at

避免失败以后无脑高频重试。

locked_by / locked_until

支持多实例 Worker 抢占和崩溃恢复。

last_error

生产环境非常有必要。

否则 DEAD 只告诉你:

失败了

却不告诉你为什么失败。


十二、业务数据和 Outbox 必须在同一个本地事务里

@Service
@RequiredArgsConstructor
public class AlarmService {

    private final AlarmMapper alarmMapper;
    private final OutboxEventMapper outboxEventMapper;

    @Transactional
    public void createAlarm(DeviceAlarm alarm) {

        alarmMapper.insert(alarm);

        String eventId = UUID.randomUUID().toString();

        OutboxEvent event = new OutboxEvent();
        event.setEventId(eventId);
        event.setAggregateType("DEVICE_ALARM");
        event.setAggregateId(
                String.valueOf(alarm.getId())
        );
        event.setEventType("ALARM_CREATED");
        event.setPayload(toJson(buildSnapshot(alarm)));
        event.setStatus("PENDING");
        event.setRetryCount(0);
        event.setNextRetryAt(LocalDateTime.now());
        event.setCreatedAt(LocalDateTime.now());

        outboxEventMapper.insert(event);
    }
}

事务里真正发生的是:

BEGIN

INSERT alarm_record
INSERT outbox_event

COMMIT

因此业务事务成功时:

alarm_record ✓
outbox_event ✓

业务事务失败时:

alarm_record ✗
outbox_event ✗

不会再出现:

业务已经被数据库认可
但系统连“还有一条消息必须发”这件事都忘了

这才是 Outbox 最重要的能力。

Outbox 不是保证 MQ 这一刻一定成功,而是保证只要业务事务成功,就一定留下一个可以恢复、可以重试的事件事实。

12.1 Outbox 依赖本地事务,所以业务事务本身不能失控

Transactional Outbox 的前提是:

Business
+
Outbox

共享同一个本地数据库事务。

这意味着,如果业务本身是一个超大事务,例如:

批量更新几千 / 几万行
+
写多个业务表
+
再写 Outbox

Outbox 会一起承担:

锁持有时间
undo / redo 压力
binlog 体积
回滚成本
连接占用
复制延迟

所以 Outbox 不是鼓励把更多事情塞进一个事务。

正确方向仍然是:

事务边界尽量小,只包含必须原子提交的业务状态和事件事实。

12.2 不要在 Business + Outbox 事务里混入 DDL

MySQL 中很多 DDL 会触发:

implicit commit

例如:

BEGIN;

INSERT INTO business_table (...);

ALTER TABLE business_table ADD COLUMN ...;

INSERT INTO outbox_event (...);

ALTER TABLE 可能直接破坏原本期待的事务边界。

即使 MySQL 8 支持 Atomic DDL,也不能把它理解成:

DDL 可以像普通 DML 一样自由加入业务事务

Atomic DDL 解决的是 DDL 自身的原子执行和崩溃恢复问题,并不等价于传统意义上的 Transactional DDL。

因此:

Outbox 业务事务里不要混入 DDL;数据库结构变更应由独立的发布 / Migration 流程完成。

image.png

十三、多实例 Publisher:不要让两个实例扫到同一批消息

最简单的代码:

SELECT *
FROM outbox_event
WHERE status = 'PENDING'
ORDER BY id
LIMIT 100;

然后:

for (OutboxEvent event : events) {
    send(event);
}

单实例低并发时能工作。

多个实例时:

Publisher-A
Publisher-B

完全可能同时读到:

E10001
E10002
E10003

然后同时发送。


十四、MySQL 8 的 SKIP LOCKED 很适合这种 queue-like 表,但不要把锁带到网络 IO

MySQL 官方文档明确说明:

SKIP LOCKED

会跳过其他事务已经锁住的行。

官方也特别指出,它并不适合一般一致性查询,但可以用于多个 Session 竞争访问 queue-like table、避免锁竞争的场景。

Outbox 正是典型的 queue-like table。

这里必须明确版本边界:

MySQL 8.0+

才支持:

FOR UPDATE SKIP LOCKED

MySQL 5.7 没有这个语法,直接执行会报语法错误。

如果系统仍运行在 MySQL 5.7,通常需要使用:

条件 UPDATE / CAS
+
locked_by
+
locked_until
+
分片或批次领取

自己实现 claim / lease,而不能直接照搬 MySQL 8.0 的 SQL。

这里也不应该写成“5.7 只能有唯一一种实现”,因为任务抢占还可以根据业务量、分区方式和表结构采用其他原子 claim 方案。

14.1 错误方式

BEGIN
    ↓
SELECT ... FOR UPDATE SKIP LOCKED
    ↓
拿着行锁调用 Kafka
    ↓
Broker 卡 5 秒
    ↓
UPDATE SENT
    ↓
COMMIT

如果 MQ 出现抖动,数据库事务也被一起拖长。

这会带来:

长事务
锁持有时间增加
连接占用
回滚成本增加
数据库吞吐下降

14.2 正确方向:短事务抢占,事务外发送

第一阶段:

BEGIN
    ↓
锁一小批待处理记录
    ↓
标记 PROCESSING + lease
    ↓
COMMIT

第二阶段:

事务外发送 MQ

第三阶段:

成功 → SENT
失败 → RETRY

14.3 抢占 SQL

SELECT id
FROM outbox_event
WHERE status IN ('PENDING', 'RETRY')
  AND next_retry_at <= NOW(3)
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED;

拿到 ID 以后,在同一个短事务中:

UPDATE outbox_event
SET status       = 'PROCESSING',
    locked_by    = #{workerId},
    locked_until = #{lockedUntil}
WHERE id IN (...);

然后立即:

COMMIT

这时数据库行锁已经释放。

Worker 再去做真正的网络发送。


十五、为什么必须有 locked_until

假设:

Worker-A 抢到 E10001
    ↓
status = PROCESSING
    ↓
Worker-A JVM Crash

如果只有:

PROCESSING

系统就无法知道:

这个事件是真的还在处理
还是处理它的 Worker 已经死了

所以需要租约:

locked_until = 2026-10-10 10:30:00.000

超过时间以后仍未完成,就可以认为:

该 Worker 的处理权已经失效

然后由恢复任务把事件重新置为:

RETRY

或允许其他 Worker 重新领取。

这里还有一个生产实现里非常重要的细节:

Lease 过期以后,旧 Worker 可能“复活”

例如:

Worker-A 领取 E10001
    ↓
A 长时间 STW / 网络卡死
    ↓
Lease 过期
    ↓
Worker-B 重新领取 E10001
    ↓
A 又恢复运行

这时 A 已经不再拥有这条任务。

因此发送成功或失败后的状态更新,不能只写:

UPDATE outbox_event
SET status = 'SENT'
WHERE id = #{id};

而应该至少带上当前租约所有者:

UPDATE outbox_event
SET status       = 'SENT',
    sent_at      = NOW(3),
    locked_by    = NULL,
    locked_until = NULL
WHERE id = #{id}
  AND status = 'PROCESSING'
  AND locked_by = #{workerId};

失败转 RETRY 时也应使用同样的 ownership 条件。

更严格的系统还可以增加:

lease_version / fencing_token

每次重新领取时递增版本,并要求状态更新携带相同版本。

这样做不能消除:

A、B 都已经把同一个 event_id 发到 MQ

这种 At-Least-Once 重复,但可以避免失去租约的旧 Worker 把新 Worker 的状态覆盖掉。

这和永久锁完全不同。

它是:

Lease + Ownership / Fencing

image.png

15.1 Publisher 放在业务应用里,还是独立部署?

前面的讨论只解决了:

Publisher 怎么正确抢占和发送

生产环境还需要回答:

Publisher 跑在哪里?

常见有两种模式。

模式 A:嵌入业务应用
Business Application
├─ HTTP / RPC
├─ Database
└─ Outbox Publisher

优点是:

部署简单
组件少
中小系统容易落地

但 Publisher 会和在线业务共享:

CPU
Heap
GC
数据库连接池
网络连接
应用生命周期

如果再错误复用业务 Executor,大量 Outbox 发送甚至可能挤占在线请求线程。

因此即使 Publisher 不独立部署,也至少应该做到:

独立线程池
有界队列
明确并发上限
独立监控
容量评估
模式 B:独立 Outbox Publisher
Business Application
        ↓
      MySQL

Outbox Publisher
        ↓
      Kafka

优点:

业务流量与消息投递资源隔离
Publisher 可以独立扩缩容
故障域更清晰

代价:

增加一个部署单元
增加监控和运维成本
仍然要处理多实例抢占

所以不是“独立服务永远更高级”。

当出现:

Outbox 堆积明显
业务高峰与发送高峰重叠
Publisher 需要单独扩容
消息投递 SLA 独立于接口 SLA

时,再认真考虑拆分更合理。


十六、Publisher 真正无法消灭的窗口:Kafka 已成功,SENT 还没写

Publisher 的正常路径:

发送 E10001
    ↓
Kafka ACK ✓
    ↓
UPDATE outbox_event
SET status = 'SENT'

故障如果正好发生在:

Kafka ACK ✓
    ↓
JVM Crash
    ↓
SENT 未更新

数据库可能仍然是:

PROCESSING

租约到期以后:

E10001 再次被领取

然后:

E10001 再次发送

这就是 Transactional Outbox 必须接受的现实:

发送侧更容易做到“至少一次”,而不是业务意义上的“绝不重复”。

如果一定要让:

Kafka 写入
+
MySQL SENT 更新

严格原子,就又回到了跨资源原子事务的问题。

所以多数系统选择:

宁可少量重复
也不要关键事件静默永久消失

下一步自然就是:

Consumer Idempotency


十七、消费者幂等:真正的底线不是 SELECT exists()

错误示例:

if (consumedEventMapper.exists(
        consumerName,
        eventId
)) {
    return;
}

doBusiness();

consumedEventMapper.insert(
        consumerName,
        eventId
);

两个线程并发时:

Thread-A:exists = false
Thread-B:exists = false

Thread-A:执行
Thread-B:执行

先查再判断不是原子操作。

最终兜底必须依靠:

数据库唯一约束

十八、消费记录表

CREATE TABLE consumed_event (
    consumer_name  VARCHAR(64) NOT NULL,
    event_id       VARCHAR(64) NOT NULL,
    consumed_at    DATETIME(3) NOT NULL,

    PRIMARY KEY (
        consumer_name,
        event_id
    )
);

为什么不是:

PRIMARY KEY(event_id)

因为同一个事件本来就可能需要被多个合法消费者各处理一次:

alarm-notification
alarm-statistics
alarm-rule-engine

真正要限制的是:

同一个 consumer
+
同一个 event_id

只能处理一次。


十九、MyBatis / JDBC 下的一种消费方式

@Transactional
public void consume(AlarmCreatedEvent event) {

    try {
        consumedEventMapper.insert(
                "alarm-notification",
                event.getEventId(),
                LocalDateTime.now()
        );
    } catch (DuplicateKeyException duplicate) {

        // 同一个 consumer 已经处理过该 event_id
        return;
    }

    notificationMapper.insert(
            event.getEventId(),
            event.getDeviceId(),
            event.getAlarmType()
    );
}

核心不是:

Java catch

而是:

PRIMARY KEY (consumer_name, event_id)

并且:

插入 consumed_event
+
真正的业务数据库变更

必须在同一个本地事务。

这样:

第一次处理成功

consumed_event ✓
business data ✓
COMMIT

业务执行失败

ROLLBACK

消费标记也跟着回滚。

下次重新投递仍然可以重新执行。

同一个事件再次到达

唯一约束直接阻止第二次进入业务逻辑。

上面的异常处理方式更适合 MyBatis / JDBC 的普通 SIMPLE 执行模式。使用 JPA/Hibernate 时要注意 SQL 可能延迟到 flush 才真正执行;MyBatis 如果使用 ExecutorType.BATCH,SQL 也可能累积到 flushStatements() / commit 才真正发送,因此唯一键异常不一定在 insert() 那一行立即抛出。

这里还要明确两个边界。

MyBatis 的:

一级缓存
二级缓存

主要影响查询结果缓存,并不是普通 INSERT 延迟执行的根本原因。

真正需要额外关注的是:

ExecutorType.BATCH

以及 JDBC Driver 的批处理行为。

另外,只能把明确的唯一键冲突解释成:

这个 consumer 已经处理过该 event_id

不能这样写:

catch (SQLException e) {
    return;
}

更不能把:

死锁
连接中断
磁盘异常
SQL 语法错误
连接池耗尽

全部吞掉并当成“已经消费”。

如果 consumed_event 上存在多个唯一约束,还要保证发生冲突的确实是:

PRIMARY KEY (consumer_name, event_id)

而不是其他唯一键。

数据库原生 UPSERT 也可以实现幂等,但必须明确验证 JDBC Driver / ORM 的 affected rows 语义,不能仅凭“返回 0 / 1 / 2”想当然判断是否首次插入。


二十、Offset 仍然可能提交失败,但重复已经变安全

即使消费者这样做:

业务 DB 本地事务提交
    ↓
提交 Kafka Offset

两个动作仍然不是 MySQL 本地原子事务。

依然可能:

业务 COMMIT ✓
    ↓
Offset Commit ✗

但现在同一条消息重放时:

consumer_name + event_id

已经存在。

第二次消费直接安全返回。

因此这里的设计思想不是:

强行让 DB 和 Offset 永远同时成功

而是:

允许消息重放
+
让重放不产生第二次业务副作用

这才是幂等真正解决的问题。


20.1 业务处理过程中抛 RuntimeException,为什么还能重试

还要看一个常见路径:

INSERT consumed_event
    ↓
执行本地业务
    ↓
RuntimeException
    ↓
ROLLBACK

只要:

consumed_event
+
本地业务数据库变更

处于同一个事务里,那么:

消费标记
+
业务数据

会一起回滚。

消息重新投递时:

consumed_event 中仍然没有该 event_id

因此可以重新处理。

这也是为什么“先写消费标记”并不意味着业务失败以后消息就被永久吃掉。

但如果真正的副作用发生在数据库之外,情况就不同了。


二十一、幂等表也不是万能的:真正的副作用可能在数据库外

假设消费者不是:

INSERT notification

而是:

调用短信平台
调用支付网关
调用设备控制接口
调用第三方 HTTP API

这时又出现同样的问题:

MySQL consumed_event
+
第三方 HTTP

还是两个资源。

例如:

短信发送成功
    ↓
Consumer Crash
    ↓
本地事务没有提交
    ↓
消息重投
    ↓
短信再次发送

所以:

消费者幂等必须一直做到真正发生业务副作用的边界。

常见方案:

1. 第三方支持 Idempotency-Key
2. 使用业务唯一号
3. 把外部调用再转换成本地 Outbox
4. 对无法幂等的动作设计补偿 / 人工恢复

例如:

Idempotency-Key: 01K7ABCDEF...

如果第三方会对同一个幂等键只执行一次,那么消息重放才不会重复产生真实副作用。


二十二、重试不能变成“失败风暴”

假设 MQ 故障:

10000 条 Outbox
×
每秒固定重试

系统会不断制造:

数据库扫描
线程占用
网络请求
日志
Broker 压力

应该使用:

指数退避
+
最大重试次数
+
随机抖动

例如基础退避:

1s
2s
4s
8s
16s
30s
60s

Java 示例:

public Duration nextRetryDelay(int retryCount) {

    long seconds = Math.min(
            60,
            1L << Math.min(retryCount, 6)
    );

    return Duration.ofSeconds(seconds);
}

实际生产可以再叠加少量 jitter,避免大量失败事件在同一秒重新苏醒。

状态:

PENDING
   ↓
PROCESSING
   ↓
发送失败
   ↓
RETRY
   ↓
next_retry_at

超过最大次数:

DEAD

但:

DEAD

绝不能只是数据库里一个没人看的字符串。

至少需要:

失败原因
最后失败时间
event_id
重试次数
监控指标
告警
人工重放入口

否则只是把:

“MQ 丢消息”

换成了:

“Outbox 里躺着一批没人发现的死消息”

22.1 DEAD 人工重放:event_id 不能换

如果人工决定重放一条 DEAD 事件,本质上是:

同一个业务事件重新投递

所以应该继续使用原来的:

event_id

不能因为“又发送一次”就创建新的事件 ID。

否则消费端会把它当成全新的业务事件,原本的幂等保护就失效了。

一个更完整的重放动作通常包括:

status         → PENDING / RETRY
next_retry_at  → NOW()
locked_by      → NULL
locked_until   → NULL

retry_count 是否清零取决于运维语义。

更推荐保留原始失败历史,并额外记录:

replay_count
replayed_by
replayed_at
replay_reason

还有一个边界:

DEAD

不代表 Kafka 一定从未收到过这条消息。

有些失败可能发生在:

Broker 已成功
但 Publisher 没拿到确定结果

所以人工重放仍然必须接受:

可能重复发送

最终继续依赖消费端幂等。

22.2 Outbox 不能无限长:SENT / DEAD 都要有生命周期

长期运行以后,最容易膨胀的往往不是 PENDING,而是:

SENT
SENT
SENT
...

如果不清理,主表和索引会持续变大,最终影响:

buffer pool 命中率
索引体积
备份恢复
维护窗口
扫描活跃任务的成本

因此至少要区分:

PENDING / PROCESSING / RETRY
→ 活跃数据,不能随意清理

SENT
→ 保留一段时间后分批删除或归档

DEAD
→ 通常保留更久,用于排障和人工重放

例如 SENT 可以采用:

DELETE FROM outbox_event
WHERE status = 'SENT'
  AND sent_at < NOW() - INTERVAL 30 DAY
ORDER BY id
LIMIT 1000;

关键是:

小批量
重复执行
限制删除速率

而不是一次删除几百万行制造新的大事务。

是否需要归档表取决于业务:

有审计 / 合规 / 长期追溯需求
→ 先归档再删除

没有长期保留要求
→ TTL 后直接分批删除

Outbox 的职责是可靠发布,不应该无限承担历史事件仓库。


二十三、事件结构也要设计,不要只发数据库 ID

一条长期可维护的事件至少建议包含:

{
  "eventId": "01K7ABCDEF...",
  "eventType": "ALARM_CREATED",
  "aggregateType": "DEVICE_ALARM",
  "aggregateId": "ALARM_100086",
  "occurredAt": "2026-10-10T10:30:15.218+08:00",
  "schemaVersion": 1,
  "payload": {
    "deviceId": "DEVICE_001",
    "alarmType": "OVER_TEMPERATURE",
    "temperature": 82.7
  }
}

eventId

用于:

去重
追踪
重放
排障

aggregateId

业务聚合 ID,例如:

alarmId
orderId
deviceId
accountId

如果 Kafka 中同一个聚合要求有序,可以考虑将其作为 Message Key,使同一 Key 稳定落到同一分区。

这里仍要注意:

同一分区内有序

并不等于:

整个系统所有消息全局有序

eventType

不要让消费者通过 payload 猜语义:

ALARM_CREATED
ALARM_RECOVERED
DEVICE_ONLINE
DEVICE_OFFLINE

schemaVersion

事件结构一定会演进。

没有版本治理,几年以后消费者兼容会越来越痛苦。


二十四、Polling Outbox 和 CDC Outbox 不应该混成一张“万能表”

很多文章会直接写:

Outbox = 一张 status 表 + 定时任务

其实不准确。

Outbox 真正的核心只有一个:

业务状态
+
必须发布的事件

在同一个数据库事务中原子落地。

后面的 Relay 可以有不同实现。


24.1 Polling Publisher

Application
   ↓
MySQL Transaction
   ↓
Business + Outbox
   ↓
Polling Publisher
   ↓
Kafka

这种模式适合使用:

status
retry_count
next_retry_at
locked_until

因为发送状态由应用自己维护。

优点:

实现直观
可控
不需要额外 CDC 基础设施

代价:

轮询延迟
数据库扫描压力
Outbox 清理
Publisher 运维

24.2 CDC / Debezium

另一条路线:

Application
   ↓
MySQL Transaction
   ↓
Business + Outbox INSERT
   ↓
Binlog
   ↓
Debezium
   ↓
Outbox Event Router
   ↓
Kafka

Debezium 官方提供专门的 Outbox Event Router。

这里有几个非常重要的实现边界。

CDC Outbox 更接近 append-only

Debezium Outbox Event Router 期望 Outbox 事件主要通过:

INSERT

产生。

因此不要把 Polling 模式里的:

PENDING
→ PROCESSING
→ SENT
→ RETRY

整套 UPDATE 状态机原样搬到 CDC Outbox。

CDC 模式里,业务应用更适合:

INSERT 一条不可变事件

然后由:

Binlog
→ Debezium
→ Kafka Connect
→ Kafka

负责传播。

也就是说:

CDC 模式不再由业务应用维护 SENT / RETRY 状态;CDC → Kafka 这一段的恢复语义由 Debezium / Kafka Connect 体系负责,而 Kafka 下游失败仍由消费者自己的 offset、retry、DLT 和幂等策略处理。

Payload 不一定必须 JSON,但不要无限做大

JSON 是非常常见的选择,但 Debezium Outbox 并不只支持 JSON。

真正应该强调的是:

payload 要有明确边界

超大 payload 会放大:

MySQL 行大小
binlog 流量
Debezium / Connect 内存与网络压力
Kafka record 大小
序列化成本

所以事件应该携带:

消费者真正需要的稳定业务快照

而不是为了“少查一次数据库”就把巨大对象、附件或完整历史全部塞进 Outbox。

更合理的理解是:

Polling Outbox

和:

CDC Outbox

共享的是:

“业务 + 事件”同事务落地

但 Relay 的表结构、状态管理和运行方式可以完全不同。

这比把所有 Outbox 都描述成“状态机表”更准确。


二十五、放回 IoT:一条完整可靠的告警链路

严重过温告警:

Device
   ↓
MQTT / Gateway
   ↓
Alarm Service

Alarm Service 内:

┌──────────── MySQL Transaction ────────────┐
│                                           │
│  INSERT alarm_record                      │
│  INSERT outbox_event                      │
│                                           │
└───────────────────────────────────────────┘
                    ↓
                 COMMIT

之后:

Outbox Publisher / CDC
          ↓
        Kafka
          ↓
┌─────────┬─────────────┬────────────┐
│ Notify  │ Rule Engine │ Statistics │
└─────────┴─────────────┴────────────┘
          ↓
每个消费者独立做幂等

这条链路允许:

Java Crash
网络超时
Kafka 短暂不可用
消费者重启
消息重放

但要求:

系统最终能恢复

这比幻想:

所有组件永远不失败

更接近真实生产系统。

image.png


二十六、IoT 还有一个非常重要的边界:不是所有数据都应该进 Outbox

假设:

5000 台设备
每台 1 条 / 秒

就是:

5000 events/s

如果每条:

电压
电流
功率
温度
SOC

都执行:

INSERT telemetry
+
INSERT outbox_event

很容易把 Outbox 自己做成新的写入热点。

因此 IoT 应该区分两类数据。


26.1 高频遥测

例如:

电压
电流
功率
温湿度
SOC

更适合:

MQTT
   ↓
Kafka / 流处理
   ↓
批量聚合
   ↓
时序库 / OLAP / MySQL

它们的可靠性、吞吐和存储设计,应按时序数据链路来做。


26.2 关键业务事件

例如:

严重告警
设备上线 / 离线
控制命令状态
计费结果
结算结果
订单状态
规则触发
审批状态

这种事件的共同点是:

不能静默消失
+
会驱动后续业务动作

它们才更适合用 Transactional Outbox 保护:

业务状态
↔
必须发生的业务事件

之间的一致性。

Outbox 不是“所有消息统一可靠化”的银弹,而是关键业务状态和事件之间的一致性工具。


二十七、最终到底保证了什么?

整条链路:

Business Transaction
        ↓
Business + Outbox 原子提交
        ↓
Publisher 可恢复发送
        ↓
消息可能重复发布 / 重投
        ↓
Consumer Idempotency
        ↓
Eventual Consistency

这里最好不要只写一个模糊的:

MQ At-Least-Once

因为 At-Least-Once 必须先说明范围。

在本文语境里,更准确的拆法是:

Publisher:
同一个 event_id 可能被发布一次或多次

Broker / Consumer:
同一个 record / event 可能被再次投递

Consumer:
重复执行必须安全

端到端目标:
在可恢复故障假设下,关键业务事件不能永久静默消失

所以它不是承诺:

任何消息永远只出现一次

而是在接受:

消息可能重复

的前提下,通过:

event_id
+
唯一约束
+
本地事务
+
业务幂等
+
外部 Idempotency-Key

把重复变成可以安全承受的正常现象。

可以把整套设计压缩成一句话:

发送侧首先解决“不永久丢”,消费侧解决“重复也安全”,最终一致性由两边共同完成。


二十八、生产上线前,我至少会检查这 22 项

  1. 业务数据和 Outbox 是否真的使用同一个数据库、本地事务和事务管理器。
  2. 业务事务是否足够小,是否避免把大量 DML 塞进一个超大事务。
  3. Outbox 业务事务中是否避免 DDL / implicit commit。
  4. 故障测试是否放在真实 COMMIT 边界,而不是误把 Mapper insert() 返回当成提交成功。
  5. 是否明确区分 send() 被调用和 Broker ACK 成功。
  6. Kafka 故障实验是否同时记录 acks、replication factor、min.insync.replicas 和当前 ISR。
  7. 每个事件是否都有稳定、全局唯一的 event_id。
  8. Outbox 扫描条件是否有匹配的联合索引。
  9. 当前 MySQL 版本是否支持 SKIP LOCKED;MySQL 5.7 是否使用了替代 claim 方案。
  10. 多实例 Publisher 是否会抢到同一条任务。
  11. 抢占是否使用短事务,是否避免拿数据库行锁做网络 IO。
  12. Worker Crash 后 PROCESSING 任务是否可以通过 lease 自动恢复。
  13. Lease 过期重新领取后,状态更新是否校验 locked_by 或 fencing token,防止旧 Worker 覆盖新状态。
  14. Publisher 是否与业务线程池、CPU、Heap、连接池做好资源隔离;是否需要独立部署。
  15. Kafka ACK 成功但 SENT 未更新时,是否允许安全重发。
  16. 失败重试是否有退避、jitter 和最大次数。
  17. SENT / DEAD 是否有明确的 TTL、归档或分批清理策略。
  18. DEAD 是否有监控、告警、失败原因和人工重放能力;重放是否保持原 event_id。
  19. 消费者是否依靠原子唯一约束,而不是 SELECT exists() 做最终幂等。
  20. MyBatis Batch / JPA Flush 等延迟执行模式是否被正确处理,是否只捕获真正的唯一键冲突。
  21. 消费标记和本地业务变更是否放在同一个数据库事务中。
  22. 真正的外部副作用是否支持 Idempotency-Key、业务唯一号或补偿机制。

小结

数据库事务和 MQ 的一致性问题,本质上不是:

send() 要不要 try-catch

也不是:

Producer retries 开几次

真正的问题是:

两个独立资源之间没有天然的本地原子性

所以设计重点应该从:

“怎样保证每一步永远不失败”

转成:

“任意一步失败以后,系统还能不能恢复”

最终可以归纳成六层:

1. Transactional Outbox

业务数据
+
必须发布的事件

先在一个数据库事务里原子落地。

2. Recoverable Publisher

多实例抢占
租约
失败退避
死信
人工重放

让事件即使遇到进程 Crash 和 Broker 故障,也不会永久失去恢复机会。

3. At-Least-Once

接受:

偶尔重复

而不是为了表面“不重复”牺牲重试和可恢复能力。

4. Idempotent Consumer

通过:

event_id
数据库唯一约束
本地事务
外部幂等键

把重复投递变成安全操作。

5. Operational Lifecycle

SENT 清理
DEAD 监控
人工重放
失败历史
归档 / TTL

让 Outbox 不会随着运行时间无限膨胀。

6. Resource Isolation

独立线程池
有界并发
连接池容量
必要时独立 Publisher

避免“为了可靠投递消息,反过来拖垮在线业务”。

规模继续扩大后,可以从:

Polling Outbox

演进到:

Binlog + CDC + Debezium

但底层原则始终没变:

先把业务状态和“必须发生的事件”放进一个真正的原子边界,再异步地、可恢复地把事件传播出去。

这才是数据库事务与消息队列最终一致性的核心。


可复现故障实验建议

为了避免把推演写成“实测”,建议真正验证时至少记录以下信息:

Spring Boot 版本
Spring Kafka 版本
Kafka Broker 版本
MySQL 版本
Producer acks
replication.factor
min.insync.replicas
当前 ISR
enable.idempotence
retries
Consumer enable.auto.commit
AckMode
数据库事务管理器
Kafka 是否启用事务
故障注入点
JVM 退出方式
事件 event_id
DB 查询结果
Kafka record offset
Consumer group committed offset

每个实验都保留:

event_id

作为贯穿:

数据库
Outbox
Kafka
Consumer
业务表

的唯一证据链。

这样文章里的:

DB ✓ / MQ ✗
DB ✗ / MQ ✓
重复发布
重复消费

才能真正被验证,而不是靠日志感觉。