数据库事务与 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 个失败窗口放在一起
| 故障窗口 | DB | MQ / Offset | 主要风险 |
|---|---|---|---|
| MQ 已确认,DB 最终回滚 | 失败 | 消息已存在 | 幽灵消息 |
| DB 已提交,MQ 尚未发送时宕机 | 成功 | 消息不存在 | 事件永久丢失 |
| Broker 已接收,发送方没有获得确定结果 | 成功 | 可能重试 | 取决于中间件与 Producer 幂等边界 |
| Consumer 业务已提交,Offset/ACK 前宕机 | 已执行 | 消息会再次投递 | 重复消费 |
到这里真正应该建立的不是“MQ 很容易丢”的印象。
而是:
可靠系统最重要的问题不是每一步能不能永远成功,而是任意一步失败以后,系统是否还留下足够的信息恢复。
九、为什么几个常见方案仍然不够
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 流程完成。
十三、多实例 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
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 短暂不可用
消费者重启
消息重放
但要求:
系统最终能恢复
这比幻想:
所有组件永远不失败
更接近真实生产系统。
二十六、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 项
- 业务数据和 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 的一致性问题,本质上不是:
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 ✓
重复发布
重复消费
才能真正被验证,而不是靠日志感觉。