4、状态机幂等:业务状态机流转、状态校验与幂等判断、状态机与重试的结合
状态机幂等,说白了就是把业务状态的变化,变成一张有向图。
每个节点是一个状态,每条边是一次合法操作。你想想看,如果一次重试进来,发现当前状态已经不在「允许转移」的集合里,那这次操作就直接跳过。这不就是天然的幂等判断吗?
我在项目中遇到过不少因为状态混乱导致的数据问题。嗯,今天咱们就把这块彻底聊透。
4.1 业务状态机的基本模型
一个标准的业务状态机,包含三个核心要素:
- 当前状态:实体在某个时刻所处的阶段
- 事件/操作:触发状态转移的动作
- 转移规则:从状态A到状态B的合法路径
拿订单系统举例,最常见的状态流转是这样的:
订单状态机示例:
待支付 → (支付成功) → 已支付 → (发货) → 已发货 → (确认收货) → 已完成
待支付 → (取消) → 已取消
已支付 → (退款) → 退款中 → (退款完成) → 已退款
每个箭头代表一个操作,每个操作都对应一个业务方法。重试发生时,我们只需要检查:当前状态是否允许执行这个操作。
4.2 状态校验与幂等判断
状态校验的核心逻辑其实就一句话:先查状态,再判转移,最后执行。
我个人的习惯是,把状态校验放在业务方法的最前面,做成一个独立的校验层。这样不管重试进来多少次,只要状态不对,直接返回成功。
public class OrderStateMachine {
// 状态转移表:key = "当前状态_操作", value = 目标状态
private static final Map<String, OrderStatus> TRANSITION_MAP = new HashMap<>() {{
put("PENDING_PAYMENT_PAY", OrderStatus.PAID);
put("PAID_SHIP", OrderStatus.SHIPPED);
put("SHIPPED_CONFIRM", OrderStatus.COMPLETED);
put("PENDING_PAYMENT_CANCEL", OrderStatus.CANCELLED);
put("PAID_REFUND", OrderStatus.REFUNDING);
put("REFUNDING_REFUND_COMPLETE", OrderStatus.REFUNDED);
}};
public OrderStatus transition(Order order, String action) {
// 1. 获取当前状态
OrderStatus current = order.getStatus();
String key = current.name() + "_" + action;
// 2. 校验转移是否合法
OrderStatus target = TRANSITION_MAP.get(key);
if (target == null) {
// 非法转移:要么是重复请求,要么是异常操作
// 这里直接返回当前状态,不抛异常
return current;
}
// 3. 执行状态变更(这里用乐观锁防止并发)
int updated = orderDao.updateStatus(order.getId(), current, target);
if (updated == 0) {
// 乐观锁失败,说明状态已被其他线程修改
// 重新查询最新状态,递归重试或返回
Order latest = orderDao.findById(order.getId());
return transition(latest, action);
}
return target;
}
}
避坑指南:
我曾经在状态校验时直接抛异常,结果重试框架把异常当成失败,继续重试,导致死循环。后来改成「状态不匹配时直接返回当前状态」,重试框架拿到成功响应就停了。记住:幂等接口不要抛异常,要返回成功。
4.3 状态机与重试的结合
状态机和重试结合,有几个关键点需要处理:
- 重试时的状态快照:重试请求里最好带上发起时的状态,方便服务端做对比校验
- 幂等键与状态绑定:每个状态转移操作,都对应一个唯一的幂等键
- 并发状态冲突:多个重试同时到达,要用乐观锁或分布式锁保证状态一致性
我建议的实践方案是这样的:
public class RetryStateMachineService {
@Transactional
public OrderResult processWithRetry(String orderId, String action,
OrderStatus expectedStatus, String idempotentKey) {
// 1. 幂等键去重
if (idempotentCache.exists(idempotentKey)) {
return idempotentCache.get(idempotentKey);
}
// 2. 查询订单并校验状态
Order order = orderDao.findById(orderId);
if (order.getStatus() != expectedStatus) {
// 状态不匹配,直接返回当前状态
return OrderResult.success(order.getStatus());
}
// 3. 执行状态转移(带乐观锁)
OrderStatus target = stateMachine.transition(order, action);
if (target == order.getStatus()) {
// 转移失败,可能是并发冲突
// 这里可以重试一次,或者直接返回
return OrderResult.retryLater();
}
// 4. 记录幂等结果
idempotentCache.set(idempotentKey, OrderResult.success(target));
return OrderResult.success(target);
}
}
注意:
状态机幂等有一个隐含陷阱:状态回滚。比如支付成功后状态变成「已支付」,但后续退款又把状态改回「待支付」。这时候如果重试请求带着旧的幂等键进来,就会误判为已处理。我的解决方案是:幂等键里带上状态版本号,或者用时间戳做区分。
4.4 状态机幂等的核心流程图
下面这张图,把整个流程串起来了。你可以看到重试请求进来后,状态校验是怎么一步步拦截非法操作的。
4.5 状态机幂等的适用场景
| 场景 | 推荐度 | 说明 |
|---|---|---|
| 订单生命周期管理 | ★★★★★ | 状态明确,转移路径清晰,天然适合状态机 |
| 审批流程 | ★★★★★ | 每个审批节点都是一个状态,重试不会改变已审批状态 |
| 任务调度 | ★★★★☆ | 任务状态(待执行/执行中/已完成)可以用状态机管理 |
| 资金交易 | ★★★☆☆ | 状态机可以防重复扣款,但要配合金额校验 |
个人经验:
状态机幂等最适合「状态只向前流转」的场景。如果业务允许状态回退(比如退款),那就要在幂等键里加入版本号或时间戳。我做过一个支付系统,就是因为没处理回退场景,导致重复退款,后来花了整整一周修数据。嗯,教训深刻。
4.6 总结一下
状态机幂等的核心思想,就是把重试的幂等判断,从「有没有做过」变成「能不能做」。前者依赖幂等键,后者依赖状态校验。两者结合,效果最好。
你想想看,一个重试请求进来,先查幂等键,再查状态,两个都过了才执行。这双重保险,基本能覆盖99%的重复场景。
至于那1%的边界情况,比如并发冲突、状态回滚,咱们后面章节会专门讲。今天先把状态机这块吃透,后面就轻松了。