《后端架构设计·分布式与数据一致性》6.3 从一致性模型到业务不变量

从库存只剩一件却被两个请求同时卖出的场景出发,把库存不超卖、订单状态单向推进、支付不重复扣款写成可执行谓词,再用故障注入反推每种不变量需要的最弱一致性模型,附条件更新、版本 CAS 与幂等扣款的 Go 实现和判据表,让每条规则都能被测试直接断言。

库存只剩一件,两个请求都下单成功

某个 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:准备、提交与协调者故障 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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