订单状态机:别用 status=1,2,3 糊弄

梦想恒远·7344 字·约 37 分钟

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

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

这篇只管四件事:谁能改状态、改得合不合法、重复收到怎么办、并发改怎么办;不做工作流引擎,也不做运营可配的动态状态机。

前面很多系统设计讲的是数据归谁。这一篇讲订单,也讲钱。钱这边最先出问题的,往往不是金额算错,而是系统把一笔订单当成了两种东西。

status 能回答“订单现在是什么”,却不能单独回答三个问题:谁把它改成这样的、当时允不允许改、同一件事收到两次会怎样。多数代码根本没问后面三个问题,因为状态变更被一个接口包掉了:

void updateStatus(Long orderId, Integer status);

参数里只有结果,没有原因,也没有谁。任何拿得到 id 的模块,都能尝试把这单改成任意值。真正的风险不是少了一个 if,而是每个调用方都在自己猜业务规则。

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

这篇不打算把所有订单系统都改造成工作流引擎。先把四件事做好:状态写入收口(所有改 status 的代码走同一个入口)、合法迁移、幂等记录、并发控制;等规则真的需要由运营配置,再考虑动态状态机。

先找出谁在改这一列

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

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

搜索结果需要逐处核对。看到 setStatus 不代表一定会写库;没搜到,也不代表所有写入路径都找齐了。Mapper XML、批量 SQL、管理后台、定时任务和数据迁移脚本,都可能绕过你以为的入口。

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

updateStatus(orderId, 3) 这种签名跳过的不是几行校验,而是两个必要问题:发生了什么事,以及当前状态能不能发生这件事。

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

状态值用名字,别让数字替你解释

我见过出库条件写成这样:

WHERE status NOT IN (5, 9)

5 和 9 是订单枚举里的已取消和已退款。写的人知道,查的人不知道。翻 binlog、读慢 SQL、看别人贴过来的报错时,status IN (2, 3, 5) 需要有人再去查一遍常量类,而当初写它的人往往是最先离职的那位。

更麻烦的是,订单表里的 3 可能是已支付,退款表里的 3 却是已驳回。数字本身没错,错在两套数字长得一样。状态值可以用 WAIT_PAY、PAID、SHIPPED、CANCELLED 这样的名字表达含义,但要记住,字符串解决的是可读性,不会自动阻止非法状态写入。

订单表可以从下面这样的结构起步:

