《后端架构设计·分布式与数据一致性》10.3 锁失效后的正确性:从互斥到幂等

锁会过期、会丢、会被主从切换绕过,但库存不超卖、订单状态单向推进、支付回调只生效一次仍然可以成立。本节把这三条不变量写成可运行的 Go 条件更新与幂等代码,在完全不用分布式锁的 200 并发下实测成功预占恰好 50 次、库存不为负,并给出锁、版本号与幂等键的职责切分。

锁过期后,库存为什么没有变成负数

库存服务给每个 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 边界 · 下一节:调度模型:轮询、租约与领导者选举 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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