库存只剩一件,两个请求都下单成功
某个 SKU 只剩 1 件。两个用户几乎同时下单,两个请求都先查库存,都读到 available = 1,都通过"库存充足"的判断,然后各自写回 reserved = 1。最终卖出 2 件,库存变成负数。
团队的第一反应通常是"状态机校验写在代码里,所以并发安全"。但代码里的 if available >= qty 和后面的 UPDATE inventory 是两次独立操作,中间隔着网络、锁释放和其他请求。校验通过只说明"读到的那一刻够",不说明"写入的那一刻还够"。
另一个常见误区是"上了分布式锁就安全"。锁的租约可能过期,持有者可能在暂停后继续写入,Redis 主从切换可能让两个客户端同时认为自己持锁。锁能降低冲突概率,不能单独承担正确性。
还有一种更隐蔽的错误:把"最终一致 + 对账"用在扣减上。对账能发现超卖,但不能阻止超卖已经发生;用户已经付款,补偿只能退款和赔付。正确顺序是先写清不变量,再反推需要什么模型,而不是先选一个架构再假设它够用。
把业务规则写成可执行谓词
不变量必须能写成断言,能对一次执行结果直接判定,否则讨论"够不够一致"就没有共同对象。
| 不变量 | 可执行谓词 | 读集 | 写集 | 错误后果 |
|---|---|---|---|---|
| 库存不超卖 | total - reserved - sold >= 0 | 该 SKU 的库存字段 | 同一 SKU 的 reserved/sold | 超卖、赔付 |
| 订单状态单向 | 每次转移属于允许边集合 | 订单当前状态与版本 | 同一订单的 status/version | 状态倒退、重复履约 |
| 支付不重复 | 每订单成功扣款次数 <= 1 | 幂等键、订单扣款记录 | 扣款记录、幂等键 | 重复扣款、资损 |
| 下单后本人可见 | 会话内读到自己已完成的写 | 会话写水位 | 读水位 | 重复下单 |
| 统计最终正确 | 收敛后与源数据一致 | 聚合结果 | 物化视图 | 报表偏差 |
谓词要能直接放进测试。库存要准确 无法断言,reserved + sold <= total 可以;订单状态不能乱 无法断言,每次转移在允许边集合内 可以。
反推最弱模型的三步
第一步,找原子边界。不变量涉及的所有读写是否落在同一个键、同一行、同一个分区?单键条件更新可以提供线性一致的读改写;跨聚合就必须引入事务、Saga 或对账。
第二步,判断是不是 check-then-act。决策依赖一个会被其他写者修改的读,且读与写不在同一个原子操作里,就存在竞态。消除竞态只有两条路:把检查和写合并成原子条件更新,或者让写本身幂等、可重试且不依赖旧读。
第三步,判断失败能否补偿。超卖、重复扣款不可补偿或补偿成本极高,必须用强保证;展示延迟、统计偏差可补偿,可以退到因果或最终一致。这里说的"最弱"是"足够且成本最低",不是"越弱越好"。
按这个顺序,库存和订单状态落在单键线性一致的条件更新;支付落在唯一约束加幂等键;本人可见落在会话保证;统计落在最终一致加对账。下面的实现把前三类做成可测试的组件,并用同一个测试证明弱模型会破坏不变量、强模型不会。
可运行实现
库存用互斥锁模拟单键原子条件更新,同时保留一个"按陈旧快照扣减"的函数,用来复现弱模型下的超卖。订单用期望版本 CAS 推进状态机,支付用幂等键保证每订单最多一次扣款。
// file: internal/platform/invariant/invariant.go
// tier: L1
// verify: go test ./article/invariant/ -count=1
package invariant
import (
"errors"
"fmt"
"sync"
)
// Sentinel errors shared by the invariant checks.
var (
ErrInsufficientStock = errors.New("invariant: insufficient stock")
ErrConflict = errors.New("invariant: version conflict")
ErrInvalidState = errors.New("invariant: invalid state transition")
ErrDuplicateCharge = errors.New("invariant: duplicate charge")
)
// Inventory tracks one SKU in integer units. Reserved and Sold must never
// exceed Total; available is a derived value, not stored state.
type Inventory struct {
mu sync.Mutex
total int64
reserved int64
sold int64
version uint64
}
// NewInventory creates a stock record with the given total.
func NewInventory(total int64) *Inventory { return &Inventory{total: total} }
// Available returns total - reserved - sold under the current lock.
func (i *Inventory) Available() int64 {
i.mu.Lock()
defer i.mu.Unlock()
return i.total - i.reserved - i.sold
}
// ReserveIfAvailable is the atomic conditional update: the check and the
// increment happen under one lock, so the invariant cannot be interleaved.
func (i *Inventory) ReserveIfAvailable(qty int64) (uint64, error) {
if qty <= 0 {
return 0, fmt.Errorf("invariant: reserve qty must be positive, got %d", qty)
}
i.mu.Lock()
defer i.mu.Unlock()
if i.total-i.reserved-i.sold < qty {
return 0, fmt.Errorf("%w: available %d, want %d", ErrInsufficientStock, i.total-i.reserved-i.sold, qty)
}
i.reserved += qty
i.version++
return i.version, nil
}
// ReserveFromStaleSnapshot encodes the weaker read-then-act model: the caller
// supplies an available value read earlier and the write does not revalidate.
// It exists to show which consistency level the invariant actually needs.
func (i *Inventory) ReserveFromStaleSnapshot(qty, available int64) error {
if available < qty {
return fmt.Errorf("%w: snapshot available %d, want %d", ErrInsufficientStock, available, qty)
}
i.mu.Lock()
defer i.mu.Unlock()
i.reserved += qty
i.version++
return nil
}
// Check validates total >= reserved + sold and non-negative fields.
func (i *Inventory) Check() error {
i.mu.Lock()
defer i.mu.Unlock()
if i.total < 0 || i.reserved < 0 || i.sold < 0 {
return fmt.Errorf("invariant: negative inventory field: total=%d reserved=%d sold=%d", i.total, i.reserved, i.sold)
}
if i.reserved+i.sold > i.total {
return fmt.Errorf("invariant: oversold: total=%d reserved=%d sold=%d", i.total, i.reserved, i.sold)
}
return nil
}
// Status is the order lifecycle state.
type Status string
// Order states; terminal states have no outgoing edges.
const (
Created Status = "CREATED"
InventoryReserved Status = "INVENTORY_RESERVED"
Paid Status = "PAID"
Fulfilled Status = "FULFILLED"
Cancelled Status = "CANCELLED"
Refunded Status = "REFUNDED"
)
var allowed = map[Status]map[Status]bool{
Created: {InventoryReserved: true, Cancelled: true},
InventoryReserved: {Paid: true, Cancelled: true},
Paid: {Fulfilled: true, Refunded: true},
Fulfilled: {Refunded: true},
Cancelled: {},
Refunded: {},
}
// ValidateTransition accepts exactly the edges of the order state machine.
func ValidateTransition(from, to Status) error {
if allowed[from][to] {
return nil
}
return fmt.Errorf("%w: %s -> %s", ErrInvalidState, from, to)
}
// CheckStatusSequence validates a recorded state history, including the first
// state, so a sequence that starts in the middle is rejected only if the first
// state is not a valid starting point.
func CheckStatusSequence(states []Status) error {
if len(states) == 0 {
return fmt.Errorf("%w: empty status sequence", ErrInvalidState)
}
if states[0] != Created {
return fmt.Errorf("%w: sequence starts at %s", ErrInvalidState, states[0])
}
for i := 1; i < len(states); i++ {
if err := ValidateTransition(states[i-1], states[i]); err != nil {
return fmt.Errorf("invariant: step %d: %w", i, err)
}
}
return nil
}
// Order updates its status through an expected-version compare-and-set.
type Order struct {
mu sync.Mutex
id string
status Status
version uint64
}
// NewOrder creates an order in CREATED at version zero.
func NewOrder(id string) *Order { return &Order{id: id, status: Created} }
// Snapshot returns the current status and version atomically.
func (o *Order) Snapshot() (Status, uint64) {
o.mu.Lock()
defer o.mu.Unlock()
return o.status, o.version
}
// Transition applies one edge only if the caller's version is current.
func (o *Order) Transition(to Status, expectedVersion uint64) (uint64, error) {
o.mu.Lock()
defer o.mu.Unlock()
if o.version != expectedVersion {
return 0, fmt.Errorf("%w: order %s version %d, caller expected %d", ErrConflict, o.id, o.version, expectedVersion)
}
if err := ValidateTransition(o.status, to); err != nil {
return 0, err
}
o.status = to
o.version++
return o.version, nil
}
// PaymentBook enforces at most one successful charge per order.
type PaymentBook struct {
mu sync.Mutex
charged map[string]int64
seen map[string]bool
}
// NewPaymentBook creates an empty charge ledger.
func NewPaymentBook() *PaymentBook {
return &PaymentBook{charged: make(map[string]int64), seen: make(map[string]bool)}
}
// Charge records a charge exactly once per idempotency key. It returns true
// when this call created the charge and false for a replay.
func (p *PaymentBook) Charge(orderID, idemKey string, amount int64) (bool, error) {
if orderID == "" || idemKey == "" || amount <= 0 {
return false, fmt.Errorf("invariant: invalid charge order=%q key=%q amount=%d", orderID, idemKey, amount)
}
p.mu.Lock()
defer p.mu.Unlock()
key := orderID + "|" + idemKey
if p.seen[key] {
return false, nil
}
if _, ok := p.charged[orderID]; ok {
return false, fmt.Errorf("%w: order %s already charged", ErrDuplicateCharge, orderID)
}
p.seen[key] = true
p.charged[orderID] = amount
return true, nil
}
// Charged returns the recorded amount for an order.
func (p *PaymentBook) Charged(orderID string) (int64, bool) {
p.mu.Lock()
defer p.mu.Unlock()
amount, ok := p.charged[orderID]
return amount, ok
}
ReserveFromStaleSnapshot 不是推荐写法,而是把"读快照、后写入、不重校验"这个错误模型显式编码出来。只有让错误模型可运行,才能证明条件更新不是风格偏好,而是不变量成立的必要条件。
测试读数
测试先让两个请求拿着同一个 available = 1 的快照并发扣减,再让两个请求走原子条件更新,最后验证并发状态转移与重复支付回调。
package invariant
import (
"errors"
"sync"
"testing"
)
func TestStaleSnapshotOversells(t *testing.T) {
stock := NewInventory(1)
snapshot := stock.Available()
var wg sync.WaitGroup
start := make(chan struct{})
errs := make(chan error, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
errs <- stock.ReserveFromStaleSnapshot(1, snapshot)
}()
}
close(start)
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatalf("stale reservation rejected: %v", err)
}
}
if err := stock.Check(); err == nil {
t.Fatalf("Check = nil, want an oversell violation")
}
}
func TestConditionalUpdateKeepsInvariant(t *testing.T) {
stock := NewInventory(1)
var wg sync.WaitGroup
start := make(chan struct{})
errs := make(chan error, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
_, err := stock.ReserveIfAvailable(1)
errs <- err
}()
}
close(start)
wg.Wait()
close(errs)
var ok, insufficient int
for err := range errs {
switch {
case err == nil:
ok++
case errors.Is(err, ErrInsufficientStock):
insufficient++
default:
t.Fatalf("unexpected error: %v", err)
}
}
if ok != 1 || insufficient != 1 {
t.Fatalf("ok=%d insufficient=%d, want 1 and 1", ok, insufficient)
}
if err := stock.Check(); err != nil {
t.Fatalf("Check: %v", err)
}
}
$ go test ./article/invariant/ -count=1 -v
=== RUN TestStaleSnapshotOversells
--- PASS: TestStaleSnapshotOversells (0.00s)
=== RUN TestConditionalUpdateKeepsInvariant
--- PASS: TestConditionalUpdateKeepsInvariant (0.00s)
=== RUN TestOrderStateSequence
--- PASS: TestOrderStateSequence (0.00s)
=== RUN TestConcurrentTransitionOneWins
--- PASS: TestConcurrentTransitionOneWins (0.00s)
=== RUN TestDuplicateChargeIsRejected
--- PASS: TestDuplicateChargeIsRejected (0.00s)
PASS
ok archlab/article/invariant 0.489s
读数可以直接对应到不变量。TestStaleSnapshotOversells 中两个基于陈旧快照的扣减都返回成功,随后 Check 报告 reserved = 2 > total = 1,说明最终一致或读快照模型无法阻止超卖;TestConditionalUpdateKeepsInvariant 中恰好一个成功、一个得到 ErrInsufficientStock,库存保持 1;TestConcurrentTransitionOneWins 中两个相同版本的转移只有一个成功,另一个得到 ErrConflict;TestDuplicateChargeIsRejected 中 8 个并发同键回调只产生 1 次扣款。
这些是逻辑判定,不是压力测试。并发数只有 2 和 8,目的是覆盖交错,不是测量吞吐;真实系统还要在数据库层面验证条件更新的原子性,以及唯一约束在分区下的行为。
从后果反推模型
| 不变量 | 可补偿性 | 最弱但足够的模型 | 实现手段 | 退出条件 |
|---|---|---|---|---|
| 库存不超卖 | 不可 | 单 SKU 线性一致条件更新 | 条件 UPDATE、单分区共识 | 并发扣减压测后库存恒非负 |
| 订单状态单向 | 部分可 | 单订单线性一致 CAS | 版本号 CAS + 状态机校验 | 并发支付/取消只有一个成功 |
| 支付不重复 | 不可 | 幂等键唯一 + 读己之写 | 唯一索引、去重表 | 重放与并发回调只扣一次 |
| 下单后本人可见 | 可 | 读己之写 | 会话水位、读主 | 写后首次读不落空 |
| 统计与报表 | 可 | 最终一致 | 异步投影、对账 | 收敛时间有上界且差异可解释 |
这张表的用法是从左往右:先确认错误后果能不能补,再选模型,最后写退出条件。不能从"我们用了 Raft"倒推"所以库存安全";共识只保证日志一致,业务谓词仍要在状态机里检查。
还要区分"单键线性一致"和"跨键事务"。库存条件更新只保护一个 SKU;下单还要同时创建订单、预占库存、发起支付,这三个聚合跨键,单键 CAS 不够,需要后续章节的 2PC、Saga 或 outbox。把单键原子性说成"下单整体原子"是常见的能力夸大。
常见错误与边界
第一,把应用层 if 当成原子校验。检查和写之间只要有一次独立读,就可能被其他写者插入;必须把条件写进存储的原子操作,或用版本号 CAS 让旧读失败。
第二,用最终一致做扣减,指望对账兜底。对账只能在事后发现差异,无法阻止已经发生的超卖;资金和库存类不变量不能用"事后修复"替代"事前阻止"。
第三,状态机只校验目标状态,不校验当前版本。两个请求都读到 INVENTORY_RESERVED,一个转 PAID、一个转 CANCELLED,没有版本 CAS 就会后者覆盖前者;正确做法是带上期望版本,冲突方重新读取后再决策。
第四,幂等键由客户端随意生成或过早过期。回调重试可能带新键,去重表失效后同一订单会被再次扣款;键要有稳定作用域,保留窗口不短于最大重试与对账周期。
边界在于:不变量检查器验证的是"给定执行结果是否违反谓词",不是"系统在所有故障下都保持谓词"。它能做回归与故障复现,不能替代形式化验证;跨聚合不变量还需要补偿、对账和人工处置作为最后一道防线。站内数据一致性 从架构分层角度讨论过同一组问题,本节把落点收在可执行判据上。
小结
一致性模型的选择不该从组件出发,而该从业务不变量出发:先写可执行谓词,再找原子边界,再判断竞态与可补偿性,最后才选最弱但足够的模型。库存与订单状态需要单键线性一致的条件更新或版本 CAS,支付需要幂等键唯一约束加读己之写,展示类才可以退到最终一致。代码里的 if 不是原子性,锁也不是正确性的唯一来源;能阻止超卖的是存储层原子条件更新和版本检查。下一节进入分布式事务,先看 2PC 在协调者故障时为什么会阻塞,以及这种阻塞能否被接受。
阅读导航:上一节:会话保证:读己之写、单调读与一致前缀 · 下一节:2PC 与 3PC:准备、提交与协调者故障 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。