背景/痛点
在 openclaw 做到多服务协同之后,分布式事务几乎是绕不开的问题。尤其是订单、库存、账户、积分这类业务,一旦拆成多个服务,原来单体应用里一个数据库事务能解决的问题,到了微服务架构下就会变成"跨服务数据一致性"。
举个典型场景:用户下单时,需要同时完成三件事:
- 创建订单;
- 扣减库存;
- 冻结或扣减账户余额。
如果订单服务写入成功,但库存服务扣减失败,系统就会出现"有订单但无库存锁定"的脏状态;如果库存扣减成功,但账户扣款失败,又会造成库存被占用却无法成交。很多团队一开始会尝试用本地事务包住远程调用,但这在技术上是错误方向:数据库事务无法覆盖 HTTP/RPC 调用,强行这么做只会拉长锁时间,增加超时和死锁风险。
在 openclaw 的实际项目里,我更推荐把分布式事务拆成两类处理:
| 场景 | 推荐方案 | 特点 |
|---|---|---|
| 强一致核心链路 | TCC | 明确 Try/Confirm/Cancel |
| 最终一致异步链路 | 事务消息/Outbox | 解耦、吞吐高 |
| 可补偿业务 | Saga | 每一步有补偿动作 |
本文重点讲 openclaw 中比较实用的一种组合:核心扣减用 TCC,事件通知用 Outbox,既保证关键数据可靠,又避免全链路强耦合。
核心内容讲解
openclaw 的分布式事务设计,我通常遵循三个原则。
第一,事务边界要收敛。不是所有服务都要进一个大事务,只有真正影响资金、库存、订单状态的操作才需要纳入事务协调。像短信通知、运营埋点、搜索索引同步,应该走异步事件。
第二,每个参与者必须具备幂等能力。分布式环境下,超时不等于失败,调用方重试时服务端可能已经执行成功。如果没有幂等表或业务唯一键,重复扣库存、重复扣款很容易发生。
第三,补偿逻辑要比正向逻辑更严谨。很多人写 TCC 时只关注 Try 和 Confirm,却忽略 Cancel 的边界:如果 Try 根本没执行成功,Cancel 被调用怎么办?如果 Confirm 已成功,Cancel 又来了怎么办?这些都要靠状态机控制。
在 openclaw 中,我一般会设计一张事务分支表,用来记录每个参与者的执行状态:
sql
CREATE TABLE claw_tx_branch (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
xid VARCHAR(64) NOT NULL, -- 全局事务ID
branch_id VARCHAR(64) NOT NULL, -- 分支事务ID
service_name VARCHAR(64) NOT NULL, -- 参与服务
action_name VARCHAR(64) NOT NULL, -- 业务动作
status VARCHAR(20) NOT NULL, -- TRYING/CONFIRMED/CANCELED
request_key VARCHAR(128) NOT NULL, -- 幂等键
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
UNIQUE KEY uk_branch (xid, branch_id),
UNIQUE KEY uk_request (request_key)
);
这里的 `request_key` 非常关键,它通常由 `xid + service + businessId` 组成,保证同一事务下同一业务动作只执行一次。
## 实战代码/案例
下面以"下单扣库存"为例,演示 openclaw 中 TCC 参与者的核心写法。为了便于理解,代码使用 Java 风格伪实现,实际项目中可以放在 Spring Boot 服务内。
库存服务 Try 阶段:冻结库存,不直接扣减可售库存。
```java
@Service
public class InventoryTccService {
@Autowired
private InventoryMapper inventoryMapper;
@Autowired
private TxBranchMapper txBranchMapper;
/**
* Try阶段:冻结库存
* 注意:这里只做资源预留,不做最终扣减
*/
@Transactional
public void tryFreeze(String xid, String branchId, Long skuId, Integer count) {
String requestKey = xid + ":inventory:" + skuId;
// 1. 幂等检查:如果已经执行过Try,直接返回
TxBranch existed = txBranchMapper.findByRequestKey(requestKey);
if (existed != null) {
return;
}
// 2. 插入分支事务记录
txBranchMapper.insert(new TxBranch(
xid, branchId, "inventory-service",
"freezeInventory", "TRYING", requestKey
));
// 3. 冻结库存:available减少,frozen增加
int rows = inventoryMapper.freeze(skuId, count);
if (rows == 0) {
throw new RuntimeException("库存不足,冻结失败");
}
}
/**
* Confirm阶段:确认扣减库存
*/
@Transactional
public void confirm(String xid, String branchId, Long skuId, Integer count) {
TxBranch branch = txBranchMapper.findByXidAndBranchId(xid, branchId);
// 空确认处理:Try未执行成功时,Confirm直接忽略
if (branch == null) {
return;
}
// 幂等处理:已确认则直接返回
if ("CONFIRMED".equals(branch.getStatus())) {
return;
}
// 如果已经取消,不允许再确认
if ("CANCELED".equals(branch.getStatus())) {
throw new RuntimeException("分支已取消,不能确认");
}
// frozen减少,表示库存真正消耗
inventoryMapper.confirmDeduct(skuId, count);
txBranchMapper.updateStatus(xid, branchId, "CONFIRMED");
}
/**
* Cancel阶段:释放冻结库存
*/
@Transactional
public void cancel(String xid, String branchId, Long skuId, Integer count) {
TxBranch branch = txBranchMapper.findByXidAndBranchId(xid, branchId);
// 空回滚:Try没成功,仍需记录取消状态,避免后续Try悬挂
if (branch == null) {
txBranchMapper.insert(new TxBranch(
xid, branchId, "inventory-service",
"freezeInventory", "CANCELED",
xid + ":inventory:" + skuId
));
return;
}
// 幂等处理:已取消直接返回
if ("CANCELED".equals(branch.getStatus())) {
return;
}
// 已确认则不能取消
if ("CONFIRMED".equals(branch.getStatus())) {
throw new RuntimeException("分支已确认,不能取消");
}
// 释放冻结库存:frozen减少,available增加
inventoryMapper.releaseFrozen(skuId, count);
txBranchMapper.updateStatus(xid, branchId, "CANCELED");
}
}
库存表更新 SQL 可以这样写,重点是条件更新,避免并发下超卖:
```sql
-- Try:冻结库存,必须保证可售库存充足
UPDATE product_inventory
SET available = available - #{count},
frozen = frozen + #{count}
WHERE sku_id = #{skuId}
AND available >= #{count};
-- Confirm:确认扣减冻结库存
UPDATE product_inventory
SET frozen = frozen - #{count}
WHERE sku_id = #{skuId}
AND frozen >= #{count};
-- Cancel:释放冻结库存
UPDATE product_inventory
SET available = available + #{count},
frozen = frozen - #{count}
WHERE sku_id = #{skuId}
AND frozen >= #{count};
订单服务作为事务发起方,需要协调各参与者。这里不要把远程调用放进本地数据库事务里,而是先创建订单草稿,再进入 TCC 编排。
```java
@Service
public class OrderAppService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private InventoryClient inventoryClient;
@Autowired
private AccountClient accountClient;
@Autowired
private OutboxMapper outboxMapper;
public Long createOrder(CreateOrderCommand cmd) {
String xid = UUID.randomUUID().toString();
Long orderId = orderMapper.nextId();
try {
// 1. 创建订单,状态为处理中
orderMapper.insertProcessing(orderId, cmd.getUserId(), cmd.getSkuId(), cmd.getAmount());
// 2. Try阶段:冻结库存、冻结账户余额
inventoryClient.tryFreeze(xid, "b-inventory", cmd.getSkuId(), cmd.getCount());
accountClient.tryFreeze(xid, "b-account", cmd.getUserId(), cmd.getAmount());
// 3. Confirm阶段:确认库存和账户
inventoryClient.confirm(xid, "b-inventory", cmd.getSkuId(), cmd.getCount());
accountClient.confirm(xid, "b-account", cmd.getUserId(), cmd.getAmount());
// 4. 更新订单状态为已确认
orderMapper.updateStatus(orderId, "CONFIRMED");
// 5. 写Outbox事件,后续异步通知积分、营销、搜索等系统
outboxMapper.insert("ORDER_CONFIRMED", orderId.toString(), xid);
return orderId;
} catch (Exception e) {
// Cancel阶段:尽最大努力补偿
inventoryClient.cancel(xid, "b-inventory", cmd.getSkuId(), cmd.getCount());
accountClient.cancel(xid, "b-account", cmd.getUserId(), cmd.getAmount());
orderMapper.updateStatus(orderId, "CANCELED");
throw new RuntimeException("下单失败,事务已回滚", e);
}
}
}
这里有一个实战细节:Cancel 调用不能因为某一个服务失败就中断。更稳妥的写法是分别捕获异常,然后把失败的补偿任务写入重试表,由后台任务继续执行。
```java
private void safeCancel(String xid, CreateOrderCommand cmd) {
try {
inventoryClient.cancel(xid, "b-inventory", cmd.getSkuId(), cmd.getCount());
} catch (Exception ex) {
// 写入补偿任务,交给定时任务重试
compensationMapper.insert(xid, "inventory", ex.getMessage());
}
try {
accountClient.cancel(xid, "b-account", cmd.getUserId(), cmd.getAmount());
} catch (Exception ex) {
compensationMapper.insert(xid, "account", ex.getMessage());
}
}
Outbox 的作用是把"订单已确认"这类事件可靠投递出去。订单状态更新和事件写入最好在同一个本地事务内完成,然后由独立任务扫描发送。
```java
@Scheduled(fixedDelay = 3000)
public void publishOutboxEvent() {
List<OutboxEvent> events = outboxMapper.findUnpublished(100);
for (OutboxEvent event : events) {
try {
// 发送到MQ,例如Kafka/RocketMQ
mqProducer.send(event.getTopic(), event.getBizKey(), event.getPayload());
// 发送成功后标记已发布
outboxMapper.markPublished(event.getId());
} catch (Exception e) {
// 不删除事件,等待下一轮重试
outboxMapper.increaseRetry(event.getId(), e.getMessage());
}
}
}
这套方案的优势是:交易主链路用 TCC 保证关键资源一致;非核心链路用 Outbox 保证最终一致。它比简单地依赖 MQ 事务更可控,也比全局 XA 事务更适合高并发业务。
## 总结与思考
openclaw 做分布式事务,不建议一上来追求"大一统框架"。真正落地时,最重要的是识别业务一致性等级。库存、资金、订单状态属于核心一致性,需要 TCC 这类显式补偿模型;短信、积分、搜索索引属于最终一致,使用 Outbox 加 MQ 更合适。
从职业成长角度看,分布式事务不是背几个概念就能掌握的技术点,它考验的是工程取舍能力。你要能回答:哪些状态可以短暂不一致?哪些操作必须幂等?补偿失败谁来兜底?异常记录如何追踪?这些问题比"用不用某个框架"更重要。
我在 openclaw 项目中的经验是,分布式事务的核心不是把失败隐藏起来,而是让失败可观察、可重试、可修复。只要状态机设计清晰,幂等表足够严谨,补偿任务具备持续重试能力,大多数跨服务一致性问题都可以被稳定控制在业务可接受范围内。
#云盏科技官网 #小龙虾 #云盏科技 #ai技术论坛 #skills市场