《架构设计》目录

第 3 章

订单状态机别用 status=1,2,3 糊弄——一列管不住四件事

本章目标:

  1. 看懂 status 一列管不住的四件事:谁能改、改得合不合法、重复收到怎么办、并发改怎么办
  2. 掌握三张表的分工:订单表(现状)、迁移记录表(来龙去脉)、迁移配置表(规则)
  3. 掌握统一入口的六步协议:锁订单、查幂等、找迁移边、跑守卫、记迁移、条件更新
  4. 会处理两类最凶的竞态:回调重发、超时取消撞上用户支付
  5. 知道主状态什么时候会膨胀,以及怎么防

本章基线:Spring Boot 3.x + MySQL 8.4(InnoDB)| 全部建表语句与代码可跑 | 边界:不做工作流引擎,不做运营可配的动态状态机,退款进度记在退款单上不进主状态

3.1 先看一次真实事故

订单页面显示「已退款」,仓库的待发货队列里却还有这笔订单,拣货员照着把货发了。

两边读的都是 biz_order.status:退款流程认为这单已经是 REFUNDED;出库流程只排除了自己知道的几个状态。两套理解都觉得自己没问题,最后出问题的不是某一行 SQL,而是系统里没有一个地方能回答:这次状态变更到底合不合法?

status 能回答「订单现在是什么」,却不能单独回答三个问题:谁把它改成这样的、当时允不允许改、同一件事收到两次会怎样。多数代码根本没问后面三个,因为状态变更被一个接口包掉了——updateStatus(orderId, 3) 这种签名,参数里只有结果,没有原因,也没有谁。真正的风险不是少了一个 if,而是每个调用方都在自己猜业务规则。

订单主状态与允许的迁移;红色打叉是配置表里不存在的非法边,退款进度记在退款单上不在图内

3.2 先收口:找出谁在改这一列

动设计之前先把写入路径摸出来:

grep -rnE '\.setStatus\(|updateStatus|"status"' src/main/java --include='*.java'

跑出来一般是十几处。service 内部那几处正常调用反而不是重点,边角才难缠:后台表单把前端传来的状态原样落库;定时任务直接调 Mapper 取消超时订单;还有一个模块只是想改收件地址,顺手把对象里旧的 status 一起更新了。

收口之后,调用方不再说「把状态改成 3」,而是发送 PAY_SUCCESS、SHIP、CANCEL_TIMEOUT 这样的业务事件。新增事件还没有对应规则时就明确报出来,比悄悄往数据库里塞一个新状态好——运营后台明天要加的那个按钮写进去的状态,代码评审根本看不到。

状态值用名字别用数字:WHERE status NOT IN (5, 9) 写的人知道 5 和 9 是什么,翻 binlog、读慢 SQL 的人不知道,当初写它的人往往是最先离职的那位。更麻烦的是两套表各自定义数字——订单表的 3 是已支付,退款表的 3 是已驳回,数字没错,错在长得一样。

3.3 订单表:三个时间字段各管一件事

CREATE TABLE biz_order (
  id          BIGINT      NOT NULL PRIMARY KEY,
  tenant_id   BIGINT      NOT NULL COMMENT '读、锁、更新都必须带租户',
  order_no    VARCHAR(32) NOT NULL COMMENT '本例约定只在租户内唯一',
  status      VARCHAR(24) NOT NULL DEFAULT 'WAIT_PAY' COMMENT '只记当前状态,不记原因',
  status_at   DATETIME    NOT NULL COMMENT '最近一次迁移时间,不等于行更新时间',
  version     INT         NOT NULL DEFAULT 0 COMMENT '并发控制版本;写路径需维护它',
  created_at  DATETIME    NOT NULL COMMENT '创建时间,超时筛选用',
  updated_at  DATETIME    NOT NULL COMMENT '行更新时间,改地址也会变',
  UNIQUE KEY uk_tenant_order (tenant_id, order_no),
  KEY idx_timeout_scan (tenant_id, status, id, created_at)
) ENGINE=InnoDB;

三个时间字段混用会算出解释不了的数:created_at 是订单创建时间;status_at 是最后一次状态迁移的时间,拿它算订单在 PAID 状态停了多久才对;updated_at 是订单行最后一次更新时间——改收件地址、改备注都会刷新它,拿它当迁移时间,数字永远对不上。历史表若用整数存状态,别在生产表上原地改类型:先确认 MySQL 版本、表大小、DDL 算法和锁表窗口(8.4 的 InnoDB 改列类型要重建表且不允许并发 DML),再决定新增列、回填、双写、切换的步骤。

