订单状态流转
订单从创建到完结要经过一串状态,待支付、已支付、已发货、已完成。状态不能乱跳,已支付的订单不能再变成待支付,已取消的订单不能被重复取消。本章只回答一个问题,怎么保证状态只在合法路径上流转,且并发下不会跳错。
1. 状态不能乱跳
订单状态是一条有向路径,不是随便改的字段。
PENDING ──→ PAID ──→ SHIPPING ──→ COMPLETED
│ │
↓ ↓
CANCELLED REFUNDING ──→ REFUNDED待支付只能变成已支付或已取消,已支付只能变成已发货或退款中。每个状态有固定的出口,越过出口就是脏数据。
朴素的写法是查出来改状态,先查出订单当前状态,判断要跳的目标合不合法,合法再更新。
Order order = orderMapper.selectById(orderId);
if ("PENDING".equals(order.getStatus())) {
orderMapper.updateStatus(orderId, "PAID");
}这段代码在并发下会出问题。两个请求同时读到 PENDING,都判断合法,各自把状态改成 PAID。改成已支付还不致命,改成别的就可能重复扣款、重复发货。问题的根源和库存扣减一样,「判断」和「更新」之间插入了另一个请求。
2. 条件更新,让状态机在数据库里生效
修法和乐观锁思路一致。把「当前状态是 PENDING」写进 UPDATE 的 WHERE 条件,判断和更新合成一条原子语句。
UPDATE t_order
SET status = 'PAID', paid_at = now()
WHERE order_id = ? AND status = 'PENDING';status = 'PENDING' 就是这条状态流转的守卫。只有当前确实是待支付,这条更新才生效。返回受影响行数为 0,说明状态已经被别的请求改掉了,这次流转不合法,直接返回失败,不重试。
这一步把状态机的合法性从应用代码下沉到了数据库。应用层不用再先查再判断,一条带条件的更新同时完成了「检查状态」和「改变状态」。
3. 幂等靠的就是这条守卫
状态机的守卫天然带来幂等。超时取消这个场景最能说明问题。
订单超时后,延迟队列发来一条取消消息。正常情况下这条消息只发一次,但消息队列是 at-least-once,可能重投。取消逻辑写成条件更新:
UPDATE t_order SET status = 'CANCELLED'
WHERE order_id = ? AND status = 'PENDING';第一次收到消息,订单是待支付,更新生效,返回 1,接着回补库存。第二条重复消息到达时,订单已经是已取消,status = 'PENDING' 不成立,返回 0,直接跳过,不会重复回补库存。
这条守卫就是幂等性设计里的状态机守卫,一种乐观锁式的条件更新。状态机守卫和乐观锁版本号、唯一约束、Token、去重表的关系与选型,见 幂等性设计。同一个订单从待支付到已取消只能发生一次,第二次自然失效。
4. 什么状态能跳,表驱动还是条件更新
状态多了之后,合法流转关系怎么维护。两种做法。
条件更新在每个入口写死 WHERE status = ?,适合状态少、流转简单的场景。状态一多,每个入口的守卫条件散落在各处的 SQL 里,改一处漏一处。
更复杂的做法是维护一张状态机表,记录每个状态允许跳向哪些状态,更新前先查表校验。代价是多一次查询,而且查表和更新之间又回到了「判断」和「更新」分离的老问题,还得配合锁或条件更新兜底。
结论是,条件更新是底线,无论状态机多复杂,最终都要落到一条带守卫条件的原子更新上。状态机表只是辅助组织流转关系,不能替代守卫条件。
5. 相关章节
状态流转依赖延迟队列把超时消息准时投递出来,见 延迟队列。消息重复投递的完整解法见 消息去重,订单支付状态与回调、退款和对账的联动见 支付回调与对账。库存回补的动作属于库存扣减的逆过程,见 库存扣减并发控制。