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

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

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

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

后端里很常见这样一段代码:

java 复制代码
@Transactional
public void createAlarm(DeviceAlarm alarm) {
    alarmMapper.insert(alarm);

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

看起来很稳:

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

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

text 复制代码
MySQL COMMIT

和:

text 复制代码
MQ SEND

属于两个独立资源。

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

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

真正应该问的是:

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

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

下面所有实验都按照:

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

来拆。

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

场景统一使用 IoT 告警:

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

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


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

先看:

java 复制代码
@Transactional
public void createAlarm(DeviceAlarm alarm) {
    alarmMapper.insert(alarm);

    // 这里 insert 已经返回
}

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

java 复制代码
System.exit(1);

然后声称:

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

这个实验其实不成立。

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

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

也就是说:

text 复制代码
alarmMapper.insert() 返回

只能说明 SQL 已经执行。

它不能证明:

text 复制代码
COMMIT 已经成功

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

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

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


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

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

因此:

java 复制代码
kafkaTemplate.send(topic, key, value);

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

它不等价于:

text 复制代码
Broker 已经持久化并返回 ACK

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

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

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

properties 复制代码
acks=all

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

发送代码:

java 复制代码
kafkaTemplate.send(
        "alarm-created",
        alarm.getId().toString(),
        event
).get(5, TimeUnit.SECONDS);

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

text 复制代码
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 的可靠性效果,必须和:

text 复制代码
replication.factor
min.insync.replicas
当前 ISR

一起看。

生产环境常见组合例如:

text 复制代码
replication.factor = 3
min.insync.replicas = 2
acks = all

需要避免一个常见误解:

text 复制代码
min.insync.replicas = 1

并不等价于:

text 复制代码
acks=all 自动退化成 acks=1

更准确的说法是:

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

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

text 复制代码
acks
replication.factor
min.insync.replicas
当前 ISR
enable.idempotence

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


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

这是最容易复现的一种。

3.1 前提

这里假设:

text 复制代码
MySQL:普通 Spring 本地事务
Kafka:普通 Producer 发送

没有刻意配置:

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

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

3.2 故障复现代码

java 复制代码
@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"
    );
}

执行顺序:

text 复制代码
BEGIN
  ↓
INSERT alarm_record
  ↓
SEND Kafka
  ↓
Broker ACK ✓
  ↓
抛异常
  ↓
ROLLBACK

3.3 预期观察

数据库:

sql 复制代码
SELECT *
FROM alarm_record
WHERE id = ?;

预期:

text 复制代码
0 rows

Kafka:

text 复制代码
alarm-created 中仍能看到对应 event_id

最终状态:

text 复制代码
MySQL:✗
Kafka:✓

下游看到的是:

text 复制代码
ALARM_CREATED

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

这就是典型的:

幽灵消息

如果消费者执行的是:

text 复制代码
发送短信
生成工单
执行设备联动
更新统计

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


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

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

不能这样模拟:

