ByFomo

架构实战|订单状态机 + 超时关单:别让“支付回调”把系统变成薛定谔的订单(连载第 10 篇)

2026/02/04
1
0

## 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 个系统,你靠什么保证“最终正确”?**

交易系统里最危险的不是失败,而是“以为成功”。

(完)