《互联网产品的服务端架构·交易、增长与平台化》1.2 库存扣减与超卖

以「拾光集市」限量 200 份手冲套装在大促中支付失败、库存被永久占用的事故为线索,对比下单即扣、支付后扣与预扣确认三种时机的超卖与占用风险,说明可用库存与预留库存为什么要分开、超时释放如何收尾,并给出可运行的 Go 扣减、确认、释放函数与完整生命周期测试。

零点前,限量 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 不变量。
  • 认为纯函数本身能防并发超卖,不在数据库层做条件更新或行锁。
  • 超时任务按创建时间全表扫描,既慢又容易漏掉应释放的订单。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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