java 复制代码
@Transactional
public void createAlarm(DeviceAlarm alarm) {
    alarmMapper.insert(alarm);

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

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

真正的复现应该把:

text 复制代码
数据库事务

和:

text 复制代码
MQ 发送

明确拆到提交边界两侧。

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

java 复制代码
@Service
@RequiredArgsConstructor
public class AlarmTxService {

    private final AlarmMapper alarmMapper;

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

调用方:

java 复制代码
@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 代理失效,把实验边界搞乱。

执行顺序:

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

4.2 预期观察

应用重启后查询:

sql 复制代码
SELECT *
FROM alarm_record
WHERE id = ?;

预期:

text 复制代码
1 row

Kafka 中:

text 复制代码
没有对应 ALARM_CREATED

最终:

text 复制代码
MySQL:✓
Kafka:✗

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

但所有依赖事件的系统:

text 复制代码
短信通知
规则引擎
实时大屏
工单系统
自动联动

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

这才是真正的:

事件永久丢失窗口


五、afterCommit() 能不能解决?

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

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

例如:

java 复制代码
@Transactional
public void createAlarm(DeviceAlarm alarm) {

    alarmMapper.insert(alarm);

    TransactionSynchronizationManager
        .registerSynchronization(
            new TransactionSynchronization() {

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

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

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

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

text 复制代码
它在主事务已经成功提交以后执行。

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

依然存在:

text 复制代码
DB COMMIT ✓
    ↓
进程 Crash
    ↓
afterCommit 还没执行 / MQ 还没成功

最终仍然是:

text 复制代码
DB ✓
MQ ✗

afterCommit() 解决的是:

text 复制代码
什么时候执行

不是:

text 复制代码
两个系统如何原子提交

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

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

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


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

这类故障不能简单写成:

text 复制代码
ACK 丢了
→ Producer 重试
→ Kafka 一定出现重复

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

Producer 是否启用了幂等发送

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

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

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

可能出现:

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

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

text 复制代码
E1001
E1001

重复是完全可能的。


6.2 Kafka 开启幂等 Producer 后

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

但是:

text 复制代码
Kafka Producer Idempotence

不等于:

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

看一个 Outbox 场景:

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

这次重发已经是:

text 复制代码
应用级重新发布

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

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

text 复制代码
"这个 event_id 我昨天业务上已经发成功过一次,所以今天不能再发。"

因此:

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

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

text 复制代码
event_id 幂等

依然有价值。


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

假设消费者逻辑是:

text 复制代码
收到 E1001
    ↓
更新数据库
    ↓
提交业务事务
    ↓
提交 Kafka Offset

故障恰好发生在:

text 复制代码
业务事务 COMMIT ✓
    ↓
JVM Crash
    ↓
Offset 未提交

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

7.1 可复现的边界

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

java 复制代码
@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();
    }
}

业务事务:

java 复制代码
@Transactional
public void createNotification(AlarmCreatedEvent event) {
    notificationMapper.insert(...);
}

7.2 预期结果

第一次消费:

text 复制代码
notification 表:插入成功
offset:未提交

消费者重启:

text 复制代码
同一个 Kafka record 再次到达

如果没有幂等:

text 复制代码
notification 再插一次

如果业务是:

text 复制代码
扣余额
扣库存
发短信
下发控制命令

后果会更明显。

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


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

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

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

而是:

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

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

9.1 "都写在一个 @Transactional 方法里"

java 复制代码
@Transactional
public void createAlarm(DeviceAlarm alarm) {
    alarmMapper.insert(alarm);
    kafkaTemplate.send(...);
}

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

text 复制代码
DataSourceTransactionManager
+
非事务型 Kafka Producer

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

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

text 复制代码
MySQL
+
Kafka

自动变成一个原子事务。

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

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

但要把几种情况分开。

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

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

text 复制代码
Database Transaction Commit
        ↓
Kafka Transaction Commit

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

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

旧的 ChainedKafkaTransactionManager

历史项目里还可能看到:

text 复制代码
ChainedKafkaTransactionManager

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

其链式事务语义是:

text 复制代码
按配置顺序启动事务
按相反顺序提交事务

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

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

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

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

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

text 复制代码
只因为写了 @Transactional
+
调用了 kafkaTemplate.send()

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

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


9.2 "数据库提交后再发"

能避免:

text 复制代码
DB rollback
但 MQ 已经先发布

但避免不了:

text 复制代码
DB commit
→ JVM crash
→ MQ 还没发

9.3 "发送失败就无限重试"

首先:

text 复制代码
发送方看到失败

并不总能推出:

text 复制代码
Broker 一定没成功

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

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

text 复制代码
event_id

以及明确的幂等边界。


9.4 "直接上 Exactly Once"

Exactly Once 必须先问:

text 复制代码
Exactly Once 在什么边界内?

Kafka 对:

text 复制代码
Kafka Topic
→ 处理
→ Kafka Topic

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

但链路一旦变成:

text 复制代码
Kafka
→ MySQL
→ HTTP
→ 短信供应商
→ 第三方设备平台

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

例如:

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

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

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

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

也就是:

At-Least-Once + Idempotency + Eventual Consistency


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

问题原来是:

text 复制代码
写业务数据库
+
发 MQ

两个资源。

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

而是先把问题改写成:

text 复制代码
写业务数据
+
写"必须发布的事件事实"

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

从:

text 复制代码
MySQL
+
Kafka

变成:

text 复制代码
MySQL Business Table
+
MySQL Outbox Table

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

text 复制代码
一起 COMMIT

或者:

text 复制代码
一起 ROLLBACK

十一、Polling Outbox 的表怎么设计

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

sql 复制代码
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。

用于:

text 复制代码
链路追踪
消费者幂等
故障重放
人工排查

status

例如:

text 复制代码
PENDING
PROCESSING
RETRY
SENT
DEAD

retry_count / next_retry_at

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

locked_by / locked_until

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

last_error

生产环境非常有必要。

否则 DEAD 只告诉你:

text 复制代码
失败了

却不告诉你为什么失败。


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

java 复制代码
@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);
    }
}

