4.1 订单模型:状态机与防重复提交
场景:一个订单两个支付,财务对账对到怀疑人生
第二场活动后财务找过来说,有个订单显示已支付,微信侧却收到了两笔回调,退款的时候不知道退哪一笔。查代码发现订单没有任何状态机:下单、支付、发货混在一个 status 整数字段里,各个接口各改各的,中途任何一个并发请求都可能把订单改出意料之外的状态。那次之后我们花了整个迭代把订单状态机重写了一遍。事后复盘的结论是:订单模型不是数据库设计问题,而是并发与生命周期的设计问题。状态机这种听起来很学院派的工具,恰恰是解决这类问题最朴实的办法。这一节就把那次重写的思路完整复现一遍。
方案:状态机与兜底机制
订单状态机的核心思想是状态只能沿合法路径迁移,任何非法迁移直接拒绝。我们把订单状态拆成六个,迁移规则与触发动作都写进一张表:
| 当前状态 | 允许迁移 | 触发方 |
|---|---|---|
| 待支付 | 已支付、已取消 | 支付回调、用户取消 |
| 已支付 | 发货中、已退款 | 履约与退款 |
| 发货中 | 已完成 | 物流回调 |
| 已完成 | 已退款 | 售后 |
| 已取消 | 终态,不再迁移 | 系统 |
| 任意 | 已取消(仅限待支付) | 定时任务超时 |
非法迁移的处理不是报错完事,而是把这次请求的完整上下文写进 CLS 日志,值班同学看到告警后人工确认。直接吞掉异常会让故障隐形,直接抛错又会让调用方困惑,折中是记录并降级。必填的上下文字段我们约定过:订单号、当前状态、目标状态、调用方接口名、操作人,缺一项都要被打回重写。告警阈值设为每分钟五条,超过就说明有系统性问题而不是个例。
状态机引入后还要防止另一种极端——状态泛滥。每多一个状态,迁移矩阵就多一行一列,维护成本指数级上升。我们的原则是状态数控制在六个以内,能合并的用标签而不是新状态。比如退款中的多种内部流转,都记在退款流水表里,订单状态只保留已退款这一个终态。
防重复提交用幂等令牌实现。前端进入下单页时先换取一个令牌,提交时带回来,后端校验后立即作废。秒杀场景下令牌必须与用户绑定且一次性使用,生成放在 Redis 里:
public function issueToken(int $userId, int $skuId): string
{
$token = Str::uuid()->toString();
$key = "order:token:{$userId}:{$skuId}";
Redis::setex($key, 300, $token); // 5 分钟内有效
return $token;
}
public function consumeToken(int $userId, int $skuId, string $token): bool
{
$key = "order:token:{$userId}:{$skuId}";
$script = "if redis.call('GET', KEYS[1]) == ARGV[1] then " .
"return redis.call('DEL', KEYS[1]) else return 0 end";
return (int) Redis::eval($script, 1, $key, $token) === 1;
}
用 Lua 比较并删除是原子的,两个并发请求带同一个令牌时只有一个成功,比先 get 后 del 的两步操作安全。令牌还有一个隐藏好处:它把防重复提交从整个下单链路里剥离出来。下游的排队、扣减、建单逻辑因此不必关心自己是否被重复调用,令牌层挡住了绝大多数重复流量,链路里每个环节都可以专注做自己的事。
订单号也有讲究:包含时间分片与用户编号信息,既唯一又便于按时间范围分库分表:
| 编号段 | 长度 | 说明 |
|---|---|---|
| 日期 | 8 位 | 年月日 |
| 分片 | 2 位 | 按用户哈希分表 |
| 序列 | 4 位 | 进程内自增 |
| 校验 | 2 位 | 防猜号 |
防重复提交其实有几种常见做法,我们早期也逐一试过,最终组合使用:
| 手段 | 生效位置 | 优点 | 局限 |
|---|---|---|---|
| 前端按钮置灰 | 客户端 | 简单直接 | 可被绕过 |
| 幂等令牌 | 服务端 | 一次性、可验证 | 需 Redis 配合 |
| 唯一索引 | 数据库 | 最终兜底 | 冲突报错需处理 |
| 分布式锁 | 服务端 | 串行化安全 | 有性能开销 |
生产实践是令牌做主防线、唯一索引做最后兜底。令牌挡住百分之九十九的重复提交,剩下的边角情况由订单表上的唯一索引(用户、场次、SKU)兜住,插入冲突时捕获异常并返回友好提示,而不是抛 500。
配置:状态迁移的统一入口
所有状态变更必须走一个门面,严禁各处直接 update。这既是代码规范也是兜底设计。门面内部还做了两件事:迁移时自动写入订单事件流水表,记录是谁在什么时间把订单从哪个状态改到哪个状态;迁移成功后发布领域事件,履约与通知模块订阅事件而不是被同步调用,订单服务因此不必依赖下游的可用性。门面同时对外暴露 canTransition 查询方法,前端在渲染取消按钮前先问一次后端,避免用户对着已经不可取消的订单猛点。
class OrderStateMachine
{
private const TRANSITIONS = [
'pending' => ['paid', 'cancelled'],
'paid' => ['shipping', 'refunded'],
'shipping' => ['completed'],
'completed'=> ['refunded'],
];
public function transition(Order $order, string $to): bool
{
$from = $order->status;
$allowed = self::TRANSITIONS[$from] ?? [];
if (! in_array($to, $allowed)) {
ClsLog::warning('illegal_transition', [
'order' => $order->id, 'from' => $from, 'to' => $to,
]);
return false;
}
$order->status = $to;
return $order->save();
}
}
踩坑:订单模型的三个坑
坑一:状态更新用了先读后写。 先 find 再修改 status 保存,两个并发请求同时读到 pending,都成功迁移,订单状态被覆盖出重复支付。加乐观锁或直接用条件更新 where('status', 'pending') 才能堵住。
坑二:退款直接改状态不记原因。 有个订单退款了却说不出原因,客服无法回答用户。后来所有退款类迁移必须带 reason 字段并关联原订单事件日志,每笔退款可追溯。客服后台也加了退款原因的展示,用户一问就能答上来,工单量明显下降,客服同学的感受最直观。
坑三:订单表与库存流水混在一张表。 后期分析库存变化极慢,要联表查询。库存流水独立成表,记录订单维度的每次扣减与回补,对账任务直接扫流水表,效率高得多。库存流水表的设计在 2.3 节的对账方案里已经用到。
坑四:幂等令牌发放接口本身被刷。 令牌接口没有限流,脚本循环换令牌。后来令牌接口也接入了用户级与 IP 级的频次限制,并且一人一场次只能领一次令牌,从源头治住了刷子。
坑五:迁移成功但事件流水没写。 有次发版把门面里的流水写入挪到了迁移之后,结果一次数据库抖动导致状态改了、流水没记,对账时凭空多出一笔无法解释的迁移。修正办法是流水与状态变更放在同一事务里,要么都不成功,要么都留下痕迹。
小结
订单状态机把订单的生命周期变成一张明确的迁移图,任何一次状态变更都可验证可追溯。幂等令牌挡重复提交,统一门面挡非法迁移,流水表挡对账难题。订单是电商系统的业务总线,别让它在并发面前裸奔。
给准备落地状态机的同学一个建议:先画出迁移图再写代码。我们在白纸上把六种状态和合法路径画清楚,逐条对着产品需求确认,写代码反而只花了半天。反过来先写代码再补图,几乎必然会出现遗漏路径和相互矛盾的迁移规则。状态机看起来是老生常谈,但在秒杀这种高并发、资金敏感的场景里,它是成本最低的保险绳:状态迁错一次就是一次用户投诉甚至一次资损,而一张迁移表就能把这类风险压到几乎为零,这笔账怎么算都划算。
