电商系统里的状态机要放数据库还是代码里?一次讲清状态机、处理器模式与状态日志表设计

大家好,我是一名有 4 年工作经验的 Java 后端开发。
最近在整理电商后台和业务系统里的状态流转设计,发现很多项目一开始状态少、动作少,写几个 if else 就能跑;但随着订单、售后、商品、优惠券这些模块越来越复杂,代码会很快失控。
这篇文章我想结合电商订单场景,系统聊一聊状态机到底该怎么落地,状态规则应该放数据库还是代码里,处理器模式怎么配合,状态日志表又该怎么设计。

🦅个人主页
🐼

文章目录


一、前言

很多业务系统一开始做状态判断,代码通常都长这样:

if (order.getStatus() == OrderStatus.WAIT_PAY) {
    // 可以取消
} else if (order.getStatus() == OrderStatus.PAID) {
    // 可以发货
} else if (order.getStatus() == OrderStatus.SHIPPED) {
    // 可以确认收货
} else if (order.getStatus() == OrderStatus.CLOSED) {
    // 什么都不能做
}

刚开始状态少的时候,这种写法还能忍。
但只要业务一复杂,很快就会出现这些问题:

  • 订单状态越来越多
  • 售后状态越来越多
  • 商品状态、优惠券状态、活动状态都要判断
  • 前端按钮判断一套,后端接口校验又一套
  • 某个状态新增后,到处都要改 if else
  • 状态流转规则没人能说清楚

很多人这时候会想到两个方向:

  • 要不要上状态机?
  • 状态机规则要不要放数据库?

再往下做一点,又会继续遇到:

  • “发货”不只是改状态,还要调物流、写日志、发 MQ
  • “取消订单”还要回补库存、关支付单、写操作记录
  • “确认收货”还要发积分、更新结算状态

这就说明,状态流转真正要解决的,不只是“状态值怎么判断”,而是:

当前动作能不能做、做完状态变成什么、具体业务动作又该怎么执行。

这篇文章就结合电商系统真实场景,把状态机、处理器模式、状态日志表到底怎么配合,系统讲透。


二、业务场景

先假设这样一个典型的电商订单场景。

2.1 订单状态

订单状态包括:

  • WAIT_PAY:待付款
  • PAID:已付款
  • SHIPPED:已发货
  • FINISHED:已完成
  • CLOSED:已关闭
  • REFUNDING:退款中

2.2 订单动作

订单支持这些动作:

  • 支付
  • 取消
  • 发货
  • 确认收货
  • 申请退款

2.3 真实业务要求

这个场景下,通常需要满足:

  • 不同状态下允许的动作不同
  • 状态流转规则要统一
  • 前端按钮显示和后端校验要一致
  • 新增状态或新增动作时,不要改一堆 if else
  • 每次状态变更都要留痕
  • 订单动作不仅改状态,还要执行各自业务逻辑

这类问题在电商里非常普遍:

  • 订单状态流转
  • 售后状态流转
  • 商品审核状态流转
  • 优惠券状态流转
  • 活动状态流转

三、问题现象

很多项目一开始不做统一设计,后面会出现这些典型问题。

3.1 状态判断散落在各层

比如:

  • 前端页面里一套 if
  • Controller 里一套 if
  • Service 里一套 if
  • 定时任务里又一套 if
  • MQ 消费者里还有一套 if

最后会变成:

  • 同一个状态规则到处重复
  • 业务一改,改动点特别多
  • 很容易漏改

3.2 状态流转规则说不清

比如你问:

  • 已付款订单能不能取消?
  • 已发货订单能不能退款?
  • 退款中订单能不能再次发货?

如果团队回答依赖“看代码里哪里写了”,那说明规则根本没有统一收敛。

3.3 一个动作不只是改状态

比如“发货”这个动作,通常不只是:

  • PAID -> SHIPPED

还会伴随:

  • 调物流接口
  • 生成发货单
  • 写订单操作日志
  • 发送订单已发货消息

也就是说:

状态流转和业务执行,其实是两个不同层面的事情。

3.4 前后端判断不一致

比如前端判断:

  • PAID 可以发货

但后端又加了条件:

  • 只有 PAID 且已分配仓库才能发货

结果就会出现:

  • 前端显示了发货按钮
  • 后端却拒绝执行

这种体验在后台系统里特别常见,也特别差。


四、原理分析

状态流转真正要解决的,不是“少写几个 if”,而是把规则和执行分开管理。

4.1 状态机解决什么?

状态机解决的是:

当前状态下,某个动作能不能做;如果能做,下一个状态是什么。