事务里真正发生的是:

text 复制代码
BEGIN

INSERT alarm_record
INSERT outbox_event

COMMIT

因此业务事务成功时:

text 复制代码
alarm_record ✓
outbox_event ✓

业务事务失败时:

text 复制代码
alarm_record ✗
outbox_event ✗

不会再出现:

text 复制代码
业务已经被数据库认可
但系统连"还有一条消息必须发"这件事都忘了

这才是 Outbox 最重要的能力。

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

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

Transactional Outbox 的前提是:

text 复制代码
Business
+
Outbox

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

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

text 复制代码
批量更新几千 / 几万行
+
写多个业务表
+
再写 Outbox

Outbox 会一起承担:

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

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

正确方向仍然是:

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

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

MySQL 中很多 DDL 会触发:

text 复制代码
implicit commit

例如:

sql 复制代码
BEGIN;

INSERT INTO business_table (...);

ALTER TABLE business_table ADD COLUMN ...;

INSERT INTO outbox_event (...);

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

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

text 复制代码
DDL 可以像普通 DML 一样自由加入业务事务

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

因此:

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

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

最简单的代码:

sql 复制代码
SELECT *
FROM outbox_event
WHERE status = 'PENDING'
ORDER BY id
LIMIT 100;

然后:

java 复制代码
for (OutboxEvent event : events) {
    send(event);
}

单实例低并发时能工作。

多个实例时:

text 复制代码
Publisher-A
Publisher-B

完全可能同时读到:

text 复制代码
E10001
E10002
E10003

然后同时发送。


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

MySQL 官方文档明确说明:

text 复制代码
SKIP LOCKED

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

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

Outbox 正是典型的 queue-like table。

这里必须明确版本边界:

text 复制代码
MySQL 8.0+

才支持:

sql 复制代码
FOR UPDATE SKIP LOCKED

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

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

text 复制代码
条件 UPDATE / CAS
+
locked_by
+
locked_until
+
分片或批次领取

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

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

14.1 错误方式

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

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

这会带来:

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

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

第一阶段:

text 复制代码
BEGIN
    ↓
锁一小批待处理记录
    ↓
标记 PROCESSING + lease
    ↓
COMMIT

第二阶段:

text 复制代码
事务外发送 MQ

第三阶段:

text 复制代码
成功 → SENT
失败 → RETRY

14.3 抢占 SQL

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 以后,在同一个短事务中:

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

然后立即:

text 复制代码
COMMIT

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

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


十五、为什么必须有 locked_until

假设:

text 复制代码
Worker-A 抢到 E10001
    ↓
status = PROCESSING
    ↓
Worker-A JVM Crash

如果只有:

text 复制代码
PROCESSING

系统就无法知道:

text 复制代码
这个事件是真的还在处理
还是处理它的 Worker 已经死了

所以需要租约:

text 复制代码
locked_until = 2026-10-10 10:30:00.000

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

text 复制代码
该 Worker 的处理权已经失效

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

text 复制代码
RETRY

或允许其他 Worker 重新领取。

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

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

例如:

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

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

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

sql 复制代码
UPDATE outbox_event
SET status = 'SENT'
WHERE id = #{id};

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

sql 复制代码
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 条件。

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

text 复制代码
lease_version / fencing_token

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

这样做不能消除:

text 复制代码
A、B 都已经把同一个 event_id 发到 MQ

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

这和永久锁完全不同。

它是:

Lease + Ownership / Fencing

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

前面的讨论只解决了:

text 复制代码
Publisher 怎么正确抢占和发送

生产环境还需要回答:

text 复制代码
Publisher 跑在哪里?

