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 状态机与重试的结合

状态机和重试结合,有几个关键点需要处理:

  1. 重试时的状态快照:重试请求里最好带上发起时的状态,方便服务端做对比校验
  2. 幂等键与状态绑定:每个状态转移操作,都对应一个唯一的幂等键
  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%的边界情况,比如并发冲突、状态回滚,咱们后面章节会专门讲。今天先把状态机这块吃透,后面就轻松了。