比如:

  • WAIT_PAY + PAY -> PAID
  • WAIT_PAY + CANCEL -> CLOSED
  • PAID + SHIP -> SHIPPED
  • SHIPPED + CONFIRM_RECEIVE -> FINISHED

所以状态机本质上是在管理:

  • 状态
  • 动作
  • 流转结果

4.2 处理器模式解决什么?

处理器模式解决的是:

某个动作到底要执行哪些业务逻辑。

比如“发货”动作的处理器,可能要做:

  • 校验仓库
  • 调用物流系统
  • 写发货记录
  • 发 MQ 通知

而“取消订单”动作的处理器,可能要做:

  • 校验取消条件
  • 回补库存
  • 关闭支付单
  • 写取消日志

也就是说:

  • 状态机管规则
  • 处理器管执行

4.3 为什么这两个要组合使用?

因为业务里同时存在两类问题:

规则问题
  • 当前状态允不允许这个动作
  • 动作执行后状态变成什么
执行问题
  • 这个动作具体要调哪些服务
  • 要不要发消息
  • 要不要写日志

如果把这两类问题都塞进 if else,代码一定会越来越乱。

4.4 状态机规则应该放数据库还是代码?

这是最关键的问题之一。

我先说结论:

对于电商里的核心业务状态流转,大多数情况下更建议放代码里,而不是直接放数据库。

原因很简单:

  • 核心状态规则通常比较稳定
  • 放代码里更清晰、可读、可调试
  • 更适合和事务、处理器、领域逻辑一起收敛
  • 有编译期约束,不容易被误改

而数据库更适合存的是:

  • 当前状态
  • 状态变更日志
  • 某些可配置的外围规则

五、为什么大多数电商状态机更适合放代码里

这一点我建议你在设计里一定要先想清楚。

5.1 核心业务规则通常不应该交给配置随便改

比如下面这些规则:

  • 待付款可以支付
  • 已付款可以发货
  • 已发货可以确认收货
  • 已关闭不能再发货

这些其实是订单领域里的核心业务规则。
它们通常不会像运营配置那样天天变。

所以更适合:

  • 枚举定义
  • 规则表定义
  • 代码里统一维护

5.2 放代码里更适合排查和调试

如果规则写在代码里:

  • 一眼能看到所有状态流转
  • 断点好打
  • 改动有版本记录
  • 问题定位更直接

如果规则全放数据库:

  • 还要查配置
  • 还要考虑缓存
  • 还要防误配置
  • 排障成本会高很多

5.3 动作执行逻辑本来就在代码里

就算你把状态流转规则放数据库,“发货”“取消”“退款”这些动作的真正执行逻辑还是要写代码。

比如:

  • 发货要调物流
  • 取消要回补库存
  • 确认收货要发积分

所以很多时候你会发现:

流转规则在数据库,动作实现却在代码,最后会割裂得很严重。

5.4 什么时候才适合放数据库?

只有这些情况,我才建议考虑配置化:

  • 审批流特别复杂
  • 流程节点经常改
  • 需要产品/运营可配置
  • 需要流程可视化编排

这种更像:

  • 工作流
  • 审批流
  • BPM

而不只是普通订单状态机。


六、最推荐的落地方式

如果你问我电商后台里最推荐的一套,我更建议这样做:

状态枚举 + 动作枚举 + 流转规则表 + 处理器模式 + 状态日志表

这套组合非常适合电商系统。

6.1 状态枚举

用于统一定义状态。

6.2 动作枚举

用于统一定义动作。

6.3 流转规则表

用于统一定义:

  • 当前状态
  • 当前动作
  • 下一个状态

6.4 处理器模式

用于把每个动作的具体业务执行拆开。

6.5 状态日志表

用于记录每次状态变更,方便:

  • 审计
  • 追踪
  • 排障
  • 复盘

这才是一个完整闭环。


七、落地代码示例

下面给一版比较贴近实际项目思路的 Java 代码。

7.1 定义订单状态枚举

public enum OrderStatus {
    WAIT_PAY,
    PAID,
    SHIPPED,
    FINISHED,
    CLOSED,
    REFUNDING
}

7.2 定义订单动作枚举

public enum OrderAction {
    PAY,
    CANCEL,
    SHIP,
    CONFIRM_RECEIVE,
    APPLY_REFUND
}

7.3 定义状态机规则表

public class OrderStateMachine {

    private static final Map<OrderStatus, Map<OrderAction, OrderStatus>> FLOW = new EnumMap<>(OrderStatus.class);

