《互联网产品的服务端架构·交易、增长与平台化》1.3 库存对账与回补

从「拾光集市」大促后库存快照与流水对不上、对账任务重跑导致同一笔差异被回补两次的事故出发,梳理库存漂移的常见来源,说明为什么对账必须以流水而不是快照为准、回补为什么要用业务唯一键保证幂等,并附可运行的 Go 差异计算与容差判定代码,以及覆盖双向漂移的表驱动测试。

大促后的第一次对账

大促结束第三天,「拾光集市」的财务和仓储一起做库存对账。红色 250g 的「春日手冲套装」在账面上是这样:库存流水记录入库 500 件、出库 380 件,按流水算应该有 120 件;库存快照显示在手 90 件、预留 20 件,合计 110 件。差异是负 10 件。

这 10 件不是一个小数目。它可能意味着有 10 件已经出库但没有记流水,也可能意味着某次扣减绕过了账本,还可能只是快照更新晚于流水。更要命的是,如果对账任务直接把快照改成 120,而不把调整写进流水,下一次对账仍然会对不上,因为账本根本没有记录这次修改。

这一节讨论三个问题:库存漂移通常从哪里来、为什么必须以流水为准、以及回补动作怎样才能在任务重跑时保持幂等。

库存漂移从哪来

库存系统至少有两条写入路径:一条是业务操作(预扣、确认、释放、盘点),另一条是记录这些操作的流水。两条路径不在同一个事务里时,就会出现「一边成功、一边失败」的中间态。

来源典型现象正确处理
扣减成功、出库流水未写快照偏少,差异为负补写流水,再按流水修快照
流水已写、快照更新失败快照偏多,差异为正重放流水,幂等更新快照
出库消息重复消费流水出库多记,差异为正消费端按消息 ID 去重
释放重复执行预留被减两次,可用虚高用订单 ID 加动作做幂等键
对账回补重复执行同一差异被补两次,正负来回震荡补偿单用业务唯一键
线下盘点未入账实物与系统长期不一致盘点单作为一类流水入账

这张表里最容易被低估的是最后一行。很多团队把盘点当成「直接改数据库」,盘点结果没有进入流水,于是系统永远无法解释自己为什么变了。只要调整也走流水,下一次对账就能把「盘点」当成一次合法的入库或出库。

对账以流水为准,不以快照为准

快照是可变状态,流水是追加记录。可变状态可以被覆盖、被并发更新、被人工修改;追加记录保留了「谁在什么时间因为什么改变了多少」。对账的意义不是让两个数字相等,而是找出它们为什么不等,所以必须以可解释的一方为准。

计算方式可以写成一个恒等式:

Expected = LedgerIn - LedgerOut
Diff     = OnHand + Reserved - Expected

Expected 是流水认为应该存在的数量;OnHand + Reserved 是运营表当前认为存在的数量。Diff 为正,说明运营表比流水多;Diff 为负,说明运营表比流水少。正负号不是「好」和「坏」,而是排查方向:正值优先查重复入库、重复释放和重复消费,负值优先查漏记出库、扣减未记账和未入账的盘点。

对账还要对齐时间水位。如果流水已经写到 12:00,而快照只同步到 11:59,这一分钟的差异是正常的在途,不应该被当成漂移。常见做法是给流水一个单调递增的序列号或提交时间,对账只比较「快照版本对应的流水水位」之前的数据;没有水位,就只能靠容差掩盖,而容差会把真正的问题一起放过。

容差要慎用。库存单位是整数件,正常对账的容差应该是 0。只有在异步链路有明确在途窗口、且能证明差异会自行收敛时,才允许一个很小的容差;金额对账则不应该有容差。容差是解释在途的工具,不是让报警安静的工具。

回补必须幂等

发现差异之后,回补通常分两步:先在流水里写一条调整记录,再更新快照。问题在于,对账任务可能因为超时被重跑,也可能有两个 worker 同时处理同一个分片。如果回补没有幂等键,同一笔差异会被补两次。

