一次重复点击引发的库存超扣
运营小周在后台配置了一个限量活动:五千份库存,十点开抢。审批通过后,她点了“执行”,页面转圈,没有返回。她以为没点上,又点了一次。两次请求都成功,活动库存被扣掉一万份,实际可售库存变成负数。开抢后第一批用户下单成功,第二批全部失败,客诉在十分钟内涌进来。
按案例假设值,这个活动准备了五千份库存,超扣一倍意味着至少两千多名用户在下单后等待补货。技术团队先怀疑库存服务,查了半天发现库存扣减本身没有问题:问题在于“执行”这个动作被调用了两次,而每次调用都合法地扣了一次库存。
复盘时发现三个漏洞。第一,执行接口没有幂等键,同一个操作 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 去重,可能把两个本来不同的操作误判为同一个。
- 前端按钮防抖当成幂等。脚本重试、网关重放和超时重试都不经过按钮,服务端必须有独立防线。
- 合法动作不校验操作人。执行接口只检查状态,不检查当前用户是否有执行权限,高权限账号可以绕过审批。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。