## 0. 连载回顾:我们已经把系统“救活”,但还没让它“活明白”
前 9 篇我们把交易平台从“能跑”一路推到了“能扛、能查、能对”:
- 第 2 篇用 **Outbox + 消费者幂等**把 MQ 的“至少一次”磨成“像一次”;
- 第 3/4 篇聊了库存热点、秒杀、限流与公平性,避免系统像抽奖;
- 第 5/6 篇把 MySQL/Redis 的坑踩了一遍,至少以后掉坑能更优雅;
- 第 7/8 篇做可观测性与韧性,让故障别再靠“玄学祈祷”;
- 第 9 篇做对账与清结算,明确:**账不平比宕机更可怕**。
但做到这里,你会发现还有一个“系统性玄学”:
> 同一笔订单,在不同系统里同时处于多个状态:
> - 用户端显示“已支付”
> - 订单中心显示“待支付”
> - 账本显示“已入账”
> - 客服工单写着“用户骂了三页 A4”
这不是 Bug,这是 **缺少一个足够“硬”的状态模型**。
今天(连载第 10 篇)我们聊:**订单状态机 + 超时关单**——怎么把“支付回调”的不确定性,收敛成工程可控的确定性。
---
## 1. 事故开场:回调迟到 3 分钟,客服提前开席
业务场景:我们做的是一个高并发交易平台(类电商/券包/权益),支付走第三方。
某天大促结束后,账务同学对账发现:少量订单“钱到了,货没发”。你翻链路追踪发现:
- 用户 10:00:00 下单
- 10:00:03 拉起支付
- 10:15:00 系统超时关单(释放库存、撤销优惠)
- 10:15:02 第三方支付回调姗姗来迟:支付成功
于是系统内部出现了一个“薛定谔订单”:
- 关单流程觉得它死了
- 回调流程觉得它活了
- 用户觉得它在作妖
如果你只靠 `if/else` 处理状态,最终你会得到一套“靠祈祷维持一致性”的代码:
```java
if (status == WAIT_PAY) {
// ...
} else if (status == CLOSED) {
// ...
} else if (status == PAID) {
// ...
} else {
// 这里通常写:"理论上不会走到这里"
}
```
**“理论上不会”**在交易系统里是一句高危咒语。
---
## 2. 目标:把订单变成一个“有规矩的成年人”
我们要的不是“某个接口幂等”,而是把订单生命周期规范化:
1. **状态是有限集合**,并且每个状态的含义清晰可解释(给客服也能解释那种);
2. **状态迁移是有限集合**,任何迁移都必须“有证据”——事件驱动;
3. **事件可以重复、乱序、延迟**,但订单状态不能乱;
4. **超时是第一公民**:待支付超时关单、支付后超时发货、发货后超时确认……都要有机制;
5. **可回放、可审计**:出了问题你能回答“它为什么变成这样”。
一句话:让订单从“薛定谔”变成“法治社会”。
---
## 3. 订单状态机:先把“词典”立起来
### 3.1 状态定义(示例)
我们以“买权益/券码”为例,简化但不失工程味道:
- `INIT`:订单已创建(仅内部可见)
- `WAIT_PAY`:待支付(前台可见)
- `PAYING`:支付中(可选,做风控/轮询时更好解释)
- `PAID`:支付成功(钱确认)
- `FULFILLING`:履约中(发券/发货/发放权益)
- `FULFILLED`:履约成功
- `CLOSED`:已关闭(超时/取消)
- `REFUNDING`:退款中
- `REFUNDED`:已退款
**关键点**:状态不是为了“写得多显得专业”,而是为了把业务语义钉死。
### 3.2 事件定义:迁移必须有“凭证”
- `OrderCreated`
- `PayInitiated`
- `PaySucceeded` / `PayFailed`
- `PayCallbackReceived`(与 PaySucceeded 可拆可合,取决于支付渠道)
- `OrderTimeout`(超时关单事件)
- `FulfillSucceeded` / `FulfillFailed`
- `RefundRequested` / `RefundSucceeded`
你会发现:**事件是事实**,状态是我们对事实的解释。
> 状态机的正确打开方式:
> - 存事件(可选)
> - 存状态(必须)
> - 让状态由事件驱动、可推导、可审计
---
## 4. 数据模型:不要把状态“藏”在 7 张表里
订单领域最容易犯的错:状态到处飞。
建议做一个“主表 + 事件表(可选)+ 关键索引”的结构:
```sql
CREATE TABLE t_order (
id BIGINT PRIMARY KEY,
biz_order_no VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
amount_cent BIGINT NOT NULL,
status VARCHAR(32) NOT NULL,
pay_channel VARCHAR(32) NULL,
pay_trade_no VARCHAR(64) NULL,
expire_at DATETIME NOT NULL,
paid_at DATETIME NULL,
fulfill_at DATETIME NULL,
version BIGINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY uk_biz_order_no (biz_order_no),
KEY idx_status_expire (status, expire_at),
KEY idx_pay_trade_no (pay_trade_no)
);
-- 可选:事件表(用于审计/回放/排障,按需做)
CREATE TABLE t_order_event (
id BIGINT PRIMARY KEY,
biz_order_no VARCHAR(64) NOT NULL,
event_type VARCHAR(64) NOT NULL,
event_id VARCHAR(64) NOT NULL,
payload JSON NOT NULL,
created_at DATETIME NOT NULL,
UNIQUE KEY uk_order_event (biz_order_no, event_id)
);
```
几个硬规定:
- `status` 必须在主表中,且必须是唯一解释;
- `version` 必须有(乐观锁是你的防身喷雾);
- 支付相关 `pay_trade_no` 必须索引(回调落库很靠它);
- `expire_at` + 索引支持批量扫描超时订单(或配合延迟队列)。
---
## 5. 并发与乱序:用“状态迁移约束”代替“祈祷”
支付回调的现实世界:
- **重复回调**:第三方天然至少一次
- **乱序回调**:先到失败、后到成功(见过吗?我见过)
- **延迟回调**:网络抖动、渠道重试、人工补发
### 5.1 核心原则:状态迁移必须满足前置条件
我们做一个极其朴素但有效的策略:
> 所有写状态的 SQL,都必须带上“期望的旧状态”。
例如“待支付 -> 已支付”:
```sql
UPDATE t_order
SET status = 'PAID', paid_at = NOW(), version = version + 1, updated_at = NOW(),
pay_channel = ?, pay_trade_no = ?
WHERE biz_order_no = ?
AND status IN ('WAIT_PAY', 'PAYING');
```
而“超时关单”:
```sql
UPDATE t_order
SET status = 'CLOSED', version = version + 1, updated_at = NOW()
WHERE biz_order_no = ?
AND status IN ('WAIT_PAY', 'PAYING')
AND expire_at <= NOW();
```
这样,当支付回调迟到 3 分钟时,它更新 `status IN ('WAIT_PAY','PAYING')` 会失败,因为订单已经是 `CLOSED`。
失败不是坏事——失败是你终于把不确定性显性化了。
接下来要做的,是“失败之后怎么办”。
---
## 6. “关单后支付成功”怎么办:两条路线,别都要
这是产品和架构要一起拍板的地方,不是程序员闭门造车。
### 路线 A:关单后支付成功 → 走退款
这是很多平台的默认解:
1. 订单已关闭(库存已释放、优惠已回滚)
2. 发现支付成功回调
3. 订单标记 `REFUNDING`,异步发起原路退款
4. 退款成功后 `REFUNDED`
优点:逻辑清晰、账务稳定。
缺点:用户体验一般(“我付了钱你又退我,图啥?”),但大多数大促场景可接受。
### 路线 B:关单后支付成功 → 尝试“复活订单”
这条路是高难度动作:
- 你要重新扣库存(库存可能没了)
- 你要重新占用优惠(优惠可能过期)
- 你要确保账本、履约一致
如果你选择它,请务必把它当作一个 **独立的补偿流程(Saga)**,而不是一句 `if (status == CLOSED) status=PAID`。
在本系列里,我们选择更“工程保守”的路线 A:**关单后支付成功 = 退款**。
---
## 7. Java 落地:一个不花哨但靠谱的状态机实现
我不强制你用 Spring Statemachine(它能用,但别把它当银弹)。在交易系统里,我更喜欢:
- 状态枚举 + 事件枚举
- 一张迁移表(代码里写死或配置化)
- 每次迁移都落库(带旧状态约束)
- 迁移失败要有“分支处理”
### 7.1 状态与事件
```java
public enum OrderStatus {
INIT, WAIT_PAY, PAYING, PAID, FULFILLING, FULFILLED, CLOSED, REFUNDING, REFUNDED
}
public enum OrderEvent {
ORDER_CREATED,
PAY_INITIATED,
PAY_SUCCEEDED,
PAY_FAILED,
ORDER_TIMEOUT,
FULFILL_SUCCEEDED,
FULFILL_FAILED,
REFUND_REQUESTED,
REFUND_SUCCEEDED
}
```
### 7.2 迁移规则(示意)
```java
record Transition(OrderStatus from, OrderEvent event, OrderStatus to) {}
static final Set<Transition> RULES = Set.of(
new Transition(OrderStatus.INIT, OrderEvent.ORDER_CREATED, OrderStatus.WAIT_PAY),
new Transition(OrderStatus.WAIT_PAY, OrderEvent.PAY_INITIATED, OrderStatus.PAYING),
new Transition(OrderStatus.WAIT_PAY, OrderEvent.PAY_SUCCEEDED, OrderStatus.PAID),
new Transition(OrderStatus.PAYING, OrderEvent.PAY_SUCCEEDED, OrderStatus.PAID),
new Transition(OrderStatus.WAIT_PAY, OrderEvent.ORDER_TIMEOUT, OrderStatus.CLOSED),
new Transition(OrderStatus.PAYING, OrderEvent.ORDER_TIMEOUT, OrderStatus.CLOSED),
new Transition(OrderStatus.PAID, OrderEvent.FULFILL_SUCCEEDED, OrderStatus.FULFILLED)
);
static boolean can(OrderStatus from, OrderEvent event) {
return RULES.stream().anyMatch(t -> t.from() == from && t.event() == event);
}
```
### 7.3 写库:必须带旧状态
```java
@Transactional
public void onPaySucceeded(String bizOrderNo, String tradeNo, String channel, String eventId) {
// 1) 幂等:事件去重(参考第 2 篇)
if (!eventStore.tryInsert(bizOrderNo, eventId, "PAY_SUCCEEDED")) {
return;
}
// 2) 先读当前状态(只读用于分支判断;真正并发控制在 UPDATE)
Order order = orderRepo.findByBizOrderNo(bizOrderNo);
// 3) 尝试正常迁移:WAIT_PAY/PAYING -> PAID
int updated = orderRepo.markPaidIfWaiting(bizOrderNo, tradeNo, channel);
if (updated == 1) {
// 4) 发履约事件(Outbox)
outbox.publish("OrderPaid", Map.of("bizOrderNo", bizOrderNo));
return;
}
// 5) 迁移失败:可能已经 CLOSED / PAID / REFUNDING
// 做分支处理:关单后支付成功 -> 退款
Order latest = orderRepo.findByBizOrderNo(bizOrderNo);
if (latest.status() == OrderStatus.CLOSED) {
outbox.publish("OrderPaidAfterClosed", Map.of(
"bizOrderNo", bizOrderNo,
"tradeNo", tradeNo,
"channel", channel
));
return;
}
// 其他情况:PAID/REFUNDING/REFUNDED,直接视为幂等
}
```
你会发现:**读是为了讲道理,写是为了讲规矩。**
---
## 8. 超时关单:延迟队列、时间轮、扫描任务怎么选?
订单超时是交易系统里的“空气”:
- 不做不行
- 做不好窒息
常见实现三种:
### 8.1 扫描任务(最朴素)
每分钟扫一次:`status in (WAIT_PAY,PAYING) and expire_at <= now()`。
优点:简单、好维护。
缺点:
- 高峰期可能扫到很多行,需要分片/索引/限速
- 超时触发不是精确到秒
**建议**:中小规模系统完全够用,记得加索引 `idx_status_expire`。
### 8.2 延迟队列(推荐工程路线)
下单时投递一个“关单消息”,延迟 `ttl = expireAt - now`。
- RocketMQ 延迟级别 / 定时消息
- Kafka 通常要自己做(时间轮、延迟主题)
- Redis ZSet 也能做(但注意可靠性与持久化)
优点:近实时,压力分摊到消息系统。
缺点:
- 消息可能重复/丢失(所以仍要靠落库条件兜底)
### 8.3 时间轮(高阶玩法)
适合超大量定时任务:把“什么时候触发”离散化成槽位,批处理。
工程上最重要的是一句话:
> **无论用哪种触发方式,最终都要落到“带旧状态约束的 UPDATE”。**
因为触发只能做到“提醒你”,不能做到“替你决定”。
---
## 9. 与账本/对账的衔接:别让财务成为你的集成测试
第 9 篇我们说:账本是权威。
在状态机里,我们进一步明确:
- `PAID` 表示“支付成功事实成立”,但是否入账要看账本事件;
- 对账补差只能产生“补偿事件”,不能直接改订单状态;
- 订单状态变更必须可追溯:谁触发、何时触发、凭证是什么。
建议在关键状态迁移处打点:
- 状态迁移成功/失败计数
- `PAY_SUCCEEDED` 事件到达延迟分布
- “关单后支付成功”比例(这是产品体验与风控的共同 KPI)
你会发现,可观测性(第 7 篇)在这里不是锦上添花,而是“活命装备”。
---
## 10. 一段小剧场:关于“薛定谔订单”的终局
大促夜里 00:03,值班同学在群里说:
> “又有一笔订单:已关闭但支付成功。要不要复活?”
我回:
> “别冲动。复活订单这事,跟复活前任差不多:
> 你以为你能修好,实际上你只是给自己加了更多的故障模式。”
我们最终选择了工程保守方案:
- 订单关单后支付成功 → 自动退款
- 同时把该类事件做成报表,给产品评估“超时阈值是否合理”
第二天,客服反馈:投诉量少了,原因不是用户变温柔了,而是系统终于“讲理”。
---
## 11. 小结:把不确定性交给事件,把确定性交给状态机
你可以不叫它“状态机”,但你必须拥有它的能力:
1. 明确的状态集合与语义
2. 明确的事件集合
3. 明确的迁移约束(写库带旧状态)
4. 超时作为第一公民(延迟队列/扫描/时间轮都行)
5. 幂等与乱序处理(事件去重 + 分支补偿)
下一篇(连载第 11 篇),我们会把视角从“订单”扩展到“跨领域流程”:
> **Saga / TCC / 补偿事务:当一个交易要跨 5 个系统,你靠什么保证“最终正确”?**
交易系统里最危险的不是失败,而是“以为成功”。
(完)