一笔“已付款却被关闭”的订单
「拾集」的订单有一个 30 分钟的支付窗口。用户在 21:47 下单,21:49 完成支付,渠道回调在 21:49:58 到达。与此同时,超时关闭任务在 21:50:00 扫到了这批 created 状态的订单,把它们批量改成了 closed。
用户看到的结果是:钱扣了,订单显示“已关闭”,库存被释放,仓库没有收到发货单。客服后台也查不到支付记录,因为订单关闭后,支付回调写入的流水被一条“订单状态不允许”的校验拒绝了。
这不是一个低概率的巧合。订单量越大,关闭任务和支付回调落在同一秒的概率越高。下面是一组案例假设值:日均订单 120 万,支付窗口 30 分钟,关闭任务每分钟扫描一次;按经验,每天约有 15 到 30 笔订单处在“回调已到、关单未提交”的窗口里。如果关闭逻辑不检查状态,这些订单都会被误关。
问题的根源不是定时任务不准,而是状态迁移没有约束。关闭任务认为“超过 30 分钟就该关”,支付回调认为“钱到了就该变成 paid”,两个写入者都只检查自己关心的条件,没有人检查订单当前到底是什么状态。
五个状态与四条事件
订单状态机的价值,是把“谁能把订单改成什么”写成一张所有服务都遵守的表:
| 当前状态 | 事件 | 新状态 | 说明 |
|---|---|---|---|
| created | pay | paid | 支付成功,订单进入待履约 |
| created | close | closed | 超时未支付,释放库存 |
| paid | fulfill | fulfilled | 仓库发货或虚拟商品交付完成 |
| paid | refund | refunded | 支付后取消,原路退款 |
| fulfilled | refund | refunded | 售后发起退款 |
| closed | 任意事件 | 拒绝 | 终态,不能再支付或发货 |
| refunded | 任意事件 | 拒绝 | 终态,不能再关闭或履约 |
表里没有“created 直接 fulfill”“paid 直接 close”“fulfilled 直接 close”这些迁移。不是实现漏了,而是业务上不允许:没付钱不能发货,付了钱不能因为超时被关,已履约的订单只能走退款而不是直接关闭。
终态必须拒绝一切事件。这里的“拒绝”是指返回错误,而不是静默返回原状态。静默返回会让调用方以为迁移成功,继续执行发货或退款,问题被推到更下游才暴露。
超时关闭任务要做到幂等:同一笔订单被扫描两次、任务重跑、机器重启后补跑,结果都应该一样。幂等的实现方式不是“先查状态再更新”,而是“更新时带上状态条件”,让数据库的原子性来决定谁赢。
失败场景:关闭任务与支付回调并发
回到那笔 21:47 的订单。两个写入者的时间线大致如下:
- 21:49:58,支付渠道回调到达,服务读取订单,状态是 created,校验通过。
- 21:49:59,服务准备写
state = paid,但还没有提交。 - 21:50:00,关闭任务扫描到同一笔订单,读取状态仍是 created。
- 21:50:00,关闭任务执行
UPDATE orders SET state = 'closed' WHERE id = ?,提交成功。 - 21:49:59 的支付写入随后提交,或者因为版本冲突失败:
- 如果支付写入没有状态条件,它会覆盖 closed,订单变成 paid,但库存已经被释放,履约单没有创建。
- 如果支付写入有状态条件,它会失败,回调被记为异常,渠道已经扣款,用户却看到 closed。
两种结果都很糟。第一种是超卖和履约缺失,第二种是钱货两空。根因是关闭任务的更新没有带 AND state = 'created',它把“超时”当成了充分条件,而状态机要求“超时且仍未支付”才是。
修复有四条:
- 关闭任务改成条件更新:
UPDATE orders SET state = 'closed' WHERE id = ? AND state = 'created'。影响行数为 0 就说明订单已经被别人推进,直接跳过。 - 支付回调同样带状态条件:
WHERE id = ? AND state = 'created'。失败时不要吞掉,写入待处理表并告警,由补偿任务查询渠道真实结果。 - 引入版本号或状态迁移日志,所有状态变化只能通过状态机函数计算,禁止业务代码直接写
state = ...。 - 建立对账:每天比对渠道流水、订单状态和履约单,发现“渠道成功但订单 closed”或“订单 paid 但无履约单”就自动挂起并通知人工。
超时任务的幂等还体现在重复执行上。任务重启后补跑,可能对同一批订单再关一次;只要更新带状态条件,第二次影响行数为 0,不会产生副作用。反过来,如果代码是“查到 created 就关”,重复执行本身没问题,但和支付回调并发时就会出错。
Go 实现:Next 只做纯函数
下面的代码来自本书配套的 webapp-lab。Next 不碰数据库、不发消息、不读时钟,只根据当前状态和事件返回下一个状态或错误。把状态机做成纯函数,才能用表驱动测试穷举所有组合,也才能让关闭任务、支付回调和后台操作共用同一套规则。
// file: internal/order/state.go
// tier: L1
// verify: go test ./internal/order/
package order
import "fmt"
// State 是订单状态。
type State string
const (
StateCreated State = "created"
StatePaid State = "paid"
StateFulfilled State = "fulfilled"
StateClosed State = "closed"
StateRefunded State = "refunded"
)
// Event 是驱动状态变化的外部事件。
type Event string
const (
EventPay Event = "pay"
EventFulfill Event = "fulfill"
EventClose Event = "close"
EventRefund Event = "refund"
)
// Next 计算状态机在给定事件后的新状态。
//
// 允许的迁移:
//
// created --pay--> paid
// created --close--> closed
// paid --fulfill--> fulfilled
// paid --refund--> refunded
// fulfilled --refund--> refunded
//
// closed 和 refunded 是终态,拒绝任何事件。未知状态、未知事件和非法迁移
// 都返回错误,而不是返回原状态让调用方误以为成功。
func Next(s State, e Event) (State, error) {
if !knownEvent(e) {
return "", fmt.Errorf("order: unknown event %q", e)
}
switch s {
case StateCreated:
switch e {
case EventPay:
return StatePaid, nil
case EventClose:
return StateClosed, nil
}
case StatePaid:
switch e {
case EventFulfill:
return StateFulfilled, nil
case EventRefund:
return StateRefunded, nil
}
case StateFulfilled:
if e == EventRefund {
return StateRefunded, nil
}
case StateClosed, StateRefunded:
return "", fmt.Errorf("order: state %q is terminal, event %q rejected", s, e)
default:
return "", fmt.Errorf("order: unknown state %q", s)
}
return "", fmt.Errorf("order: event %q is not allowed in state %q", e, s)
}
func knownEvent(e Event) bool {
switch e {
case EventPay, EventFulfill, EventClose, EventRefund:
return true
}
return false
}
实现里有两个顺序上的细节。第一,先判断事件是否已知,再判断状态;未知事件在任何状态下都应该报“unknown event”,而不是被终态分支误报成“terminal”。第二,终态分支放在具体状态之后,closed 和 refunded 无论收到什么事件都返回错误,错误信息里同时带上状态和事件,方便日志定位。
knownEvent 是一个小函数,但它把“事件枚举”和“迁移规则”分开了。新增事件时,如果忘记加进 knownEvent,调用方会立刻收到 unknown event;如果加进去了但忘记写迁移,会收到 not allowed。两种错误含义不同,排查时不会混。
测试:合法、非法与终态
测试分四组:合法迁移、非法迁移、终态拒绝、未知输入。表驱动让每条规则都能单独定位,新增状态时也能一眼看出哪些组合还没覆盖。
// file: internal/order/state_test.go
// tier: L1
// verify: go test ./internal/order/
package order
import (
"strings"
"testing"
)
func TestNextAllowsLegalTransitions(t *testing.T) {
cases := []struct {
from State
ev Event
want State
}{
{StateCreated, EventPay, StatePaid},
{StateCreated, EventClose, StateClosed},
{StatePaid, EventFulfill, StateFulfilled},
{StatePaid, EventRefund, StateRefunded},
{StateFulfilled, EventRefund, StateRefunded},
}
for _, c := range cases {
got, err := Next(c.from, c.ev)
if err != nil {
t.Fatalf("%s + %s: %v", c.from, c.ev, err)
}
if got != c.want {
t.Fatalf("%s + %s = %s, want %s", c.from, c.ev, got, c.want)
}
}
}
func TestNextRejectsIllegalTransitions(t *testing.T) {
cases := []struct {
from State
ev Event
}{
{StateCreated, EventFulfill},
{StateCreated, EventRefund},
{StatePaid, EventPay},
{StatePaid, EventClose},
{StateFulfilled, EventClose},
{StateFulfilled, EventPay},
}
for _, c := range cases {
if _, err := Next(c.from, c.ev); err == nil {
t.Fatalf("%s + %s: want error", c.from, c.ev)
}
}
}
func TestNextRejectsTerminalStates(t *testing.T) {
for _, s := range []State{StateClosed, StateRefunded} {
for _, e := range []Event{EventPay, EventFulfill, EventClose, EventRefund} {
_, err := Next(s, e)
if err == nil {
t.Fatalf("%s + %s: want terminal error", s, e)
}
if !strings.Contains(err.Error(), "terminal") {
t.Fatalf("%s + %s: error %q does not mention terminal", s, e, err)
}
}
}
}
func TestNextRejectsUnknownInput(t *testing.T) {
if _, err := Next("shipped", EventPay); err == nil || !strings.Contains(err.Error(), "unknown state") {
t.Fatalf("unknown state error = %v", err)
}
if _, err := Next(StateCreated, "cancel"); err == nil || !strings.Contains(err.Error(), "unknown event") {
t.Fatalf("unknown event error = %v", err)
}
}
TestNextRejectsTerminalStates 遍历了两个终态和四种事件的笛卡尔积,确保没有任何漏网的迁移。TestNextRejectsUnknownInput 分别验证未知状态和未知事件,避免以后把 default 分支合并成一个笼统的错误。
测试没有覆盖“超时”本身,因为超时是任务层的判断,不是状态机的职责。状态机只回答“created 收到 close 会怎样”,至于什么时候发 close,由关闭任务根据 created_at 和当前时间决定。这个边界如果不分清,状态机就会依赖时钟,测试也只能靠 sleep。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/order/ -run 'TestNext' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestNextAllowsLegalTransitions
--- PASS: TestNextAllowsLegalTransitions (0.00s)
=== RUN TestNextRejectsIllegalTransitions
--- PASS: TestNextRejectsIllegalTransitions (0.00s)
=== RUN TestNextRejectsTerminalStates
--- PASS: TestNextRejectsTerminalStates (0.00s)
=== RUN TestNextRejectsUnknownInput
--- PASS: TestNextRejectsUnknownInput (0.00s)
PASS
ok webapp/internal/order 0.264s
输出里四组测试全部通过。它证明的是状态迁移表与实现一致,不证明超时窗口设置合理。30 分钟是否合适、关闭任务多久扫一次、支付回调延迟的 P99 是多少,仍然要靠线上数据和渠道协议决定。
小结
订单状态机不是一张画给产品经理看的图,而是所有写入者共同遵守的约束。created 只能通过支付或关闭离开,paid 只能履约或退款,closed 和 refunded 是终点。任何绕过状态机直接改库的代码,都会在并发时制造出无法解释的订单。
超时关闭的关键不是定时器,而是条件更新和幂等。关闭任务必须带 state = 'created',支付回调也必须带状态条件;两边都只在自己预期的状态上成功,冲突时由数据库的原子更新决定胜负,再由对账兜底。
阅读导航:上一节:2.1 下单、支付与履约 · 下一节:2.3 订单查询与用户可见性 。
常见错误与边界
- 关闭任务无条件更新状态。只要扫描到超时订单就写 closed,会把并发支付的订单一起关掉。
- 支付回调不校验当前状态。订单已经 closed 或 refunded 仍然写入 paid,造成重复入账或状态回退。
- 把“终态”实现成静默返回原状态。调用方以为成功,继续发货或退款,错误被推迟到更下游。
- 状态迁移散落在业务代码里。每个服务各写一套
if state == ...,新增状态时必然漏改。 - 用“先查后改”实现幂等。查询和更新之间有时间窗,并发下仍然会重复执行副作用。
- 把超时判断放进状态机函数。状态机一旦依赖时钟,测试要 sleep,复用也困难。
- 只记录当前状态,不记录迁移日志。出问题时无法回答“谁在什么时候把订单改成了 closed”。
- 认为退款是关闭的一种。退款是资金链路,关闭是订单生命周期,两者的事件、审计和补偿都不同。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。