    static {
        Map<OrderAction, OrderStatus> waitPayMap = new EnumMap<>(OrderAction.class);
        waitPayMap.put(OrderAction.PAY, OrderStatus.PAID);
        waitPayMap.put(OrderAction.CANCEL, OrderStatus.CLOSED);

        Map<OrderAction, OrderStatus> paidMap = new EnumMap<>(OrderAction.class);
        paidMap.put(OrderAction.SHIP, OrderStatus.SHIPPED);
        paidMap.put(OrderAction.APPLY_REFUND, OrderStatus.REFUNDING);

        Map<OrderAction, OrderStatus> shippedMap = new EnumMap<>(OrderAction.class);
        shippedMap.put(OrderAction.CONFIRM_RECEIVE, OrderStatus.FINISHED);
        shippedMap.put(OrderAction.APPLY_REFUND, OrderStatus.REFUNDING);

        FLOW.put(OrderStatus.WAIT_PAY, waitPayMap);
        FLOW.put(OrderStatus.PAID, paidMap);
        FLOW.put(OrderStatus.SHIPPED, shippedMap);
    }

    public static boolean canDo(OrderStatus currentStatus, OrderAction action) {
        return FLOW.containsKey(currentStatus) && FLOW.get(currentStatus).containsKey(action);
    }

    public static OrderStatus nextStatus(OrderStatus currentStatus, OrderAction action) {
        if (!canDo(currentStatus, action)) {
            throw new IllegalStateException("当前状态不允许执行该操作");
        }
        return FLOW.get(currentStatus).get(action);
    }

    public static Set<OrderAction> allowedActions(OrderStatus currentStatus) {
        if (!FLOW.containsKey(currentStatus)) {
            return Collections.emptySet();
        }
        return FLOW.get(currentStatus).keySet();
    }
}

这个类只做一件事:

  • 判断某个动作是否允许
  • 计算下一个状态

7.4 定义动作处理器接口

public interface OrderActionHandler {

    OrderAction action();

    void handle(Order order);
}

7.5 发货处理器

@Component
public class ShipOrderHandler implements OrderActionHandler {

    @Override
    public OrderAction action() {
        return OrderAction.SHIP;
    }

    @Override
    public void handle(Order order) {
        // 1. 校验仓库
        // 2. 调物流接口
        // 3. 写发货单
        // 4. 发 MQ 通知
    }
}

7.6 取消订单处理器

@Component
public class CancelOrderHandler implements OrderActionHandler {

    @Override
    public OrderAction action() {
        return OrderAction.CANCEL;
    }

    @Override
    public void handle(Order order) {
        // 1. 回补库存
        // 2. 关闭支付单
        // 3. 写取消记录
    }
}

7.7 统一执行入口

@Service
public class OrderActionService {

    private final Map<OrderAction, OrderActionHandler> handlerMap = new EnumMap<>(OrderAction.class);

    public OrderActionService(List<OrderActionHandler> handlers) {
        for (OrderActionHandler handler : handlers) {
            handlerMap.put(handler.action(), handler);
        }
    }

    @Transactional
    public void execute(Order order, OrderAction action) {
        if (!OrderStateMachine.canDo(order.getStatus(), action)) {
            throw new RuntimeException("当前状态不允许执行该操作");
        }

        OrderActionHandler handler = handlerMap.get(action);
        if (handler == null) {
            throw new RuntimeException("未找到动作处理器");
        }

        OrderStatus fromStatus = order.getStatus();
        handler.handle(order);

        OrderStatus nextStatus = OrderStateMachine.nextStatus(fromStatus, action);
        order.setStatus(nextStatus);
        orderMapper.updateStatus(order.getId(), nextStatus);

        orderStatusLogService.record(order.getId(), fromStatus, nextStatus, action);
    }
}

这里你可以清楚看到:

  • 状态机先判断是否合法
  • 处理器执行具体业务动作
  • 然后统一改状态
  • 最后记录状态日志

这套结构比到处写 if else 清晰很多。


八、数据库里到底该存什么?

这部分特别关键。

8.1 当前状态要落数据库

比如 order_info 表里要有:

  • status

因为业务最终状态肯定要持久化。

8.2 状态变更日志也建议落数据库

这个非常值得做。

比如建一张:

order_status_log

字段建议:

字段 说明
id 主键
order_id 订单 ID
from_status 原状态
to_status 新状态
action 动作
operator_id 操作人
operator_type 操作人类型
remark 备注
created_at 创建时间

这张表的价值非常大:

  • 查问题时知道订单怎么变过来的
  • 审计操作轨迹
  • 复盘异常流转
  • 支持后台展示状态时间线

