Skip to content

订单状态机是怎么烂掉的,以及怎么不烂

谁会卡在这

客服问你:这单到底什么状态?

你查了一下,订单表里 paid = true,但 status = 0(待支付)。旁边还有个 refund_status = 1(退款申请中),以及一个 cancel_status = 2(用户已取消)。

四个字段,四个说法。你也不知道该信哪个。

踩错的代价

订单状态烂掉,不会在某一天突然发生。它是一点一点长出来的。

第一阶段,你有一个 status 字段,值是 0/1/2,清清爽爽。

第二阶段,产品说要支持退款。退款和订单状态是两码事——订单可能已收货了才退款——于是你加了个 refund_status。合理。

第三阶段,运营说要区分「用户取消」和「超时自动取消」,因为要出报表。你在 status 里加不下(那是个业务状态),于是加了 cancel_status。也合理。

第四阶段,拼团上线了。拼团订单有自己的生命周期,成团/未成团,跟发货没关系。再加一个字段。

每一步都合理。合理了四次之后,你有了五个状态字段,和一个没人说得清的订单。

一个真实的订单表长什么样

这是一个跑了几年的电商订单表,状态相关的字段:

字段类型取值
paidBoolean是否已支付
statusInteger0 待支付、1 待发货、2 部分发货、3 待核销、4 待收货、5 已收货、6 已完成、9 已取消
refund_statusInteger0 未退款、1 申请中、2 部分退款、3 已退款
cancel_statusInteger0 未取消、1 系统取消、2 用户取消
group_buy_statusInteger0 进行中、10 已成功、-1 已失败、99 非拼团订单

几个值得看的地方:

paidstatus = 0 说的是同一件事。 两个字段表达同一个事实,就意味着它们可能不一致。paid = truestatus = 0 这种组合,数据库拦不住你。

status = 9(已取消)cancel_status 也重叠。 取消这件事被记了两遍:一遍在主状态里,一遍在专门的字段里。

状态值跳号:0、1、2、3、4、5、6、9。 中间的 7、8 去哪了?大概率是加过又删了,或者留给未来。这种跳号本身不是问题,但它是「这个枚举被反复改过」的痕迹。

99 = 非拼团订单 用一个魔数表示「本字段不适用」。这是个很诚实的妥协——因为字段是 NOT NULL 的,总得填点什么。

光是这四个字段的组合就有 8 × 4 × 3 × 2 = 192 种。 实际合法的可能不到二十种。剩下那一百七十多种,没有任何东西阻止它们出现在你的数据库里。

光看表还不够,去数代码里的常量

表结构只是结果,代码里的常量文件才是现场

这套系统装订单常量的那个文件:204 行,80 个有效常量,另外还有 6 个被注释掉但没删。

按前缀数一下:

前缀有效注释掉
主状态 ORDER_STATUS_80
退款状态 ORDER_REFUND_STATUS_80
商户退款状态70
店铺端筛选状态101
订单类型33
订单二级类型80

有两行不对劲。

第一行是退款状态:8 个常量,但退款状态只有 4 个值。

翻到文件里,答案是它有两组,一组在第 132 行,一组在第 180 行,隔了 48 行,在同一个文件里:

第一组第二组
0未退款未退款
1申请中申请中
2退款中部分退款
3已退款已退款

0、1、3 三行的注释一模一样,只有 2 是冲突的——「退款中」是过程态,「部分退款」是结果态,是两件事。

后果很实在:你写 refundStatus == 2 的时候,在同一个文件里能找到两种依据。用哪个常量名,决定了你以为它是什么意思。而字段只有一个。

第二行是订单类型:3 个有效,3 个被注释掉。

被注释掉的那三个——视频号、云盘、卡密——在「二级类型」里原样重新出现了,值是 4、5、6。而二级类型这一组的值是 0、1、2、4、5、6、7、8,跳过了 3

这就是一次分类重构留下的全部痕迹:一级类型搬到二级,旧常量注释掉留在原地,新的一组给搬过来的三个让出了 4、5、6,自己跳过 3。

没有人写文档说过这件事。它只存在于「哪几行被注释掉了」和「哪个数字缺了」里。

再加上主状态那 8 个数字(0~6 和 9,同样跳了 7、8)、另一个类里的 7 个状态字符串和 13 条日志文案、店铺端那 10 个筛选字符串——

「这个订单现在是什么状态」这一件事,在代码里由 40 多个常量分四套描述。

客服问你这单什么状态,你答不上来,不是因为你不熟业务。

四条规则

一、只能有一个「唯一真相」字段

订单当前处于哪一步,只由一个字段决定。 其他的都是补充信息,不是状态本身。

paid 这种字段最好别存——它可以从 status 算出来。存了就要维护两处,维护两处就会不一致。

判断方法:如果两个字段能互相推导,那就只留一个。

拿上面那张表说:paid 看着可以删——但不能直接用 status >= 1 代替
超时未付被系统取消的订单,status 是 9,同样 ≥ 1,而它根本没付过钱。
要删得写成 status BETWEEN 1 AND 6,把取消态排除掉。

