《互联网产品的服务端架构·交易、增长与平台化》12.2 运营操作的状态机

运营在后台重复点了两次“执行”,活动库存被扣了两次;审批流、状态机和幂等缺少任何一层都会出问题。文章说明草稿、待审、通过、驳回、已执行的迁移规则,终态为什么必须拒绝所有动作,以及调用方如何用幂等键和条件更新兜住重复请求,并给出 Op 与 Next 的 Go 实现和真实测试输出。

一次重复点击引发的库存超扣

运营小周在后台配置了一个限量活动:五千份库存,十点开抢。审批通过后,她点了“执行”,页面转圈,没有返回。她以为没点上,又点了一次。两次请求都成功,活动库存被扣掉一万份,实际可售库存变成负数。开抢后第一批用户下单成功,第二批全部失败,客诉在十分钟内涌进来。

按案例假设值,这个活动准备了五千份库存,超扣一倍意味着至少两千多名用户在下单后等待补货。技术团队先怀疑库存服务,查了半天发现库存扣减本身没有问题:问题在于“执行”这个动作被调用了两次,而每次调用都合法地扣了一次库存。

复盘时发现三个漏洞。第一,执行接口没有幂等键,同一个操作 ID 的两次请求被当成两次独立执行;第二,状态更新是“先读后写”,两个并发请求都读到 approved,都判断可以执行,然后都写入 executed;第三,前端按钮没有防重复提交,第一次请求超时后按钮恢复可点,用户自然会再点一次。

这三个漏洞分别对应幂等、并发控制和交互体验。只修其中任何一个都不够:前端防抖挡不住脚本重试,幂等键挡不住“两个不同操作 ID 指向同一活动”,状态机则负责回答“当前这个动作到底合不合法”。

审批流、状态机与幂等是三件事

很多团队把审批、状态和幂等混成一个“流程引擎”,结果每个问题都只解决了一半。它们解决的是不同层面的问题,必须分开设计,再组合使用。

机制解决的问题不负责什么缺失后的典型表现
审批流谁有权批准、需要几级批准不保证执行只发生一次没审批就上线高价活动
状态机当前状态允许哪些动作不保证并发安全驳回后仍能执行
幂等键同一请求重复到达只生效一次不判断业务上是否合法重试导致库存扣两次
条件更新并发请求只有一个能改成功不负责语义校验两个请求都读到旧状态
审计谁在何时做了什么不阻止错误动作出事无法还原

审批流回答“该不该做”,状态机回答“现在能不能做”,幂等回答“重复请求算不算一次”。三者都通过,才应该产生副作用。状态机的职责应该收敛成一个纯函数:输入当前状态和动作,输出下一个状态或错误,不读写数据库,也不扣库存。这样它容易测试,也容易在服务端和后台页面之间共享同一套规则。

判断一个运营操作是否值得引入状态机,可以问四个问题:

  • 同一个动作在不同阶段是否含义不同?例如“执行”只应该发生在审批通过之后。
  • 是否存在不可逆的副作用?扣库存、发券、改价、下线商品都属于这一类。
  • 是否需要多角色参与?提交、审批、执行往往由不同岗位完成。
  • 出事后是否需要还原路径?状态迁移加上审计才能回答“谁在什么时候把它推到了这一步”。

纯函数还有一个好处:它迫使调用方显式处理“状态已经变了”的情况。如果 Next 返回错误,调用方不能假装成功;如果返回新状态,调用方必须用条件更新把它写回去,并且检查影响行数。规则和执行被拆开,重复点击和并发请求才有地方被挡住。

失败场景:驳回后还能执行

另一个团队的活动操作有审批,但执行接口只检查了“操作不存在或已经执行过”,没有检查当前状态。结果一个被财务驳回的活动,运营用高权限账号直接调用执行接口,活动照样上线。驳回意见还挂在后台页面上,但没有任何代码读取它。