8.3 核心状态规则不建议直接放数据库

对于订单、售后、商品这些核心规则,我更建议:

  • 规则写代码
  • 结果落数据库
  • 日志落数据库

而不是:

  • 规则也放数据库

因为核心状态规则通常不该让它变成“随时可配”的。


九、什么时候适合把状态规则做成数据库配置?

这里也要说清楚,不是永远不能放数据库。

更适合数据库配置的场景通常是:

  • 审批流节点经常变化
  • 流程需要产品/运营配置
  • 要支持可视化流程编排
  • 某些外层规则变化频繁

比如:

  • 商家入驻审核流
  • 平台风控审核流
  • 可配置的审批节点流程

这些更像“工作流”,不完全是普通状态机。

所以我的建议是:

放代码里的

  • 订单状态机
  • 售后状态机
  • 商品状态机
  • 优惠券状态机

放数据库里的

  • 当前状态
  • 状态变更日志
  • 审批流配置
  • 某些运营可调规则

也就是:

核心状态规则代码化,状态结果和状态日志数据库化。


十、为什么很多项目上了状态机,最后还是不好维护?

这也是线上项目很常见的情况。

10.1 只有状态机,没有动作处理器

结果还是把所有业务动作塞在一个大方法里,if else 只是从别处搬到了状态机类里。

10.2 规则写在数据库,执行逻辑写在代码,最后割裂

调试时特别痛苦,排查也很麻烦。

10.3 前端按钮判断和后端状态机没统一

后端已经有 allowedActions 了,前端还自己再写一套 if,这样很容易不一致。

10.4 没有状态日志

出了问题根本不知道状态是怎么流转过来的。

10.5 状态判断和权限判断、数据权限判断混在一起

比如“发货”这件事,实际上应该拆成:

  • 有没有 order:ship 权限
  • 当前订单是不是当前仓库可操作数据
  • 当前状态是不是允许发货

这三层不能混成一个 if。


十一、面试中怎么回答这个问题?

如果面试官问你:

电商系统里的状态机你会怎么设计?规则放数据库还是代码里?

你可以这样回答。

11.1 回答思路

第一,我会先区分“状态流转规则”和“动作执行逻辑”这两个层面。状态机主要负责定义当前状态下哪些动作允许执行、执行后流转到哪个状态;处理器模式则负责每个动作背后的具体业务逻辑,比如发货要调物流、取消订单要回补库存。

第二,对于订单、售后、商品这类核心业务状态机,我通常更建议把状态枚举、动作枚举和流转规则写在代码里,而不是直接放数据库。因为这些规则通常比较稳定,放代码里更清晰、可调试、可版本管理,也更适合和事务、领域逻辑放在一起维护。

第三,数据库里我会存两类东西:一类是业务当前状态,比如订单表里的 status;另一类是状态变更日志,比如 order_status_log,用来做审计、排障和状态时间线追踪。

第四,如果某些流程属于审批流、节点经常变化、需要运营可配置,那更适合做成数据库配置或者流程引擎,而不是和订单核心状态机混在一起。

11.2 面试官更想听到什么?

面试官真正想听的,通常不是一句“用状态机”,而是你有没有这些意识:

  • 你知道状态机管规则,处理器管执行
  • 你知道核心状态规则更适合代码化
  • 你知道数据库更适合存状态结果和状态日志
  • 你知道前后端按钮判断最好统一用 allowedActions
  • 你知道权限判断、数据权限判断、状态判断要分层

如果你能把这些点讲清楚,面试官会明显觉得你做过真实业务设计,而不只是知道状态机这个概念。


十二、总结

状态流转这件事,真正难的不是“少写几个 if else”,而是如何把:

  • 状态规则
  • 动作执行
  • 持久化状态
  • 状态日志

真正拆清楚、收拢起来。

如果只记一句结论,我觉得可以记住这句:

大多数电商核心状态流转场景下,更推荐“状态规则代码化 + 动作处理器化 + 状态结果数据库化 + 状态日志持久化”。

这套方案不一定最省代码,但通常是最清晰、最好维护、最接近真实线上系统的一种设计。


十三、后续可以继续展开的内容

如果这篇你觉得还可以,后面这个系列我还可以继续写:

  • 电商后台的订单状态机怎么设计
  • 售后状态机怎么设计
  • 状态日志表怎么和审计日志结合
  • 前端按钮怎么和后端 allowedActions 统一
  • 审批流什么时候该用状态机,什么时候该上流程引擎

十四、结尾

如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、关注。
后面我会继续整理一些更偏实战的 Java 后端和电商系统设计文章。

Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