DB 操作与 MQ 发送消息的一致性问题
更新: 1/10/2026 字数: 0 字 时长: 0 分钟
——从“顺序幻觉”到工程上真正可靠的选择
在业务系统中,「写数据库 + 发送 MQ 消息」几乎是不可避免的组合:
- 创建订单后,通知库存、物流
- 扣减余额后,触发风控、审计
- 状态变更后,驱动异步流程
代码层面看起来非常简单,但大量分布式一致性问题,正是从这里开始的。
要把这个问题真正讲清楚,必须从最常见、也最容易写出来的业务代码谈起。
一、最直观的两种业务实现方式
在实际项目中,大多数系统最初都会写成下面两种形式之一。
方式一:先写数据库,再发送消息
void createOrder() {
// 1. 写数据库
insertOrder();
// 2. 发送 MQ 消息
sendMessage();
}方式二:先发送消息,再写数据库
void createOrder() {
// 1. 发送 MQ 消息
sendMessage();
// 2. 写数据库
insertOrder();
}乍一看,这两种方式只是执行顺序不同; 但一旦结合业务语义,很快就会发现:
它们都存在严重的一致性隐患。
二、为什么这两种方式都不可取?
在业务系统中,DB 和 MQ 承担的是两种本质不同的职责:
DB 保存的是“事实”
- 订单是否存在
- 余额是否已扣减
MQ 承载的是“事实的传播”
- 通知下游系统
- 驱动异步流程
因此,这里讨论的“一致性”,并不是严格意义上的分布式事务一致性,而是一个更贴近工程现实的目标:
系统对外暴露的事实与事件,不能自相矛盾。
换句话说:
任何下游系统的行为,都必须建立在真实存在的业务事实上。
哪种不一致更危险?
| 不一致情况 | 本质 | 风险评估 |
|---|---|---|
| 消息被消费,但 DB 中没有对应数据 | 传播了“假事实” | ❌ 语义层面极其危险,往往难以补偿 |
| DB 已成功,但消息未被消费 | 事实未被传播 | ⚠️ 工程上可补偿、可对账 |
需要强调的是:
- “假事实”并非在所有系统中都绝对不可修复
- 但一旦被下游系统放大,或触发不可逆业务操作
- 修复成本和系统风险会急剧上升
因此,工业系统真正要优先避免的,是传播不存在的事实。
这也定义了本文后续所有技术讨论的核心目标:
不让系统基于不存在的事实做决策。
三、为什么“加上数据库事务”仍然不够?
意识到可以将发送消息纳入数据库事务中,很多系统会自然演进到下面这种写法:
@Transactional
void createOrder() {
insertOrder();
sendMessage();
}表面上看,这似乎已经解决了问题:
- 数据写在事务中
sendMessage()失败就回滚- DB 和 MQ 仿佛被“绑定”在了一起
但这里隐藏着一个非常关键、却常被忽略的前提:
你假设
sendMessage()的成功或失败是确定的。
而现实并非如此。
四、请求 + 响应,并不是一个原子动作(TCP 视角)
在应用代码中,我们通常看到的是类似这样的调用:
Response response = send(request);这很容易让人形成一种直觉:
请求发出 → 对端处理 → 返回响应
仿佛这是一个同步、确定的过程。
但在网络协议层面,这至少拆成了两件彼此独立的事情:
- 请求是否成功到达对端
- 对端的处理结果是否成功返回
TCP 的“可靠性”保证的是:
- 字节不乱序、不重复
- 在连接未断开的前提下尽力重传
但 TCP 并不保证:
- 对端一定完成了业务处理
- 你一定能收到“处理完成”的确认
因此:
sendMessage()返回失败,并不等价于 MQ 没有接收到消息。
一种典型场景是:
- 消息已经被 MQ 接收并进入其内部处理流程
- 但 ACK 在网络中丢失
- 生产者误以为发送失败,从而回滚数据库事务
此时:
- MQ 中可能已经记录了这条消息
- 消息是否对消费者可见,取决于具体 MQ 的事务模型
- 而生产者却基于错误判断撤销了本地事实
“假事实传播”的风险,正是从这里产生的。
五、事务消息:不确定性并没有消失,只是被后移
为了解决“发送成功却被误判失败”的问题,引入了事务消息。
从开发者视角,它往往被理解成如下模型:
@Transactional
void createOrder() {
mq.beginTransaction();
insertOrder();
mq.sendMessage();
mq.commitTransaction();
}这里必须非常清楚一点:
数据库事务和 MQ 事务,是两个完全独立的事务系统。
而 mq.commitTransaction() 本身:
仍然只是一条普通的网络请求。
这意味着:
- 不确定性并没有消失
- 只是从
sendMessage()转移到了commitTransaction()
网络的不确定性,依然客观存在。
六、Kafka 事务消息的边界
Kafka 的事务模型遵循一个非常明确的设计原则:
无法确认,就不提交。
当 Producer 无法确认 commitTransaction() 是否成功时:
- Kafka 会让事务保持未完成状态
- 消息对消费者不可见
这在 Kafka 内部是完全安全的,但 Kafka 无法:
- 感知数据库事务是否已经提交
- 主动回查应用侧的真实业务状态
于是可能出现这样一种状态:
- DB 已经 commit
- Kafka 事务未成功提交
- 消息长期对消费者不可见,直到事务被 abort 或超时处理
Kafka 事务消息解决的是:
Kafka 内部的数据一致性问题
而不是:
DB 与 MQ 之间的跨系统一致性问题。
这是 Kafka 有意为之的设计边界。
七、RocketMQ 事务消息:通过回查消除不确定性
RocketMQ 在事务消息上的核心设计差异在于:
Broker 不长期接受不确定状态,而是主动推动结果收敛。
其核心模型是:半消息 + 事务回查。
事务消息示例
TransactionMQProducer producer = new TransactionMQProducer("order_tx_group");
producer.setTransactionListener(new TransactionListener() {
@Override
public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
try {
insertOrder();
return LocalTransactionState.COMMIT_MESSAGE;
} catch (Exception e) {
return LocalTransactionState.ROLLBACK_MESSAGE;
}
}
@Override
public LocalTransactionState checkLocalTransaction(Message msg) {
return queryOrderStatus(msg);
}
});其工作流程是:
- Producer 先发送半消息(消费者不可见)
- 执行本地数据库事务
- 返回提交或回滚结果
- 若 Broker 未收到明确结果,主动回查 Producer 的本地事务状态
通过回查机制:
- 即使提交响应在网络中丢失
- Broker 也不会永久停留在不确定状态
- 最终会根据本地事务真实结果决定消息去留
需要注意的是,RocketMQ 的前提条件包括:
- 本地事务状态必须可查询
- Producer 在回查期间仍然可用
否则,系统仍需通过对账或人工介入兜底。
八、本地消息表:最符合工程现实的解法
当你真正接受这样一个事实:
任何一次网络请求,都不具备绝对确定性
解决思路就会发生根本转变。
本地消息表的核心思想不是追求“更强的事务”,而是:
把事实和事件,落在同一份可靠的数据里。
在同一个数据库事务中:
- 写业务表(事实)
- 写消息表(事件描述)
DB COMMIT 成功
= 事实一定存在
= 事件一定存在随后:
- 异步任务扫描消息表
- 失败就重试
- 可对账、可补偿、可恢复
它并不试图消灭失败,而是:
承认失败是分布式系统的常态,并让失败可观测、可控制、可修复。
从这个角度看,本地消息表并不是“退而求其次的土办法”, 而是一种正视不确定性、并围绕不确定性设计系统的工程解法。
