Skip to content

DB 操作与 MQ 发送消息的一致性问题

更新: 1/10/2026 字数: 0 字 时长: 0 分钟

——从“顺序幻觉”到工程上真正可靠的选择

在业务系统中,「写数据库 + 发送 MQ 消息」几乎是不可避免的组合:

  • 创建订单后,通知库存、物流
  • 扣减余额后,触发风控、审计
  • 状态变更后,驱动异步流程

代码层面看起来非常简单,但大量分布式一致性问题,正是从这里开始的。

要把这个问题真正讲清楚,必须从最常见、也最容易写出来的业务代码谈起。


一、最直观的两种业务实现方式

在实际项目中,大多数系统最初都会写成下面两种形式之一。

方式一:先写数据库,再发送消息

java
void createOrder() {
    // 1. 写数据库
    insertOrder();

    // 2. 发送 MQ 消息
    sendMessage();
}

方式二:先发送消息,再写数据库

java
void createOrder() {
    // 1. 发送 MQ 消息
    sendMessage();

    // 2. 写数据库
    insertOrder();
}

乍一看,这两种方式只是执行顺序不同; 但一旦结合业务语义,很快就会发现:

它们都存在严重的一致性隐患。


二、为什么这两种方式都不可取?

在业务系统中,DB 和 MQ 承担的是两种本质不同的职责:

  • DB 保存的是“事实”

    • 订单是否存在
    • 余额是否已扣减
  • MQ 承载的是“事实的传播”

    • 通知下游系统
    • 驱动异步流程

因此,这里讨论的“一致性”,并不是严格意义上的分布式事务一致性,而是一个更贴近工程现实的目标:

系统对外暴露的事实与事件,不能自相矛盾。

换句话说:

任何下游系统的行为,都必须建立在真实存在的业务事实上。

哪种不一致更危险?

不一致情况本质风险评估
消息被消费,但 DB 中没有对应数据传播了“假事实”语义层面极其危险,往往难以补偿
DB 已成功,但消息未被消费事实未被传播⚠️ 工程上可补偿、可对账

需要强调的是:

  • “假事实”并非在所有系统中都绝对不可修复
  • 但一旦被下游系统放大,或触发不可逆业务操作
  • 修复成本和系统风险会急剧上升

因此,工业系统真正要优先避免的,是传播不存在的事实

这也定义了本文后续所有技术讨论的核心目标

不让系统基于不存在的事实做决策。


三、为什么“加上数据库事务”仍然不够?

意识到可以将发送消息纳入数据库事务中,很多系统会自然演进到下面这种写法:

java
@Transactional
void createOrder() {
    insertOrder();
    sendMessage();
}

表面上看,这似乎已经解决了问题:

  • 数据写在事务中
  • sendMessage() 失败就回滚
  • DB 和 MQ 仿佛被“绑定”在了一起

但这里隐藏着一个非常关键、却常被忽略的前提:

你假设 sendMessage() 的成功或失败是确定的。

而现实并非如此。


四、请求 + 响应,并不是一个原子动作(TCP 视角)

在应用代码中,我们通常看到的是类似这样的调用:

java
Response response = send(request);

这很容易让人形成一种直觉:

请求发出 → 对端处理 → 返回响应

仿佛这是一个同步、确定的过程。

但在网络协议层面,这至少拆成了两件彼此独立的事情

  1. 请求是否成功到达对端
  2. 对端的处理结果是否成功返回

TCP 的“可靠性”保证的是:

  • 字节不乱序、不重复
  • 在连接未断开的前提下尽力重传

但 TCP 并不保证

  • 对端一定完成了业务处理
  • 你一定能收到“处理完成”的确认

因此:

sendMessage() 返回失败,并不等价于 MQ 没有接收到消息。

一种典型场景是:

  • 消息已经被 MQ 接收并进入其内部处理流程
  • 但 ACK 在网络中丢失
  • 生产者误以为发送失败,从而回滚数据库事务

此时:

  • MQ 中可能已经记录了这条消息
  • 消息是否对消费者可见,取决于具体 MQ 的事务模型
  • 而生产者却基于错误判断撤销了本地事实

“假事实传播”的风险,正是从这里产生的。


五、事务消息:不确定性并没有消失,只是被后移

为了解决“发送成功却被误判失败”的问题,引入了事务消息

从开发者视角,它往往被理解成如下模型:

java
@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 不长期接受不确定状态,而是主动推动结果收敛。

其核心模型是:半消息 + 事务回查

事务消息示例

java
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);
    }
});

其工作流程是:

  1. Producer 先发送半消息(消费者不可见)
  2. 执行本地数据库事务
  3. 返回提交或回滚结果
  4. 若 Broker 未收到明确结果,主动回查 Producer 的本地事务状态

通过回查机制:

  • 即使提交响应在网络中丢失
  • Broker 也不会永久停留在不确定状态
  • 最终会根据本地事务真实结果决定消息去留

需要注意的是,RocketMQ 的前提条件包括:

  • 本地事务状态必须可查询
  • Producer 在回查期间仍然可用

否则,系统仍需通过对账或人工介入兜底。


八、本地消息表:最符合工程现实的解法

当你真正接受这样一个事实:

任何一次网络请求,都不具备绝对确定性

解决思路就会发生根本转变。

本地消息表的核心思想不是追求“更强的事务”,而是:

把事实和事件,落在同一份可靠的数据里。

在同一个数据库事务中:

  • 写业务表(事实)
  • 写消息表(事件描述)
text
DB COMMIT 成功
= 事实一定存在
= 事件一定存在

随后:

  • 异步任务扫描消息表
  • 失败就重试
  • 可对账、可补偿、可恢复

它并不试图消灭失败,而是:

承认失败是分布式系统的常态,并让失败可观测、可控制、可修复。

从这个角度看,本地消息表并不是“退而求其次的土办法”, 而是一种正视不确定性、并围绕不确定性设计系统的工程解法

Released under the MIT License.

本站访客数 人次 本站总访问量