CREATE TABLE biz_order (
  id          BIGINT       NOT NULL AUTO_INCREMENT COMMENT '订单主键',
  tenant_id   BIGINT       NOT NULL COMMENT '租户 ID;读、锁、更新都必须带可信租户上下文',
  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 '订单行最后更新时间,改地址或备注也可能变化',
  PRIMARY KEY (id),
  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 是最后一次状态迁移时间;updated_at 是订单行最后一次更新时间。改收件地址、改备注都可能刷新 updated_at,所以拿它计算订单在 PAID 状态停了多久,会算出一个解释不了的数。

tenant_id 和订单号一起进唯一键,是因为订单号只在租户内唯一,全局唯一可能只是巧合,不是约束。查询、加锁、更新和写迁移记录时也必须带租户条件。租户 ID 应来自可信的服务端上下文或已验证的业务映射,不能直接相信浏览器提交的 tenantId。即使 id 是全局自增,也不能把它当成跳过租户授权检查的理由。

历史表如果用整数存状态,不要看到字符串更好读,就直接在生产表上原地改类型。先确认 MySQL 版本、表大小、DDL 算法和可接受的锁表窗口,再决定新增列、回填、双写校验、切换读取、预留回滚窗口等步骤。以 MySQL 8.4 的 InnoDB 为例,修改列数据类型需要重建表,且该操作不允许并发 DML;其他 DDL 操作和版本的行为并不完全一样,执行前要确认实际计划。

先在低峰期看看数据库里到底有哪些状态:

SELECT status, COUNT(*)
FROM biz_order
GROUP BY status;

如果出现应用枚举里没有的值,先找写入来源。可能是旧迁移残留、后台修正接口,也可能是有人绕过了统一入口。这个查询是否能从索引获益,要看表大小、索引覆盖情况和执行计划,别把“低基数列索引没用”当成绝对规律;跑一下 EXPLAIN,比拍脑袋靠谱。

一张表记变更,一套规则管合法性

只有当前状态,没有状态变化的来龙去脉,线上排查通常只能去翻 binlog 和应用日志。建议把每次迁移的事件、迁移前后的状态、业务幂等键、操作者、来源和时间记录下来。

CREATE TABLE biz_order_transition (
  id          BIGINT       NOT NULL AUTO_INCREMENT COMMENT '迁移记录主键',
  tenant_id   BIGINT       NOT NULL COMMENT '租户 ID;必须与本次订单操作所属租户一致',
  order_id    BIGINT       NOT NULL COMMENT '订单主键;与 tenant_id 一起定位订单',
  event       VARCHAR(32)  NOT NULL COMMENT '业务事件,如 PAY_SUCCESS、SHIP、CANCEL_TIMEOUT',
  from_status VARCHAR(24)  NOT NULL COMMENT '迁移前状态,保留当时看到的起点',
  to_status   VARCHAR(24)  NOT NULL COMMENT '迁移后目标状态',
  biz_key     VARCHAR(64)  NOT NULL COMMENT '稳定的业务幂等键,同一事件重试必须不变',
  operator    VARCHAR(40)  NOT NULL
              COMMENT '操作主体,如用户 ID、客服 ID、system:timeoutCancelJob',
  source      VARCHAR(16)  NOT NULL COMMENT '入口来源,如 HTTP、MQ、JOB、API',
  reason      VARCHAR(255) NULL COMMENT '人工修正原因;自动事件可按规则为空',
  created_at  DATETIME     NOT NULL COMMENT '迁移记录创建时间',
  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 必须保持一致;业务上允许同一订单重复发生的事件,每一次则应有不同的键。比如部分发货不能把 SHIP 永远配成同一个空键,否则第二次合法发货也会被当成重复。

这里把 biz_key 设为 NOT NULL,不是为了让数据库帮调用方猜默认值,而是明确要求调用方提供幂等语义。MySQL 唯一索引允许多个 NULL 值;如果幂等键可以为空,唯一约束就可能拦不住缺少键值的重复记录。需要特别注意:NOT NULL 也不代表一个随手填写的字符串就有正确的幂等语义,键仍然必须按业务事件设计。

operator 要能区分人和机器。system:timeoutCancelJob 加上 source=JOB,在排查时比一个空值有用得多。人工改状态应强制填写 reason,并由服务端校验操作者是否具备对应租户、订单和操作类型的权限。审计记录负责解释“谁做了什么”,权限校验负责阻止“谁不该做却做了”,两者不能互相替代。

合法迁移规则和变更记录不是一回事:前者回答“什么情况下允许发生”,后者回答“这次究竟发生过什么”。状态少、规则稳定时,枚举加一个 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 'Spring OrderGuard 的 bean 名;为空表示无额外守卫',
  PRIMARY KEY (event, from_status)
) ENGINE=InnoDB;

INSERT INTO cfg_order_transition (event, from_status, to_status, guard_bean) VALUES
  ('PAY_SUCCESS',    'WAIT_PAY', 'PAID',      'paidAmountGuard'),
  ('SHIP',           'PAID',     'SHIPPED',   'stockDeductedGuard'),
  ('CANCEL_TIMEOUT', 'WAIT_PAY', 'CANCELLED', 'unpaidGuard'),
  ('REFUND_DONE',    'PAID',     'REFUNDED',  'refundAmountGuard'),
  ('FINISH',         'SHIPPED',  'COMPLETED', 'signedOrAutoConfirmGuard');

这个主键有一个前提:同一事件从同一个状态出发,只能对应一条目标迁移。如果同一个事件需要根据金额、渠道或订单类型走不同的目标状态,就要重新设计规则的匹配方式和唯一约束,不能照搬这张表。

guard_bean 存 Spring 容器里的 OrderGuard 实现。守卫负责检查当前迁移的业务前提:支付成功时,回调金额是否与应付金额一致;发货时,库存是否已按业务规则完成预占或扣减;退款完成时,累计退款是否超过实付金额。

别把守卫写成一个巨大的 OrderValidator.validate(order),把库存、金额、地址和权限全塞进去。一个守卫不到二十行并不是什么硬指标,重要的是它只检查这条迁移真正依赖的前提,测试能独立写清楚。

还有一个边界要说透:锁住订单行,并不等于锁住库存行或退款汇总。 两个不同订单可能同时争抢最后一件商品,它们分别锁自己的订单行,并不能阻止库存被重复扣减。库存需要自己的锁或原子条件更新;累计退款也需要针对退款单及金额做一致性约束。涉及行外数据时,要明确锁定顺序,降低死锁风险;不要拿着数据库行锁去等远程服务返回。

所有状态变更走同一个方法

核心流程不需要一上来就包装成很多层抽象。把入口、事件和幂等键表达清楚就够了。下面是简化示例;tenantId 从可信服务端上下文取得,不能直接信任外部请求参数。facts 表示经过校验的业务事实,例如验签后的支付流水和金额。

@Transactional
public TransitResult transit(
        long orderId,
        OrderEvent event,
        String bizKey,
        Operator operator,
        TransitionFacts facts) {

    // 从服务端认证上下文解析租户;定时任务也必须显式建立租户上下文。
    long tenantId = tenantContext.requireTenantId();

    // 租户条件必须进入 SQL;不能只依赖 orderId 全局唯一。
    Order order = orderMapper.selectForUpdate(tenantId, orderId);
    if (order == null) {
        return TransitResult.NOT_FOUND;
    }

    // 先锁订单,再查幂等记录;同一订单的迁移入口需要遵守相同锁协议。
    if (transitionRepo.exists(tenantId, orderId, event, bizKey)) {
        return TransitResult.DUPLICATE;
    }

    // 当前状态没有该事件对应的迁移边,不能把目标状态直接写入数据库。
    Transition t = cfgRepo.find(order.getStatus(), event).orElse(null);
    if (t == null) {
        return TransitResult.ILLEGAL;
    }

    // 守卫校验本条迁移的业务前提;涉及库存或退款记录时要自行保护对应数据。
    if (t.getGuardBean() != null) {
        guards.get(t.getGuardBean()).check(order, event, operator, facts);
    }

    // 迁移记录与订单更新在同一事务中提交;只把指定幂等键冲突识别为重复。
    boolean recorded = transitionRepo.tryRecord(
            tenantId, orderId, event, bizKey,
            order.getStatus(), t.getToStatus(), operator);
    if (!recorded) {
        return TransitResult.DUPLICATE;
    }

    // status 校验迁移起点,version 发现记录在读取后被其他写入改变。
    int rows = orderMapper.casStatus(
            tenantId, orderId, order.getStatus(), order.getVersion(),
            t.getToStatus(), LocalDateTime.now());
    if (rows == 0) {
        // 必须抛出会触发事务回滚的异常,否则迁移记录可能单独提交。
        throw new ConcurrentTransition(orderId, t.getFromStatus());
    }

    // 这是进程内事件,不代表 MQ 已可靠投递;可靠消息请看下文 Outbox 说明。
    eventPublisher.publishEvent(new OrderStateChanged(
            tenantId, orderId, event, t.getToStatus()));
    return TransitResult.APPLIED;
}

示例中的 NOT_FOUND、DUPLICATE、ILLEGAL 和 APPLIED 分别代表订单不存在、事件已处理、当前状态没有合法迁移、本次迁移已经应用。对外接口是否需要区分“不存在”和“当前租户无权访问”,应按安全要求决定,避免泄露其他租户的数据存在性。

守卫失败时,应抛出明确的业务异常,并确保事务按预期回滚;也可以设计显式失败结果,但不能在迁移记录已经写入后直接返回失败。Spring 的回滚行为取决于异常类型和事务配置,不能假设所有异常都会自动回滚。相关配置和测试需要在实际项目中确认。

守卫是第一道闩,条件更新是第二道:

<update id="casStatus">
  UPDATE biz_order
     SET status = #{to},
         status_at = #{now},
         version = version + 1,
         updated_at = #{now}
   WHERE tenant_id = #{tenantId}  <!-- 防止只凭订单 ID 更新跨租户数据 -->
     AND id = #{id}
     AND status = #{from}         <!-- 迁移起点必须仍然成立 -->
     AND version = #{version}     <!-- 检查读取后是否出现并发写入 -->
</update>

status = #{from} 和 version = #{version} 不是重复条件。版本号可能因为修改备注等无关字段而变化;状态条件则回答“我要走的这条迁移是否仍然成立”。两个一起用没问题,但前提是相关写入路径正确维护 version。如果有人更新业务行却不递增版本号,乐观锁的保护范围就会被削弱。

更新影响行数为 0 时,不能把前面插入的迁移记录留在数据库里;应该回滚事务,再由上层决定是否重读并重试。死锁、锁等待超时和业务状态冲突也要区别处理,别把任何异常都当成“重试一次就好”。

为什么先锁订单,再查幂等记录

假设支付渠道对同一笔支付并发发送两次回调。先普通查询幂等记录、再锁订单,两个请求有可能都查到“还没有处理”,随后才排队抢锁。第二个请求拿到锁时,必须重新基于最新状态和幂等记录判断,不能沿用锁之前的查询结果。

先 SELECT ... FOR UPDATE 锁住订单,再查幂等记录,能让遵守同一加锁协议的同订单请求排队执行。MySQL InnoDB 的锁定读与普通一致性读行为不同,事务隔离级别也会影响普通查询看到的快照。不要在获得订单锁之前先做普通查询,再把那份可能过期的结果当作幂等判断依据。唯一键仍然要保留,因为它负责兜底那些绕过统一入口的写入路径。

事务内做什么,事务外做什么

eventPublisher.publishEvent(...) 发的是 Spring 进程内事件,不等于消息已经可靠地发到 MQ。普通同步监听器可能在事务提交前执行;如果它直接调用远程接口,之后事务回滚,外部副作用却已经发生。

对于允许重试或补偿的普通通知,可以使用 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT),并配套失败监控与补偿机制。但 AFTER_COMMIT 本身并不提供持久化投递保证:数据库刚提交、进程却在真正发送消息前崩溃,事件仍可能丢失。