3.4 迁移记录表:uk_idem 是最值钱的地方

只有当前状态、没有变化的来龙去脉,线上排查只能去翻 binlog。作者第一次补这张表,就是在翻完 binlog 之后:按 order_id 把状态变更一条条捞出来对时间,却看不出是谁发起的——SQL 里没有操作者,也没有业务事件。

CREATE TABLE biz_order_transition (
  id          BIGINT      NOT NULL PRIMARY KEY AUTO_INCREMENT,
  tenant_id   BIGINT      NOT NULL COMMENT '与本次订单操作所属租户一致',
  order_id    BIGINT      NOT NULL,
  event       VARCHAR(32) NOT NULL COMMENT 'PAY_SUCCESS / SHIP / CANCEL_TIMEOUT',
  from_status VARCHAR(24) NOT NULL COMMENT '迁移前状态,保留当时看到的起点',
  to_status   VARCHAR(24) NOT NULL,
  biz_key     VARCHAR(64) NOT NULL COMMENT '稳定的业务幂等键,重试必须不变',
  operator    VARCHAR(40) NOT NULL COMMENT '用户 ID / system:timeoutCancelJob',
  source      VARCHAR(16) NOT NULL COMMENT 'HTTP / MQ / JOB',
  reason      VARCHAR(255) NULL COMMENT '人工修正原因;自动事件可为空',
  created_at  DATETIME    NOT NULL,
  PRIMARY KEY (id),
  UNIQUE KEY uk_idem (tenant_id, order_id, event, biz_key),
  KEY idx_order (tenant_id, order_id, id)
) ENGINE=InnoDB;

uk_idem 挡住同一事件的重复记录:同一笔回调第二次进来,数据库唯一键直接拦下。但不要以为有了唯一键就能用「先查再插」代替并发控制——两个线程可能同时查到没有记录然后各自插入,唯一键只负责兜底,应用仍要正确处理预期的重复键冲突。

biz_key 必须是业务上稳定、可追踪的值,按事件各取各的:

事件 biz_key 取什么 为什么
支付成功 支付渠道交易流水号 渠道重发带同一流水号
退款完成 退款单号 一次退款一个单号
发货 发货单号或物流单号 部分发货每次是新发货单
超时取消 同订单本次取消动作的稳定键 同一次取消重试不变

biz_key 设为 NOT NULL 不是为了让数据库猜默认值,是逼调用方把幂等语义想清楚:MySQL 唯一索引允许多个 NULL,键可为空,唯一约束就拦不住重复。operator 要能区分人和机器(system:timeoutCancelJob 加 source=JOB 在排查时比空值有用得多);人工改状态强制填 reason,审计记录负责解释「谁做了什么」,权限校验负责阻止「谁不该做却做了」,两者不能互相替代。

3.5 合法迁移:规则和记录不是一回事

迁移规则回答「什么情况下允许发生」,变更记录回答「这次究竟发生过什么」。状态少、规则稳定时,代码里一个 Map<OrderState, Set<OrderState>> 就能挡住大部分乱改;等规则真的需要被查询和管理,再把迁移边放进配置表:

CREATE TABLE cfg_order_transition (
  event       VARCHAR(32) NOT NULL COMMENT '触发迁移的业务事件',
  from_status VARCHAR(24) NOT NULL COMMENT '允许迁移的起始状态',
  to_status   VARCHAR(24) NOT NULL COMMENT '该事件对应的目标状态',
  guard_bean  VARCHAR(40) NULL COMMENT 'OrderGuard 的 bean 名;空表示无额外守卫',
  PRIMARY KEY (event, from_status)
);

这个主键有一个前提:同一事件从同一个状态出发只能对应一条目标迁移。如果同一事件要按金额、渠道或订单类型走不同目标状态,就要重新设计规则的匹配方式,不能照搬。guard_bean 挂守卫,守卫只检查这条迁移真正依赖的业务前提:支付回调的金额对不对、发货前库存有没有预占、累计退款有没有超过实付。别把守卫写成巨大的 OrderValidator.validate(order)——一个守卫不到二十行不是硬指标,重要的是它测试能独立写清楚。

3.6 统一入口:六步协议

核心流程不需要很多层抽象,把入口、事件、幂等键表达清楚就够了。六步:锁订单 → 查幂等记录 → 找迁移边 → 跑守卫 → 记迁移 → 条件更新改状态。

