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 级的频次限制,并且一人一场次只能领一次令牌,从源头治住了刷子。

坑五:迁移成功但事件流水没写。 有次发版把门面里的流水写入挪到了迁移之后,结果一次数据库抖动导致状态改了、流水没记,对账时凭空多出一笔无法解释的迁移。修正办法是流水与状态变更放在同一事务里,要么都不成功,要么都留下痕迹。

小结

订单状态机把订单的生命周期变成一张明确的迁移图,任何一次状态变更都可验证可追溯。幂等令牌挡重复提交,统一门面挡非法迁移,流水表挡对账难题。订单是电商系统的业务总线,别让它在并发面前裸奔。

给准备落地状态机的同学一个建议:先画出迁移图再写代码。我们在白纸上把六种状态和合法路径画清楚,逐条对着产品需求确认,写代码反而只花了半天。反过来先写代码再补图,几乎必然会出现遗漏路径和相互矛盾的迁移规则。状态机看起来是老生常谈,但在秒杀这种高并发、资金敏感的场景里,它是成本最低的保险绳:状态迁错一次就是一次用户投诉甚至一次资损,而一张迁移表就能把这类风险压到几乎为零,这笔账怎么算都划算。

延伸阅读