订单状态变化必须可靠投递时,可以用 Transactional Outbox:在更新订单的同一个数据库事务里写入待投递事件,再由独立任务投递到 MQ,成功后记录投递状态。Outbox 降低了数据库提交和消息发送之间的丢失窗口,但消费者仍可能收到重复消息,仍需按业务键幂等处理。

还有一种容易漏掉的情况:支付金额不一致,需要保存一条待核查记录。如果记录和订单迁移在同一事务里,而守卫抛异常导致事务回滚,待核查记录也会被回滚。要么在明确设计的独立事务中保存,要么通过其他可靠补偿方式处理;别只写日志就认为后续一定有人能找回来。

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

重复不是意外,是常态。支付网关超时后会按策略重发;MQ 通常采用至少一次投递;用户可能连点两次退款;两个定时任务实例也可能扫到同一订单。

判重键不能只用 order_id + status。同一订单收到两次 PAY_SUCCESS,与同一订单两次变成 PAID,不是一回事。前者是业务事件是否重复,后者是状态更新是否成立。数据库唯一键与统一迁移入口分别负责不同层面的约束。

还要小心另一种情况:同一订单在微信和支付宝各付了一次。两个回调携带不同的支付流水号,都可能是真的。第二笔不能仅仅因为订单已支付就悄悄丢掉;需要按第二笔交易流水号可靠地创建待退款或待核查任务。

