订单状态机:别用 status=1,2,3 糊弄
订单页面显示“已退款”,仓库的待发货队列里却还有这笔订单,拣货员照着把货发了。
两边读的都是 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 换成几个英文单词。真正有用的是每次变更都有业务原因、迁移规则可以验证、重复事件有稳定结果,出了问题也能查出足够的信息。把这几件事做好,比一开始就引入一个没人敢动的框架更重要。
参考资料
- MySQL 8.4:Locking Reads(SELECT … FOR UPDATE)
- MySQL 8.4:Online DDL Operations
- Spring Framework:Transaction-bound Events
系列:上一篇 多租户系统最容易出事的 8 个位置 | 下一篇 支付回调出错时系统一声不吭 | 相关 线上线下一套库存
相关文章
多租户不是加一个 tenant_id:三种隔离模型与选型
多租户不是加个 tenant_id 就完了。共享表、独立 schema、独立库,三种模型的成本和失效方式不一样。这篇按 Spring Boot + MySQL 讲我怎么选、怎么落地,以及异步、定时任务、MQ、导出这些地方怎么漏。
部门、门店、代理商,一张 sys_dept 表撑不住这三层
需求说“要加门店”,开发在 sys_dept 上加了一列 type,两行代码当天上线。三个月后加代理商,type 多个值;半年后要求门店同时归区域运营、归总部核算,parent_id 只有一个。行政组织、经营单元、外部主体这三层的差别不在层级深浅,在于谁需要历史、谁会算钱、谁是外部人。
权限系统:RBAC 不够用的地方,数据权限怎么补
一句“销售只能看自己的客户”,里面藏着行、列、入口、失效四个判定,RBAC 一个都答不了。这篇讲数据范围怎么落成一条 SQL、@DataScope 这类拼法会在哪一层漏、成本价为什么会整整齐齐出现在导出的 Excel 里,以及为什么详情接口是行级权限最大的口子。