常见有两种模式。

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

优点是:

text 复制代码
部署简单
组件少
中小系统容易落地

但 Publisher 会和在线业务共享:

text 复制代码
CPU
Heap
GC
数据库连接池
网络连接
应用生命周期

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

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

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

Outbox Publisher
        ↓
      Kafka

优点:

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

代价:

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

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

当出现:

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

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


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

Publisher 的正常路径:

text 复制代码
发送 E10001
    ↓
Kafka ACK ✓
    ↓
UPDATE outbox_event
SET status = 'SENT'

故障如果正好发生在:

text 复制代码
Kafka ACK ✓
    ↓
JVM Crash
    ↓
SENT 未更新

数据库可能仍然是:

text 复制代码
PROCESSING

租约到期以后:

text 复制代码
E10001 再次被领取

然后:

text 复制代码
E10001 再次发送

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

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

如果一定要让:

text 复制代码
Kafka 写入
+
MySQL SENT 更新

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

所以多数系统选择:

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

下一步自然就是:

Consumer Idempotency


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

错误示例:

java 复制代码
if (consumedEventMapper.exists(
        consumerName,
        eventId
)) {
    return;
}

doBusiness();

consumedEventMapper.insert(
        consumerName,
        eventId
);

两个线程并发时:

text 复制代码
Thread-A:exists = false
Thread-B:exists = false

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

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

最终兜底必须依靠:

text 复制代码
数据库唯一约束

十八、消费记录表

sql 复制代码
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
    )
);

为什么不是:

text 复制代码
PRIMARY KEY(event_id)

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

text 复制代码
alarm-notification
alarm-statistics
alarm-rule-engine

真正要限制的是:

text 复制代码
同一个 consumer
+
同一个 event_id

只能处理一次。


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

java 复制代码
@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()
    );
}

核心不是:

text 复制代码
Java catch

而是:

text 复制代码
PRIMARY KEY (consumer_name, event_id)

并且:

text 复制代码
插入 consumed_event
+
真正的业务数据库变更

必须在同一个本地事务。

这样:

第一次处理成功

text 复制代码
consumed_event ✓
business data ✓
COMMIT

业务执行失败

text 复制代码
ROLLBACK

消费标记也跟着回滚。

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

同一个事件再次到达

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

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

这里还要明确两个边界。

MyBatis 的:

text 复制代码
一级缓存
二级缓存

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

真正需要额外关注的是:

text 复制代码
ExecutorType.BATCH

以及 JDBC Driver 的批处理行为。

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

text 复制代码
这个 consumer 已经处理过该 event_id

不能这样写:

java 复制代码
catch (SQLException e) {
    return;
}

更不能把:

text 复制代码
死锁
连接中断
磁盘异常
SQL 语法错误
连接池耗尽

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

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

text 复制代码
PRIMARY KEY (consumer_name, event_id)

而不是其他唯一键。

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


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

即使消费者这样做:

text 复制代码
业务 DB 本地事务提交
    ↓
提交 Kafka Offset

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

依然可能:

text 复制代码
业务 COMMIT ✓
    ↓
Offset Commit ✗

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

text 复制代码
consumer_name + event_id

已经存在。

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

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

text 复制代码
强行让 DB 和 Offset 永远同时成功

而是:

text 复制代码
允许消息重放
+
让重放不产生第二次业务副作用

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


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

还要看一个常见路径:

text 复制代码
INSERT consumed_event
    ↓
执行本地业务
    ↓
RuntimeException
    ↓
ROLLBACK

只要:

text 复制代码
consumed_event
+
本地业务数据库变更

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

text 复制代码
消费标记
+
业务数据

会一起回滚。

消息重新投递时:

text 复制代码
consumed_event 中仍然没有该 event_id

因此可以重新处理。

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

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


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

假设消费者不是:

text 复制代码
INSERT notification

而是:

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

这时又出现同样的问题:

text 复制代码
MySQL consumed_event
+
第三方 HTTP

还是两个资源。

例如:

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

所以:

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

常见方案:

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

例如:

http 复制代码
Idempotency-Key: 01K7ABCDEF...

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


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

假设 MQ 故障:

text 复制代码
10000 条 Outbox
×
每秒固定重试

系统会不断制造:

text 复制代码
数据库扫描
线程占用
网络请求
日志
Broker 压力

应该使用:

text 复制代码
指数退避
+
最大重试次数
+
随机抖动

例如基础退避:

text 复制代码
1s
2s
4s
8s
16s
30s
60s

Java 示例:

java 复制代码
public Duration nextRetryDelay(int retryCount) {

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

    return Duration.ofSeconds(seconds);
}

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

状态:

text 复制代码
PENDING
   ↓
PROCESSING
   ↓
发送失败
   ↓
RETRY
   ↓
next_retry_at

超过最大次数:

text 复制代码
DEAD

但:

text 复制代码
DEAD

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

至少需要:

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

否则只是把:

text 复制代码
"MQ 丢消息"

换成了:

text 复制代码
"Outbox 里躺着一批没人发现的死消息"

22.1 DEAD 人工重放:event_id 不能换

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

text 复制代码
同一个业务事件重新投递

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

text 复制代码
event_id

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

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

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

text 复制代码
status         → PENDING / RETRY
next_retry_at  → NOW()
locked_by      → NULL
locked_until   → NULL

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

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

text 复制代码
replay_count
replayed_by
replayed_at
replay_reason

还有一个边界:

text 复制代码
DEAD

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

有些失败可能发生在:

text 复制代码
Broker 已成功
但 Publisher 没拿到确定结果

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

text 复制代码
可能重复发送

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

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

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

text 复制代码
SENT
SENT
SENT
...

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

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

因此至少要区分:

text 复制代码
PENDING / PROCESSING / RETRY
→ 活跃数据,不能随意清理

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

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

例如 SENT 可以采用:

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

关键是:

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

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

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

text 复制代码
有审计 / 合规 / 长期追溯需求
→ 先归档再删除

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

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


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

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