这个漏洞的根因是把“状态”当成展示字段,而不是约束。页面根据状态显示不同按钮,接口却只认操作 ID。只要有人绕过页面,状态就失效了。状态机必须放在服务端,并且是执行路径上的强制检查,不能只用于渲染。

并发场景更隐蔽。两个请求同时到达,都执行“读取操作 → 判断状态为 approved → 扣库存 → 写入 executed”。数据库默认的读已提交隔离级别下,两个事务都能读到 approved;如果没有行锁或条件更新,两个都会成功。加锁能解决,但锁的粒度、顺序和超时又会引入新问题。更简单的做法是条件更新:UPDATE ops SET state='executed' WHERE id=? AND state='approved',影响行数为 1 才继续扣库存,为 0 就说明别人已经改过。

还有一种失败是“先扣库存,后写状态”。库存扣减成功,写状态时数据库超时,调用方重试,库存又扣一次。副作用和状态更新必须尽量放在同一个事务里;如果库存服务是独立系统,就要用幂等键或补偿事务,不能假设网络调用一定成功。

幂等键的粒度也容易出错。如果拿活动 ID 做幂等键,那么“上线”和“下线”两个不同操作会被当成同一个,第二次直接被拒。幂等键应该绑定一次具体的操作意图,通常是操作 ID 加动作,或者由一个独立的请求 ID 标识。重复请求携带同一个键,不同意图必须使用不同的键。

状态迁移本身也要审计。只记录最终状态,事后无法知道中间是否被驳回、谁批准、执行了几次。每次迁移都应该写入一条记录:操作 ID、原状态、新状态、动作、操作者、时间、原因。审计和状态更新最好在同一个事务里,否则状态改了、审计丢了,仍然还原不了过程。

用 Op 与 Next 把迁移规则集中

Op 只保存操作的身份和当前状态。Next 是一个纯函数:输入状态和动作,返回下一个状态或错误。它不碰数据库,也不执行副作用,因此可以在测试里穷举所有组合。

// file: internal/admin/operation.go
// tier: L1
// verify: go test ./internal/admin/

package admin

import (
	"errors"
	"fmt"
)

var (
	// ErrUnknownState 表示状态不在运营操作状态机的定义里。
	ErrUnknownState = errors.New("admin: unknown operation state")
	// ErrInvalidAction 表示当前状态不接受这个动作。
	ErrInvalidAction = errors.New("admin: invalid action")
	// ErrTerminalState 表示操作已经结束,不能再迁移。
	ErrTerminalState = errors.New("admin: terminal state")
)

// Op 是一条运营操作。ID 用于幂等和审计,Type 区分改价、发券、下架等活动,
// State 是它当前所处的状态。
type Op struct {
	ID    string
	Type  string
	State string
}

// Next 计算一次状态迁移的结果,但不修改 Op 本身。调用方应先持久化迁移
// 结果并写入审计,再执行副作用。终态 rejected 与 executed 拒绝任何动作;
// 未定义的状态返回 ErrUnknownState,而不是把它当成草稿。
func Next(state, action string) (string, error) {
	switch state {
	case "draft":
		if action == "submit" {
			return "pending", nil
		}
	case "pending":
		switch action {
		case "approve":
			return "approved", nil
		case "reject":
			return "rejected", nil
		}
	case "approved":
		if action == "execute" {
			return "executed", nil
		}
	case "rejected", "executed":
		return "", fmt.Errorf("%w: %q", ErrTerminalState, state)
	default:
		return "", fmt.Errorf("%w: %q", ErrUnknownState, state)
	}
	return "", fmt.Errorf("%w: %q on %q", ErrInvalidAction, action, state)
}

