如何保证事务?
事务一般都如何保证,在项目中,单体项目大概用注解都可以解决。对于微服务或者跨服务的呢?常用的解决方案。
微服务里的事务,核心不是"有没有一个注解可以解决",而是要先接受一个事实:
@Transactional 只能保证当前服务、当前数据库连接里的本地事务。
一旦涉及:
- 服务 A 调服务 B
- 服务 A 写自己的库,服务 B 写自己的库
- Feign / HTTP / MQ / RPC 参与业务链路
- 一个业务动作要跨多个数据库完成
就不能靠一个 @Transactional 注解解决了。一般会从"强一致事务"转向"最终一致性 + 幂等 + 补偿 + 对账"。
1. 单体项目里事务怎么保证?
单体项目大多数情况确实可以靠@Transactional:
scss
@Transactional
public void createOrder(CreateOrderRequest request) {
orderMapper.insert(order);
stockMapper.deductStock(request.getSkuId(), request.getCount());
accountMapper.freezeAmount(request.getUserId(), request.getAmount());
}
因为这些操作通常满足几个条件:
| 条件 | 说明 |
|---|---|
| 一个应用 | 都在同一个 Spring Boot 进程里 |
| 一个数据库或同一个事务管理器 | Spring 能拿到同一个数据库连接 |
| 一个方法边界 | 方法成功提交,异常回滚 |
| 本地 ACID | 由数据库保证原子性、一致性、隔离性、持久性 |
流程大概是:
xml
```mermaid
flowchart TD
A["Controller 请求"] --> B["Service 方法"]
B --> C["@Transactional 开启事务"]
C --> D["insert 订单"]
D --> E["扣库存"]
E --> F["冻结金额"]
F --> G{"是否异常?"}
G -- "否" --> H["commit 提交"]
G -- "是" --> I["rollback 回滚"]
```
在单体里,只要你注意几个坑,@Transactional 基本能覆盖大部分场景:
- 方法必须是
public - 不能同类方法内部调用导致代理失效
- 默认只回滚
RuntimeException - 异常不能被吞掉
- 数据库表要支持事务,比如 MySQL 用 InnoDB
- 多数据源时要确认用的是正确的事务管理器
2. 微服务里为什么不能简单靠 @Transactional?
假设现在有三个服务:
css
订单服务 order-service
库存服务 stock-service
账户服务 account-service
订单服务里这样写:
scss
@Transactional
public void createOrder(CreateOrderRequest request) {
orderMapper.insert(order);
stockFeign.deductStock(request.getSkuId(), request.getCount());
accountFeign.freezeAmount(request.getUserId(), request.getAmount());
}
很多人一开始会误以为:
这个方法加了
@Transactional,只要后面出错,所有东西都会回滚。
这是错的。
@Transactional 只能回滚订单服务自己的数据库:
css
order-service 的数据库:可以回滚
stock-service 的数据库:回滚不了
account-service 的数据库:回滚不了
因为 Feign 调用的是远程 HTTP/RPC 请求,远程服务已经自己提交了自己的数据库事务。
举个问题场景:
markdown
1. 订单服务插入订单成功
2. 库存服务扣库存成功
3. 账户服务冻结金额失败
4. 订单服务本地事务回滚
最后结果可能是:
订单没有了
库存却被扣了
金额没有冻结
这就是典型的跨服务一致性问题。
3. 微服务事务的本质问题是什么?
本质问题是:
一个业务动作被拆到了多个服务、多个数据库、多个事务里。
单体里是:
一个业务动作 = 一个本地事务
微服务里变成:
一个业务动作 = 多个本地事务 + 网络调用 + 失败重试 + 超时 + 消息延迟
网络调用会带来很多不确定性:
| 问题 | 例子 |
|---|---|
| 超时 | 服务 B 实际成功了,但服务 A 没收到响应 |
| 重试 | 服务 A 重试调用,服务 B 可能重复扣库存 |
| 部分成功 | 订单成功,库存成功,支付失败 |
| 消息延迟 | MQ 消息晚几秒才被消费 |
| 服务宕机 | 执行到一半服务挂了 |
| 回滚困难 | 远程服务已经提交,没法被本地事务直接回滚 |
所以微服务事务一般不是简单追求"全部一起提交、全部一起回滚",而是设计成:
每个服务保证自己的本地事务
服务之间通过消息、状态机、补偿、幂等来保证最终一致
4. 真实项目里常用的解决方案
常见方案主要有这几类:
| 方案 | 一致性 | 常用程度 | 适合场景 |
|---|---|---|---|
| 本地事务 + MQ 最终一致性 | 最终一致 | 很常用 | 订单、通知、审批、异步处理 |
| 事务消息 | 最终一致 | 很常用 | 下单后发消息、支付后改状态 |
| Outbox Pattern 本地消息表 | 最终一致 | 很常用 | 对可靠性要求高的业务事件 |
| Saga | 最终一致 | 常用 | 跨多个服务的长流程 |
| TCC | 较强一致 | 有一定使用 | 资金、库存冻结、资源预留 |
| Seata AT | 近似分布式事务 | 国内项目常见 | 改造成本较低的 Java 微服务 |
| Seata TCC / Saga / XA | 取决于模式 | 特定场景 | 强一致或流程型事务 |
| XA / 2PC | 强一致 | 不太常用 | 银行、金融核心、低并发强一致 |
| 人工对账 + 补偿任务 | 最终一致 | 非常常见 | 支付、结算、财务、三方系统 |
一、本地事务 + MQ 最终一致性
这是企业项目里最常见、最实用的方案之一。
比如用户提交报销单:
markdown
报销服务:
1. 保存报销单
2. 提交审批
3. 发送"报销单已提交"消息
审批服务:
1. 消费消息
2. 创建审批流程
不要让报销服务同步强依赖审批服务一定成功,而是通过 MQ 解耦。
流程:
rust
```mermaid
sequenceDiagram
participant A as 报销服务
participant DB1 as 报销库
participant MQ as 消息队列
participant B as 审批服务
participant DB2 as 审批库
A->>DB1: 保存报销单
A->>DB1: 本地事务提交
A->>MQ: 发送报销单已提交消息
MQ->>B: 推送消息
B->>DB2: 创建审批流程
```
但是这里有一个关键问题:
如果数据库提交成功了,但是发送 MQ 失败怎么办?
所以直接这样写是不够严谨的:
typescript
@Transactional
public void submitExpense(Long expenseId) {
expenseMapper.updateStatus(expenseId, "SUBMITTED");
mqProducer.send("expense.submitted", expenseId);
}
这段代码的问题是:
DB 提交成功,MQ 发送失败,审批服务永远不知道这个单据已提交。
所以更可靠的做法是:本地消息表,也叫 Outbox Pattern。
二、Outbox Pattern:本地消息表
核心思想:
业务数据和待发送消息放在同一个本地事务里提交。
比如报销服务提交单据时,不是直接发 MQ,而是先写业务表和消息表:
python
@Transactional
public void submitExpense(Long expenseId) {
expenseMapper.updateStatus(expenseId, "SUBMITTED");
OutboxMessage message = new OutboxMessage();
message.setBizType("EXPENSE_SUBMITTED");
message.setBizId(expenseId.toString());
message.setPayload("""
{
"expenseId": %d
}
""".formatted(expenseId));
message.setStatus("PENDING");
outboxMessageMapper.insert(message);
}
这两个操作在一个本地事务里:
更新报销单状态
插入待发送消息
要么都成功,要么都失败。
然后有一个后台任务专门扫描消息表:
scss
@Scheduled(fixedDelay = 3000)
public void publishPendingMessages() {
List<OutboxMessage> messages = outboxMessageMapper.findPendingMessages();
for (OutboxMessage message : messages) {
try {
mqProducer.send(message.getTopic(), message.getPayload());
outboxMessageMapper.markSent(message.getId());
} catch (Exception e) {
outboxMessageMapper.increaseRetryCount(message.getId());
}
}
}
流程:
xml
```mermaid
flowchart TD
A["提交报销单"] --> B["@Transactional 本地事务"]
B --> C["更新报销单状态"]
B --> D["插入 outbox_message"]
C --> E["事务提交"]
D --> E
E --> F["定时任务扫描待发送消息"]
F --> G["发送 MQ"]
G --> H["发送成功,标记 SENT"]
G --> I["发送失败,等待重试"]
```
这个方案的好处:
| 优点 | 说明 |
|---|---|
| 可靠 | 业务数据和消息数据在一个事务里 |
| 解耦 | 下游服务挂了也不影响当前服务提交 |
| 可重试 | 消息发送失败可以继续扫表重试 |
| 可追踪 | 消息表里能看到发送状态 |
| 适合企业项目 | 订单、审批、报销、通知、AI任务都能用 |
这个方案的代价:
| 代价 | 说明 |
|---|---|
| 最终一致 | 不是马上完成所有动作 |
| 需要幂等 | 下游可能重复收到消息 |
| 需要补偿 | 下游处理失败要能重试或人工处理 |
| 需要清理 | 历史消息表要归档 |
三、事务消息
有些 MQ 自身支持事务消息,比如 RocketMQ 的事务消息。
流程大概是:
markdown
1. 先发送半消息
2. 执行业务本地事务
3. 本地事务成功,提交消息
4. 本地事务失败,回滚消息
5. 如果状态不确定,MQ 反查业务服务
流程:
rust
```mermaid
sequenceDiagram
participant A as 订单服务
participant MQ as RocketMQ
participant DB as 订单库
participant B as 库存服务
A->>MQ: 发送半消息
A->>DB: 创建订单
DB-->>A: 本地事务成功
A->>MQ: 提交消息
MQ->>B: 投递消息
B->>B: 扣库存
```
适合场景:
本地事务成功后,需要可靠通知其他服务。
比如:
- 支付成功后通知订单服务改状态
- 订单创建后通知库存服务预占库存
- 报销单提交后通知审批服务创建流程
- 文件上传成功后通知 Python AI 服务做 RAG 入库
优点:
| 优点 | 说明 |
|---|---|
| MQ 原生支持 | 不用自己维护部分消息表逻辑 |
| 可靠性高 | 有事务回查机制 |
| 适合异步业务 | 订单、支付、审批、AI入库 |
缺点:
| 缺点 | 说明 |
|---|---|
| 依赖 MQ 能力 | 不是所有 MQ 都支持事务消息 |
| 业务仍需幂等 | 消息可能重复投递 |
| 仍是最终一致 | 不是全局强一致事务 |
四、Saga 模式
Saga 是微服务里很经典的分布式事务方案。
它的思想是:
把一个大事务拆成多个本地事务,每一步都有对应的补偿动作。
例如下单流程:
markdown
正向流程:
1. 创建订单
2. 扣减库存
3. 冻结余额
4. 创建物流单
补偿流程:
1. 取消物流单
2. 解冻余额
3. 释放库存
4. 取消订单
流程:
xml
```mermaid
flowchart TD
A["创建订单"] --> B["扣减库存"]
B --> C["冻结余额"]
C --> D["创建物流单"]
D --> E["下单成功"]
B -->|失败| B1["取消订单"]
C -->|失败| C1["释放库存"]
C1 --> C2["取消订单"]
D -->|失败| D1["解冻余额"]
D1 --> D2["释放库存"]
D2 --> D3["取消订单"]
```
Saga 有两种常见方式。
1. 编排式 Saga
有一个"流程编排服务"控制每一步。
markdown
订单编排器:
1. 调订单服务创建订单
2. 调库存服务扣库存
3. 调账户服务冻结金额
4. 如果中途失败,按反方向调用补偿接口
代码形态可能是:
scss
public void createOrderSaga(CreateOrderCommand command) {
Long orderId = orderClient.createOrder(command);
try {
stockClient.deduct(command.getSkuId(), command.getCount());
accountClient.freeze(command.getUserId(), command.getAmount());
orderClient.markSuccess(orderId);
} catch (Exception e) {
accountClient.unfreeze(command.getUserId(), command.getAmount());
stockClient.release(command.getSkuId(), command.getCount());
orderClient.cancel(orderId);
throw e;
}
}
真实项目里不会这么裸写,通常会配合:
- 状态机
- MQ
- 工作流引擎
- 任务表
- 重试机制
- 幂等控制
- 补偿记录
2. 事件驱动 Saga
没有中心编排器,每个服务监听事件后推进下一步。
订单服务发布 OrderCreated
库存服务消费后扣库存,发布 StockDeducted
账户服务消费后冻结金额,发布 AccountFrozen
订单服务最终标记成功
流程:
rust
```mermaid
sequenceDiagram
participant O as 订单服务
participant MQ as MQ
participant S as 库存服务
participant A as 账户服务
O->>O: 创建订单
O->>MQ: 发布 OrderCreated
MQ->>S: 库存服务消费
S->>S: 扣库存
S->>MQ: 发布 StockDeducted
MQ->>A: 账户服务消费
A->>A: 冻结余额
A->>MQ: 发布 AccountFrozen
MQ->>O: 订单服务消费
O->>O: 标记订单成功
```
Saga 适合:
- 订单流程
- 审批流程
- 报销流程
- 开票流程
- 出库入库流程
- AI 长任务处理流程
Saga 不适合:
- 必须瞬间强一致的资金核心扣款
- 每一步都无法补偿的操作
- 业务没有明确状态机的场景
五、TCC 模式
TCC 是:
vbnet
Try
Confirm
Cancel
它比 Saga 更强一些,适合"资源预留"类业务。
以库存为例。
Try:尝试阶段
先冻结库存,不是真的扣掉。
typescript
商品库存 100
冻结库存 10
可用库存 90
public void tryDeductStock(Long skuId, Integer count) {
stockMapper.freezeStock(skuId, count);
}
Confirm:确认阶段
业务整体成功后,把冻结库存转成真正扣减。
typescript
public void confirmDeductStock(Long skuId, Integer count) {
stockMapper.confirmDeduct(skuId, count);
}
Cancel:取消阶段
业务失败后,释放冻结库存。
typescript
public void cancelDeductStock(Long skuId, Integer count) {
stockMapper.releaseFrozenStock(skuId, count);
}
流程:
xml
```mermaid
flowchart TD
A["订单服务发起交易"] --> B["库存 Try: 冻结库存"]
B --> C["账户 Try: 冻结余额"]
C --> D{"所有 Try 是否成功?"}
D -- "是" --> E["库存 Confirm: 确认扣减"]
E --> F["账户 Confirm: 确认扣款"]
F --> G["交易成功"]
D -- "否" --> H["库存 Cancel: 释放库存"]
H --> I["账户 Cancel: 解冻余额"]
I --> J["交易失败"]
```
TCC 的优点:
| 优点 | 说明 |
|---|---|
| 一致性更强 | 先预留资源,再统一确认 |
| 适合核心资源 | 库存、余额、额度、券码 |
| 可控 | 每个服务明确 Try/Confirm/Cancel |
TCC 的缺点:
| 缺点 | 说明 |
|---|---|
| 开发成本高 | 每个接口要写三套逻辑 |
| 业务侵入强 | 表结构和接口都要支持冻结/确认/取消 |
| 幂等要求高 | Confirm 和 Cancel 可能被重复调用 |
| 悬挂问题 | Cancel 先到,Try 后到,要处理 |
| 空回滚问题 | Try 没成功,Cancel 也被调用,要能正常返回 |
TCC 常见表字段设计:
diff
account_balance
- user_id
- total_amount
- available_amount
- frozen_amount
stock
- sku_id
- total_stock
- available_stock
- frozen_stock
真实项目里,TCC 一定要考虑三个问题:
| 问题 | 含义 |
|---|---|
| 幂等 | Try/Confirm/Cancel 重复执行,结果不能错 |
| 空回滚 | Try 没执行成功,Cancel 调过来也不能报错 |
| 防悬挂 | Cancel 已执行,后来的 Try 不能再成功 |
六、Seata 分布式事务
Seata 支持几种模式:
| 模式 | 说明 | 适合场景 |
|---|---|---|
| AT 模式 | 自动代理数据库操作,生成 undo_log | 普通数据库 CRUD |
| TCC 模式 | 业务自己实现 Try/Confirm/Cancel | 核心资源控制 |
| Saga 模式 | 状态机 + 补偿事务 | 长流程 |
| XA 模式 | 基于数据库 XA 协议 | 强一致但性能较低 |
最常见的是 AT 模式。
代码大概是这样:
scss
@GlobalTransactional
public void createOrder(CreateOrderRequest request) {
orderMapper.insert(order);
stockFeign.deductStock(request.getSkuId(), request.getCount());
accountFeign.freezeAmount(request.getUserId(), request.getAmount());
}
和 @Transactional 不同,@GlobalTransactional 是 Seata 提供的全局事务注解。
大致流程:
xml
```mermaid
flowchart TD
A["订单服务 @GlobalTransactional"] --> B["开启全局事务 XID"]
B --> C["订单服务写订单库"]
C --> D["库存服务扣库存"]
D --> E["账户服务冻结金额"]
E --> F{"全局是否成功?"}
F -- "是" --> G["全局提交"]
F -- "否" --> H["根据 undo_log 回滚各服务本地变更"]
```
Seata AT 模式优点:
| 优点 | 说明 |
|---|---|
| 接入相对简单 | 业务代码改动比 TCC 少 |
| 对 CRUD 友好 | 普通 insert/update/delete 比较适合 |
| Java 生态常见 | Spring Cloud 项目里资料多 |
Seata AT 模式缺点:
| 缺点 | 说明 |
|---|---|
| 有性能损耗 | 要记录 undo_log、加全局锁 |
| 有数据库侵入 | 每个库通常需要 undo_log 表 |
| 不适合长事务 | 全局事务时间长会影响并发 |
| 不适合所有 SQL | 复杂 SQL、批量更新、存储过程要谨慎 |
| 运维成本 | 需要 Seata Server,高可用部署 |
什么时候可以考虑 Seata AT?
markdown
1. Java 微服务体系
2. 多服务、多库,但是业务希望接近本地事务体验
3. 并发量不是特别夸张
4. SQL 类型比较常规
5. 团队愿意接受 Seata 的部署和治理成本
什么时候不建议优先上 Seata AT?
markdown
1. 高并发核心链路
2. 长流程业务,比如审批、报销、AI任务处理
3. 跨外部系统,比如三方支付、短信、OSS、Python AI服务
4. 业务天然可以异步最终一致
5. 团队还没做好幂等、重试、补偿、对账
七、XA / 2PC 强一致事务
XA / 2PC 是比较传统的分布式事务方案。
它分两阶段:
sql
第一阶段:prepare,问所有参与者能不能提交
第二阶段:commit / rollback,统一提交或回滚
流程:
rust
```mermaid
sequenceDiagram
participant TC as 事务协调器
participant A as 订单库
participant B as 库存库
participant C as 账户库
TC->>A: prepare
TC->>B: prepare
TC->>C: prepare
A-->>TC: OK
B-->>TC: OK
C-->>TC: OK
TC->>A: commit
TC->>B: commit
TC->>C: commit
```
优点:
强一致。
缺点也很明显:
性能差
锁持有时间长
协调器复杂
参与方阻塞
对数据库和中间件要求高
所以普通互联网业务、普通企业管理系统里,不是首选。
更常见的选择是:
本地事务 + MQ + 幂等 + 补偿 + 对账
八、最大量使用的通用方案:本地事务 + 状态机 + MQ + 幂等
项目里,我更建议你建立这个默认思路:
不要一上来就想全局事务。
先看能不能拆成状态流转。
每个服务只保证自己的本地事务。
跨服务通过事件推动。
失败靠重试、补偿、对账解决。
以报销审批为例。
报销服务
diff
expense_bill
- id
- user_id
- amount
- status
状态:
DRAFT 草稿
SUBMITTED 已提交
APPROVING 审批中
APPROVED 审批通过
REJECTED 审批拒绝
PAYING 打款中
PAID 已打款
FAILED 失败
提交报销单时:
java
@Transactional
public void submit(Long billId) {
ExpenseBill bill = expenseBillMapper.selectById(billId);
if (!"DRAFT".equals(bill.getStatus())) {
throw new BusinessException("当前状态不允许提交");
}
expenseBillMapper.updateStatus(billId, "SUBMITTED");
outboxMessageMapper.insert(
OutboxMessage.of("EXPENSE_SUBMITTED", billId.toString())
);
}
审批服务消费消息:
csharp
@Transactional
public void handleExpenseSubmitted(ExpenseSubmittedEvent event) {
if (approvalFlowMapper.existsByBizId(event.getBillId())) {
return;
}
approvalFlowMapper.createFlow(event.getBillId(), "EXPENSE");
}
这里有几个关键点:
| 点 | 作用 |
|---|---|
| 状态机 | 控制业务不能乱跳 |
| 本地事务 | 保证当前服务自己的数据正确 |
| Outbox | 保证消息一定能发出去 |
| MQ 重试 | 下游失败可以自动重试 |
| 幂等 | 消息重复消费也不会重复创建审批 |
| 补偿任务 | 长时间异常的数据可以修复 |
| 对账任务 | 定期发现不一致数据 |
九、幂等是微服务事务的底座
微服务里你必须假设:
同一个请求可能来多次。
同一条 MQ 消息可能消费多次。
同一个 Feign 调用可能因为超时被重试。
所以每个关键接口都要支持幂等。
常见幂等方案:
| 方案 | 例子 |
|---|---|
| 唯一业务单号 | request_no、order_no、biz_id |
| 唯一索引 | (biz_type, biz_id) |
| 状态判断 | 只有 DRAFT 才能提交 |
| 幂等表 | 记录每次请求是否处理过 |
| 去重表 | MQ 消费时记录 message_id |
| Redis 锁 | 防止短时间重复提交 |
| 乐观锁 | version 字段控制并发更新 |
| 数据库条件更新 | where status = 'DRAFT' |
例如审批服务消费消息:
arduino
@Transactional
public void createApprovalFlow(ExpenseSubmittedEvent event) {
boolean exists = approvalFlowMapper.existsByBizId(event.getBillId());
if (exists) {
return;
}
approvalFlowMapper.insert(event.getBillId(), "EXPENSE");
}
更稳的做法是加唯一索引:
sql
alter table approval_flow
add unique key uk_biz_type_biz_id (biz_type, biz_id);
然后代码里即使并发消费两次,也不会创建两条审批流。
十、补偿机制是什么?
补偿不是简单的"回滚数据库"。
补偿是:
当一个流程后续失败时,通过反向业务动作把系统恢复到合理状态。
例如:
| 正向动作 | 补偿动作 |
|---|---|
| 创建订单 | 取消订单 |
| 冻结余额 | 解冻余额 |
| 预占库存 | 释放库存 |
| 创建审批流 | 关闭审批流 |
| 创建 RAG 入库任务 | 标记任务失败或重新入库 |
| 调用三方支付 | 发起退款 |
比如 AI 文件入库:
Java 服务上传文件成功
Python 服务开始切片、向量化、入库
如果 Python 入库失败
Java 文件不能回滚删除
正确做法是标记知识库文档状态为 INDEX_FAILED
用户可以重试入库
后台任务可以自动补偿
这就是典型的最终一致性。
状态可能是:
UPLOADED 已上传
INDEXING 入库中
INDEXED 已入库
INDEX_FAILED 入库失败
DELETED 已删除
流程:
xml
```mermaid
flowchart TD
A["Java 上传文件"] --> B["保存文件元数据"]
B --> C["写入 outbox 消息"]
C --> D["Python 消费文件入库事件"]
D --> E["下载 OSS 文件"]
E --> F["切片"]
F --> G["向量化"]
G --> H{"入库成功?"}
H -- "是" --> I["回调 Java 标记 INDEXED"]
H -- "否" --> J["回调 Java 标记 INDEX_FAILED"]
J --> K["等待重试或人工处理"]
```
这个场景就不适合强分布式事务。
因为你无法把这些动作放进一个数据库事务里:
OSS 上传文件
Python 切片
调用 embedding 模型
写向量库
回调 Java
正确方案就是:
状态机 + MQ + 重试 + 幂等 + 失败可见 + 补偿任务
十一、Feign 调用能不能放在事务里?
可以写,但要非常谨慎。
比如:
scss
@Transactional
public void createOrder(CreateOrderRequest request) {
orderMapper.insert(order);
stockFeign.deductStock(request.getSkuId(), request.getCount());
}
这段代码有几个风险:
1. 本地事务时间变长
数据库事务打开后,又去等远程服务响应。
如果远程服务慢,本地数据库锁就会持有更久。
开启事务
插入订单,占用数据库连接和锁
等待库存服务 3 秒
事务才提交
这会影响并发。
2. 远程调用不能跟着本地事务回滚
库存服务扣减成功后,订单服务后面异常,订单服务可以回滚,但是库存服务不会自动回滚。
3. 超时结果不确定
订单服务调用库存服务超时:
订单服务看到:失败
库存服务实际:可能成功了
如果订单服务重试,可能导致重复扣库存。
所以项目一般不建议在本地事务里随便放远程调用。
更推荐:
本地事务里只做本服务数据库操作
事务提交后再发消息或触发下游流程
如果必须同步调用远程服务,要注意:
| 要求 | 说明 |
|---|---|
| 远程接口必须幂等 | 防止重复调用 |
| 超时时间要短 | 防止本地事务持锁过久 |
| 有明确状态 | 不能只靠异常判断 |
| 有补偿接口 | 失败后能撤销 |
| 有对账任务 | 修复不一致状态 |
| 不要长事务 | 尽量缩小事务范围 |
十二、怎么选方案?
可以按这个顺序判断。
1. 能不能变成本地事务?
这是第一优先级。
如果两个数据强相关,经常要一起提交、一起回滚,说明它们可能不该拆到两个服务里。
例如:
订单主表
订单明细表
订单扩展表
这些一般应该属于订单服务自己的数据库,本地事务解决即可。
不要为了"微服务"把强一致数据硬拆开。
2. 能不能接受最终一致?
大多数业务其实可以接受几秒内一致。
比如:
提交报销单后,审批流晚 1 秒创建
文件上传后,知识库晚 10 秒可检索
订单创建后,库存稍后预占
支付成功后,订单状态稍后更新
这种场景优先用:
本地事务 + Outbox + MQ + 幂等消费
3. 是否需要资源预留?
如果是库存、余额、额度这类资源,不能简单异步处理,因为可能超卖或超扣。
这时考虑:
TCC
冻结库存 / 冻结余额
Confirm / Cancel
4. 是否是长流程?
如果流程比较长,比如:
申请
审批
复核
打款
归档
通知
这类流程不适合数据库长事务。
应该用:
Saga
状态机
工作流引擎
事件驱动
5. 是否真的需要强一致?
如果是金融核心记账、资金清结算、账户余额核心链路,可能需要更强一致。
可以考虑:
TCC
Seata TCC
XA
数据库层强约束
账务流水 + 对账
但即使是金融系统,也不是所有地方都用强分布式事务。很多时候会用:
流水不可变
状态流转
冲正
对账
补偿
十三、常见业务场景推荐
| 场景 | 推荐方案 |
|---|---|
| 单体项目内多个表一起更新 | @Transactional |
| 一个服务内一个库 | @Transactional |
| 一个服务内多数据源 | 尽量避免;必要时 JTA/XA 或拆业务 |
| Java 上传文件后通知 Python RAG 入库 | Outbox + MQ + 状态机 |
| 报销单提交后创建审批流程 | Outbox + MQ,或同步调用 + 补偿 |
| 支付成功后改订单状态 | 事务消息 / MQ + 幂等 |
| 下单扣库存 | TCC / 预占库存 / Saga |
| 发短信、发通知 | MQ,不能放核心事务里强依赖 |
| 调三方支付 | 本地流水 + 状态机 + 回调 + 对账 |
| AI 长任务编排 | 任务表 + MQ + 状态机 + 重试 |
| 强资金一致性 | TCC / XA / 账务流水 / 对账 |
| 普通后台管理系统跨服务修改 | 优先最终一致,不要滥用分布式事务 |
| 老系统想快速接分布式事务 | 可评估 Seata AT,但要压测和验证 SQL 支持 |
十四、举例说明 Java + Python AI 项目
架构是:
Java 微服务:业务系统、权限、文件元数据、单据模板、审批、用户、租户
Python AI 服务:智能体编排、RAG 入库、RAG 检索、LLM 调用
这里不应该追求 Java 和 Python 之间的强分布式事务。
比如上传知识库文件:
markdown
Java:
1. 接收文件上传
2. 上传到 OSS
3. 保存文件元数据
4. 写入 outbox 消息:DOCUMENT_UPLOADED
5. 返回前端:上传成功,入库中
Python:
1. 消费 DOCUMENT_UPLOADED
2. 调 Java 接口获取文件元数据和权限信息
3. 下载 OSS 文件
4. 切片
5. embedding
6. 写入向量库
7. 回调 Java 更新状态:INDEXED / INDEX_FAILED
这就是标准的最终一致方案。
状态设计:
UPLOADED
INDEX_PENDING
INDEXING
INDEXED
INDEX_FAILED
DELETE_PENDING
DELETED
Java 文档表可以这样设计:
sql
create table knowledge_document (
id bigint primary key,
tenant_id bigint not null,
file_name varchar(255) not null,
file_url varchar(1000) not null,
category varchar(100) not null,
index_status varchar(50) not null,
version int not null default 0,
created_at datetime not null,
updated_at datetime not null
);
Outbox 消息表:
sql
create table outbox_message (
id bigint primary key,
topic varchar(100) not null,
biz_type varchar(100) not null,
biz_id varchar(100) not null,
payload text not null,
status varchar(50) not null,
retry_count int not null default 0,
next_retry_time datetime null,
created_at datetime not null,
updated_at datetime not null,
unique key uk_biz_type_biz_id (biz_type, biz_id)
);
Python 入库服务要保证幂等:
同一个 document_id + version 重复入库,不应该产生重复向量数据。
向量库里可以存:
chunk_id
document_id
document_version
tenant_id
permission_scope
chunk_index
chunk_text
embedding
metadata
如果重新入库:
先按 document_id + document_version 删除旧 chunk
再写入新的 chunk
或者新版本写入成功后切换 active_version
这里最重要的不是"分布式事务",而是:
状态清楚
消息可靠
消费幂等
失败可重试
数据可对账
十五、一个项目里比较稳的标准模板
你可以把跨服务事务都拆成这个模板思考:
markdown
1. 当前服务先完成自己的本地事务
2. 本地事务里写业务表 + 消息表
3. 后台任务或事务消息把事件发出去
4. 下游服务消费事件
5. 下游服务本地事务处理自己的数据
6. 下游服务保证幂等
7. 失败自动重试
8. 多次失败进入失败表或死信队列
9. 定时任务对账
10. 必要时人工补偿
流程图:
xml
```mermaid
flowchart TD
A["业务请求"] --> B["服务 A 本地事务"]
B --> C["写业务数据"]
B --> D["写 outbox 消息"]
C --> E["提交事务"]
D --> E
E --> F["消息发布器发送 MQ"]
F --> G["服务 B 消费消息"]
G --> H{"是否已处理?"}
H -- "是" --> I["直接返回成功"]
H -- "否" --> J["服务 B 本地事务"]
J --> K["写服务 B 业务数据"]
K --> L["记录消费成功"]
L --> M["流程结束"]
G --> N["消费失败"]
N --> O["自动重试"]
O --> P{"超过重试次数?"}
P -- "否" --> G
P -- "是" --> Q["进入死信队列或失败表"]
Q --> R["补偿任务或人工处理"]
```