@Transactional
public TransitResult transit(long orderId, OrderEvent event,
                             String bizKey, Operator operator) {
    long tenantId = tenantContext.requireTenantId();      // 定时任务也要显式建上下文
    Order order = orderMapper.selectForUpdate(tenantId, orderId);   // 1. 先锁
    if (order == null) return TransitResult.NOT_FOUND;
    if (transitionRepo.exists(tenantId, orderId, event, bizKey)) {  // 2. 后查幂等
        return TransitResult.DUPLICATE;
    }
    Transition t = cfgRepo.find(order.getStatus(), event).orElse(null); // 3. 迁移边
    if (t == null) return TransitResult.ILLEGAL;
    if (t.getGuardBean() != null) {                                  // 4. 守卫
        guards.get(t.getGuardBean()).check(order, event, operator);
    }
    boolean recorded = transitionRepo.tryRecord(tenantId, orderId,  // 5. 记迁移
            event, bizKey, order.getStatus(), t.getToStatus(), operator);
    if (!recorded) return TransitResult.DUPLICATE;
    int rows = orderMapper.casStatus(tenantId, orderId,             // 6. 条件更新
            order.getStatus(), order.getVersion(), t.getToStatus());
    if (rows == 0) throw new ConcurrentTransition(orderId);  // 触发回滚,记录不能单独提交
    return TransitResult.APPLIED;
}

条件更新是第二道闩,status = #{from} 和 version = #{version} 不是重复条件:

<update id="casStatus">
  UPDATE biz_order
     SET status = #{to},
         status_at = #{now},
         version = version + 1
   WHERE tenant_id = #{tenantId}  <!-- 防止只凭订单 ID 更新跨租户数据 -->
     AND id = #{id}
     AND status = #{from}         <!-- 迁移起点必须仍然成立 -->
     AND version = #{version}     <!-- 读取后被别人改过就放弃 -->
</update>

版本号可能因为改备注这类无关字段而变化,状态条件回答的是「我要走的这条迁移是否仍然成立」。两个条件一起用有个前提——所有写路径都老老实实维护 version,有人更新业务行却不递增版本,乐观锁的保护范围就被削掉了。

三个容易忽略的顺序问题:

  • 先锁后查:支付渠道并发发两次回调时,先普通查询幂等记录再锁订单,两个请求可能都查到「还没有处理」。先 SELECT ... FOR UPDATE 锁住订单再查幂等记录,遵守同一加锁协议的同订单请求就排成了队;唯一键保留,兜那些绕过统一入口的路径。
  • 同进同出:迁移记录和订单状态更新必须同一个事务提交。条件更新影响行数为 0 时回滚整个事务再重试,别让迁移记录单独提交,留下「记录说改了、状态没改」的缺口。
  • 锁的范围:锁住订单行,不等于锁住库存行或退款汇总。两个不同订单抢最后一件商品,它们分别锁自己的订单行,挡不住库存被重复扣减——库存要自己的原子条件更新,涉及行外数据时明确锁定顺序。

3.7 回调会重发,钱不能重复推进

重复不是意外,是常态:支付网关超时会重发,MQ 至少一次投递,用户会连点两次退款。判重键不能只用 order_id + status:同一订单收到两次 PAY_SUCCESS,是业务事件重复,归唯一键管;同一订单两次变成 PAID,是状态更新成立,归统一入口管。还有一种情况要小心——同一订单在微信和支付宝各付了一次,两个回调带不同流水号,都可能是真的,第二笔不能因为订单已支付就悄悄丢掉,要按它的流水号可靠地落一条待核查或待退款任务。

@PostMapping("/notify/pay")
public String payNotify(@RequestBody PayNotify notify) {
    // 验签、校验金额,并通过服务端映射确定订单和租户;不信任请求体里的 tenantId
    VerifiedPayNotify verified = verifier.verifyAndResolve(notify);
    TransitResult r = transitService.transit(verified.orderId(),
            OrderEvent.PAY_SUCCESS, verified.transactionId(), Operator.gateway());
    if (r == TransitResult.APPLIED || r == TransitResult.DUPLICATE) {
        return paymentChannel.successAcknowledgement();   // 已处理的同一事件按协议应答成功
    }
    if (r == TransitResult.ILLEGAL) {
        // 订单已取消但支付已到账:先可靠落待退款任务,再向渠道确认
        refundTaskService.persistOnce(verified.tenantId(), verified.orderId(),
                                      verified.transactionId());
        return paymentChannel.successAcknowledgement();
    }
    throw new RetryableNotificationException();  // 让渠道按协议重试,别假装成功
}

