零点前,限量 200 份
「拾光集市」的「春日手冲套装」在大促当天做限量活动,一共 200 份。运营在后台把活动库存设成 200,订单服务的扣减逻辑只有一句条件更新:可用库存大于零就减一,然后调支付。
活动从零点开始,两分钟内 200 份全部被下单。支付窗口是 30 分钟,其中有 37 笔因为余额不足、验证码超时和用户主动取消没有完成。订单服务只在支付成功时回调确认,没有任何失败回补逻辑。30 分钟过去,reserved 仍然是 200,available 是 0,但真正卖出去的只有 163 份。
第二天客服发现「明明还有 37 份没付款,为什么显示售罄」,于是手动把可用库存加了 37。这个动作没有同步减少预留库存,系统里出现了 200 份预留加 37 份可售、而实物只有 200 份的状态。后续订单再卖出 37 份,超卖就发生了。
这一节讨论的不是「怎么把一条 SQL 写对」,而是库存到底应该在什么时刻、以什么方式被占用,以及失败路径由谁负责收尾。
三种扣减时机
库存扣减的时机决定了风险落在哪一侧。常见做法有三种。
| 做法 | 超卖风险 | 占用风险 | 适用场景 |
|---|---|---|---|
| 下单立即扣减可用库存 | 低,条件更新能挡住并发 | 高,支付失败后容易永久占用 | 支付窗口很短、订单量可控 |
| 支付成功后才扣减 | 高,多笔支付可能同时通过 | 低,未支付不占库存 | 低并发、允许人工兜底 |
| 预扣 + 确认 + 超时释放 | 低,预扣时就占住 | 可控,由超时任务释放 | 大促、限量、秒杀 |
第一种做法把库存当成一个计数器,下单就减。它的问题不在扣减本身,而在失败路径:只要有一笔订单没有走到「支付成功」或「主动取消」,这个计数器就不会自己恢复。没有超时释放,占用就是永久的。
第二种做法把扣减推迟到支付成功。支付回调可能重复、可能乱序,也可能两笔支付同时到达;如果扣减只是简单的「减一」,两笔都能成功,库存就穿底了。要让它安全,仍然需要在支付回调里做条件更新,并且处理「支付成功但库存不足」的退款路径,复杂度并没有消失,只是换了一个位置。
第三种做法把一次扣减拆成两步:下单时预扣,把数量从可用挪到预留;支付成功时确认,预留数量真正出库;支付失败或超时时释放,把数量从预留挪回可用。多出来的状态不是为了好看,而是为了让失败路径有明确的归属。
可用库存与预留库存为什么要分开
如果只有一个 available 字段,系统无法回答三个问题:这 100 件里有多少已经被订单占住、哪些占用已经超时、一次支付成功应该减少的是「可售」还是「已售」。把库存拆成两个桶之后,这三个问题都有答案。
Available:还没有被任何有效订单占用的数量,新订单只能从这里扣。Reserved:已经被订单占住、但还没有完成支付或出库的数量。- 确认(Confirm):支付成功,预留数量离开系统,不再回到可用。
- 释放(Release):订单关闭或支付超时,预留数量回到可用。
在不考虑仓库盘差的前提下,可以维护一个不变量:Available + Reserved 等于当前可售的实物数量。下单预扣只在这两个桶之间搬运,不改变总和;确认让总和减少;释放也让总和不变。任何「直接改 Available」的运维动作都会破坏这个不变量,必须通过盘点或流水调整来完成。
两个桶还带来一个可观测性收益:Reserved 的年龄分布可以直接暴露支付链路的问题。如果大量预留超过 30 分钟仍未确认,说明支付回调或超时任务出了问题,而不是库存本身不够。
需要强调,下面这些函数只描述单次状态转换,不负责并发控制。真正的扣减要么在数据库里用 UPDATE ... WHERE available >= n 的条件更新,要么对 SKU 行加锁,要么用版本号做乐观锁。纯函数保证的是「给定一个合法状态和数量,转换结果正确」,不是「多个请求同时进来不会超卖」。
失败场景:支付失败没有回补
把「春日手冲套装」的失败路径拆开看,问题出在三个地方。
第一,预扣成功之后,订单状态只记录了「已下单」,没有记录「已预扣多少」。支付失败时,系统不知道该释放多少,也没有字段能证明这笔预扣存在。
第二,超时任务只扫描「待支付」订单,但只做关单,不调用释放。关单和释放被当成两件事,结果关单成功了,库存还挂在预留里。
第三,客服看到售罄后手动增加可用库存,绕过了预留桶。这个动作让 Available + Reserved 大于实物数量,等于凭空创造了 37 件库存。
正确的收尾方式是把订单状态和库存转换绑在一起:下单成功即进入 reserved,支付成功进入 paid 并确认,支付失败、用户取消或超时进入 closed 并释放。每次转换都要带幂等键(通常是订单 ID 加动作),确保重复回调不会重复确认,也不会重复释放。超时任务按「预扣到期时间」扫描,而不是按创建时间扫全表。
对账时还要比较 Reserved 和「所有处于待支付状态的订单预扣量之和」。两者不相等,说明有订单漏了释放或漏了确认,应该报警,而不是让人手动改库存。
代码:Reserve、Confirm、Release
下面的 internal/catalog/stock.go 把三种转换写成纯函数。每个函数先校验库存状态和数量,再检查对应桶是否足够;失败时返回原值和错误,调用方不会拿到一个「改了一半」的库存。
// file: internal/catalog/stock.go
// tier: L1
// verify: go test ./internal/catalog/
package catalog
import (
"errors"
"fmt"
)
// Stock splits one SKU's inventory into two buckets. Available can still be
// promised to a new order; Reserved already belongs to an order that is
// waiting for payment or fulfillment. Keeping them apart is what makes the
// reserve-confirm-release cycle auditable.
type Stock struct {
SKU string
Available int64
Reserved int64
}
var (
ErrInvalidStock = errors.New("catalog: invalid stock")
ErrInvalidQuantity = errors.New("catalog: quantity must be positive")
ErrInsufficientStock = errors.New("catalog: insufficient stock")
)
func validateStock(s Stock) error {
if s.SKU == "" {
return fmt.Errorf("%w: empty sku", ErrInvalidStock)
}
if s.Available < 0 || s.Reserved < 0 {
return fmt.Errorf("%w: negative bucket", ErrInvalidStock)
}
return nil
}
// Reserve moves n units from Available to Reserved. It is the first half of
// the two-phase write: the order now owns the units even though they have not
// been paid for. A failed reservation returns the input unchanged.
func Reserve(s Stock, n int64) (Stock, error) {
if err := validateStock(s); err != nil {
return s, err
}
if n <= 0 {
return s, ErrInvalidQuantity
}
if s.Available < n {
return s, fmt.Errorf("%w: sku=%s available=%d want=%d", ErrInsufficientStock, s.SKU, s.Available, n)
}
if s.Reserved > maxInt64-n {
return s, fmt.Errorf("%w: reserved bucket overflow", ErrInvalidStock)
}
s.Available -= n
s.Reserved += n
return s, nil
}
// Confirm is the second half: payment succeeded, so the reserved units leave
// the system permanently. Only Reserved moves; Available was already reduced
// at reservation time.
func Confirm(s Stock, n int64) (Stock, error) {
if err := validateStock(s); err != nil {
return s, err
}
if n <= 0 {
return s, ErrInvalidQuantity
}
if s.Reserved < n {
return s, fmt.Errorf("%w: sku=%s reserved=%d want=%d", ErrInsufficientStock, s.SKU, s.Reserved, n)
}
s.Reserved -= n
return s, nil
}
// Release returns reserved units to Available when an order is cancelled or
// its payment times out. Without this path a failed payment would occupy the
// units forever.
func Release(s Stock, n int64) (Stock, error) {
if err := validateStock(s); err != nil {
return s, err
}
if n <= 0 {
return s, ErrInvalidQuantity
}
if s.Reserved < n {
return s, fmt.Errorf("%w: sku=%s reserved=%d want=%d", ErrInsufficientStock, s.SKU, s.Reserved, n)
}
if s.Available > maxInt64-n {
return s, fmt.Errorf("%w: available bucket overflow", ErrInvalidStock)
}
s.Reserved -= n
s.Available += n
return s, nil
}
// maxInt64 is written out instead of pulled from a float helper because the
// stock path must stay exact: every unit is an integer.
const maxInt64 = int64(^uint64(0) >> 1)
这段代码有几个刻意保留的边界。
Reserve只从Available扣,同时增加Reserved;Confirm只减Reserved;Release减Reserved并加回Available。三条路径各自只动该动的桶。- 数量必须大于零。零和负数在业务上没有意义,静默接受只会把错误推迟到对账阶段。
- 桶不足时返回
ErrInsufficientStock,并且返回的库存与入参完全一致。调用方可以安全地重试或向上返回,不会污染内存中的状态。 int64的加法做了上界检查。库存数量通常离MaxInt64很远,但溢出检查的成本很低,而溢出后的负数会被误判成「库存不足」或「库存异常」,排查代价很高。- 错误用
errors.Is可识别,调用方不需要解析错误字符串来判断是「数量非法」还是「库存不足」。
测试:生命周期与失败不改入参
测试覆盖四类行为:预扣搬运、确认与释放只动预留桶、完整订单生命周期、失败时入参不变。
// file: internal/catalog/stock_test.go
// tier: L1
// verify: go test ./internal/catalog/
package catalog
import (
"errors"
"testing"
)
func TestReserveMovesAvailableToReserved(t *testing.T) {
in := Stock{SKU: "sku-1001", Available: 10, Reserved: 2}
got, err := Reserve(in, 3)
if err != nil {
t.Fatal(err)
}
if got.Available != 7 || got.Reserved != 5 {
t.Fatalf("stock = %+v, want available=7 reserved=5", got)
}
if in.Available != 10 || in.Reserved != 2 {
t.Fatalf("input mutated: %+v", in)
}
}
func TestReserveInsufficientLeavesInputUntouched(t *testing.T) {
in := Stock{SKU: "sku-1001", Available: 2, Reserved: 1}
got, err := Reserve(in, 3)
if !errors.Is(err, ErrInsufficientStock) {
t.Fatalf("err = %v, want ErrInsufficientStock", err)
}
if got != in {
t.Fatalf("stock changed on failure: got %+v want %+v", got, in)
}
}
func TestConfirmAndReleaseMoveOnlyReserved(t *testing.T) {
s := Stock{SKU: "sku-1001", Available: 7, Reserved: 5}
afterConfirm, err := Confirm(s, 2)
if err != nil {
t.Fatal(err)
}
if afterConfirm.Available != 7 || afterConfirm.Reserved != 3 {
t.Fatalf("confirm = %+v, want available=7 reserved=3", afterConfirm)
}
afterRelease, err := Release(afterConfirm, 3)
if err != nil {
t.Fatal(err)
}
if afterRelease.Available != 10 || afterRelease.Reserved != 0 {
t.Fatalf("release = %+v, want available=10 reserved=0", afterRelease)
}
}
func TestFullOrderLifecycle(t *testing.T) {
s := Stock{SKU: "sku-1001", Available: 10}
steps := []struct {
name string
fn func(Stock) (Stock, error)
want Stock
}{
{"reserve-3", func(x Stock) (Stock, error) { return Reserve(x, 3) }, Stock{SKU: "sku-1001", Available: 7, Reserved: 3}},
{"confirm-2", func(x Stock) (Stock, error) { return Confirm(x, 2) }, Stock{SKU: "sku-1001", Available: 7, Reserved: 1}},
{"release-1", func(x Stock) (Stock, error) { return Release(x, 1) }, Stock{SKU: "sku-1001", Available: 8, Reserved: 0}},
}
for _, step := range steps {
next, err := step.fn(s)
if err != nil {
t.Fatalf("%s: %v", step.name, err)
}
if next != step.want {
t.Fatalf("%s: got %+v want %+v", step.name, next, step.want)
}
s = next
}
}
func TestStockRejectsInvalidInput(t *testing.T) {
valid := Stock{SKU: "sku-1001", Available: 5, Reserved: 1}
cases := []struct {
name string
fn func() (Stock, error)
want error
}{
{"empty-sku", func() (Stock, error) { return Reserve(Stock{Available: 1}, 1) }, ErrInvalidStock},
{"negative-available", func() (Stock, error) { return Reserve(Stock{SKU: "s", Available: -1}, 1) }, ErrInvalidStock},
{"negative-reserved", func() (Stock, error) { return Confirm(Stock{SKU: "s", Reserved: -1}, 1) }, ErrInvalidStock},
{"zero-quantity", func() (Stock, error) { return Reserve(valid, 0) }, ErrInvalidQuantity},
{"negative-quantity", func() (Stock, error) { return Release(valid, -2) }, ErrInvalidQuantity},
{"confirm-too-much", func() (Stock, error) { return Confirm(valid, 2) }, ErrInsufficientStock},
{"release-too-much", func() (Stock, error) { return Release(valid, 2) }, ErrInsufficientStock},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if _, err := c.fn(); !errors.Is(err, c.want) {
t.Fatalf("err = %v, want %v", err, c.want)
}
})
}
}
func TestStockRejectsBucketOverflow(t *testing.T) {
full := Stock{SKU: "sku-1001", Available: maxInt64, Reserved: 1}
if _, err := Reserve(Stock{SKU: "sku-1001", Available: 1, Reserved: maxInt64}, 1); !errors.Is(err, ErrInvalidStock) {
t.Fatalf("reserve overflow err = %v, want ErrInvalidStock", err)
}
if _, err := Release(full, 1); !errors.Is(err, ErrInvalidStock) {
t.Fatalf("release overflow err = %v, want ErrInvalidStock", err)
}
}
TestFullOrderLifecycle 是最值得保留的用例。它从 10 件可用库存开始,依次执行预扣 3、确认 2、释放 1,最后得到可用 8、预留 0。这个序列把「卖出 2 件、退回 1 件」的账算清楚了:如果确认时错误地又减了一次可用,最终可用会变成 7;如果释放时忘记加回可用,最终会变成 7。
TestReserveInsufficientLeavesInputUntouched 验证失败路径。Go 的值传递让这件事看起来理所当然,但一旦库存结构里加入 map 或指针字段,失败路径就可能悄悄修改入参;用测试把这个约定固定下来,比在注释里写「不会修改」可靠。
TestStockRejectsBucketOverflow 覆盖两个方向:预留桶和可用桶都可能在加法时溢出。正常业务几乎不会触发,但这类测试的价值在于证明函数对极端输入有确定行为,而不是返回一个看似正常的负数。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/catalog/ -count=1
一次本机运行的真实输出如下,耗时随机器变化:
ok webapp/internal/catalog 0.228s
ok 表示包内所有测试通过。这里的测试只验证单次状态转换;并发场景需要数据库条件更新或行锁来保证,测试本身不能替代压测和故障演练。
小结
库存扣减的核心不是「减一」这条语句,而是「谁在什么时刻占住库存、失败后谁负责还回来」。下单即扣简单,但失败路径容易留下永久占用;支付后扣看起来不占库存,却把超卖风险推给了并发回调;预扣、确认、释放三段式把风险拆开,用预留桶和超时任务换取可审计的失败处理。
「春日手冲套装」的 37 笔失败支付说明,占用问题不会自己消失,只会在下一次盘点或超卖时暴露。把释放写进订单状态机、给每次转换加幂等键、用 Reserved 与待支付订单对账,才是完整的闭环。
阅读导航:上一节:1.1 商品模型与 SKU · 下一节:1.3 库存对账与回补 。
常见错误与边界
- 只用一个可用库存字段,分不清「已售」和「已被订单占住」。
- 先扣库存再支付,支付失败或超时后没有释放任务。
- 关单和释放分成两个独立任务,关单成功但库存没有回补。
- 释放没有幂等键,回调重试把预留桶减成负数或重复加回可用。
- 确认和释放并发执行,同一笔订单既被确认又被释放。
- 客服绕过预留桶直接改可用库存,破坏
Available + Reserved不变量。 - 认为纯函数本身能防并发超卖,不在数据库层做条件更新或行锁。
- 超时任务按创建时间全表扫描,既慢又容易漏掉应释放的订单。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。