@PostMapping("/notify/pay")
public String payNotify(@RequestBody PayNotify notify) {
    // 验签、校验商户与金额,并通过服务端映射确定订单和租户;不要信任请求体里的 tenantId。
    VerifiedPayNotify verified = paymentNotificationVerifier.verifyAndResolve(notify);

    TransitResult result = transitService.transit(
            verified.orderId(), OrderEvent.PAY_SUCCESS,
            verified.transactionId(), Operator.gateway(),
            TransitionFacts.from(verified));

    if (result == TransitResult.APPLIED || result == TransitResult.DUPLICATE) {
        // 已处理或已处理过的同一支付事件,按渠道协议返回成功应答。
        return paymentChannel.successAcknowledgement();
    }

    if (result == TransitResult.ILLEGAL) {
        // 例如订单已取消但支付已到账:先可靠保存待退款任务,再向渠道确认。
        // persistOnce 必须以渠道交易流水号等稳定键保证幂等,并在提交后才返回。
        refundTaskService.persistOnce(
                verified.tenantId(), verified.orderId(), verified.transactionId());
        return paymentChannel.successAcknowledgement();
    }

    // NOT_FOUND 或未解决的临时错误不能默认返回成功;让渠道按协议重试或进入对账流程。
    throw new RetryableNotificationException("Payment notification was not durably handled");
}