假设红色 250g 的差异是负 10。第一次回补写入「盘盈 10」并把快照从 110 改成 120,账实相符。任务超时后重跑,如果它拿的还是旧快照或旧的差异结果,就会再写一条「盘盈 10」,快照变成 130,差异变成正 10。下一次对账又要求「盘亏 10」,库存开始来回震荡。更危险的是,如果这期间有订单预扣,虚高的可用库存会直接导致超卖。

幂等键不能是任务运行 ID。每次重跑都会生成新的运行 ID,用它做唯一键等于没有约束。幂等键应该来自业务事实,例如 (sku, 对账周期, 流水水位),或者由对账结果生成的稳定摘要。数据库对幂等键建唯一索引,插入补偿单时如果冲突,就直接返回「已处理」,不再执行第二次调整。

更稳妥的顺序是「先插补偿单,再改库存」:补偿单状态从 pending 到 applied,改库存时带条件更新(例如 WHERE version = ?),成功后回写状态。这样即使进程在两步之间崩溃,重启后也能根据补偿单继续,而不是重新算一遍差异再补一次。

代码:Diff 与 Broken

下面的 internal/catalog/reconcile.go 只做两件事:计算快照与流水的差异,以及判断差异是否超过容差。它不直接改库存,也不生成补偿单;把「计算」和「执行」分开,才能让对账结果可重放、可审计。

// file: internal/catalog/reconcile.go
// tier: L1
// verify: go test ./internal/catalog/

package catalog

import (
	"errors"
	"fmt"
)

// Snapshot is one SKU's inventory view at a point in time. LedgerIn and
// LedgerOut come from the append-only inventory journal; OnHand and Reserved
// come from the operational stock table. The journal is the source of truth:
// a snapshot that disagrees with it is drift, not a new balance.
type Snapshot struct {
	SKU       string
	LedgerIn  int64
	LedgerOut int64
	OnHand    int64
	Reserved  int64
}

var (
	ErrInvalidSnapshot  = errors.New("catalog: invalid snapshot")
	ErrInvalidTolerance = errors.New("catalog: tolerance must not be negative")
)

// Diff returns OnHand + Reserved - (LedgerIn - LedgerOut). A positive result
// means the stock table holds more units than the journal explains; a
// negative result means units are missing. Zero means the two views agree.
func Diff(s Snapshot) (int64, error) {
	if err := validateSnapshot(s); err != nil {
		return 0, err
	}
	expected := s.LedgerIn - s.LedgerOut
	// OnHand and Reserved are individually valid but their sum can still
	// overflow. Reject that instead of returning a wrapped number that would
	// look like a huge reconciliation difference.
	if s.OnHand > maxInt64-s.Reserved {
		return 0, fmt.Errorf("%w: stock sum overflow", ErrInvalidSnapshot)
	}
	return s.OnHand + s.Reserved - expected, nil
}

// Broken reports whether the absolute difference exceeds tolerance. The
// comparison avoids an abs() call so that no intermediate value can overflow.
func Broken(s Snapshot, tolerance int64) (bool, error) {
	if tolerance < 0 {
		return false, ErrInvalidTolerance
	}
	d, err := Diff(s)
	if err != nil {
		return false, err
	}
	return d > tolerance || d < -tolerance, nil
}

func validateSnapshot(s Snapshot) error {
	if s.SKU == "" {
		return fmt.Errorf("%w: empty sku", ErrInvalidSnapshot)
	}
	if s.LedgerIn < 0 || s.LedgerOut < 0 || s.OnHand < 0 || s.Reserved < 0 {
		return fmt.Errorf("%w: negative quantity", ErrInvalidSnapshot)
	}
	if s.LedgerOut > s.LedgerIn {
		return fmt.Errorf("%w: ledger out exceeds in", ErrInvalidSnapshot)
	}
	return nil
}

