订单状态机是怎么烂掉的,以及怎么不烂
谁会卡在这
客服问你:这单到底什么状态?
你查了一下,订单表里 paid = true,但 status = 0(待支付)。旁边还有个 refund_status = 1(退款申请中),以及一个 cancel_status = 2(用户已取消)。
四个字段,四个说法。你也不知道该信哪个。
踩错的代价
订单状态烂掉,不会在某一天突然发生。它是一点一点长出来的。
第一阶段,你有一个 status 字段,值是 0/1/2,清清爽爽。
第二阶段,产品说要支持退款。退款和订单状态是两码事——订单可能已收货了才退款——于是你加了个 refund_status。合理。
第三阶段,运营说要区分「用户取消」和「超时自动取消」,因为要出报表。你在 status 里加不下(那是个业务状态),于是加了 cancel_status。也合理。
第四阶段,拼团上线了。拼团订单有自己的生命周期,成团/未成团,跟发货没关系。再加一个字段。
每一步都合理。合理了四次之后,你有了五个状态字段,和一个没人说得清的订单。
一个真实的订单表长什么样
这是一个跑了几年的电商订单表,状态相关的字段:
| 字段 | 类型 | 取值 |
|---|---|---|
paid | Boolean | 是否已支付 |
status | Integer | 0 待支付、1 待发货、2 部分发货、3 待核销、4 待收货、5 已收货、6 已完成、9 已取消 |
refund_status | Integer | 0 未退款、1 申请中、2 部分退款、3 已退款 |
cancel_status | Integer | 0 未取消、1 系统取消、2 用户取消 |
group_buy_status | Integer | 0 进行中、10 已成功、-1 已失败、99 非拼团订单 |
几个值得看的地方:
paid 和 status = 0 说的是同一件事。 两个字段表达同一个事实,就意味着它们可能不一致。paid = true 但 status = 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_ | 8 | 0 |
退款状态 ORDER_REFUND_STATUS_ | 8 | 0 |
| 商户退款状态 | 7 | 0 |
| 店铺端筛选状态 | 10 | 1 |
| 订单类型 | 3 | 3 |
| 订单二级类型 | 8 | 0 |
有两行不对劲。
第一行是退款状态: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。
正确的做法是只留一个方法能改状态:
// 唯一的状态迁移入口
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 里抽出来,变成一份能读的数据:
// 两个维度拼成一个 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)表示「不适用」 | 能接受,但要在枚举里显式命名,别留裸数字 |
这条链路上的其他几篇
- 《支付回调一定会重复,你的幂等挡不挡得住》
- 《微信支付和支付宝,接入时到底差在哪》