这里的关键不是最后一行返回什么字符串,而是业务动作有没有可靠落地。如果保存待退款任务时数据库故障,不能假装已经处理完成并返回成功;应让异常传递到回调适配层,按渠道要求返回可重试的失败响应。任务已保存、但响应丢在网络里也没关系:下一次回调会通过相同流水号命中幂等约束。

不同支付渠道以及同一渠道的不同 API 版本,成功应答格式可能不同。应按正在接入的产品文档实现,不要把某个通道的 success 或 SUCCESS 复制到所有回调接口。

Redis SETNX 可以用来削峰或快速拦截,但别让它成为资金类幂等的唯一依据。缓存重启、淘汰或故障切换都可能让记录消失;数据库唯一约束和同事务的业务记录更适合承担关键约束。迁移记录和订单状态分两次提交,等于给每次故障都留了一个“状态改了、记录没写”的缺口。

订单侧的重复到这里就讲完了:uk_idem 挡住同一事件同一凭证,条件更新挡住并发推进。渠道回调的重复比这密得多,幂等键该取渠道流水号还是你自己的退款单号,是两个方向两个答案,那是 06 的事。这里只留一句要记住的:DUPLICATE 不是异常,调用方按返回值分支,别抛。

超时取消撞上用户支付

任务在 20:00:00.000 扫出一批超时未付订单。20:00:00.412,支付回调把其中一单改成 PAID。20:00:00.980,超时任务拿着那单的 ID 调用 transit(),等到锁后读到的已经是 PAID,CANCEL_TIMEOUT 在这个状态上没有合法迁移,于是任务跳过并记录结果。

如果任务扫描时看见 WAIT_PAY,随后不加锁、不重新校验就直接更新成 CANCELLED,中间那几百毫秒就足够让支付回调插进来。结果是钱收到了,订单却被取消,第二天客服接一个电话。

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

下面是游标扫描示例。maxId 在本轮开始时确定,用来给本轮任务一个明确的上界:

SELECT id
FROM biz_order
WHERE tenant_id = #{tenantId}              -- 只扫当前租户
  AND status = 'WAIT_PAY'                  -- 只处理待支付订单
  AND created_at < NOW() - INTERVAL 30 MINUTE -- 超过业务规定的付款窗口
  AND created_at > NOW() - INTERVAL 30 DAY    -- 避免任务恢复后直接扫多年以前的遗留订单
  AND id > #{lastId}                       -- 上一批处理到的游标
  AND id <= #{maxId}                       -- 本轮启动时确定的最大 ID,限制本轮扫描范围
ORDER BY id
LIMIT 500;

id > lastId 能避免 LIMIT offset 越翻越深;id <= maxId 则避免本轮扫描范围随着新订单持续创建而不断扩大。maxId 是本轮上界,不是数据库快照:如果某条更早分配了 ID 的记录在游标经过后才提交,它仍可能错过当前轮。因此任务应有下一轮重扫或补偿机制;如果使用持久化游标且永不回头,就必须另外处理迟提交记录。

