《互联网产品的服务端架构·交易、增长与平台化》2.2 订单状态机与超时关闭

用一笔“已付款却被关闭”的订单说明订单状态机与超时关闭:created、paid、fulfilled、closed、refunded 五态和 pay、fulfill、close、refund 四事件如何约束迁移,超时任务为什么要幂等,已支付订单为什么不能被关闭。给出纯函数 Next 的 Go 实现与测试,并复盘关闭任务与支付回调并发导致订单被误关的故障。

一笔“已付款却被关闭”的订单

「拾集」的订单有一个 30 分钟的支付窗口。用户在 21:47 下单,21:49 完成支付,渠道回调在 21:49:58 到达。与此同时,超时关闭任务在 21:50:00 扫到了这批 created 状态的订单,把它们批量改成了 closed。

用户看到的结果是:钱扣了,订单显示“已关闭”,库存被释放,仓库没有收到发货单。客服后台也查不到支付记录,因为订单关闭后,支付回调写入的流水被一条“订单状态不允许”的校验拒绝了。

这不是一个低概率的巧合。订单量越大,关闭任务和支付回调落在同一秒的概率越高。下面是一组案例假设值:日均订单 120 万,支付窗口 30 分钟,关闭任务每分钟扫描一次;按经验,每天约有 15 到 30 笔订单处在“回调已到、关单未提交”的窗口里。如果关闭逻辑不检查状态,这些订单都会被误关。

问题的根源不是定时任务不准,而是状态迁移没有约束。关闭任务认为“超过 30 分钟就该关”,支付回调认为“钱到了就该变成 paid”,两个写入者都只检查自己关心的条件,没有人检查订单当前到底是什么状态。

五个状态与四条事件

订单状态机的价值,是把“谁能把订单改成什么”写成一张所有服务都遵守的表:

当前状态事件新状态说明
createdpaypaid支付成功,订单进入待履约
createdcloseclosed超时未支付,释放库存
paidfulfillfulfilled仓库发货或虚拟商品交付完成
paidrefundrefunded支付后取消,原路退款
fulfilledrefundrefunded售后发起退款
closed任意事件拒绝终态,不能再支付或发货
refunded任意事件拒绝终态,不能再关闭或履约

表里没有“created 直接 fulfill”“paid 直接 close”“fulfilled 直接 close”这些迁移。不是实现漏了,而是业务上不允许:没付钱不能发货,付了钱不能因为超时被关,已履约的订单只能走退款而不是直接关闭。

终态必须拒绝一切事件。这里的“拒绝”是指返回错误,而不是静默返回原状态。静默返回会让调用方以为迁移成功,继续执行发货或退款,问题被推到更下游才暴露。

超时关闭任务要做到幂等:同一笔订单被扫描两次、任务重跑、机器重启后补跑,结果都应该一样。幂等的实现方式不是“先查状态再更新”,而是“更新时带上状态条件”,让数据库的原子性来决定谁赢。

失败场景:关闭任务与支付回调并发

回到那笔 21:47 的订单。两个写入者的时间线大致如下:

  1. 21:49:58,支付渠道回调到达,服务读取订单,状态是 created,校验通过。
  2. 21:49:59,服务准备写 state = paid,但还没有提交。
  3. 21:50:00,关闭任务扫描到同一笔订单,读取状态仍是 created。
  4. 21:50:00,关闭任务执行 UPDATE orders SET state = 'closed' WHERE id = ?,提交成功。
  5. 21:49:59 的支付写入随后提交,或者因为版本冲突失败:
    • 如果支付写入没有状态条件,它会覆盖 closed,订单变成 paid,但库存已经被释放,履约单没有创建。
    • 如果支付写入有状态条件,它会失败,回调被记为异常,渠道已经扣款,用户却看到 closed。

两种结果都很糟。第一种是超卖和履约缺失,第二种是钱货两空。根因是关闭任务的更新没有带 AND state = 'created',它把“超时”当成了充分条件,而状态机要求“超时且仍未支付”才是。

修复有四条:

  1. 关闭任务改成条件更新:UPDATE orders SET state = 'closed' WHERE id = ? AND state = 'created'。影响行数为 0 就说明订单已经被别人推进,直接跳过。
  2. 支付回调同样带状态条件:WHERE id = ? AND state = 'created'。失败时不要吞掉,写入待处理表并告警,由补偿任务查询渠道真实结果。
  3. 引入版本号或状态迁移日志,所有状态变化只能通过状态机函数计算,禁止业务代码直接写 state = ...。
  4. 建立对账:每天比对渠道流水、订单状态和履约单,发现“渠道成功但订单 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”。
  • 认为退款是关闭的一种。退款是资金链路,关闭是订单生命周期,两者的事件、审计和补偿都不同。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查