实现里有几个关键点:

  • 迁移规则集中在一个 switch 里:draft 只能 submit,pending 只能 approve 或 reject,approved 只能 execute。规则读起来就是业务文档,不需要在多个接口里拼凑。
  • rejected 和 executed 是终态,任何动作都返回 ErrTerminalState。终态判断放在动作判断之前,避免“驳回后执行”这种绕过。
  • 未知状态返回 ErrUnknownState,而不是默认当成 draft。状态字段可能来自旧数据、手工修库或拼写错误,静默降级会让非法数据获得合法迁移。
  • 合法状态加非法动作返回 ErrInvalidAction。调用方可以用 errors.Is 区分“状态不认识”和“动作不允许”,前者通常要告警,后者通常是用户操作错误。
  • 函数不修改 Op,也不返回新 Op。调用方拿到新状态后,用条件更新写入,并检查影响行数;这一步才是并发安全的边界。
  • 幂等键不放在 Next 里。Next 只回答状态迁移;幂等键应该由执行入口检查,通常以操作 ID 为键,保证同一操作的重复请求不会再次进入副作用。

生产实现还应该在状态更新和副作用之间定义清楚顺序。推荐先做条件更新,把 approved 改成 executing 或 executed,成功后再扣库存;扣库存失败则进入补偿或人工处理。这样重复请求在第一步就会被挡住,不会走到副作用。

不要把迁移规则分散到数据库触发器和应用代码两处。触发器难以测试、难以版本化,出问题时也不容易在本地复现。规则集中在一个纯函数里,数据库只负责条件更新和唯一约束,职责更清楚。

状态机还需要可观测性。每次返回 ErrInvalidAction 时,应该记录操作 ID、当前状态和动作,方便判断是用户点错了,还是页面展示了不该展示的按钮。ErrUnknownState 则通常意味着数据被手工修改或旧版本残留,应该触发告警而不是静默吞掉。

测试:合法迁移、终态与未知状态

测试要覆盖四类问题:四条合法迁移是否正确、终态是否拒绝所有动作、未知状态是否报错、合法状态下的非法动作是否报错。

// file: internal/admin/operation_test.go
// tier: L1
// verify: go test ./internal/admin/

package admin

import (
	"errors"
	"testing"
)

func TestNextTransitions(t *testing.T) {
	cases := []struct {
		state  string
		action string
		want   string
	}{
		{"draft", "submit", "pending"},
		{"pending", "approve", "approved"},
		{"pending", "reject", "rejected"},
		{"approved", "execute", "executed"},
	}
	for _, c := range cases {
		got, err := Next(c.state, c.action)
		if err != nil {
			t.Fatalf("Next(%q, %q) = %v, want nil", c.state, c.action, err)
		}
		if got != c.want {
			t.Fatalf("Next(%q, %q) = %q, want %q", c.state, c.action, got, c.want)
		}
	}
}

func TestNextTerminal(t *testing.T) {
	for _, state := range []string{"rejected", "executed"} {
		for _, action := range []string{"submit", "approve", "reject", "execute"} {
			if _, err := Next(state, action); !errors.Is(err, ErrTerminalState) {
				t.Fatalf("Next(%q, %q) = %v, want ErrTerminalState", state, action, err)
			}
		}
	}
}

func TestNextUnknownState(t *testing.T) {
	for _, state := range []string{"", "archived", "Draft"} {
		if _, err := Next(state, "submit"); !errors.Is(err, ErrUnknownState) {
			t.Fatalf("Next(%q, submit) = %v, want ErrUnknownState", state, err)
		}
	}
}

func TestNextMatrix(t *testing.T) {
	states := []string{"draft", "pending", "approved", "rejected", "executed", "archived"}
	actions := []string{"submit", "approve", "reject", "execute", "unknown"}
	legal := map[string]string{
		"draft/submit":     "pending",
		"pending/approve":  "approved",
		"pending/reject":   "rejected",
		"approved/execute": "executed",
	}
	for _, state := range states {
		for _, action := range actions {
			key := state + "/" + action
			got, err := Next(state, action)
			want, ok := legal[key]
			if ok {
				if err != nil || got != want {
					t.Fatalf("%s = %q, %v; want %q, nil", key, got, err, want)
				}
				continue
			}
			if err == nil {
				t.Fatalf("%s = %q, nil; want error", key, got)
			}
		}
	}
}

