锁过期后,库存为什么没有变成负数
库存服务给每个 SKU 加了一把 10 秒的 Redis 锁,扣减前先 GET 库存、判断足够、再 SET 新值。某次大促,锁服务主从切换,两个实例同时拿到同一把锁;它们都读到库存 50,都判断“足够扣 1”,然后各自写回 49。两个订单都成功,库存只减了 1。更糟的是,如果两个实例分别扣 30 和 40,它们都可能基于 50 判断成功,最后写回 20 或 10,超卖了 20 件。
错误直觉是“只要锁足够可靠,库存就不会错”。锁服务能降低并发冲突的概率,却不能为业务不变量背书:锁会过期、会因主从切换丢失、会被进程暂停绕过。真正守住库存的是存储端的条件更新——UPDATE inventory SET stock = stock - ? WHERE sku = ? AND stock >= ?,数据库保证判断与扣减在同一个原子操作里完成。
站内 幂等与可靠性设计 讨论过请求去重与重试语义,本节把它落到 arch-lab 的库存、订单和支付三个聚合上:先写不变量,再给条件更新与幂等实现,最后用无锁并发测试验证“锁不在场时仍然正确”。
先写不变量,再选机制
arch-lab 的库存与订单有三条必须始终成立的不变量:
| 不变量 | 形式化表达 | 失效后果 | 执行点 |
|---|---|---|---|
| 库存不为负 | stock >= 0 | 超卖,无法履约 | 扣减时的条件更新 |
| 订单状态单向推进 | CREATED → INVENTORY_RESERVED → PAID → FULFILLED | 重复支付、已取消订单被发货 | 带前置状态的条件转移 |
| 支付回调只生效一次 | 同一 order_id 最多一次扣款/入账 | 重复扣款、重复发货 | 幂等键或状态条件 |
这三条都不需要“同一时刻只有一个线程”这种强互斥。库存需要的是“判断与扣减原子”;订单状态需要的是“比较当前状态再更新”;支付回调需要的是“同一业务键的副作用最多一次”。三种需求对应三种存储能力:条件更新、状态机条件转移、唯一约束。锁只是减少冲突的优化,不是这些能力的替代品。
把不变量写成 SQL 条件,会比在代码里写 if 更可靠,因为判断和写入在同一个语句里完成:
UPDATE inventory
SET stock = stock - 1, version = version + 1
WHERE sku = 'sku-1' AND stock >= 1 AND version = 7;
受影响行数为 0 时,调用方重新读取并重试;受影响行数为 1 时,扣减已经生效。没有“先查再改”的窗口,也不需要额外的分布式锁。
版本号与条件更新
下面的实现用 Store.CAS 模拟数据库的单行原子条件写。Service.Reserve 先读当前行,检查库存,再带着读到的版本号提交;版本不匹配就重试。注意 Store 内部的互斥锁只是模拟数据库单行的原子性,不是业务层的分布式锁——真实系统里这段原子性由数据库的 WHERE version = ? 保证。
// file: internal/platform/correctness/inventory.go
// tier: L2
// verify: go test ./correctness/ -count=1
package correctness
import (
"errors"
"fmt"
"sync"
)
var (
// ErrConflict means the row changed between read and conditional write.
ErrConflict = errors.New("correctness: version conflict")
// ErrInsufficientStock means the reservation would make stock negative.
ErrInsufficientStock = errors.New("correctness: insufficient stock")
// ErrNotFound means the order does not exist.
ErrNotFound = errors.New("correctness: order not found")
// ErrInvalidState means the order is not in the expected source state.
ErrInvalidState = errors.New("correctness: invalid order state")
)
const maxAttempts = 64
// Item is a stock row. Version is the optimistic concurrency token; no
// distributed lock participates in the update.
type Item struct {
Stock int64
Version int64
}
// Reservation is the result of one successful stock reservation.
type Reservation struct {
OrderID string
SKU string
Qty int64
}
// Store is a single-row atomic store. CAS is the only write primitive, which
// is what a database offers as UPDATE ... WHERE version = ?.
type Store struct {
mu sync.Mutex
items map[string]Item
conflicts int
}
// NewStore returns an empty store.
func NewStore() *Store {
return &Store{items: make(map[string]Item)}
}
// Seed inserts or replaces a stock row at version 0.
func (s *Store) Seed(sku string, stock int64) {
s.mu.Lock()
defer s.mu.Unlock()
s.items[sku] = Item{Stock: stock, Version: 0}
}
// Load returns the current row; a missing row reads as zero.
func (s *Store) Load(sku string) Item {
s.mu.Lock()
defer s.mu.Unlock()
return s.items[sku]
}
// CAS commits next only if the row is still at expected version.
func (s *Store) CAS(sku string, expected int64, next Item) bool {
s.mu.Lock()
defer s.mu.Unlock()
cur, ok := s.items[sku]
if !ok || cur.Version != expected {
s.conflicts++
return false
}
next.Version = expected + 1
s.items[sku] = next
return true
}
// Conflicts reports failed CAS attempts.
func (s *Store) Conflicts() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.conflicts
}
// Service reserves stock using read-check-CAS retries.
type Service struct {
store *Store
}
// NewService wires a service to a store.
func NewService(store *Store) *Service { return &Service{store: store} }
// Reserve decrements stock for one order. It never holds a lock across the
// read and write; correctness comes from the version check and Stock >= 0.
func (s *Service) Reserve(orderID, sku string, qty int64) (Reservation, error) {
for attempt := 0; attempt < maxAttempts; attempt++ {
cur := s.store.Load(sku)
if cur.Stock < qty {
return Reservation{}, fmt.Errorf("reserve %s/%s qty=%d stock=%d: %w",
orderID, sku, qty, cur.Stock, ErrInsufficientStock)
}
next := Item{Stock: cur.Stock - qty}
if s.store.CAS(sku, cur.Version, next) {
return Reservation{OrderID: orderID, SKU: sku, Qty: qty}, nil
}
}
return Reservation{}, fmt.Errorf("reserve %s after %d attempts: %w", orderID, maxAttempts, ErrConflict)
}
// Idempotent applies fn at most once per key. The in-process mutex stands in
// for an INSERT ... ON CONFLICT DO NOTHING unique index; the effect must still
// be safe if the same key arrives after a crash.
type Idempotent struct {
mu sync.Mutex
results map[string]Reservation
}
// NewIdempotent returns an empty idempotency table.
func NewIdempotent() *Idempotent {
return &Idempotent{results: make(map[string]Reservation)}
}
// Do returns the cached result or executes fn once.
func (i *Idempotent) Do(key string, fn func() (Reservation, error)) (Reservation, error) {
i.mu.Lock()
defer i.mu.Unlock()
if r, ok := i.results[key]; ok {
return r, nil
}
r, err := fn()
if err != nil {
return Reservation{}, err
}
i.results[key] = r
return r, nil
}
// OrderState is the order state machine value.
type OrderState string
// Order states used by arch-lab.
const (
StateCreated OrderState = "CREATED"
StateReserved OrderState = "INVENTORY_RESERVED"
StatePaid OrderState = "PAID"
StateFulfilled OrderState = "FULFILLED"
StateCancelled OrderState = "CANCELLED"
)
// Order is a versioned order row.
type Order struct {
ID string
State OrderState
Version int64
}
// Orders stores orders and enforces conditional state transitions.
type Orders struct {
mu sync.Mutex
orders map[string]Order
}
// NewOrders returns an empty order table.
func NewOrders() *Orders { return &Orders{orders: make(map[string]Order)} }
// Create inserts a new order in CREATED at version 0.
func (o *Orders) Create(id string) Order {
o.mu.Lock()
defer o.mu.Unlock()
ord := Order{ID: id, State: StateCreated}
o.orders[id] = ord
return ord
}
// Get returns an order.
func (o *Orders) Get(id string) (Order, error) {
o.mu.Lock()
defer o.mu.Unlock()
ord, ok := o.orders[id]
if !ok {
return Order{}, fmt.Errorf("order %s: %w", id, ErrNotFound)
}
return ord, nil
}
// Transition moves id from one state to another only if it is currently in
// from. A duplicate callback sees the new state and is rejected.
func (o *Orders) Transition(id string, from, to OrderState) error {
o.mu.Lock()
defer o.mu.Unlock()
ord, ok := o.orders[id]
if !ok {
return fmt.Errorf("transition %s: %w", id, ErrNotFound)
}
if ord.State != from {
return fmt.Errorf("transition %s from %s, current %s: %w", id, from, ord.State, ErrInvalidState)
}
ord.State = to
ord.Version++
o.orders[id] = ord
return nil
}
代码里有四个边界值得说明。第一,Reserve 的重试次数必须有上限;持续冲突说明热点过高或某个事务长时间持锁,无限重试只会把线程池拖死。第二,库存检查和版本检查在真实 SQL 中应该写在同一个 WHERE 里;如果应用先 SELECT 判断库存、再执行只带版本条件的 UPDATE,两句话之间库存可能已被别的请求改变。第三,CAS 失败不是业务错误,不应直接返回给用户;应当短暂退避后重试,超过上限再报告冲突。第四,Idempotent.Do 在内存里把整个执行串行化,生产上不能这样——它只能保证同一个进程内的重复请求,跨进程需要数据库唯一键或 Redis 的原子 SET NX。
幂等:重复回调只生效一次
支付回调可能重复投递:网关超时重试、消息队列至少一次投递、人工补发都会造成同一笔支付被处理多次。处理方式有两层。
第一层是业务状态条件。订单从 INVENTORY_RESERVED 到 PAID 的转移带前置状态:第一次回调把状态改成 PAID,第二次回调看到当前状态已经是 PAID,条件不满足,返回 ErrInvalidState,不会重复入账。这要求状态转移是原子的,生产上对应 UPDATE orders SET state = 'PAID', version = version + 1 WHERE id = ? AND state = 'INVENTORY_RESERVED'。
第二层是幂等键。对于没有天然状态机的副作用(例如调用支付网关退款、发优惠券),用业务键(订单号 + 操作类型)建立唯一约束,先插入键再执行副作用;插入冲突说明已经处理过,直接返回上次结果。关键是“键写入”和“副作用”必须在同一个事务里,或者副作用本身可以按结果查询后重放,否则会出现“键已写入但副作用没执行”的悬挂。
Idempotent.Do 演示的是同一进程内的语义:同一个 key 的 20 个并发调用只会执行一次 Reserve,其余返回缓存结果。真实实现要把 results 换成带唯一索引的表,并明确结果保留多久;保留太短会让迟到的重试重新执行,保留太久则表无限增长。
本机实测:无锁并发下的库存与回调
在独立 module 中运行,Go 1.26.0、darwin/arm64。库存测试完全不使用分布式锁,200 个 goroutine 同时预占库存 50 的 SKU:
$ go test ./correctness/ -v -count=1
=== RUN TestNoOversellWithoutLock
inventory_test.go:48: no lock: ok=50 short=150 final_stock=0 conflicts=1
--- PASS: TestNoOversellWithoutLock (0.00s)
=== RUN TestCASRejectsStaleVersion
inventory_test.go:66: stale CAS rejected: stock=4 version=1 conflicts=1
--- PASS: TestCASRejectsStaleVersion (0.00s)
=== RUN TestReserveRetriesOnVersionConflict
--- PASS: TestReserveRetriesOnVersionConflict (0.00s)
=== RUN TestDuplicateReservationAppliesOnce
inventory_test.go:130: 20 duplicate callbacks: stock=9 version=1
--- PASS: TestDuplicateReservationAppliesOnce (0.00s)
=== RUN TestOrderStateMachineRejectsDuplicateCallback
inventory_test.go:152: duplicate callback: state=PAID version=2
--- PASS: TestOrderStateMachineRejectsDuplicateCallback (0.00s)
PASS
ok archlab/correctness 0.450s
$ go test ./correctness/ -race -count=1
ok archlab/correctness 1.260s
读数:200 个并发预占里恰好 50 个成功、150 个因库存不足失败,最终库存为 0,没有负数;本次运行记录到 1 次 CAS 冲突,冲突次数随调度变化,不是固定值。版本条件测试里,两个调用读到同一个版本 0,第一个提交成功,第二个被拒绝,库存只从 5 变成 4、版本变成 1。重复回调测试用 20 个并发请求处理同一个支付回调,库存只从 10 减到 9,版本只增加 1。订单状态测试中,第一次 INVENTORY_RESERVED → PAID 成功,第二次同样的转移返回 ErrInvalidState,最终状态 PAID、版本 2。
这些结果是单进程内存存储上的验证,不是 MySQL、PostgreSQL 或 Redis 的实测,也没有覆盖事务隔离级别、死锁重试和连接池耗尽。它证明的是:在没有分布式锁的情况下,只要条件更新和状态条件成立,三条业务不变量仍然成立。
锁、版本号、幂等键的分工
| 机制 | 能保证什么 | 不能保证什么 | 典型用法 |
|---|---|---|---|
| 分布式锁 | 租约期内的互斥,减少并发冲突 | 过期后的陈旧写入、跨主从切换 | 降低重试、保护短临界区 |
| 版本号/CAS | 不覆盖并发更新,冲突可检测 | 调用方不重试时请求失败 | 库存扣减、配置更新 |
| 状态条件转移 | 只允许合法状态迁移 | 同一状态的重复副作用 | 订单支付、发货、取消 |
| 幂等键 | 同一业务键最多一次副作用 | 键与副作用不原子时会悬挂 | 支付回调、退款、发券 |
| 对账 | 发现并收敛已发生的差异 | 实时性,不能替代预防 | 资金、库存日终核对 |
一个实用的组合是:锁只用来降低冲突率,条件更新保证单次操作正确,幂等键保证重复请求不重复生效,对账兜底发现前四者都没覆盖的差异。四层里任何一层都不能单独承担“正确性”的全部责任。
常见错误与边界
第一,先查库存再无条件写回。GET 和 SET 之间没有原子性,两个请求都读到 50 就会都写 49;必须把判断写进条件更新的 WHERE,并检查受影响行数。
第二,版本号只在应用层比较。应用读到版本 7,比较后执行 UPDATE ... SET stock = ?,却没有把 version = 7 放进 WHERE,等于没有乐观锁;版本号必须参与存储端的条件。
第三,幂等表先写“已处理”再执行副作用。副作用失败后键已经存在,重试会被当成重复请求直接返回成功,实际业务没有发生;正确顺序是同一事务内写键和副作用,或把副作用做成可查询、可重放。
第四,把冲突当业务错误直接返回。高并发下 CAS 冲突是正常现象,应该短暂退避重试;直接返回 500 会让用户重复提交,反而放大冲突。
第五,用锁替代幂等。锁失效后重复请求仍然会执行两次;对于支付、退款、发券这类副作用,幂等键是必需的,锁只是可选的性能优化。
小结
锁失效不是数据错误的充分条件。库存不超卖靠的是“判断与扣减在同一个条件更新里”,订单状态靠的是“带前置状态的条件转移”,支付回调靠的是“幂等键或状态条件”。本机 200 并发无锁测试得到 50 次成功预占、库存 0、无负数,20 个重复回调只扣减一次,验证了这些不变量在没有锁时仍然成立。它们没有验证真实数据库的隔离级别和故障恢复,生产上还要用对账兜底。到这里,卷三关于“单点协调”的讨论告一段落:ID 解决标识,锁解决短期归属,条件更新解决最终正确性;下一章转向调度,看轮询、租约与领导者选举如何决定任务归属。
阅读导航:上一节:分布式锁:租约、fencing token 与 Redis 边界 · 下一节:调度模型:轮询、租约与领导者选举 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。