173 lines
7.4 KiB
Markdown
173 lines
7.4 KiB
Markdown
|
|
---
|
|||
|
|
tags: [MQ, 事务消息, 分布式事务, RocketMQ, Kafka]
|
|||
|
|
create time: 2026-05-24 19:52
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# 事务消息
|
|||
|
|
|
|||
|
|
## 概述
|
|||
|
|
|
|||
|
|
事务消息解决的核心问题是:**本地数据库操作与消息发送的原子性**。要么数据库写成功且消息发出去,要么两边都失败——不能出现"数据库写成功了但消息没发出去"或反过来的情况。RocketMQ 原生支持事务消息,Kafka 通过 Producer 事务机制实现类似效果,两者的设计哲学截然不同。
|
|||
|
|
|
|||
|
|
## 正文
|
|||
|
|
|
|||
|
|
### 为什么需要事务消息
|
|||
|
|
|
|||
|
|
考虑一个经典场景:订单服务创建订单后,需要通知库存服务扣减库存。代码可能是:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
func CreateOrder(order Order) error {
|
|||
|
|
// 1. 写数据库
|
|||
|
|
db.Insert(order)
|
|||
|
|
// 2. 发消息通知库存服务
|
|||
|
|
mq.Send("topic_inventory", order)
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
这段代码有两个致命问题:
|
|||
|
|
|
|||
|
|
- **消息发送失败**:数据库写成功了,但 `mq.Send` 网络超时,库存不会扣减,数据不一致。
|
|||
|
|
- **数据库回滚但消息已发出**:如果在事务提交前消息就发出去了,库存扣了但订单没创建。
|
|||
|
|
|
|||
|
|
> [!question] 思考
|
|||
|
|
> 能不能把消息发送放在数据库事务里面?比如先 `BEGIN`,写库,发消息,再 `COMMIT`?
|
|||
|
|
|
|||
|
|
不行。消息发送是网络 IO,不在数据库事务的管辖范围内。即使你把发消息放在 `COMMIT` 之后,`COMMIT` 成功到消息发送成功之间仍然有窗口期可能失败。这就是分布式事务的经典难题。
|
|||
|
|
|
|||
|
|
### 两阶段流程:半消息 → 本地事务 → 提交/回滚
|
|||
|
|
|
|||
|
|
RocketMQ 事务消息的核心思想是引入"半消息"(Half Message)——消息先发送到 Broker,但对 Consumer 不可见,等本地事务执行完毕后再决定是提交(Commit)还是回滚(Rollback)。
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
sequenceDiagram
|
|||
|
|
participant P as Producer
|
|||
|
|
participant B as Broker
|
|||
|
|
participant DB as 本地数据库
|
|||
|
|
participant C as Consumer
|
|||
|
|
|
|||
|
|
P->>B: 1. 发送半消息(Half Message)
|
|||
|
|
B-->>P: 返回发送结果(成功/失败)
|
|||
|
|
|
|||
|
|
Note over P: 半消息发送成功,开始执行本地事务
|
|||
|
|
P->>DB: 2. 执行本地事务(写订单表)
|
|||
|
|
DB-->>P: 事务结果
|
|||
|
|
|
|||
|
|
alt 本地事务成功
|
|||
|
|
P->>B: 3a. 提交半消息(Commit)
|
|||
|
|
B->>C: 消息可见,正常消费
|
|||
|
|
else 本地事务失败
|
|||
|
|
P->>B: 3b. 回滚半消息(Rollback)
|
|||
|
|
Note over B: 消息被丢弃,Consumer 不可见
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
style P fill:#4A90D9,color:#fff
|
|||
|
|
style B fill:#F5A623,color:#fff
|
|||
|
|
style DB fill:#6EC1E0,color:#fff
|
|||
|
|
style C fill:#4A90D9,color:#fff
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
关键点:半消息写入 Broker 后存在内部 Topic(`RMQ_SYS_TRANS_HALF_TOPIC`),不会投递给 Consumer。只有 Commit 之后,消息才会被转移到真正的目标 Topic。
|
|||
|
|
|
|||
|
|
### 事务状态回查
|
|||
|
|
|
|||
|
|
如果 Producer 发送 Commit/Rollback 时网络断了怎么办?Broker 收不到最终决定,半消息就"卡住"了。
|
|||
|
|
|
|||
|
|
RocketMQ 的解决方案是**事务状态回查**:Broker 定期扫描超时的半消息(默认 6 次,每次间隔递增),主动向 Producer 发起回查请求,Producer 根据本地事务执行状态返回 Commit、Rollback 或 Unknown。
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// 事务消息的 Producer 核心逻辑
|
|||
|
|
type TransactionListener interface {
|
|||
|
|
// ExecuteLocalTransaction 执行本地事务
|
|||
|
|
ExecuteLocalTransaction(msg *Message) LocalTransactionState
|
|||
|
|
|
|||
|
|
// CheckLocalTransaction Broker 回查时调用
|
|||
|
|
CheckLocalTransaction(msg *MessageExt) LocalTransactionState
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 示例实现
|
|||
|
|
type OrderTransactionListener struct {
|
|||
|
|
db *sql.DB
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (l *OrderTransactionListener) ExecuteLocalTransaction(msg *Message) LocalTransactionState {
|
|||
|
|
// 执行本地事务:创建订单
|
|||
|
|
order := parseOrder(msg.Body)
|
|||
|
|
err := l.db.Exec("INSERT INTO orders ...", order)
|
|||
|
|
if err != nil {
|
|||
|
|
return RollbackMessage // 失败则回滚半消息
|
|||
|
|
}
|
|||
|
|
return CommitMessage // 成功则提交
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (l *OrderTransactionListener) CheckLocalTransaction(msg *MessageExt) LocalTransactionState {
|
|||
|
|
// Broker 回查:查询订单是否已创建
|
|||
|
|
orderID := msg.GetProperty("ORDER_ID")
|
|||
|
|
var count int
|
|||
|
|
l.db.QueryRow("SELECT COUNT(*) FROM orders WHERE id = ?", orderID).Scan(&count)
|
|||
|
|
|
|||
|
|
if count > 0 {
|
|||
|
|
return CommitMessage // 订单存在,说明本地事务成功了
|
|||
|
|
}
|
|||
|
|
return RollbackMessage // 订单不存在,回滚
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Kafka 的 Producer 事务
|
|||
|
|
|
|||
|
|
Kafka 的思路不同——它不提供"半消息"机制,而是让 Producer 开启事务后,所有写入要么全部可见,要么全部不可见。
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// Kafka Producer 事务示例(confluent-kafka-go 风格)
|
|||
|
|
producer, _ := kafka.NewProducer(&kafka.ConfigMap{
|
|||
|
|
"transactional.id": "order-service-001", // 事务 ID,用于幂等
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
producer.InitTransactions(context.Background())
|
|||
|
|
|
|||
|
|
producer.BeginTransaction()
|
|||
|
|
// 在事务中发送多条消息
|
|||
|
|
producer.Produce(orderMsg, nil)
|
|||
|
|
producer.Produce(inventoryMsg, nil)
|
|||
|
|
// 提交事务——所有消息同时可见
|
|||
|
|
err := producer.CommitTransaction(context.Background())
|
|||
|
|
if err != nil {
|
|||
|
|
producer.AbortTransaction(context.Background())
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Consumer 端配合 `isolation.level=read_committed`,只能读到已提交事务的消息,未提交的会被过滤掉。这和数据库的 MVCC 隔离级别是同一个思路。
|
|||
|
|
|
|||
|
|
### 事务消息 vs 其他分布式事务方案
|
|||
|
|
|
|||
|
|
| 维度 | 事务消息 | 2PC | Saga | TCC |
|
|||
|
|
|------|---------|-----|------|-----|
|
|||
|
|
| 一致性模型 | 最终一致性 | 强一致性 | 最终一致性 | 最终一致性 |
|
|||
|
|
| 性能 | 高 | 低(阻塞) | 高 | 中 |
|
|||
|
|
| 实现复杂度 | 低 | 中 | 高 | 很高 |
|
|||
|
|
| 业务侵入 | 低 | 低 | 高(补偿逻辑) | 很高(三个接口) |
|
|||
|
|
| 适用场景 | 事件驱动、异步解耦 | 数据库分布式事务 | 长流程编排 | 资金类强一致 |
|
|||
|
|
| 失败处理 | 回查 + 人工介入 | 全局回滚 | 补偿事务 | Cancel + 防悬挂 |
|
|||
|
|
|
|||
|
|
事务消息的核心优势是**解耦**——Producer 不需要知道下游有哪些 Consumer,也不需要定义补偿逻辑。它适合"本地事务成功后通知其他服务"的场景,不适合需要跨服务同步等待结果的场景。
|
|||
|
|
|
|||
|
|
> [!question] 思考
|
|||
|
|
> 事务消息的回查机制在网络分区时可能失败,如何保证最终一致性?
|
|||
|
|
|
|||
|
|
回查失败时 Broker 会按递增间隔多次重试(默认最多 6 次)。如果全部失败,半消息会进入死信队列(`RMQ_SYS_TRANS_OP_HALF_TOPIC`),运维人员可以介入处理。本质上,事务消息保证的是"最终一致性"而非"强一致性"——在极端故障下,需要人工兜底。生产环境中,建议配合对账任务定期检查本地事务和消息状态的一致性。
|
|||
|
|
|
|||
|
|
### 事务消息的局限性
|
|||
|
|
|
|||
|
|
- **单向通知**:事务消息只保证"本地事务 + 消息发送"的原子性,不保证 Consumer 消费成功。
|
|||
|
|
- **延迟**:半消息从发送到 Consumer 可见有延迟(取决于本地事务执行时间和可能的回查)。
|
|||
|
|
- **不支持批量**:一次事务消息只能关联一条消息,不支持多条消息的原子发送(Kafka 的 Producer 事务支持批量)。
|
|||
|
|
- **回查依赖网络**:回查机制在网络分区时可能失效,需要运维兜底。
|
|||
|
|
|
|||
|
|
## 关联笔记
|
|||
|
|
|
|||
|
|
- [[06-高级特性/17-MQ-延迟消息与定时消息|MQ 延迟消息与定时消息]]
|
|||
|
|
- [[06-高级特性/19-MQ-消息过滤与路由|MQ 消息过滤与路由]]
|
|||
|
|
- [[05-可靠性保障/13-MQ-Exactly-Once-语义|MQ Exactly-Once 语义]]
|
|||
|
|
- [[12-架构与实战/46-MQ-分布式事务实践|MQ 分布式事务实践]]
|
|||
|
|
- [[07-主流MQ对比/24-RocketMQ|RocketMQ]]
|