一个布尔字段换成一段带边界的范围判断,这就是删字段的真实代价。
cancel_status 更不能删——它记的是「谁取消的」,这个信息 status 里没有。

区别在于:paid 是状态的重复表达,cancel_status 是状态的附加属性。 前者要删,后者要留。

二、派生状态不要存,要算

订单页面要显示「待评价」。你可能想加个 is_commented 字段。

先问一句:这个状态能不能从已有数据算出来? 已收货 + 没有评价记录 = 待评价。能算出来就别存。

存下来的每一个派生状态,都是一个将来会跟事实不一致的地方——因为总有一条代码路径会忘记更新它。

但也别走极端。 如果这个派生状态要被高频查询、要建索引、要排序,那存下来是对的,代价是你得保证它跟事实同步(用触发器、或者集中在一处更新)。

判断线:只在展示层用 → 算;要参与查询和排序 → 存,但必须有唯一的更新入口。

三、所有迁移走同一个入口

这条最重要,也最容易破。

状态迁移会在很多地方发生:用户点确认收货、后台点发货、定时任务超时取消、支付回调改成已支付、退款成功改状态。

如果每个地方都直接 update order set status = ?,那你的状态机就不存在了——它只存在于文档里,代码里全是散落的 UPDATE。

正确的做法是只留一个方法能改状态

java
// 唯一的状态迁移入口
public void transfer(Long orderId,
                     OrderEvent event,
                     String operator) {
    Order order = load(orderId);
    Integer from = order.getStatus();
    // 查迁移表,表里没有就是非法迁移
    Integer to = TABLE.get(
            key(from, event));

    if (to == null) {
        throw new IllegalStateException(
            "订单 " + orderId + " 不能从 "
            + from + " 执行 " + event);
    }
    order.setStatus(to);
    save(order);
    // 每次迁移都留痕
    writeLog(orderId, from, to,
             event, operator);
}

关键是那张 TRANSITIONS 迁移表。 它把「哪些迁移是合法的」从一堆 if 里抽出来,变成一份能读的数据:

java
// 两个维度拼成一个 key,Map 才装得下
// 用 Guava 的 Table 也行,一个意思
static String key(Integer from,
                  OrderEvent e) {
    return from + ":" + e;
}

// from + event → to
// 表里没有的组合一律拒绝
// allow(...) 往 TABLE 里塞一条
allow(WAIT_PAY,  PAY_SUCCESS, WAIT_SEND);
allow(WAIT_PAY,  CANCEL,      CANCELLED);
allow(WAIT_SEND, DELIVER,     WAIT_RECV);
allow(WAIT_RECV, RECEIPT,     RECEIVED);

// 表里没有 WAIT_PAY → DELIVER
// 所以「没付款就发货」这条路走不通

这张表最大的价值不是它允许什么,是它禁止什么。 那 172 种非法组合,在这里被一次性挡掉。

四、每次迁移都写日志

这条项目里做对了,值得单独说。

真实实现里,每个状态变更都配一条操作日志,而且日志文案是模板化的:

"订单生成"
"用户付款成功"
"快递 {deliveryName} 单号 {deliveryCode}"
"订单超时,系统自动取消订单"
"系统自动收货"

看这几条日志你会发现一件事:同一个「已发货」状态,来源有四种——虚拟发货、快递发货、商家配送、无需发货。「已收货」有两种——用户点的、系统自动的。「已取消」也有两种——用户取消、超时取消。

状态只有 8 个,但迁移路径远不止 8 条。

这就是为什么状态机会烂:大部分人画了状态图,但没画迁移的触发源。 图上「待发货 → 待收货」是一条线,代码里是四个不同的调用点,各写各的。

状态迁移日志是唯一能让你事后还原真相的东西。等到有人问「这单为什么是这个状态」,日志是你唯一的证据。没有它,你只能猜。

常见问题对照表

现象真正的原因解法
同一个订单,两个状态字段互相矛盾两个字段表达同一事实,更新时漏了一个能互相推导的字段只留一个
订单状态莫名跳变,查不到谁改的状态迁移散落在多处直接 UPDATE收成唯一入口,每次迁移写日志
出现了业务上不可能的状态组合没有迁移表校验,什么都能改用 from+event→to 的迁移表,表外一律拒绝
未付款的订单被发货了发货接口没校验当前状态迁移表里就没有这条路径,自然挡住
加了新状态,老代码没处理,走到 default 分支状态判断散落在各处 switch迁移集中后,新增状态只改一张表
报表统计和订单列表数字对不上一个查 status,一个查 paid统一口径,只认唯一真相字段
拼团订单的状态字段对非拼团订单没意义用魔数(如 99)表示「不适用」能接受,但要在枚举里显式命名,别留裸数字

这条链路上的其他几篇

  • 《支付回调一定会重复,你的幂等挡不挡得住》
  • 《微信支付和支付宝,接入时到底差在哪》

签名-C

大粽子