func TestNextInvalidAction(t *testing.T) {
	cases := []struct {
		state  string
		action string
	}{
		{"draft", "approve"},
		{"draft", "execute"},
		{"pending", "submit"},
		{"pending", "execute"},
		{"approved", "submit"},
		{"approved", "reject"},
		{"draft", ""},
	}
	for _, c := range cases {
		if _, err := Next(c.state, c.action); !errors.Is(err, ErrInvalidAction) {
			t.Fatalf("Next(%q, %q) = %v, want ErrInvalidAction", c.state, c.action, err)
		}
	}
}

TestNextTransitions 逐条验证四个合法迁移的目标状态。TestNextTerminal 对 rejected 和 executed 分别尝试 submit、approve、reject、execute,确认全部返回 ErrTerminalState。TestNextUnknownState 覆盖空字符串、archived 和大小写错误的 Draft,确认不会被当成草稿。TestNextInvalidAction 覆盖草稿直接批准、待审重复提交、已通过重复驳回等七种组合。

TestNextMatrix 用六个状态和五个动作做全组合,共三十条路径,只允许其中四条成功。它不枚举具体错误类型,而是断言“除了合法迁移,其余组合都必须返回错误”。这种矩阵测试能抓住新增状态或动作后遗漏的分支,比逐条手写用例更难漏。

这些用例固定了状态机的边界:终态优先于动作、未知状态不降级、非法动作不静默通过。以后如果有人为了“方便”给 rejected 加上重新提交,测试会立刻失败,提醒他这改变了业务语义。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/admin/ -run 'TestNext' -count=1 -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestNextTransitions
--- PASS: TestNextTransitions (0.00s)
=== RUN   TestNextTerminal
--- PASS: TestNextTerminal (0.00s)
=== RUN   TestNextUnknownState
--- PASS: TestNextUnknownState (0.00s)
=== RUN   TestNextInvalidAction
--- PASS: TestNextInvalidAction (0.00s)
PASS
ok  	webapp/internal/admin	0.246s

PASS 表示 4 个状态机用例全部通过。它们保证的是迁移规则本身;条件更新、幂等键、库存补偿和审批权限属于调用方与基础设施,需要另外的集成测试和故障演练。

阅读导航:上一节:12.1 后台的权限与审计 · 下一节:12.3 报表口径与数据一致性 。

小结

审批流、状态机和幂等是三件事,分别回答“该不该做”“现在能不能做”“重复请求算不算一次”。Op 保存操作身份与状态,Next 用纯函数集中迁移规则,终态拒绝所有动作,未知状态明确报错。调用方还必须用条件更新和幂等键把并发与重试挡在副作用之前。

常见错误与边界

  • 只做审批不做状态机。审批通过后,执行接口仍然可以被任意调用,驳回意见形同虚设。
  • 只做状态机不做条件更新。两个并发请求都读到 approved,都判断可以执行,状态机本身挡不住竞态。
  • 把终态当成普通状态。给 rejected 或 executed 增加动作,会让已经结束的操作重新产生副作用。
  • 未知状态默认当成 draft。旧数据、手工修库或拼写错误会获得合法迁移路径,问题被静默放大。
  • 先扣库存后写状态。写状态失败后重试会重复扣减,副作用必须和状态更新放在同一事务或可靠补偿里。
  • 用操作 ID 之外的东西做幂等键。按活动 ID 或用户 ID 去重,可能把两个本来不同的操作误判为同一个。
  • 前端按钮防抖当成幂等。脚本重试、网关重放和超时重试都不经过按钮,服务端必须有独立防线。
  • 合法动作不校验操作人。执行接口只检查状态,不检查当前用户是否有执行权限,高权限账号可以绕过审批。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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