几个实现细节值得说明。

  • Diff 先校验快照:SKU 非空、所有数量非负、LedgerOut 不能大于 LedgerIn。流水出库大于入库,说明账本本身有问题,应该拒绝计算,而不是返回一个负数让下游猜。
  • OnHand + Reserved 做了溢出检查。两个非负 int64 相加仍可能溢出;溢出后的负数会被误读成「库存严重不足」,触发错误的回补方向。
  • Broken 用 d > tolerance || d < -tolerance 判断,而不是先取绝对值。abs(MinInt64) 会溢出,而双向比较没有这个问题。
  • 容差是包含边界的:差异正好等于容差时不算 broken。这个语义要在测试里固定,否则不同实现对「边界算不算超限」会给出不同答案。

测试:平衡、双向漂移与容差边界

测试覆盖四个方向:账实相符、正漂移、负漂移、容差边界,以及非法输入和溢出。

// file: internal/catalog/reconcile_test.go
// tier: L1
// verify: go test ./internal/catalog/

package catalog

import (
	"errors"
	"testing"
)

func TestDiffBalancedSnapshot(t *testing.T) {
	s := Snapshot{
		SKU:       "sku-1001",
		LedgerIn:  100,
		LedgerOut: 37,
		OnHand:    50,
		Reserved:  13,
	}
	got, err := Diff(s)
	if err != nil {
		t.Fatal(err)
	}
	if got != 0 {
		t.Fatalf("diff = %d, want 0", got)
	}
}

func TestDiffReportsBothDirections(t *testing.T) {
	base := Snapshot{SKU: "sku-1001", LedgerIn: 100, LedgerOut: 30}
	cases := []struct {
		name string
		s    Snapshot
		want int64
	}{
		{"over-stock", func() Snapshot { x := base; x.OnHand = 60; x.Reserved = 15; return x }(), 5},
		{"missing-stock", func() Snapshot { x := base; x.OnHand = 60; x.Reserved = 5; return x }(), -5},
		{"reserved-counts", func() Snapshot { x := base; x.OnHand = 60; x.Reserved = 10; return x }(), 0},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			got, err := Diff(c.s)
			if err != nil {
				t.Fatal(err)
			}
			if got != c.want {
				t.Fatalf("diff = %d, want %d", got, c.want)
			}
		})
	}
}

func TestBrokenUsesInclusiveTolerance(t *testing.T) {
	s := Snapshot{SKU: "sku-1001", LedgerIn: 100, LedgerOut: 30, OnHand: 60, Reserved: 15}
	negative := Snapshot{SKU: "sku-1001", LedgerIn: 100, LedgerOut: 30, OnHand: 60, Reserved: 5}
	cases := []struct {
		name      string
		s         Snapshot
		tolerance int64
		want      bool
	}{
		{"positive-at-boundary", s, 5, false},
		{"positive-below-boundary", s, 4, true},
		{"positive-above-boundary", s, 6, false},
		{"negative-at-boundary", negative, 5, false},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			got, err := Broken(c.s, c.tolerance)
			if err != nil {
				t.Fatal(err)
			}
			if got != c.want {
				t.Fatalf("broken = %v, want %v", got, c.want)
			}
		})
	}
}

func TestBrokenNegativeDrift(t *testing.T) {
	s := Snapshot{SKU: "sku-1001", LedgerIn: 100, LedgerOut: 30, OnHand: 60, Reserved: 5}
	got, err := Broken(s, 4)
	if err != nil {
		t.Fatal(err)
	}
	if !got {
		t.Fatal("negative drift of 5 should exceed tolerance 4")
	}
}

