背景与问题
做微服务之后,业务拆开了,事务也跟着碎了。
单体时代,一个本地数据库事务基本能解决大部分问题:扣库存、创建订单、冻结余额,放进一个事务里,要么都成功,要么都回滚。但到了微服务架构里,这几个动作可能分别在订单服务、库存服务、账户服务里,各自有独立数据库。此时再指望一个数据库事务兜底,已经不现实。
最典型的问题有三类:
-
强一致要求高
比如资金扣减、额度控制,这类场景通常不能接受“过几秒再一致”。 -
高吞吐优先,允许短暂不一致
比如下单后发积分、发优惠券、更新推荐画像。失败后重试或补偿通常可以接受。 -
跨系统、跨组织边界
有些调用方甚至不是自己团队维护,根本没法要求对方接入统一事务框架。
所以,“分布式事务怎么选”从来不是一个纯技术问题,而是一个业务一致性等级、性能目标、团队控制力、运维复杂度的综合取舍题。
这篇文章我会从实战角度,把常见三类方案放在一起看:
- Seata(以 AT / TCC 为代表)
- 可靠消息最终一致性
- 补偿机制(Saga / 业务补偿)
目标不是讲概念大全,而是回答三个更实在的问题:
- 什么场景适合哪种方案?
- 落地时代码怎么写?
- 线上容易踩哪些坑,怎么排查?
先给结论:怎么选更靠谱
如果你时间有限,先看这张图。
flowchart TD
A[开始:是否需要分布式事务] --> B{是否要求强一致且可接受较高耦合?}
B -- 是 --> C{参与服务是否可统一接入框架?}
C -- 是 --> D[优先 Seata AT/TCC]
C -- 否 --> E[考虑 TCC 或业务补偿]
B -- 否 --> F{是否允许异步化和短暂不一致?}
F -- 是 --> G[可靠消息最终一致性]
F -- 否 --> H[补偿机制/Saga 拆分流程]
D --> I[资金/库存预留/核心链路]
G --> J[积分/通知/非核心派生数据]
H --> K[跨系统长流程/人工介入]
我自己的经验可以概括成一句话:
- 核心链路、短事务、自己能控全链路:优先考虑 Seata
- 高吞吐、可异步、允许延迟一致:优先考虑 可靠消息最终一致性
- 长流程、多系统、失败不可避免且需要显式回退动作:优先考虑 补偿机制
这三种方案并不是互斥关系,很多成熟系统其实是组合拳:
- 下单主流程用 Seata 控制订单和库存
- 发券、发短信、埋点用 MQ 做最终一致
- 跨境支付或外部供应商对接用补偿机制兜底
方案对比与取舍分析
先把三种方案放到一张表里看。
| 方案 | 一致性 | 性能 | 侵入性 | 适用场景 | 典型风险 |
|---|---|---|---|---|---|
| Seata AT | 较强 | 中 | 中 | 自研服务、关系型数据库、本地事务改造少 | 全局锁、回滚失败、SQL 限制 |
| Seata TCC | 强 | 中低 | 高 | 资金、库存冻结等可预留资源场景 | 开发复杂、幂等空回滚悬挂 |
| 可靠消息最终一致性 | 最终一致 | 高 | 中 | 异步业务、通知类、积分等 | 消息重复、丢失、消费失败 |
| 补偿机制 / Saga | 最终一致 | 中 | 高 | 长流程、多系统、外部接口 | 补偿失败、状态机复杂、人工介入 |
选型维度建议
1. 先问业务能接受多长时间不一致
- 不能接受:往 Seata / TCC 靠
- 几秒到几分钟可以接受:MQ 最终一致
- 可能持续更久,需要人处理:补偿机制
2. 再问是否能控制参与方
如果所有服务都在自己团队边界内,统一引入 Seata 难度会小很多。
如果有第三方支付、外部仓储、老系统,就别指望全链路统一事务框架了,补偿往往更现实。
3. 最后看吞吐量和成本
- 订单高峰每秒几千以上,主流程尽量不要塞太多同步操作
- Seata 带来的额外分支事务、锁冲突、undo log 开销,需要提前压测
- MQ 方案扩展性更好,但要建立完整的重试、幂等、死信和对账能力
核心原理
一、Seata 的核心思路
Seata 最常见的是 AT 模式。
它的思路不是搞一个真正意义上的跨库两阶段提交,而是:
- 业务执行本地事务
- 在提交前记录回滚日志
undo_log - 向事务协调器(TC)注册分支事务
- 全局事务成功则提交
- 全局事务失败则根据
undo_log反向回滚
角色一般是:
- TC(Transaction Coordinator):协调全局事务
- TM(Transaction Manager):开启、提交、回滚全局事务
- RM(Resource Manager):管理分支事务和本地资源
sequenceDiagram
participant Client as 调用方
participant Order as 订单服务(TM/RM)
participant Stock as 库存服务(RM)
participant TC as Seata TC
Client->>Order: 创建订单
Order->>TC: 开启全局事务
TC-->>Order: 返回 XID
Order->>Stock: 扣减库存(XID 透传)
Stock->>TC: 注册分支事务
Stock->>Stock: 写业务数据 + undo_log
Stock-->>Order: 成功
Order->>Order: 写订单数据 + undo_log
Order->>TC: 提交全局事务
TC-->>Order: 全局提交成功
Seata AT 的优点
- 对业务代码侵入相对较低
- 基于本地事务,开发心智负担小
- 很适合“订单 + 库存”这种短流程事务
Seata AT 的边界
- 对 SQL 和数据源有要求,不是所有写法都适合
- 热点行容易出现全局锁冲突
- 长事务体验会很差,锁持有时间长,吞吐会明显下降
二、可靠消息最终一致性的核心思路
这个方案其实是很多电商、营销系统的主力方案。
核心思想很朴素:
- 在本地事务中,业务数据和待发送消息一起落库
- 本地事务提交成功后,异步把消息投递到 MQ
- 消费方处理自己的本地事务
- 如果某一步失败,就重试、幂等、对账补偿
其中最关键的是 Outbox 模式:
不要“先写数据库,再直接发 MQ”而没有中间持久化,否则数据库成功、MQ 失败时,消息就丢了。
flowchart LR
A[订单服务本地事务] --> B[写订单表]
A --> C[写 outbox 消息表]
C --> D[消息投递任务]
D --> E[MQ]
E --> F[积分服务消费]
F --> G[本地事务处理]
G --> H[更新消费状态/幂等记录]
可靠消息方案的优点
- 吞吐高,链路解耦明显
- 很适合非核心派生动作
- 对跨团队、跨系统协作更友好
边界
- 天然不是强一致
- 重试和幂等必须自己兜住
- 需要完善的可观测性,不然线上很难排查
三、补偿机制的核心思路
补偿机制常用于长事务,也常被归到 Saga 思路里。
它不是要求所有步骤同时成功,而是:
- 步骤按顺序执行
- 如果某一步失败,就执行之前步骤的“反向补偿动作”
例如:
- 创建订单
- 锁库存
- 扣款
- 创建发货单
如果扣款失败,就需要:
- 释放库存
- 取消订单
这里有个很关键的认知:
补偿不是数据库回滚,它是业务语义上的撤销。
所以补偿动作需要业务自己定义,而且并不总能做到“完全恢复现场”。
stateDiagram-v2
[*] --> Created
Created --> StockLocked: 锁库存成功
StockLocked --> Paid: 扣款成功
Paid --> ShippingCreated: 创建发货单
ShippingCreated --> Completed
StockLocked --> Cancelled: 扣款失败/补偿取消订单
Paid --> Refunding: 发货创建失败
Refunding --> Cancelled: 退款并释放资源
实战代码(可运行)
下面我用 Spring Boot 风格给出两个可运行示例:
- Seata AT 模式示例
- Outbox + 定时投递的可靠消息示例
为了让核心逻辑更清楚,我尽量保持代码短一些。
示例一:Seata AT 模式下的下单扣库存
场景
- 订单服务:创建订单
- 库存服务:扣减库存
- 两者必须一起成功或一起失败
关键依赖
```xml
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>1.5.2</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.2</version>
</dependency>
</dependencies>
上面这个代码块我特意保留最常见依赖。实际项目中版本要和 Seata Server、Spring Boot 版本一起对齐,不要单独抄。
### 表结构
#### 订单表
```sql
CREATE TABLE t_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
amount INT NOT NULL,
status VARCHAR(20) NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
库存表
CREATE TABLE t_stock (
product_id BIGINT PRIMARY KEY,
count INT NOT NULL
);
INSERT INTO t_stock(product_id, count) VALUES (1, 100);
Seata undo_log 表
CREATE TABLE undo_log (
id BIGINT NOT NULL AUTO_INCREMENT,
branch_id BIGINT NOT NULL,
xid VARCHAR(128) NOT NULL,
context VARCHAR(128) NOT NULL,
rollback_info LONGBLOB NOT NULL,
log_status INT NOT NULL,
log_created DATETIME NOT NULL,
log_modified DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY ux_undo_log (xid, branch_id)
);
订单服务代码
package com.example.order.service;
import com.example.order.client.StockClient;
import com.example.order.mapper.OrderMapper;
import com.example.order.model.OrderDO;
import io.seata.spring.annotation.GlobalTransactional;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private final OrderMapper orderMapper;
private final StockClient stockClient;
public OrderService(OrderMapper orderMapper, StockClient stockClient) {
this.orderMapper = orderMapper;
this.stockClient = stockClient;
}
@GlobalTransactional(name = "create-order-tx", rollbackFor = Exception.class)
@Transactional
public Long createOrder(Long userId, Long productId, Integer amount) {
stockClient.deduct(productId, amount);
OrderDO order = new OrderDO();
order.setUserId(userId);
order.setProductId(productId);
order.setAmount(amount);
order.setStatus("CREATED");
orderMapper.insert(order);
return order.getId();
}
}
库存服务代码
package com.example.stock.service;
import com.example.stock.mapper.StockMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class StockService {
private final StockMapper stockMapper;
public StockService(StockMapper stockMapper) {
this.stockMapper = stockMapper;
}
@Transactional
public void deduct(Long productId, Integer amount) {
int updated = stockMapper.deduct(productId, amount);
if (updated == 0) {
throw new IllegalStateException("库存不足");
}
}
}
Mapper SQL
package com.example.stock.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface StockMapper {
@Update("UPDATE t_stock SET count = count - #{amount} " +
"WHERE product_id = #{productId} AND count >= #{amount}")
int deduct(@Param("productId") Long productId, @Param("amount") Integer amount);
}
OpenFeign 调用接口
package com.example.order.client;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
@FeignClient(name = "stock-service")
public interface StockClient {
@PostMapping("/stock/deduct")
void deduct(@RequestParam("productId") Long productId,
@RequestParam("amount") Integer amount);
}
这个示例怎么验证
- 库存初始 100
- 下单 amount=2,订单表新增,库存减 2
- 人为让订单插入后抛异常,观察库存是否自动回滚
例如在 createOrder 最后临时加一句:
if (true) {
throw new RuntimeException("模拟异常");
}
如果 Seata 配置正确,你会看到:
- 订单数据回滚
- 库存数据也回滚
这就是 Seata AT 的基本效果。
示例二:Outbox 模式实现可靠消息最终一致性
场景
- 订单创建成功后,给积分服务发送“订单完成”事件
- 积分发放允许几秒内到账
- 主流程不能被积分服务拖慢
表结构
订单表
CREATE TABLE biz_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status VARCHAR(20) NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
outbox 消息表
CREATE TABLE outbox_event (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
event_type VARCHAR(64) NOT NULL,
biz_key VARCHAR(64) NOT NULL,
payload TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'NEW',
retry_count INT NOT NULL DEFAULT 0,
next_retry_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_biz_key_event (biz_key, event_type)
);
订单创建:业务数据与消息同事务落库
package com.example.outbox.service;
import com.example.outbox.mapper.OrderMapper;
import com.example.outbox.mapper.OutboxMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;
@Service
public class OrderAppService {
private final OrderMapper orderMapper;
private final OutboxMapper outboxMapper;
private final ObjectMapper objectMapper;
public OrderAppService(OrderMapper orderMapper,
OutboxMapper outboxMapper,
ObjectMapper objectMapper) {
this.orderMapper = orderMapper;
this.outboxMapper = outboxMapper;
this.objectMapper = objectMapper;
}
@Transactional
public Long createOrder(Long userId, BigDecimal amount) throws Exception {
Long orderId = orderMapper.insertAndReturnId(userId, amount, "CREATED");
Map<String, Object> event = new HashMap<>();
event.put("orderId", orderId);
event.put("userId", userId);
event.put("amount", amount);
String payload = objectMapper.writeValueAsString(event);
outboxMapper.insert("ORDER_CREATED", String.valueOf(orderId), payload);
return orderId;
}
}
定时任务投递消息
这里用“打印日志模拟 MQ 发送”,你替换成 RocketMQ / Kafka 都可以。
package com.example.outbox.job;
import com.example.outbox.mapper.OutboxMapper;
import com.example.outbox.model.OutboxEvent;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.List;
@Component
public class OutboxPublishJob {
private final OutboxMapper outboxMapper;
public OutboxPublishJob(OutboxMapper outboxMapper) {
this.outboxMapper = outboxMapper;
}
@Scheduled(fixedDelay = 3000)
public void publish() {
List<OutboxEvent> events = outboxMapper.findPending(LocalDateTime.now(), 20);
for (OutboxEvent event : events) {
try {
// 这里替换成真正的 MQ 发送逻辑
System.out.println("send message: " + event.getPayload());
outboxMapper.markSuccess(event.getId());
} catch (Exception ex) {
outboxMapper.markRetry(
event.getId(),
event.getRetryCount() + 1,
LocalDateTime.now().plusSeconds(30)
);
}
}
}
}
消费端幂等处理
这是可靠消息方案最容易被忽略的点。
消息一定可能重复,所以消费端必须幂等。
CREATE TABLE consumer_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
msg_key VARCHAR(64) NOT NULL,
consumer_group VARCHAR(64) NOT NULL,
status VARCHAR(20) NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_msg_group (msg_key, consumer_group)
);
package com.example.points.service;
import com.example.points.mapper.ConsumerLogMapper;
import com.example.points.mapper.PointsMapper;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class PointsConsumerService {
private final ConsumerLogMapper consumerLogMapper;
private final PointsMapper pointsMapper;
public PointsConsumerService(ConsumerLogMapper consumerLogMapper,
PointsMapper pointsMapper) {
this.consumerLogMapper = consumerLogMapper;
this.pointsMapper = pointsMapper;
}
@Transactional
public void onMessage(String msgKey, Long userId, Integer points) {
try {
consumerLogMapper.insert(msgKey, "points-service", "DONE");
} catch (DuplicateKeyException ex) {
return;
}
pointsMapper.addPoints(userId, points);
}
}
运行逻辑
- 订单创建成功,一定会写入 outbox
- 就算 MQ 短暂不可用,定时任务也会重试投递
- 就算消息重复,消费端也不会重复加积分
这套东西一旦搭好,后续很多异步链路都能复用。
常见坑与排查
这一部分我建议你认真看,因为真正花时间的往往不是“方案选哪一个”,而是“为什么线上它没按预期工作”。
1. Seata 全局事务没生效
现象
- 订单回滚了,库存没回滚
- 日志里看不到 XID 透传
- 子服务压根不知道自己在分布式事务里
常见原因
- 数据源没被 Seata 代理
- RPC 调用没透传 XID
- 入口方法不是代理对象调用
- 异常被吃掉,导致没触发全局回滚
排查建议
- 打印
RootContext.getXID()看调用链是否有值 - 查看子服务日志是否注册 branch
- 检查是否用了正确的 DataSourceProxy
- 确认异常没有被
try-catch后静默吞掉
2. Seata AT 模式出现锁冲突、性能抖动
现象
- 高峰期下单 RT 飙升
- 日志里出现 lock retry
- 某个热点商品经常扣库存失败
原因分析
AT 模式本质上会对相关数据形成全局锁语义。
如果所有请求都在更新同一行,例如一个爆款商品库存只有一行数据,那就很容易形成热点竞争。
解决建议
- 把热点库存拆分桶化
- 缩短事务时间,不要在全局事务里做远程慢调用
- 降低单事务包含的步骤
- 对极热点资源改用预扣 / 异步削峰设计
我当时踩过一个坑:把优惠券校验、活动资格、库存扣减都放在一个全局事务里,结果高峰期 RT 一路飙。后来拆成“主链路强一致 + 非核心异步处理”后才稳定下来。
3. 可靠消息方案出现重复消费
现象
- 用户积分被加了两次
- 短信发送重复
- 库存被重复释放
根因
MQ 的“至少一次投递”语义决定了重复消息是常态,不是异常。
排查与处理
- 检查消费端是否有唯一约束或幂等表
- 检查业务操作是否天然幂等
- 消费成功确认时机是否正确
- 是否存在“业务成功但 ack 失败”导致重复投递
最佳实践
- 每条消息必须有业务唯一键
- 消费端落库时使用唯一索引去重
- 不要只靠 Redis setnx 做幂等,重启和过期后有坑
- 真正关键链路,幂等记录要放数据库持久化
4. Outbox 消息堆积,最终一致性变成“迟迟不一致”
现象
- 订单成功了,但积分半小时才到
- outbox 表积压越来越多
- 定时任务扫描越来越慢
原因
- 扫描条件没走索引
- 单批发送量过小
- 重试策略过于激进或过于保守
- 下游消费能力不足
排查思路
EXPLAIN
SELECT * FROM outbox_event
WHERE status IN ('NEW', 'RETRY')
AND next_retry_time <= NOW()
ORDER BY id
LIMIT 20;
看是否命中索引,如果没有,先补索引。
其次观察:
- 每分钟新增消息数
- 每分钟成功投递数
- 重试消息比例
- 最老未完成消息滞留时间
这几个指标比单纯盯错误日志更有用。
5. 补偿机制越做越乱
现象
- 业务状态过多
- 失败路径没人说得清
- 人工处理越来越频繁
根因
补偿方案本身就是把复杂度从“数据库事务”转移到了“业务状态机”。
建议
- 先把状态机画出来,再写代码
- 每一步都要有明确的前置条件和补偿条件
- 补偿动作必须幂等
- 预留人工介入入口,不要幻想 100% 自动化
安全/性能最佳实践
这一节我分成“通用”和“按方案”两部分讲。
通用最佳实践
1. 以业务一致性等级驱动技术方案
不要因为团队刚接入了 Seata,就把所有跨服务调用都包进全局事务。
也不要因为 MQ 很香,就把资金扣减做成异步。
技术方案永远服务业务目标:
- 钱、库存、额度:先考虑强一致
- 积分、通知、日志、画像:优先异步化
2. 设计幂等键
无论 Seata、MQ 还是补偿,幂等都是底层生存能力。
建议每个核心业务动作都有一个幂等键,例如:
orderIdpaymentNobizType + bizIdrequestId
3. 建立对账机制
只靠事务框架和重试机制还不够。
真正上线稳定的系统,通常都有对账任务:
- 订单与库存对账
- 订单与支付对账
- outbox 与 MQ 投递状态对账
- 消费记录与业务记录对账
对账是“最后一道保险”。
Seata 的最佳实践
1. 不要把长耗时逻辑塞进全局事务
例如:
- 调第三方接口
- 做复杂报表计算
- 等待人工审批
- 执行大批量更新
全局事务应该尽量短、小、快。
2. SQL 保持简单可回滚
AT 模式对 SQL 支持不是无限制的。
复杂 SQL、批量更新、非标准写法都要提前验证,不要等上线后再看 undo 是否可用。
3. 注意热点资源
- 热门商品库存
- 账户余额主表
- 单行配置表
这些表如果被大量并发更新,Seata 的锁竞争会很明显。
4. 保护事务上下文
XID 属于敏感上下文信息,跨服务透传要按框架规范来,不要自己乱拼接 Header。
同时,日志里打印时注意脱敏和采样,避免无谓暴露链路细节。
可靠消息方案的最佳实践
1. 一定使用本地消息表或事务消息
最忌讳的是下面这种写法:
// 反例:数据库成功了,MQ 发送失败就丢消息
createOrderInDb();
mqProducer.send(msg);
正确思路至少要做到:
- 订单成功与消息待发送记录同事务落库
- 由后台任务可靠投递
2. 消费端优先做“先去重,再处理”
这样即使业务执行到一半失败,也能通过事务边界控制一致性。
3. 重试要有退避和上限
不要固定每秒重试一次,这会把故障放大。
建议:
- 指数退避或阶梯退避
- 超过阈值进入死信队列
- 配合告警和人工处理
4. 保护消息内容
消息里尽量不要塞敏感明文,比如身份证号、银行卡号。
确实需要传,也尽量传业务键,到消费端再查明细。
补偿机制的最佳实践
1. 补偿动作要比正向动作更稳
正向流程可以失败重试,补偿流程如果也不稳定,系统很快就会积累大量脏状态。
2. 状态机驱动,而不是 if-else 满天飞
建议明确状态流转表:
| 当前状态 | 事件 | 下一个状态 | 动作 |
|---|---|---|---|
| CREATED | 锁库存成功 | STOCK_LOCKED | 记录锁单 |
| STOCK_LOCKED | 扣款失败 | CANCELLED | 释放库存 |
| PAID | 发货失败 | REFUNDING | 发起退款 |
3. 预留人工兜底
有些事情程序确实处理不了,例如外部系统返回成功但网络超时、状态不明确。
这时要有后台页面和运营流程支持人工修复。
容量估算与落地建议
对于 architecture 类型文章,我更想补一段很多团队容易忽略的内容:容量估算。
1. Seata 的容量关注点
主要看:
- 全局事务 TPS
- 平均事务时长
- 热点资源竞争程度
- TC 集群和存储配置
一个简单判断方式:
如果你的核心链路高峰达到几千 TPS,且大量请求都更新相同资源行,那么 Seata AT 很可能先遇到锁竞争瓶颈,而不是 CPU 不够。
2. Outbox 的容量关注点
主要看:
- 每秒生成消息量
- 投递任务吞吐
- 消费积压增长速度
- 幂等表写入压力
例如每秒 2000 单,每单触发 3 条消息,就是 6000 msg/s。
这时候 outbox 表如果还靠单线程扫描,基本很快就顶不住了,需要:
- 分片扫描
- 批量发送
- 分区表或归档策略
- 消费端并发扩容
3. 补偿机制的容量关注点
主要看“异常流量占比”。
补偿链路平时可能很轻,但一旦下游大面积失败,补偿任务会暴涨。
所以补偿系统本身也要具备:
- 限流
- 重试退避
- 失败隔离
- 人工接管
一套比较实用的落地组合
如果让我给中型业务系统一套相对稳妥的默认组合,我会这么建议:
核心主链路
- 订单、库存、账户这类强一致动作
- 优先 Seata AT 或 TCC
- 保持事务短小
派生异步链路
- 发积分、发通知、更新画像、同步搜索索引
- 使用 Outbox + MQ 最终一致性
- 消费端严格幂等
长流程和外部系统协作
- 支付确认、供应链履约、退款逆向
- 使用状态机 + 补偿机制
- 补充对账与人工兜底
这个组合的优点是:
把强一致能力用在最贵的地方,把高吞吐能力用在最合适的地方。
总结
分布式事务没有银弹,只有权衡。
如果把这篇文章压缩成几条可执行建议,我会给出下面这份版本:
-
先分级,再选型
- 强一致核心链路:Seata / TCC
- 可异步派生链路:可靠消息最终一致性
- 长流程跨系统:补偿机制
-
不要迷信单一方案
- 真正成熟的微服务系统大多是组合使用
- 一个系统里同时存在 Seata、MQ、补偿是正常现象
-
Outbox、幂等、对账,三件套必须有
- 没有它们,最终一致性很容易退化成“不可控的不一致”
-
Seata 适合短事务,不适合长事务
- 尤其是热点资源更新时,要重点压测锁冲突
-
补偿不是回滚,是业务撤销
- 一定要显式设计状态机和人工兜底
最后给一个比较直白的边界判断:
- 如果你要解决的是“下单时库存不能错”,优先考虑 Seata
- 如果你要解决的是“订单成功后积分别丢”,优先考虑 可靠消息
- 如果你要解决的是“跨多个外部系统的履约流程失败后如何收拾残局”,优先考虑 补偿机制
做架构时,最怕的是“为了统一而统一”。
真正好的方案,往往不是最“高级”的,而是团队能理解、能压测、能监控、能兜底的那一种。