痛点:订单状态为什么成了泥潭
订单是电商系统里状态最复杂的领域对象:待支付、已支付、已发货、已签收、已取消、退款中、已退款……每个状态又对应支付回调、发货、确认收货、取消、申请退款、退款成功等多个事件。新手项目最常见的写法,是把状态判断散落在 Service 里堆 if/else:
if (order.getStatus() == 1 && "PAY".equals(event)) {
// 改成已支付
} else if (order.getStatus() == 2 && "SHIP".equals(event)) {
// 改成已发货
}
这种写法在订单量小的时候还能跑,一旦并发上来就会踩到四个坑:
- 非法流转无拦截:已签收的订单理论上不可能再支付,但代码里没人校验,任何状态都能被强行改过去。
- 并发导致状态回退:用户取消订单与支付回调同时到达,先执行取消再执行支付回调,订单就从「已取消」被改回「已支付」——这是电商最严重的事故之一。
- 流转无审计:出问题后查不到「谁在什么时候把订单从 A 状态改成了 B 状态」,只能靠猜。
- 逻辑分散:改一个状态要全局搜索所有 Service,改一处漏一处。
方案设计:把状态流转建模成状态机
状态机的三要素:状态(当前在哪)、事件(发生了什么)、流转规则(当前状态 + 事件 → 下一个状态)。先把订单状态和事件收敛成枚举:
public enum OrderStatus {
PENDING_PAYMENT(1, "待支付"),
PAID(2, "已支付"),
SHIPPED(3, "已发货"),
COMPLETED(4, "已签收"),
CANCELLED(5, "已取消"),
REFUNDING(6, "退款中"),
REFUNDED(7, "已退款");
private final int code;
private final String desc;
// 构造器与 getter 省略
}
public enum OrderEvent {
PAY_SUCCESS("支付成功"),
SHIP("发货"),
CONFIRM("确认收货"),
CANCEL("取消订单"),
APPLY_REFUND("申请退款"),
REFUND_SUCCESS("退款成功");
private final String desc;
// 构造器与 getter 省略
}
核心是流转规则表。用「源状态 → 事件 → 目标状态」的二维映射来描述,整张表一目了然:
public class OrderStateMachine {
private static final Map<OrderStatus, Map<OrderEvent, OrderStatus>> TRANSITIONS = new HashMap<>();
static {
// 待支付:可支付、可取消
put(PENDING_PAYMENT, PAY_SUCCESS, PAID);
put(PENDING_PAYMENT, CANCEL, CANCELLED);
// 已支付:可发货、可申请退款
put(PAID, SHIP, SHIPPED);
put(PAID, APPLY_REFUND, REFUNDING);
// 已发货:可确认收货
put(SHIPPED, CONFIRM, COMPLETED);
// 退款中:退款成功
put(REFUNDING, REFUND_SUCCESS, REFUNDED);
}
private static void put(OrderStatus source, OrderEvent event, OrderStatus target) {
TRANSITIONS.computeIfAbsent(source, k -> new HashMap<>()).put(event, target);
}
/** 校验并返回目标状态,非法流转直接抛异常 */
public static OrderStatus next(OrderStatus source, OrderEvent event) {
Map<OrderEvent, OrderStatus> map = TRANSITIONS.get(source);
OrderStatus target = map == null ? null : map.get(event);
if (target == null) {
throw new IllegalStateException("非法状态流转: " + source + " -[" + event + "]-> ?");
}
return target;
}
}
注意这个设计里「已取消」和「已退款」是终态,流转表里没有以它们为源状态的行,任何事件都无法把它们再改走——从源头杜绝状态回退。
并发控制:乐观锁 + CAS 条件更新
状态机只解决「能不能流转」,不解决「并发下会不会串」。状态回退的真正根因是并发:两个请求都读到「待支付」,先后执行 UPDATE ... SET status = 2 WHERE id = ?,后执行的那个把已取消的订单又改回了已支付。解法是条件更新:把「期望的当前状态」写进 WHERE 条件。
<update id="casUpdateStatus">
UPDATE t_order
SET status = #{targetStatus},
version = version + 1,
update_time = now()
WHERE id = #{orderId}
AND status = #{expectedStatus}
AND version = #{version}
</update>
int rows = orderMapper.casUpdateStatus(orderId, expectedStatus, targetStatus, version);
if (rows == 0) {
// 状态已被别人改掉,说明发生并发冲突或状态已变
throw new BizException("订单状态已变更,请刷新后重试");
}
关键点:WHERE 里带期望状态 + version。两个并发请求即使都读到「待支付」,也只有一个能成功更新到「已支付」,另一个更新 0 行直接失败,不会把订单改错。这就是数据库层面的 CAS,比纯代码加锁更可靠。
状态机引擎:把校验、变更、审计收口到一个入口
光有状态机和乐观锁还不够,如果每个 Service 都自己拼 SQL,还是会漏。正确做法是提供一个统一流转入口,所有状态变更都必须走它:
@Transactional
public OrderStatus transit(Long orderId, OrderEvent event, String operator) {
Order order = orderMapper.selectById(orderId);
if (order == null) {
throw new BizException("订单不存在");
}
// 幂等:目标状态 == 当前状态,说明事件已处理过,直接返回
OrderStatus target = OrderStateMachine.next(order.getStatus(), event);
if (order.getStatus() == target) {
return target;
}
// 乐观锁 CAS:防止并发状态回退
int rows = orderMapper.casUpdateStatus(
orderId, order.getStatus(), target, order.getVersion());
if (rows == 0) {
throw new BizException("订单状态已变更,请刷新后重试");
}
// 审计:每次流转写一条记录,可追溯
orderFlowMapper.insert(OrderFlow.builder()
.orderId(orderId)
.fromStatus(order.getStatus())
.toStatus(target)
.event(event)
.operator(operator)
.build());
// 发布领域事件,解耦下游:短信、推送、库存回滚
eventPublisher.publishEvent(new OrderStatusChangedEvent(orderId, target));
return target;
}
这个入口同时解决了两类常见问题:
- 重复事件幂等:MQ 重复投递支付回调时,第二次进来状态已是「已支付」,
next()算出目标还是「已支付」,直接返回成功,不抛异常、不重复写审计。 - 非法流转拦截:任何 Service 想绕过校验直接改状态,都会被 CAS 的 WHERE 条件挡住(期望状态不匹配,更新 0 行)。
踩坑记录
- 状态回退事故:早期实现只按 id 更新状态,没带 WHERE status 条件,压测时复现了「已取消订单被支付回调改回已支付」。修复:CAS 条件更新 + 状态机校验双保险,缺一不可。
- 重复事件当异常处理:第一次做幂等时,把「同态同事件」直接抛了非法流转异常,结果支付回调重复投递就报错,用户支付成功却看到订单异常。修复:同态同事件视为幂等成功,只有「不同事件试图非法流转」才抛异常。
- 审计表缺失:上线初期没有流转记录表,客诉「订单被自动取消」时查无对证。修复:新增
t_order_flow表,每条流转记录旧状态、新状态、事件、操作人、时间,形成完整链路。 - 流转表被改坏:有人新增「仅退款」需求时只改了业务代码,忘了补流转规则,线上直接抛非法流转。修复:用单元测试锁死所有「状态 × 事件」组合,任何非法组合必须抛异常,任何合法组合必须返回正确目标。
@Test
void illegalTransitionShouldThrow() {
// 已签收的订单不可能再支付
assertThrows(IllegalStateException.class,
() -> OrderStateMachine.next(OrderStatus.COMPLETED, OrderEvent.PAY_SUCCESS));
}
@Test
void legalTransitionShouldWork() {
assertEquals(OrderStatus.PAID,
OrderStateMachine.next(OrderStatus.PENDING_PAYMENT, OrderEvent.PAY_SUCCESS));
}
最佳实践总结
- 状态机管规则、乐观锁管并发:两者互补。状态机决定「能不能」,CAS 决定「并发下谁先谁后」,缺一个都会出事。
- 所有状态变更走统一入口:禁止任何 Service 直接
UPDATE t_order SET status,靠 code review 不如靠代码结构强制。 - 每条流转都写审计:电商纠纷处理时,流转记录就是证据链,别省这张表。
- 状态变更后发领域事件:短信通知、优惠券发放、库存回滚都通过事件异步处理,与主流程解耦。
- 状态枚举与数据库值固定映射:数据库存 code(int),禁止魔法数字散落各处;新增状态要评估对历史数据的影响。
- 流转表用单元测试锁死:状态机是全局唯一事实来源,测试覆盖所有组合,防止后人改坏。
状态机看起来是「多写一张映射表」,实际是把订单生命周期从「散落的 if/else」变成「一张可读、可测、可审计的流转图」。配合乐观锁 CAS,即使支付回调、取消、退款三个事件同时到达,订单状态也永远只会走合法路径——这是电商订单模块值得投入的工程化改造。