订单状态机:别用 status=1,2,3 糊弄
订单显示已退款,仓库的待发货队列里它还在,拣货员照着发了。两边读的都是 biz_order.status 这一列:退款流程写的是“这单已经是 REFUNDED”,出库流程写的是“这单不在不能发的那几个状态里”。同一列,两套理解,各写各的条件,谁也没问对方那句条件到底覆盖到哪。
前面四篇讲的是数据归谁,这一篇开始讲钱。钱这边最先出问题的往往不是金额,是这条订单现在算什么。
status 一列管得住“现在是什么”,管不住“谁把它改成这样”“当时允不允许改”“同一件事收到两次会怎样”。后三个问题在多数代码里根本没人问,因为状态变更被一个接口包掉了:
void updateStatus(Long orderId, Integer status);
参数里只有结果,没有原因,也没有谁。任何拿得到 id 的模块,都能把这单设成任何值。

谁在改这一列
先把范围摸出来,再谈设计。
grep -rn "\.setStatus(\|updateStatus\|\"status\"" src/main/java --include=*.java
跑出来一般是十几处。我接手过的两套订单里,除去 service 内部那几处正常调用,另外几处每次都不重样:一个后台表单把前端传上来的状态原样落库,下拉框里有什么,库里就能出现什么;一个是定时任务,超时取消也从这里走。
还有一处是别的模块越过 service 直接调 mapper,理由那段代码拿不到订单 service,而它只是想改个地址,顺手把状态字段带上了。
updateStatus(orderId, 3) 这个签名把两个必要问题一起跳过了:发生了什么事(支付成功、退款到账、超时没付),以及这单现在能不能变成那个结果。
改不动的原因也在这儿:调用点收不完,是因为变更的语义散在调用方脑子里。收口之后新增调用会先撞上找不到事件的异常,这比代码评审管用,因为评审看不到运营后台明天要加的那个按钮。
状态值用名字,别在库里留数字
出库那句我见过写成 where status not in (5, 9):5 和 9 是订单枚举里的已取消和已退款,写的人知道,查的人不知道,翻出常量类还要先确认是哪套枚举。
status = 3 在订单表里是已支付,在退款表里是已驳回。有一次是把退款单的常量抄进了订单表,两个 3 在同一条 join 里活了好久,没人发现。数字本身没错,错在两套数字长得一样。翻 binlog、读慢 SQL、看别人贴过来的报错时,status in (2,3,5) 需要有人去查一遍常量类,而查的人往往是最先离职的那位。
加状态更麻烦:历史数据里那个 6,是新枚举的第 6 项,还是老系统迁移留下的 6?
CREATE TABLE biz_order (
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
order_no VARCHAR(32) NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'WAIT_PAY',
status_at DATETIME NOT NULL,
version INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_tenant_order (tenant_id, order_no),
KEY idx_timeout_scan (tenant_id, status, id, created_at)
);
tenant_id 和订单号一起进唯一键,是系列第一篇那条规矩的延续:订单号只在租户内唯一,全局唯一是巧合,不是约束。created_at 留给后面的超时扫描,status_at 看状态停了多久,updated_at 只回答这一行最后一次被改是什么时候,三个时间各管一件事。
已经有一列 int 的,别原地改类型。加一列状态名,双写,把读切过去,旧列留着当历史,等下一版再删。原地 modify column 在几百万行的表上会锁表重建,而且改完之后没人知道 5 该变成哪个单词。
status_at 单独存,别拿 updated_at 代替。改收件地址、改备注都会刷 updated_at,用它判断哪单超时未付会漏,算某一单在 PAID 停了多久会算出一个没法解释的数。
取值分布这条查询是我判断一个订单模块改造量的起点,比读代码诚实:
select status, count(*) from biz_order group by status;
低峰跑。别指望 status 上有索引能帮你,低基数列建索引对这种全表聚合没什么用。跑出来如果有一个值不在你的枚举里,说明有一条写入路径绕过了全部校验,通常是某个用来修正订单的后台接口,或者一次没清理完的迁移。
一张记变更,一张管合法性
状态迁移要落库,这是排查的前提。
CREATE TABLE biz_order_transition (
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
order_id BIGINT NOT NULL,
event VARCHAR(32) NOT NULL, -- PAY_SUCCESS / SHIP / CANCEL_TIMEOUT
from_status VARCHAR(24) NOT NULL,
to_status VARCHAR(24) NOT NULL,
biz_key VARCHAR(64) NOT NULL DEFAULT '', -- 支付流水号、退款单号、发货单号
operator VARCHAR(40) NOT NULL, -- 用户 id,或者 system:timeoutCancelJob
source VARCHAR(16) NOT NULL, -- HTTP / MQ / JOB / API
reason VARCHAR(255) NULL, -- 人工改状态时必填
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)
);
uk_idem 是这张表最值钱的地方:同一笔回调第二次进来,在插入那一刻就被数据库挡掉。只靠先查后插挡不住,并发下两个线程都查到没有记录,然后各自插一条。键从 tenant_id 开头,跟第一篇那条规矩一致;order_id 现在是全局自增,看着多余,等这张表按租户拆出去,键里没有租户就得回头改。而这张表里最容易写错的一列是 biz_key,它必须 NOT NULL DEFAULT '':MySQL 的唯一索引允许任意多个 NULL,写成可空的话重复回调照样插入第二条,幂等静默失效,测试环境还看不出来。反过来,如果这个事件天生会重复发生,比如部分退款一单能退三次,那它的 biz_key 该放退款单号,而不是留空。
operator 要能区分人和机器。system:timeoutCancelJob 在排查时比一个空值有用得多,也是唯一能说明这一单被任务取消过的东西。
合法迁移放另一张表,不过先说清楚:这张表不是必需品。枚举加一个 Map<OrderState, Set<OrderState>>,十行代码,能挡住大部分乱改。等客服开始追着你问这一单还能改成什么,再把它搬进表。
CREATE TABLE cfg_order_transition (
event VARCHAR(32) NOT NULL,
from_status VARCHAR(24) NOT NULL,
to_status VARCHAR(24) NOT NULL,
guard_bean VARCHAR(40) NULL, -- 空表示这条边无条件允许
PRIMARY KEY (event, from_status)
);
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 存一个 bean 名字,代码里注入 Map<String, OrderGuard>,Spring 按名字给你。别做成规则表达式:多数系统的“可配置状态机”,最后是一张没人配置、也没人敢改的表,运营看了两遍就放弃。真要在后台改边,得连灰度和回滚一起做,那是另一个工程量。
配置进表的价值不在省掉 switch,在于这条边合不合法第一次有了一个能被问的地方。客服问这单为什么不能改成已发货,答案是查表;评审时有人说漏了一条边,答案是查表;库里出现 COMPLETED 到 PAID 的记录,答案是有人绕开了入口方法。
这张表是全平台共享的,不建 tenant_id;真遇到租户要自定义状态机,加 tenant_id 并把主键改成 (tenant_id, event, from_status),别在代码里按租户写 if。
所有状态变更走同一个方法
@Transactional
public TransitResult transit(long orderId, OrderEvent event, String bizKey, Operator op) {
Order order = orderMapper.selectForUpdate(orderId); // 锁后读到的状态才作数
if (transitionRepo.exists(orderId, event, bizKey)) {
return TransitResult.DUPLICATE; // 排在别人后面拿到锁,已经落过了
}
Transition t = cfgRepo.find(order.getStatus(), event).orElse(null);
if (t == null) {
return TransitResult.ILLEGAL; // 当前状态上没有这条边
}
if (!transitionRepo.tryRecord(orderId, event, bizKey,
order.getStatus(), t.getToStatus(), op)) {
return TransitResult.DUPLICATE; // 唯一键兜底
}
if (t.getGuardBean() != null) { // 空表示无条件允许
guards.get(t.getGuardBean()).check(order, event, op);
}
int rows = orderMapper.casStatus(orderId, order.getStatus(), order.getVersion(),
t.getToStatus(), LocalDateTime.now());
if (rows == 0) {
throw new ConcurrentTransition(orderId, t.getFromStatus());
}
eventPublisher.publish(new OrderStateChanged(orderId, event, t.getToStatus()));
return TransitResult.APPLIED;
}
TransitResult 就三个值:APPLIED 表示这次推进成功,DUPLICATE 表示同一事件同一凭证已经落过,ILLEGAL 表示当前状态上没有这条边。重复不是异常,非法迁移也不是——调用方按返回值分支,只有真正并发的 CAS 冲突才抛出去让上层重试。
<update id="casStatus">
update biz_order
set status = #{to}, status_at = #{now}, version = version + 1
where id = #{id} and status = #{from} and version = #{version}
</update>
where 里多出来的那半句是第二道闩。锁在的时候它很少命中;有人绕过 transit 直接写状态、或者为了省锁把 selectForUpdate 去掉,它就负责让这次更新失败。CAS 影响 0 行,说明你读到它之后它已经被别人推进过了,这时抛 ConcurrentTransition 让上层重读。它是重试信号,不是业务失败。
乐观锁 version 代替不了它。version 挡的是“有人在我之后改了这一行”,包括客服改了个备注;and status = ? 挡的是“我要走的那条边已经不成立”,这才是状态机要问的。两个一起加没问题,只加 version 的话回调会因为一次无关的字段更新白重试一轮。
顺序是先锁订单行,再查这条事件有没有落过。先查再锁会漏一种情况:两个重复回调同时进来,都查到没有记录,都往下走;后拿到锁的那个读到的是新状态,PAY_SUCCESS 在 PAID 上又没有边,于是它被当成非法迁移,转去退款。锁后这次查询把这条路堵死。
还有一层和锁无关:RR 隔离级别下,事务里第一条普通查询就把快照定住了。这条存在性查询放在加锁之前,加锁之后也看不见别人刚提交的那条记录。
唯一键是最后一层:哪天有人绕过这个方法直接写状态,它会在插入那一刻把重复挡下来;CAS 失败会连这条迁移记录一起回滚,不留半成品。并发插入同一个键偶尔报 1213 死锁,重试一次即可。
selectForUpdate 的代价是这一单在事务里只能有一个写者,拿不到锁的回调要排队。状态机需要它:读到的 from_status 和后面查出来的边必须对应同一时刻。守卫要读行外的库存、累计退款时,这个锁更是前提——不锁的话,你算前提时看到的是旧账,等到更新时前提已经变了。
eventPublisher.publish 发的是进程内事件,监听器跟着同一个事务跑。要发 MQ、短信、推送就别在监听器里直接调远程:落一张 outbox,或者用 @TransactionalEventListener(AFTER_COMMIT)。事务回滚了消息却已经发出去,比没发更麻烦。
守卫放的是这条边的前提条件,不是通用校验。paidAmountGuard 只看一件事:回调带回来的金额和这单的应付金额对不对得上,对不上就不推进,另落一条待核查记录;这条记录要用独立事务写,不然守卫一抛,它跟着回滚。refundAmountGuard 看的是累计退款不超过实付。写成这样,一个守卫不到二十行,测试好起:造一单两个状态就能断言。
我见过把守卫写成 OrderValidator.validate(order) 的,一个方法里塞了库存、金额、地址、权限,最后没人敢改,因为它同时管着三条边,而那三条边的条件根本不一样。差一分钱是渠道改了优惠还是有人动过订单,那是另一个流程要回答的,别塞进状态机。
代价落在客服后台那个下拉框接口上,它得重写,因为要的不再是目标状态,而是事件和原因;已有的十几处调用要逐个想清楚自己发的到底是哪个事件。真正的阻力在这里,不在建模。
Spring State Machine 我多数情况不上:guard 和 action 绑在框架的状态图上,改一条边得先学一遍框架。上面这张表加一个方法,新人十分钟能看懂。
订单侧的重复到这里就讲完了:uk_idem 挡住同一事件同一凭证,CAS 挡住并发推进。渠道回调的重复比这密得多,幂等键该取渠道流水号还是你自己的退款单号,是两个方向两个答案,那是 06 的事。这里只留一句要记住的:DUPLICATE 不是异常,调用方按返回值分支,别抛。
超时取消撞上用户支付
任务在 20:00:00.000 扫出一批超时未付订单。20:00:00.412,支付回调把其中一单改成 PAID,库存扣了。20:00:00.980,任务拿着这一单的 id 调 transit,锁等到回调提交之后才拿到,读出来是 PAID,CANCEL_TIMEOUT 在 PAID 上没有合法的边,返回 ILLEGAL,跳过并记一行日志。如果任务不等锁、先查后改,中间那几百毫秒足够让回调插进来,于是这单既付了钱又被取消,第二天客服接一个电话。
同一种撞法还有两个任务实例同时扫到这一单:一个 CAS 成功,另一个返回 0 行,处理方式一样,跳过。

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}
order by id
limit 500;
索引建 (tenant_id, status, id, created_at),上面那张表的 idx_timeout_scan 就是它。别把 created_at 放在 id 前面:created_at 上两个范围条件之后,索引里的 id 已经不再有序,order by id 只能 filesort。前三列管等值、游标和排序,最后一列用来过滤 created_at,省一次回表。
用 id > #{lastId} 做游标而不是 limit offset,扫到一半被插入会让分页错位。那个 30 天的下限是业务保护:任务停过三个月再启动,没有下限就会把三年前的遗留单全取消一遍。
延迟消息也能干这件事,RabbitMQ 用 TTL 加死信队列,RocketMQ 有延迟等级。代价是取消这个动作离开了你的数据库:消息在 broker 里,重发一次就重复取消一次,幂等还是得靠那条唯一键。我一般先上扫表,等超时窗口要精确到秒、或者单量大到扫描扛不住,再换延迟队列。
顺序反过来是另一回事:任务先取消成功,回调后到。回调的 transit 读到 CANCELLED,PAY_SUCCESS 在这上面没有边,返回 ILLEGAL;钱已经收了,所以它要落到 06 处理 ILLEGAL 的那一步:不推进订单,把原始记录标成待人工,另落一条退款任务,而不是把异常抛给网关。
多实例部署要不要给这个任务加分布式锁,取决于你的 transit 有没有那半句条件更新和订单行锁。有,两个实例扫到同一单,一个先拿到锁改成 CANCELLED,另一个读到 CANCELLED,CANCEL_TIMEOUT 没有这条边,返回 ILLEGAL 跳过;没有,锁也只是把竞态窗口从几百毫秒拉大到几秒,问题还在。
取消任务要带租户上下文。理由 04 那篇讲过。
还有几处会咬人,但不用今天做
部分退款最容易先撑不住。加了 PART_REFUNDED 之后你会发现需要七个状态才说得清退了一半又退一次,因为金额是连续的,状态是离散的,把金额编进枚举,这笔单的历史就没法查了。主状态保持不动,退款进度放在退款单上,退到第几次、每次多少钱,一条查询就出来。退款单自己也要一套状态,两套状态互相触发的时候方向要定死:退款单推进订单,订单不回写退款单。两边都能改对面那张表的 status,重复回调就能把主单拖进一个谁都没配过的值,而那条边在配置表里查不到。
发货是同样性质的问题。出库、揽收、签收是三个事件,物流商只回了揽收而你写成已签收,客服判未收到货的责任时拿到的是一个系统认证过的假结论。
合单拆单也是:主单状态由子单聚合出来,两个维度挤在同一列,就会留下主单已发货、子单待出库这种没人解释得了的组合,而报表读的正好是主单。
人工改状态要有 reason 必填、只放开白名单事件,再加上 02 那篇讲的行级权限。少任何一条,记录表最后变成一份记录谁乱改的录音证据,改还是照样改。
我第一次补这张表,是在翻完 binlog 之后
那次是在查一单“已取消却又变成已支付”。那天下午我在 binlog 里按 order_id 把这个字段的所有变更捞出来,一条条对时间,看不出是谁发的——SQL 里没有操作者。补了记录表之后,同一件事变成一条 select event, operator, source, created_at from biz_order_transition where order_id = ?,两行结果就指到了任务上。
第二处补的是那个唯一键,起因是网关重发把一笔退款执行了两次。两样都是出事之后才加的,都比一开始就建贵。你手上要正好有一单状态解释不了,跑一遍那条 group by,看看库里是不是有你代码里没有的值。
系列:上一篇 多租户系统最容易出事的 8 个位置 | 下一篇 支付回调出错时系统一声不吭 | 相关 线上线下一套库存