分布式事务是微服务项目里绕不开的问题。
但真正落到项目里的问题:
一个业务操作拆到了多个服务里,本地事务不再管用,数据开始出现不一致。
我们从一个常见的下单链路开始,复盘一次从本地事务到最终一致性方案的演进过程。
业务场景如下:
用户下单
|
|-- 创建订单
|-- 预占库存
|-- 预占优惠券
|-- 创建支付单
|-- 支付成功后加积分
|-- 发送通知系统拆分后,服务和数据库大概是这样:
order-service -> order_db
stock-service -> stock_db
coupon-service -> coupon_db
payment-service -> payment_db
points-service -> points_db
notify-service -> notify_db早期系统里,这些表可能都在一个库中,一个 @Transactional 就能解决。
拆成微服务之后,问题就变了:
订单创建成功了,库存没扣
库存冻结成功了,优惠券使用失败
支付成功了,积分迟迟没到账
通知发送失败了,但主流程已经完成这些问题本质上都是跨服务、跨数据库的一致性问题。
原来的方案
最开始系统还是单体应用。
订单、库存、优惠券都在一个 MySQL 库中。
代码大概是这样:
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private StockMapper stockMapper;
@Autowired
private CouponMapper couponMapper;
@Transactional(rollbackFor = Exception.class)
public Long createOrder(CreateOrderRequest request) {
// 1. 创建订单
Order order = Order.create(request);
orderMapper.insert(order);
// 2. 扣减库存
int stockRows = stockMapper.deductStock(
request.getSkuId(),
request.getQuantity()
);
if (stockRows <= 0) {
throw new BizException("库存不足");
}
// 3. 使用优惠券
if (request.getCouponId() != null) {
int couponRows = couponMapper.useCoupon(
request.getUserId(),
request.getCouponId()
);
if (couponRows <= 0) {
throw new BizException("优惠券不可用");
}
}
return order.getId();
}
}
这种写法在单体应用里没问题。
因为订单表、库存表、优惠券表都在同一个数据库里,Spring 本地事务可以保证这些操作一起提交或一起回滚。
后来系统拆成了微服务。
订单、库存、优惠券分别拆到了不同服务中。
代码变成了这样:
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private StockClient stockClient;
@Autowired
private CouponClient couponClient;
@Transactional(rollbackFor = Exception.class)
public Long createOrder(CreateOrderRequest request) {
// 1. 创建订单
Order order = Order.create(request);
orderMapper.insert(order);
// 2. 远程扣减库存
stockClient.deduct(request.getSkuId(), request.getQuantity());
// 3. 远程使用优惠券
if (request.getCouponId() != null) {
couponClient.use(request.getUserId(), request.getCouponId());
}
return order.getId();
}
}
这段代码看起来问题不大,但实际风险很高。
关键原因是:
@Transactional只能管理当前服务的本地数据库事务,不能管理远程服务里的数据库事务。
也就是说:
order-service 的 @Transactional
只能回滚 order_db
不能回滚 stock-service 的 stock_db
也不能回滚 coupon-service 的 coupon_db一旦远程调用中间某一步失败,就可能出现部分成功、部分失败。
问题在哪里
1. 本地事务和远程调用不是一个事务
假设下单流程是:
1. order_db 插入订单成功
2. stock-service 扣减库存成功
3. coupon-service 使用优惠券失败
4. order-service 抛异常,回滚本地订单最终结果可能变成:
订单没了
库存扣了
优惠券没用从订单服务看,本地事务回滚了。
但库存服务已经提交了自己的本地事务。
这就是典型的分布式一致性问题。
2. 远程调用超时,不代表对方没有执行
微服务调用里经常会遇到超时:
订单服务调用库存服务
|
|-- 请求已到达库存服务
|-- 库存服务扣减成功
|-- 响应返回时网络超时
|
订单服务认为调用失败调用方看到的是失败。
但被调用方可能已经执行成功。
如果订单服务这时候直接回滚本地订单,数据就不一致了。
3. 简单重试可能导致重复执行
很多人遇到远程调用失败,第一反应是重试。
但没有幂等的重试很危险。
例如:
第一次扣库存成功,但响应超时
订单服务发起第二次重试
库存再次扣减这会导致库存重复扣减。
所以只要系统里存在远程调用、消息消费、定时补偿,就必须设计幂等。
常见幂等键包括:
requestNo
orderNo
payNo
eventId
第三方回调流水号4. 长链路同步调用会影响用户体验
如果下单接口里同步完成所有动作:
创建订单
扣库存
用优惠券
创建支付单
加积分
发送短信
同步数据那么链路越长,接口越慢。
其中任何一个服务抖动,都会影响用户下单。
对用户来说,表现就是:
下单按钮一直转圈
偶尔下单失败
重复点击产生重复订单所以这不是单纯的事务问题,也是业务链路拆分问题。
优化思路
分布式事务不能一上来就套框架。
应该先区分业务动作的一致性要求。
在下单场景里,不同动作的重要程度并不一样:
创建订单 核心动作,必须可靠
预占库存 核心动作,不能超卖
预占优惠券 核心动作,不能重复使用
创建支付单 核心动作,需要和订单状态对齐
支付后加积分 可最终一致
发送通知 可弱一致
数据同步 可最终一致所以更合理的做法是分层处理:
核心链路:同步处理,状态可恢复
后置动作:异步处理,最终一致
通知动作:最大努力通知
整体链路可以调整为:
用户下单
|
v
订单服务创建订单,状态 INIT
|
v
预占库存、预占优惠券
|
v
预占成功,订单状态 CREATED
|
v
用户支付
|
v
支付回调
|
v
更新支付单和订单状态
|
v
写入本地事件表
|
v
异步发布消息
|
|-- 积分服务加积分
|-- 通知服务发通知
|-- 其他系统同步数据这里的重点不是追求一个“大而全”的全局事务。
而是把业务拆成几类:
必须同步保证的,放在核心链路
可以延迟完成的,放到异步事件
失败可以接受的,做最大努力通知
异常必须可恢复,依赖状态机和补偿任务核心实现
方案演进
第一阶段:订单状态机 + 资源预占
第一阶段适合业务量不大、服务拆分刚开始的系统。
核心思路是:
1. 本地事务只处理本地数据
2. 远程调用接口必须幂等
3. 核心资源采用预占模型
4. 异常状态通过补偿任务恢复订单状态可以设计为:
INIT 初始化
RESERVING 资源预占中
CREATED 订单创建成功,等待支付
RESERVE_FAILED 资源预占失败
PAID 已支付
CLOSED 已关闭状态流转如下:
INIT
|
| 资源预占
v
RESERVING
|
| 预占成功
v
CREATED
|
| 支付成功
v
PAID
RESERVING
|
| 预占失败
v
RESERVE_FAILED / CLOSED
下单时先创建订单。
@Service
public class OrderAppService {
@Autowired
private OrderMapper orderMapper;
@Transactional(rollbackFor = Exception.class)
public Long createOrder(CreateOrderRequest request) {
// 1. 防重复下单
Order exists = orderMapper.selectByRequestNo(request.getRequestNo());
if (exists != null) {
return exists.getId();
}
// 2. 创建订单,初始状态为 INIT
Order order = Order.init(request);
orderMapper.insert(order);
return order.getId();
}
}
这里建议对 request_no 加唯一索引。
ALTER TABLE orders
ADD UNIQUE KEY uk_request_no (request_no);这样即使用户重复点击,或者接口被重试,也不会创建多笔订单。
接下来执行资源预占。
资源预占不要简单地先查订单状态再执行,因为多个线程可能同时处理同一笔订单。
更稳妥的做法是用状态抢占。
@Service
public class OrderReserveService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private StockClient stockClient;
@Autowired
private CouponClient couponClient;
public void reserveResource(Long orderId) {
// 1. 抢占订单处理权:INIT -> RESERVING
int rows = orderMapper.updateStatus(
orderId,
OrderStatus.INIT,
OrderStatus.RESERVING
);
if (rows <= 0) {
return;
}
Order order = orderMapper.selectById(orderId);
boolean stockReserved = false;
boolean couponReserved = false;
try {
// 2. 预占库存
stockClient.reserve(new StockReserveRequest(
order.getOrderNo(),
order.getSkuId(),
order.getQuantity()
));
stockReserved = true;
// 3. 预占优惠券
if (order.getCouponId() != null) {
couponClient.reserve(new CouponReserveRequest(
order.getOrderNo(),
order.getUserId(),
order.getCouponId()
));
couponReserved = true;
}
// 4. 预占全部成功,订单进入 CREATED
orderMapper.updateStatus(
orderId,
OrderStatus.RESERVING,
OrderStatus.CREATED
);
} catch (Exception e) {
// 5. 只要中间失败,需要释放已经预占成功的资源
releaseReservedResource(order, stockReserved, couponReserved);
orderMapper.updateStatus(
orderId,
OrderStatus.RESERVING,
OrderStatus.RESERVE_FAILED
);
throw e;
}
}
private void releaseReservedResource(Order order,
boolean stockReserved,
boolean couponReserved) {
if (couponReserved) {
couponClient.release(new CouponReleaseRequest(
order.getOrderNo(),
order.getUserId(),
order.getCouponId()
));
}
if (stockReserved) {
stockClient.release(new StockReleaseRequest(
order.getOrderNo(),
order.getSkuId()
));
}
}
}
这里有一个关键点:
库存预占成功、优惠券预占失败时,必须释放已经冻结的库存。
不能只依赖订单超时任务慢慢释放。
否则在高峰期可能出现大量库存被冻结,用户看到有库存但无法购买。
订单状态更新 SQL 可以写成 CAS 形式:
UPDATE orders
SET status = #{newStatus},
update_time = NOW()
WHERE id = #{orderId}
AND status = #{oldStatus};
这样可以避免并发状态覆盖。
库存服务如何实现预占
库存服务不要直接扣减最终库存。
更推荐使用“可用库存 + 冻结库存”的模型。
available_stock 可用库存
frozen_stock 冻结库存预占库存时:
available_stock - quantity
frozen_stock + quantity确认扣减时:
frozen_stock - quantity释放库存时:
available_stock + quantity
frozen_stock - quantity预占接口示例:
@Service
public class StockService {
@Autowired
private StockMapper stockMapper;
@Autowired
private StockFreezeMapper stockFreezeMapper;
@Transactional(rollbackFor = Exception.class)
public void reserve(StockReserveRequest request) {
// 1. 幂等判断
StockFreeze exists = stockFreezeMapper.selectByBizNo(request.getOrderNo());
if (exists != null) {
return;
}
// 2. 扣减可用库存,增加冻结库存
int rows = stockMapper.reserve(
request.getSkuId(),
request.getQuantity()
);
if (rows <= 0) {
throw new BizException("库存不足");
}
// 3. 记录冻结明细
stockFreezeMapper.insert(StockFreeze.reserved(
request.getOrderNo(),
request.getSkuId(),
request.getQuantity()
));
}
}对应 SQL:
UPDATE product_stock
SET available_stock = available_stock - #{quantity},
frozen_stock = frozen_stock + #{quantity},
update_time = NOW()
WHERE sku_id = #{skuId}
AND available_stock >= #{quantity};冻结记录表:
CREATE TABLE stock_freeze (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
biz_no VARCHAR(64) NOT NULL,
sku_id BIGINT NOT NULL,
quantity INT NOT NULL,
status VARCHAR(32) NOT NULL,
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
UNIQUE KEY uk_biz_no (biz_no)
);biz_no 可以使用订单号。
这个唯一索引是幂等的最后防线。
不要只依赖 Redis 防重。
Redis 可以做前置拦截,但数据库唯一索引才是最终兜底。
释放冻结库存:
@Transactional(rollbackFor = Exception.class)
public void release(StockReleaseRequest request) {
StockFreeze freeze = stockFreezeMapper.selectByBizNo(request.getOrderNo());
if (freeze == null) {
return;
}
if (FreezeStatus.RELEASED.equals(freeze.getStatus())) {
return;
}
if (FreezeStatus.CONFIRMED.equals(freeze.getStatus())) {
throw new BizException("库存已经确认扣减,不能释放");
}
int rows = stockMapper.release(
freeze.getSkuId(),
freeze.getQuantity()
);
if (rows <= 0) {
throw new BizException("释放库存失败");
}
stockFreezeMapper.updateStatus(
freeze.getBizNo(),
FreezeStatus.RELEASED
);
}释放库存 SQL:
UPDATE product_stock
SET available_stock = available_stock + #{quantity},
frozen_stock = frozen_stock - #{quantity},
update_time = NOW()
WHERE sku_id = #{skuId}
AND frozen_stock >= #{quantity};确认扣减库存:
@Transactional(rollbackFor = Exception.class)
public void confirm(StockConfirmRequest request) {
StockFreeze freeze = stockFreezeMapper.selectByBizNo(request.getOrderNo());
if (freeze == null) {
throw new BizException("库存冻结记录不存在");
}
if (FreezeStatus.CONFIRMED.equals(freeze.getStatus())) {
return;
}
if (FreezeStatus.RELEASED.equals(freeze.getStatus())) {
throw new BizException("库存已释放,不能确认扣减");
}
int rows = stockMapper.confirm(
freeze.getSkuId(),
freeze.getQuantity()
);
if (rows <= 0) {
throw new BizException("确认扣减库存失败");
}
stockFreezeMapper.updateStatus(
freeze.getBizNo(),
FreezeStatus.CONFIRMED
);
}确认扣减 SQL:
UPDATE product_stock
SET frozen_stock = frozen_stock - #{quantity},
update_time = NOW()
WHERE sku_id = #{skuId}
AND frozen_stock >= #{quantity};这个模型不等于严格强一致。
它适合中小规模业务或系统演进初期,依赖的是:
状态机
幂等
补偿任务
冻结记录
异常可恢复第二阶段:本地事件表 Outbox 保证最终一致
订单支付成功后,通常还要做很多后置动作:
加积分
发通知
同步搜索系统
同步数据仓库
触发营销任务这些动作不应该阻塞支付回调主链路。
更合适的做法是使用本地事件表,也就是 Outbox Pattern。
核心思路:
业务数据和事件数据写在同一个本地事务里
后续由异步任务把事件投递到 MQ
消费端通过幂等保证重复消费不出问题支付回调流程:
支付回调
|
v
本地事务:
1. 更新支付单状态
2. 更新订单状态为 PAID
3. 写入 outbox_event
|
v
异步任务扫描 outbox_event
|
v
发送 RocketMQ 消息
|
v
积分服务、通知服务消费消息事件表设计:
CREATE TABLE outbox_event (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID',
event_id VARCHAR(64) NOT NULL COMMENT '事件唯一 ID,用于事件幂等和排查',
event_type VARCHAR(64) NOT NULL COMMENT '事件类型,例如 ORDER_PAID、ORDER_CREATED',
biz_no VARCHAR(64) NOT NULL COMMENT '业务单号,例如订单号、支付单号',
payload TEXT NOT NULL COMMENT '事件内容,通常存储 JSON 字符串',
status VARCHAR(32) NOT NULL COMMENT '事件状态:PENDING=待投递,PROCESSING=投递中,RETRY=等待重试,SUCCESS=投递成功,FAILED=投递失败',
retry_count INT NOT NULL DEFAULT 0 COMMENT '已重试次数',
next_retry_time DATETIME NOT NULL COMMENT '下次可重试时间',
create_time DATETIME NOT NULL COMMENT '创建时间',
update_time DATETIME NOT NULL COMMENT '更新时间',
UNIQUE KEY uk_event_id (event_id),
KEY idx_status_retry_time (status, next_retry_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本地事件表,用于保证业务操作与消息投递的最终一致性';支付成功后写入事件:
@Service
public class PayCallbackService {
@Autowired
private PaymentMapper paymentMapper;
@Autowired
private OrderMapper orderMapper;
@Autowired
private OutboxEventMapper outboxEventMapper;
@Transactional(rollbackFor = Exception.class)
public void handlePaySuccess(PayCallbackRequest request) {
Payment payment = paymentMapper.selectByPayNo(request.getPayNo());
if (payment == null) {
throw new BizException("支付单不存在");
}
// 支付回调幂等
if (PaymentStatus.SUCCESS.equals(payment.getStatus())) {
return;
}
paymentMapper.updateStatus(
payment.getId(),
PaymentStatus.SUCCESS
);
orderMapper.updateStatus(
payment.getOrderId(),
OrderStatus.CREATED,
OrderStatus.PAID
);
OrderPaidEvent event = new OrderPaidEvent(
payment.getOrderNo(),
payment.getUserId(),
payment.getPayAmount()
);
outboxEventMapper.insert(OutboxEvent.pending(
IdGenerator.uuid(),
"ORDER_PAID",
payment.getOrderNo(),
JsonUtils.toJson(event)
));
}
}
这段逻辑保证了一件事:
订单支付成功,事件一定存在
订单支付失败,事件一定不存在因为订单状态和事件表在同一个本地事务中。
Outbox 事件发布
事件发布任务负责扫描 PENDING 或 RETRY 状态的事件。
多实例部署时,不能简单查询出来就发送。
需要先抢占事件。
@Component
public class OutboxEventPublisher {
@Autowired
private OutboxEventMapper outboxEventMapper;
@Autowired
private RocketMQTemplate rocketMQTemplate;
@Scheduled(fixedDelay = 3000)
public void publish() {
List<OutboxEvent> events = outboxEventMapper.selectPendingEvents(100);
for (OutboxEvent event : events) {
int locked = outboxEventMapper.markProcessing(event.getId());
if (locked <= 0) {
continue;
}
try {
rocketMQTemplate.convertAndSend(
event.getEventType(),
event.getPayload()
);
outboxEventMapper.markSuccess(event.getId());
} catch (Exception e) {
outboxEventMapper.markRetry(
event.getId(),
calculateNextRetryTime(event.getRetryCount() + 1)
);
}
}
}
private LocalDateTime calculateNextRetryTime(int retryCount) {
int[] intervals = {1, 5, 10, 30, 60, 120};
int minutes = intervals[Math.min(retryCount - 1, intervals.length - 1)];
return LocalDateTime.now().plusMinutes(minutes);
}
}
抢占 SQL:
UPDATE outbox_event
SET status = 'PROCESSING',
update_time = NOW()
WHERE id = #{id}
AND status IN ('PENDING', 'RETRY')
AND next_retry_time <= NOW();发送成功:
UPDATE outbox_event
SET status = 'SUCCESS',
update_time = NOW()
WHERE id = #{id}
AND status = 'PROCESSING';
发送失败:
UPDATE outbox_event
SET status = 'RETRY',
retry_count = retry_count + 1,
next_retry_time = #{nextRetryTime},
update_time = NOW()
WHERE id = #{id}
AND status = 'PROCESSING';
这里还要补一个生产环境细节。
如果服务把事件抢占成 PROCESSING 后宕机,这条事件会一直卡住。
所以必须有超时恢复任务。
@Component
public class OutboxEventRecoverJob {
@Autowired
private OutboxEventMapper outboxEventMapper;
@Scheduled(fixedDelay = 60000)
public void recoverProcessingEvents() {
outboxEventMapper.recoverTimeoutProcessingEvents(
LocalDateTime.now().minusMinutes(5)
);
}
}
恢复 SQL:
UPDATE outbox_event
SET status = 'RETRY',
next_retry_time = NOW(),
update_time = NOW()
WHERE status = 'PROCESSING'
AND update_time < #{timeoutTime};这样 Outbox 方案才形成闭环:
业务表 + 事件表同事务写入
|
v
PENDING / RETRY 事件扫描
|
v
抢占为 PROCESSING
|
v
发送 MQ
|
|-- 成功:SUCCESS
|-- 失败:RETRY
|-- 宕机:PROCESSING 超时恢复
|
v
消费端幂等处理注意,Outbox 不保证消息只发送一次。
比如 MQ 发送成功后,服务还没来得及把事件改成 SUCCESS 就宕机了。
恢复后可能会再次发送。
所以消费端必须幂等。
消费端幂等
以积分服务为例。
订单支付成功后,积分服务消费 ORDER_PAID 事件。
@Component
@RocketMQMessageListener(
topic = "ORDER_PAID",
consumerGroup = "points-service-order-paid-consumer"
)
public class OrderPaidConsumer implements RocketMQListener<String> {
@Autowired
private PointsService pointsService;
@Override
public void onMessage(String message) {
OrderPaidEvent event = JsonUtils.fromJson(message, OrderPaidEvent.class);
pointsService.addPoints(event);
}
}
积分入账逻辑:
@Service
public class PointsService {
@Autowired
private PointsAccountMapper pointsAccountMapper;
@Autowired
private PointsRecordMapper pointsRecordMapper;
@Transactional(rollbackFor = Exception.class)
public void addPoints(OrderPaidEvent event) {
// 1. 幂等判断
PointsRecord exists = pointsRecordMapper.selectByBizNo(event.getOrderNo());
if (exists != null) {
return;
}
int points = calculatePoints(event.getPayAmount());
// 2. 增加积分账户余额
pointsAccountMapper.increase(
event.getUserId(),
points
);
// 3. 记录积分流水
pointsRecordMapper.insert(PointsRecord.create(
event.getOrderNo(),
event.getUserId(),
points
));
}
private int calculatePoints(BigDecimal payAmount) {
return payAmount.intValue();
}
}
积分流水表需要对业务单号加唯一索引:
CREATE TABLE points_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID',
biz_no VARCHAR(64) NOT NULL COMMENT '业务单号,用于幂等控制,例如订单号',
user_id BIGINT NOT NULL COMMENT '用户 ID',
points INT NOT NULL COMMENT '积分变动数量,正数表示增加积分,负数表示扣减积分',
create_time DATETIME NOT NULL COMMENT '创建时间',
UNIQUE KEY uk_biz_no (biz_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='积分流水表,用于记录积分变动明细并保证消费幂等';
这样即使消息重复消费,也不会重复加积分。
这个方案的边界很清楚:
支付成功后,积分不一定立即到账
但只要事件没有丢,最终会到账
即使消息重复投递,消费端也不会重复加积分
这就是最终一致性。
常见分布式事务方案怎么选
原文里提到了很多方案,包括 2PC、3PC、TCC、事务消息、最大努力通知、Seata AT、本地事件表。
这些方案不是并列地随便选。
更重要的是看业务场景。
1. 2PC / XA:强一致,但性能和可用性成本高
2PC 是两阶段提交。
可以简化理解为:
第一阶段:Prepare
协调者询问所有参与者能否提交
参与者执行事务但不提交,并锁定资源
第二阶段:Commit / Rollback
所有参与者都准备成功,则统一提交
任一参与者失败,则统一回滚
流程:
Coordinator
|
| prepare
v
Participant A -> yes
Participant B -> yes
Participant C -> yes
|
| commit
v
All Commit
优点是强一致。
缺点也明显:
同步阻塞
资源锁持有时间长
协调者故障会影响整体事务
不适合高并发长链路业务它更适合低频、关键、强一致的内部系统。
普通互联网订单链路里,不建议默认使用 2PC。
2. 3PC:更多是理论方案,项目中较少直接落地
3PC 在 2PC 基础上增加了预提交阶段:
CanCommit
PreCommit
DoCommit它试图降低 2PC 的阻塞问题。
但在真实 Java 后端项目里,3PC 很少作为常规方案直接落地。
原因是:
实现复杂
网络分区下仍然可能不一致
工程上通常会选择最终一致性或补偿方案
所以 3PC 可以作为理论知识了解,不建议在普通业务系统里强行引入。
3. TCC:适合核心资源预占,但业务侵入强
TCC 是业务层面的补偿事务。
它把一个业务动作拆成三个阶段:
Try 预留资源
Confirm 确认提交
Cancel 取消释放以下单扣库存为例:
Try:冻结库存
Confirm:扣减冻结库存
Cancel:释放冻结库存接口可以设计成:
public interface StockTccService {
void tryReserve(String orderNo, Long skuId, Integer quantity);
void confirm(String orderNo);
void cancel(String orderNo);
}
TCC 必须有明确状态机:
RESERVED -> CONFIRMED
RESERVED -> CANCELED
CONFIRMED 不能再 CANCELED
CANCELED 不能再 CONFIRMEDConfirm 示例:
@Transactional(rollbackFor = Exception.class)
public void confirm(String orderNo) {
StockFreeze freeze = stockFreezeMapper.selectByBizNo(orderNo);
if (freeze == null) {
throw new BizException("冻结记录不存在");
}
if (FreezeStatus.CONFIRMED.equals(freeze.getStatus())) {
return;
}
if (FreezeStatus.CANCELED.equals(freeze.getStatus())) {
throw new BizException("已取消的冻结记录不能确认");
}
stockMapper.confirmDeduct(
freeze.getSkuId(),
freeze.getQuantity()
);
stockFreezeMapper.updateStatus(
orderNo,
FreezeStatus.CONFIRMED
);
}
Cancel 示例:
@Transactional(rollbackFor = Exception.class)
public void cancel(String orderNo) {
StockFreeze freeze = stockFreezeMapper.selectByBizNo(orderNo);
if (freeze == null) {
// 空回滚:Cancel 先于 Try 到达
stockFreezeMapper.insertCancelRecord(orderNo);
return;
}
if (FreezeStatus.CANCELED.equals(freeze.getStatus())) {
return;
}
if (FreezeStatus.CONFIRMED.equals(freeze.getStatus())) {
throw new BizException("已确认扣减的库存不能取消");
}
stockMapper.release(
freeze.getSkuId(),
freeze.getQuantity()
);
stockFreezeMapper.updateStatus(
orderNo,
FreezeStatus.CANCELED
);
}
TCC 还要处理几个问题:
幂等:Try / Confirm / Cancel 都可能重复调用
空回滚:Cancel 可能先于 Try 到达
悬挂:Cancel 执行后,Try 又到达
状态流转:不能出现已确认后又取消所以 TCC 适合库存、优惠券、账户余额这类核心资源预占场景。
不适合积分、通知、数据同步这类后置动作。
4. 可靠消息最终一致性:适合主流程成功后的后置动作
可靠消息最终一致性适合:
支付成功后加积分
订单完成后发通知
发货后同步物流状态
订单变更后同步搜索系统
核心原则是:
消息不能丢
消费可以重试
消费端必须幂等
常见实现方式有两种:
本地事件表 Outbox
RocketMQ 事务消息
RocketMQ 事务消息的基本流程:
发送 half message
|
v
executeLocalTransaction 执行本地事务
|
|-- 本地事务成功:COMMIT
|-- 本地事务失败:ROLLBACK
|-- 本地事务未知:UNKNOWN
|
v
MQ 对 UNKNOWN 状态进行事务回查
需要注意:
RocketMQ 事务消息解决的是“本地事务和消息发送的一致性”,不是消费端业务一定成功。
消费者仍然要自己保证:
幂等
重试
失败告警
人工补偿
如果项目已经使用本地事件表,而且排查和补偿都比较方便,不一定非要换成 RocketMQ 事务消息。
5. 最大努力通知:适合短信、邮件、第三方回调
最大努力通知适合一致性要求较低的场景:
短信通知
邮件通知
站内信
第三方回调
运营消息
它的特点是:
主流程已经完成
通知失败不影响主流程
失败后按策略重试
多次失败后记录异常或人工处理
不要在业务线程里 Thread.sleep 重试。
错误示例:
public void notify(String event) throws InterruptedException {
for (int i = 0; i < 5; i++) {
boolean success = send(event);
if (success) {
return;
}
Thread.sleep(60_000);
}
}
这种写法会长时间占用业务线程。
更合理的方式是记录通知任务,然后异步重试。
CREATE TABLE notify_task (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID',
biz_no VARCHAR(64) NOT NULL COMMENT '业务单号,例如订单号、支付单号',
notify_type VARCHAR(64) NOT NULL COMMENT '通知类型,例如 SMS=短信,EMAIL=邮件,SITE_MESSAGE=站内信,WEBHOOK=第三方回调',
payload TEXT NOT NULL COMMENT '通知内容,通常存储 JSON 字符串',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING'
COMMENT '通知状态:PENDING=待通知,PROCESSING=通知中,RETRY=等待重试,SUCCESS=通知成功,FAILED=通知失败',
retry_count INT NOT NULL DEFAULT 0 COMMENT '已重试次数',
next_retry_time DATETIME NOT NULL COMMENT '下次可重试时间',
create_time DATETIME NOT NULL COMMENT '创建时间',
update_time DATETIME NOT NULL COMMENT '更新时间',
UNIQUE KEY uk_biz_no_type (biz_no, notify_type),
KEY idx_status_retry_time (status, next_retry_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知任务表,用于异步通知、失败重试和最大努力通知';重试任务:
@Component
public class NotifyRetryJob {
@Autowired
private NotifyTaskMapper notifyTaskMapper;
@Autowired
private NotifySender notifySender;
@Scheduled(fixedDelay = 10000)
public void retry() {
List<NotifyTask> tasks = notifyTaskMapper.selectRetryTasks(100);
for (NotifyTask task : tasks) {
try {
notifySender.send(task);
notifyTaskMapper.markSuccess(task.getId());
} catch (Exception e) {
int nextRetryCount = task.getRetryCount() + 1;
if (nextRetryCount >= 8) {
notifyTaskMapper.markFailed(task.getId());
continue;
}
notifyTaskMapper.markRetry(
task.getId(),
nextRetryCount,
calculateNextRetryTime(nextRetryCount)
);
}
}
}
private LocalDateTime calculateNextRetryTime(int retryCount) {
int[] intervals = {1, 5, 10, 30, 60, 120, 240, 480};
int minutes = intervals[Math.min(retryCount - 1, intervals.length - 1)];
return LocalDateTime.now().plusMinutes(minutes);
}
}
这个方案不保证马上通知成功。
它保证的是:
失败可查
可以重试
可以告警
可以人工处理
6. Seata AT:侵入较低,但不是银弹
Seata AT 不是自动化 TCC。
TCC 需要业务自己实现 Try、Confirm、Cancel。
Seata AT 的核心是:
代理数据源
解析业务 SQL
记录 undo_log
一阶段提交本地事务
二阶段根据全局事务结果删除 undo_log 或执行反向补偿
可以简化理解为:
业务 SQL 执行前,记录 before image
业务 SQL 执行后,记录 after image
如果全局事务回滚,根据 undo_log 反向恢复
例如业务 SQL:
UPDATE product_stock
SET available_stock = available_stock - 10
WHERE sku_id = 1001;
Seata 会记录类似信息:
before image: available_stock = 100
after image : available_stock = 90
如果需要回滚,再根据回滚日志恢复。
Seata AT 的优点:
业务侵入较低
对常规数据库更新场景比较友好
开发成本低于手写 TCC
但它也有边界:
依赖数据库本地事务
复杂 SQL 支持有限
热点数据冲突会影响性能
undo_log 需要正确维护
不适合所有高并发核心链路
所以不要看到分布式事务就直接上 Seata。
大致可以这样选:
低频后台跨库操作
-> 可以考虑 Seata AT
核心高并发资源扣减
-> 优先考虑业务冻结模型或 TCC 思路
支付成功后加积分
-> 更适合可靠消息最终一致性
短信、邮件、通知
-> 最大努力通知即可
生产环境注意事项
1. 先做一致性分级
不要把所有动作都放进一个分布式事务里。
可以按业务重要性拆分:
库存、优惠券:尽量同步处理,避免超卖和重复使用
支付状态:必须可靠,状态流转要可恢复
积分、数据同步:最终一致
短信、邮件:最大努力通知
一致性要求不同,方案就应该不同。
2. 幂等必须落到数据库
只要有重试,就必须有幂等。
常见幂等键:
requestNo
orderNo
payNo
eventId
callbackNo
建议在数据库层面加唯一索引。
Redis 可以做前置防重,但不能替代数据库唯一约束。
因为 Redis 可能过期、丢失、被误删,数据库唯一索引才是最后防线。
3. 不要在本地事务里做大量远程调用
下面这种写法要谨慎:
@Transactional(rollbackFor = Exception.class)
public void createOrder() {
orderMapper.insert(order);
stockClient.reserve();
couponClient.reserve();
paymentClient.create();
}
问题是:
本地事务持有时间变长
数据库连接占用时间变长
远程服务抖动会拖垮当前服务
本地事务回滚不了远程服务的数据
更好的方式是缩短本地事务范围,让远程调用通过状态机和补偿机制变得可恢复。
4. 冻结资源必须有超时释放
库存冻结、优惠券冻结不能无限期占用。
例如订单 30 分钟未支付,需要关闭订单并释放资源。
订单超时关闭
|
|-- 释放冻结库存
|-- 释放冻结优惠券
|-- 更新订单状态 CLOSED
释放动作必须幂等。
因为这些动作可能同时发生:
用户主动取消订单
订单超时关闭任务
支付回调延迟到达
人工后台关闭订单
没有状态机和幂等,很容易出现重复释放或错误释放。
5. Outbox 表要考虑积压和归档
本地事件表不是写完就结束。
生产环境里至少要关注:
PENDING 数量
RETRY 数量
PROCESSING 超时数量
最大重试次数
最老未成功事件时间
如果事件量很大,还要考虑:
按月归档
按业务类型分表
定期清理 SUCCESS 历史数据
失败事件告警
后台手动重推
否则事件表会越来越大,后续查询和补偿都会变慢。
6. 补偿任务必须可观测
分布式事务最终一定会遇到异常。
不要只写补偿任务,还要能看见补偿任务的状态。
后台最好能查到:
业务单号
当前状态
失败原因
最近一次重试时间
下一次重试时间
重试次数
处理实例
否则线上排查会很痛苦。
7. 不要过度设计
不是所有跨服务操作都需要分布式事务框架。
可以简单按下面规则判断:
单库操作
-> 本地事务足够
低频后台操作
-> 本地事务 + 人工补偿,或考虑 Seata AT
核心交易资源预占
-> 业务冻结模型或 TCC 思路
主流程成功后的后置动作
-> Outbox 或事务消息
通知类动作
-> 最大努力通知
技术方案应该服务业务主线,而不是为了显得架构复杂。
总结
分布式事务真正难的地方,不是知道多少种方案。
而是能不能把业务动作拆清楚:
哪些必须同步成功
哪些可以最终一致
哪些可以失败重试
哪些允许人工介入
哪些根本不需要分布式事务
在本文这个下单场景里,更合理的处理方式是:
下单核心链路:
订单状态机 + 库存预占 + 优惠券预占
支付成功后:
本地事件表 Outbox 保证事件不丢
积分和通知:
异步消费 + 消费端幂等
异常场景:
补偿任务 + 超时恢复 + 告警 + 人工处理
不要把 2PC、3PC、TCC、Seata、MQ 全部硬塞进一个系统。
对于大多数 Java 后端项目来说,真正能落地的分布式一致性设计,往往不是某个框架本身,而是下面这些基础能力:
清晰的状态机
可靠的幂等设计
可重试的补偿机制
可观测的异常任务
合理的一致性分级
这些做好了,系统才有恢复能力。
否则即使引入再多分布式事务框架,线上出问题时,依然很难排查和兜底。