json 复制代码
{
  "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

用于:

text 复制代码
去重
追踪
重放
排障

aggregateId

业务聚合 ID,例如:

text 复制代码
alarmId
orderId
deviceId
accountId

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

这里仍要注意:

text 复制代码
同一分区内有序

并不等于:

text 复制代码
整个系统所有消息全局有序

eventType

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

text 复制代码
ALARM_CREATED
ALARM_RECOVERED
DEVICE_ONLINE
DEVICE_OFFLINE

schemaVersion

事件结构一定会演进。

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


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

很多文章会直接写:

text 复制代码
Outbox = 一张 status 表 + 定时任务

其实不准确。

Outbox 真正的核心只有一个:

text 复制代码
业务状态
+
必须发布的事件

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

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


24.1 Polling Publisher

text 复制代码
Application
   ↓
MySQL Transaction
   ↓
Business + Outbox
   ↓
Polling Publisher
   ↓
Kafka

这种模式适合使用:

text 复制代码
status
retry_count
next_retry_at
locked_until

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

优点:

text 复制代码
实现直观
可控
不需要额外 CDC 基础设施

代价:

text 复制代码
轮询延迟
数据库扫描压力
Outbox 清理
Publisher 运维

24.2 CDC / Debezium

另一条路线:

text 复制代码
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 事件主要通过:

text 复制代码
INSERT

产生。

因此不要把 Polling 模式里的:

text 复制代码
PENDING
→ PROCESSING
→ SENT
→ RETRY

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

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

text 复制代码
INSERT 一条不可变事件

然后由:

text 复制代码
Binlog
→ Debezium
→ Kafka Connect
→ Kafka

负责传播。

也就是说:

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

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

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

真正应该强调的是:

text 复制代码
payload 要有明确边界

超大 payload 会放大:

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

所以事件应该携带:

text 复制代码
消费者真正需要的稳定业务快照

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

更合理的理解是:

text 复制代码
Polling Outbox

和:

text 复制代码
CDC Outbox

共享的是:

text 复制代码
"业务 + 事件"同事务落地

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

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


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

严重过温告警:

text 复制代码
Device
   ↓
MQTT / Gateway
   ↓
Alarm Service

Alarm Service 内:

text 复制代码
┌──────────── MySQL Transaction ────────────┐
│                                           │
│  INSERT alarm_record                      │
│  INSERT outbox_event                      │
│                                           │
└───────────────────────────────────────────┘
                    ↓
                 COMMIT

之后:

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

这条链路允许:

text 复制代码
Java Crash
网络超时
Kafka 短暂不可用
消费者重启
消息重放

但要求:

text 复制代码
系统最终能恢复

这比幻想:

text 复制代码
所有组件永远不失败

更接近真实生产系统。


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

假设:

text 复制代码
5000 台设备
每台 1 条 / 秒

就是:

text 复制代码
5000 events/s

如果每条:

text 复制代码
电压
电流
功率
温度
SOC

都执行:

text 复制代码
INSERT telemetry
+
INSERT outbox_event

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

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


26.1 高频遥测

例如:

text 复制代码
电压
电流
功率
温湿度
SOC

更适合:

text 复制代码
MQTT
   ↓
Kafka / 流处理
   ↓
批量聚合
   ↓
时序库 / OLAP / MySQL

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


26.2 关键业务事件

例如:

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

这种事件的共同点是:

text 复制代码
不能静默消失
+
会驱动后续业务动作

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

text 复制代码
业务状态
↔
必须发生的业务事件

之间的一致性。

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


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

整条链路:

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

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

text 复制代码
MQ At-Least-Once

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

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

text 复制代码
Publisher:
同一个 event_id 可能被发布一次或多次

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

Consumer:
重复执行必须安全

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

所以它不是承诺:

text 复制代码
任何消息永远只出现一次

而是在接受:

text 复制代码
消息可能重复

的前提下,通过:

text 复制代码
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 的一致性问题,本质上不是:

text 复制代码
send() 要不要 try-catch

也不是:

text 复制代码
Producer retries 开几次

真正的问题是:

text 复制代码
两个独立资源之间没有天然的本地原子性

所以设计重点应该从:

text 复制代码
"怎样保证每一步永远不失败"

转成:

text 复制代码
"任意一步失败以后,系统还能不能恢复"

最终可以归纳成六层:

1. Transactional Outbox

text 复制代码
业务数据
+
必须发布的事件

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

2. Recoverable Publisher

text 复制代码
多实例抢占
租约
失败退避
死信
人工重放

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

3. At-Least-Once

接受:

text 复制代码
偶尔重复

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

4. Idempotent Consumer

通过:

text 复制代码
event_id
数据库唯一约束
本地事务
外部幂等键

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

5. Operational Lifecycle

text 复制代码
SENT 清理
DEAD 监控
人工重放
失败历史
归档 / TTL

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

6. Resource Isolation

text 复制代码
独立线程池
有界并发
连接池容量
必要时独立 Publisher

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

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

text 复制代码
Polling Outbox

演进到:

text 复制代码
Binlog + CDC + Debezium

但底层原则始终没变:

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

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


可复现故障实验建议

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

text 复制代码
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

每个实验都保留:

text 复制代码
event_id

作为贯穿:

text 复制代码
数据库
Outbox
Kafka
Consumer
业务表

的唯一证据链。

这样文章里的:

text 复制代码
DB ✓ / MQ ✗
DB ✗ / MQ ✓
重复发布
重复消费

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

相关推荐
是希燃亚1 小时前
AI Agent 的未来,可能不是更强,而是更简单
算法·架构
AI小白Lin1 小时前
我的 Agent 迁移"成功"了:每道门都在册,没有一道能跑
架构·llm·agent
柚yuzumi1 小时前
React 19 Hooks 入门:函数组件的四大法宝 (useState / useEffect / useRef / useContext)
前端·javascript·架构
meiying88381 小时前
模板建站 vs 定制开发:从技术债、TCO 与可维护性算一笔工程账
架构·saas·建站
真上帝的左手2 小时前
27. 数据产品-数据中台架构规划
数据仓库·架构·数据分析·数据中台
杭州领祺科技2 小时前
零碳园区绿电直连并网验收:688 号文 + 27 号令双合规下 7 产品 4 区架构工程正解
架构·储能·电力
hhb_6182 小时前
AIAgent高可用架构与自愈实战
架构
Thneonl3 小时前
让 LLM 值第一班岗:只读的活放手交,变更的活一条别给
后端·架构
阿标在干嘛3 小时前
缓存架构的“场景适配“:我们为什么在部分服务中采用了本地缓存?
缓存·架构