func TestReconcileRejectsInvalidInput(t *testing.T) {
	valid := Snapshot{SKU: "sku-1001", LedgerIn: 10, LedgerOut: 2, OnHand: 6, Reserved: 2}
	cases := []struct {
		name string
		fn   func() (int64, error)
		want error
	}{
		{"empty-sku", func() (int64, error) { return Diff(Snapshot{LedgerIn: 1}) }, ErrInvalidSnapshot},
		{"negative-on-hand", func() (int64, error) {
			x := valid
			x.OnHand = -1
			return Diff(x)
		}, ErrInvalidSnapshot},
		{"negative-reserved", func() (int64, error) {
			x := valid
			x.Reserved = -1
			return Diff(x)
		}, ErrInvalidSnapshot},
		{"ledger-out-exceeds-in", func() (int64, error) {
			x := valid
			x.LedgerOut = 11
			return Diff(x)
		}, ErrInvalidSnapshot},
		{"invalid-tolerance", func() (int64, error) {
			_, err := Broken(valid, -1)
			if err != nil {
				return 0, err
			}
			return 1, nil
		}, ErrInvalidTolerance},
	}
	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 TestDiffRejectsStockSumOverflow(t *testing.T) {
	s := Snapshot{SKU: "sku-1001", LedgerIn: maxInt64, LedgerOut: 0, OnHand: maxInt64, Reserved: 1}
	if _, err := Diff(s); !errors.Is(err, ErrInvalidSnapshot) {
		t.Fatalf("err = %v, want ErrInvalidSnapshot", err)
	}
}

TestDiffBalancedSnapshot 用入库 100、出库 37、在手 50、预留 13 构造了一个恰好平衡的快照,差异为 0。它看起来简单,但能把公式写反的错误立刻暴露出来:如果把 OnHand + Reserved - Expected 写成 Expected - OnHand - Reserved,这个用例会返回 0 之外的另一个值。

TestDiffReportsBothDirections 固定正负号的含义。同样是入库 100、出库 30,在手 60、预留 15 时差异是正 5;预留改成 5 时差异是负 5。正负方向不能靠注释约定,必须由测试锁住,因为下游的回补逻辑会根据符号决定写盘盈还是盘亏。

TestBrokenUsesInclusiveTolerance 和 TestBrokenNegativeDrift 覆盖容差边界。差异正好等于容差时返回 false,小于容差时返回 true,负向漂移也同样按绝对值比较。这两个用例能防止有人把 > 写成 >=,导致边界值被反复报警。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/catalog/ -count=1

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

ok  	webapp/internal/catalog	0.228s

ok 表示包内所有测试通过。对账代码只负责判断差异,真正的回补还需要补偿单、唯一索引和条件更新;测试通过不等于回补幂等,后者要在任务层和数据库约束上验证。

小结

库存漂移不是某一次 bug,而是两条写入路径长期不同步的结果。对账要以流水为准,因为流水可解释、可重放,快照只是流水的一个视图;比较时要对齐时间水位,容差只用于解释在途,不用于掩盖问题。

回补的关键不是「把数字改对」,而是「同一笔差异只能被补一次」。幂等键来自业务事实而不是任务运行 ID,执行顺序是先补偿单、后改库存,再用条件更新和状态机收尾。只有这样,对账任务才能安全地重跑,库存才不会在正负漂移之间来回震荡。

阅读导航:上一节:1.2 库存扣减与超卖 · 下一节:2.1 下单、支付与履约 。

常见错误与边界

  • 直接改快照让两个数字相等,不把调整写进流水,下一次对账仍然对不上。
  • 用任务运行 ID 当补偿幂等键,重跑时生成新 ID,同一差异被补两次。
  • 不比较时间水位,把正常的异步在途当成漂移,频繁误报。
  • 用容差掩盖长期差异,真正的漏记出库一直没有被处理。
  • 先改库存再写补偿单,进程崩溃后无法判断这次调整是否已经执行。
  • 流水出库大于入库时仍然计算差异,负数被下游当成盘亏处理。
  • 正负号含义没有测试锁定,回补方向写反,库存越补越偏。
  • 对账和回补放在同一个事务里跨服务执行,失败后既没有账本记录也没有状态可恢复。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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