这里的关键不是最后一行返回什么字符串,而是业务动作有没有可靠落地:保存待退款任务时数据库故障,不能假装处理完成返回成功。任务已保存、响应丢在网络里没关系——下一次回调带同一流水号命中幂等约束。DUPLICATE 不是异常,调用方按返回值分支,别抛。Redis 的 SETNX 可以削峰,但别让它成为资金类幂等的唯一依据——缓存重启、淘汰、故障切换都可能让记录消失,关键约束落在数据库唯一键和同事务的业务记录上。

3.8 超时取消撞上用户支付

任务在 20:00:00.000 扫出一批超时未付订单;20:00:00.412,支付回调把其中一单改成 PAID;20:00:00.980,超时任务拿到锁,读到的已经是 PAID,CANCEL_TIMEOUT 在这个状态上没有合法迁移,跳过并记录。这个顺序是对的,前提是任务「不加锁、不重新校验就更新」的那条路被堵死了——中间那几百毫秒足够让回调插进来,结果是钱收到了、订单被取消,第二天客服接电话。

支付回调和超时取消同时到达,两条路径争同一行锁,条件更新决定谁生效

扫描本身加两个保护:游标翻页别用 LIMIT offset 越翻越深(用 id > lastId AND id <= maxId,maxId 是本轮启动时确定的上界);扫描窗口带下限(比如 30 天),任务停了三个月再启动,不至于把三年前的遗留订单全部取消一遍,超窗口的进独立对账流程。

顺序反过来是另一回事:任务先取消成功、支付回调随后才到。回调读到 CANCELLED,PAY_SUCCESS 在这上面没有边,返回 ILLEGAL——钱已经收了,不能吞掉,落待退款任务(3.7 的分支),而不是把异常抛给网关。多实例部署时,分布式锁可以减少重复扫描,但不能代替订单行锁、条件更新和幂等约束:有这些约束,两个实例撞上同一单也能收敛到一个有效结果;没有,锁只是把竞态窗口变大或变小。

3.9 主状态别膨胀

  • 部分退款:金额是连续变化的,状态是离散的。把「退了一半」「又退了一次」编码进订单枚举,主单状态会多到没人敢改;退款单记金额、次数、渠道流水和处理状态,主订单只留业务真正需要的聚合状态,两边的状态推进方向要讲清楚,避免互相回写。
  • 物流进度:出库、揽收、签收是三个不同事件。物流商只回了揽收,你写成已签收,客服处理未收到货的投诉时,拿到的是一个系统认证过的假结论。
  • 合单拆单:主单状态由子单聚合出来时,要明确聚合规则和更新时间,别让主单、子单都能随意改对方的 status,最后留下一组没人解释得了的组合。
  • 人工修正:改状态强制填 reason,只开放白名单事件,并做租户和行级权限检查——否则迁移记录最后变成一份记录谁乱改的证据,改还是照样改。

3.10 本章小结

排查从一条查询开始:

SELECT event, from_status, to_status, operator, source, biz_key, created_at
FROM biz_order_transition
WHERE tenant_id = ? AND order_id = ?
ORDER BY id;

真正有用的是每次变更都有业务原因、迁移规则可以验证、重复事件有稳定结果。如果你手上正好有一单状态解释不了,先别急着上工作流引擎:跑一遍 GROUP BY status 找出代码里没有的值,把写入点搜出来,把关键事件的合法迁移和幂等约束收口到一个入口。这几件事做好,比引入一个没人敢动的框架更重要。

3.11 练习题

  1. 在你的订单代码里跑 3.2 的 grep,把每一处 setStatus 分类:走统一入口的、绕过的、顺手更新的。绕过的那一列就是你的真实风险。
  2. 复现 3.8 的竞态:两个线程一个发 PAY_SUCCESS 一个发 CANCEL_TIMEOUT,不加防护跑一次、加 FOR UPDATE + 条件更新跑一次,对比两种结局。
  3. 给 uk_idem 写一组用例:同一流水号回调两次(第二次应 DUPLICATE)、部分发货三次(三次都应 APPLIED)、不同渠道各付一次(第二次落待核查)。
  4. 把 3.10 的排查 SQL 对着一笔历史订单跑一遍,回答三个问题:这单经过了几次迁移、哪几次是机器发起的、有没有一次说不清原因。

3.12 延伸读