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

