大促后的第一次对账
大促结束第三天,「拾光集市」的财务和仓储一起做库存对账。红色 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,同一差异被补两次。
- 不比较时间水位,把正常的异步在途当成漂移,频繁误报。
- 用容差掩盖长期差异,真正的漏记出库一直没有被处理。
- 先改库存再写补偿单,进程崩溃后无法判断这次调整是否已经执行。
- 流水出库大于入库时仍然计算差异,负数被下游当成盘亏处理。
- 正负号含义没有测试锁定,回补方向写反,库存越补越偏。
- 对账和回补放在同一个事务里跨服务执行,失败后既没有账本记录也没有状态可恢复。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。