索引示例是 (tenant_id, status, id, created_at)。租户和状态是等值条件,id 支持游标与排序,created_at 在该索引里可能主要承担后续过滤。由于 id 已经是范围条件,不能想当然地认为 created_at 也能缩小索引扫描范围,更不能保证一定省回表或避免所有排序。用实际数据跑 EXPLAIN,看扫描行数、排序和回表情况,再按工作负载调整索引顺序。

30 天 的下限不是纯粹的性能优化,而是业务保护。任务停过三个月再启动,没有上限和补偿流程就把三年前的遗留单全部取消一遍,未必是你想要的结果。这个窗口需要按真实业务规则确定,超出窗口的订单应进入独立对账或人工处理流程。

延迟消息也能做超时取消,例如 RabbitMQ TTL 加死信队列,或者消息系统提供的延迟投递能力。它可以减少扫描延迟,但消息可能重复或迟到;收到消息时仍要重新读取订单状态并走统一迁移入口。先用扫表把正确性做好,等精度和规模真的成为瓶颈,再决定是否切换延迟队列。

顺序反过来是另一回事:任务先取消成功,支付回调随后才到。回调的 transit 读到 CANCELLED,PAY_SUCCESS 在这上面没有边,返回 ILLEGAL;钱已经收了,所以不能简单吞掉,它要落到 06 处理 ILLEGAL 的那一步:不推进订单,把原始记录标成待人工,另落一条退款任务,而不是把异常抛给网关。

多实例部署时,分布式锁可以减少重复扫描,但不能代替订单行锁、条件更新和幂等约束。有这些约束,两个任务实例撞上同一单也能收敛到一个有效结果;没有这些约束,分布式锁只是把竞态窗口变大或变小,并没有消灭它。

还有几处会咬人,但不用今天做

部分退款最容易让主状态膨胀。金额是连续变化的,状态是离散的;把“退了一半”“又退了一次”都编码进订单枚举,最后主单状态会多到没人敢改。更好维护的方式通常是让退款单分别记录金额、次数、渠道流水和处理状态,主订单只保留业务真正需要的聚合状态。两边的状态推进方向也要讲清楚,避免订单和退款单互相回写对方的状态。

物流进度也一样。出库、揽收、签收是三个不同事件。物流商只回了揽收,你却写成已签收,客服处理未收到货的投诉时,拿到的是一个系统认证过的假结论。

合单拆单需要区分主单和子单。如果主单状态由子单聚合出来,就要明确聚合规则和更新时间;不要让主单、子单都能随意改对方的 status,最后留下一组没人解释得了的组合。

人工改状态要有 reason 必填,只开放白名单事件,并做租户范围和行级权限检查。否则迁移记录最后变成一份记录谁乱改的录音证据,改还是照样改。

我第一次补这张表,是在翻完 binlog 之后

那次是在查一单“已取消却又变成已支付”。我在 binlog 里按 order_id 把状态字段的变更捞出来,一条条对时间,却看不出到底是谁发起的——SQL 里没有操作者,也没有业务事件。

补了迁移记录之后,排查至少可以从一条查询开始:

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;

它不能取代应用日志、支付渠道流水或 binlog,但能把数据库里的状态变化和业务事件连起来。第二次补强的是唯一键,起因是网关重发让一笔退款执行了两次。两样都是出事之后才加的,都比一开始设计清楚贵。

如果你手上正好有一单状态解释不了,先别急着上工作流引擎。先跑一遍 GROUP BY status,找出代码里没有的值;再把所有状态写入点搜出来;最后把关键业务事件、合法迁移和幂等约束收口到一个入口。库存、退款、任务调度和消息投递,则按各自的数据一致性要求补并发控制和补偿机制。

状态机不是把 1、2、3 换成几个英文单词。真正有用的是每次变更都有业务原因、迁移规则可以验证、重复事件有稳定结果,出了问题也能查出足够的信息。把这几件事做好,比一开始就引入一个没人敢动的框架更重要。

参考资料


系列:上一篇 多租户系统最容易出事的 8 个位置 | 下一篇 支付回调出错时系统一声不吭 | 相关 线上线下一套库存

相关文章