《后端架构设计·分布式与数据一致性》13.2 一次跨库下单的一致性验证

订单在 3 号分片、库存在 7 号分片时,本地事务失效。本节用进程内 L2 实验跑通 outbox + Saga 下单链路,注入重复投递、提交后超时、relay 崩溃和支付拒绝,断言库存不超卖、订单状态与预占一致、支付不重复,并让对账任务把差异收敛到零。

订单在 3 号分片,库存在 7 号分片

用户 A 的订单按 userID 落在 3 号分片,商品 SKU 的库存按 SKU 落在 7 号分片。下单要同时做三件事:写订单、占库存、扣支付。第一件和第三件可能在订单库,第二件在库存库,没有跨库的本地事务。常见直觉是“用 outbox 就不会不一致”。outbox 解决的是业务写入与消息发布的原子性:订单和 OrderCreated 事件在同一个本地事务里提交,relay 至少投递一次。它不保证消费端只执行一次,也不保证超时后调用方知道对方到底提交没有。重复投递、提交后超时、relay 发完消息后崩溃,都会让“至少一次”变成重复副作用或中间态。要守住不变量,还需要消费端 inbox 去重、支付幂等键、Saga 补偿和对账收敛。订单分片的路由与迁移门在订单系统的分布式改造 已经定义;Saga 的补偿顺序和语义锁在 Saga:补偿、语义锁与不可补偿步骤 有完整讨论。本节只做一件事:把跨库下单跑成进程内 L2 场景,注入故障并断言不变量。

链路、故障与四条不变量

  1. 订单分片本地事务写 Order(CREATED) 和 outbox OrderCreated。
  2. 库存分片消费 OrderCreated,inbox 去重后扣减可售库存、写预占行和 outbox InventoryReserved;库存不足则写 InventoryRejected。
  3. 订单分片消费 InventoryReserved,用订单号作为支付幂等键扣款,成功置 PAID;支付拒绝则置 CANCELLED 并写 ReleaseRequested。
  4. 库存分片消费 ReleaseRequested,释放预占并把数量加回可售库存。

四条不变量:不超卖(可售库存 + 持有预占 == 初始库存 且不为负)、状态一致(PAID 必须有支付记录和持有预占)、取消干净(CANCELLED 不能留下持有预占)、支付唯一(每个订单最多一条支付记录)。

故障注入:重复、超时、崩溃

测试用确定性 broker,不 sleep、不连真实中间件;每个场景跑完链路后再跑三轮对账。

注入模拟方式要证明什么
重复投递broker 对每条消息投两次inbox 去重后库存只扣一次
库存超时预占提交后返回 ErrTimeout重试读到 inbox,不会二次预占
支付超时扣款记录后返回 ErrTimeout订单号幂等键阻止二次扣款
relay 崩溃发布后不清 outbox,下轮重发重复消息不改变最终状态
库存不足10 个订单抢 5 件库存5 个 PAID、5 个 CANCELLED,库存为 0
支付拒绝扣款前拒绝并触发补偿CANCELLED 后被拒订单的预占被释放,数量加回库存

代码:两个分片、一个 broker、一个对账任务

下面的文件是自包含的 orderflow 包。两个 Shard 各自持有业务表、inbox 和 outbox;Broker 支持重复投递;Drain 负责 relay 与消费;Reconcile 负责补发丢失的尾事件、修复“已扣款但状态未更新”和“已取消但预占未释放”。所有修复都复用原事件 ID,因此可以反复执行。同一 module 的 orderflow_test.go 对 7 个场景断言支付数、取消数、库存、Diff() 为空和队列收敛。

// file: internal/orderflow/orderflow.go
// tier: L2
// verify: go test ./internal/orderflow/ -count=1
package orderflow

import (
	"errors"
	"fmt"
	"sync"
)

// ErrTimeout models a dependency that committed but answered too late.
var ErrTimeout = errors.New("injected timeout after commit")

type Status string

const (
	Created   Status = "CREATED"
	Reserved  Status = "INVENTORY_RESERVED"
	Paid      Status = "PAID"
	Cancelled Status = "CANCELLED"
)

// Order is the aggregate owned by the order shard.
type Order struct {
	ID, UserID, SKU string
	Qty             int
	Status          Status
}

type Event struct {
	ID, Topic string
	Payload   any
}

type CreatedPayload struct {
	OrderID, SKU string
	Qty          int
}
type ReservedPayload struct{ OrderID string }
type RejectedPayload struct{ OrderID string }

// Shard holds one database shard's business rows, inbox and outbox. The
// order shard uses orders/payments; the inventory shard uses stock and
// reservations; the struct is shared to keep the lab small.
type Shard struct {
	mu       sync.Mutex
	orders   map[string]*Order
	held     map[string]int
	payments map[string]bool
	stock    int
	initial  int
	inbox    map[string]bool
	outbox   []Event
}

func newShard() *Shard {
	return &Shard{orders: make(map[string]*Order), held: make(map[string]int),
		payments: make(map[string]bool), inbox: make(map[string]bool)}
}

func (s *Shard) push(ev Event) { s.outbox = append(s.outbox, ev) }

// Broker is an at-least-once queue. duplicate makes every publish deliver
// twice; a timeout requeues the message after a handler returns an error.
type Broker struct {
	q         []Event
	duplicate bool
}

func (b *Broker) Publish(ev Event) {
	b.q = append(b.q, ev)
	if b.duplicate {
		b.q = append(b.q, ev)
	}
}

type System struct {
	orders, inventory *Shard
	broker            *Broker
	reserveTimeouts   map[string]bool
	payTimeouts       map[string]bool
	declines          map[string]bool
	relayCrash        bool
}

func NewSystem(stock int) *System {
	orders, inv := newShard(), newShard()
	inv.stock, inv.initial = stock, stock
	return &System{orders: orders, inventory: inv, broker: &Broker{},
		reserveTimeouts: map[string]bool{}, payTimeouts: map[string]bool{}, declines: map[string]bool{}}
}

// Place commits the order and its outbox row in one local transaction.
func (s *System) Place(id, user, sku string, qty int) {
	s.orders.mu.Lock()
	defer s.orders.mu.Unlock()
	s.orders.orders[id] = &Order{ID: id, UserID: user, SKU: sku, Qty: qty, Status: Created}
	s.orders.push(Event{ID: "created-" + id, Topic: "OrderCreated",
		Payload: CreatedPayload{OrderID: id, SKU: sku, Qty: qty}})
}

// Relay moves unsent outbox rows to the broker. A crash after publish leaves
// the rows in place, so the next relay publishes them again.
func (s *System) Relay() {
	for _, sh := range []*Shard{s.orders, s.inventory} {
		sh.mu.Lock()
		rows := append([]Event(nil), sh.outbox...)
		for _, ev := range rows {
			s.broker.Publish(ev)
		}
		if !s.relayCrash {
			sh.outbox = sh.outbox[len(rows):]
		}
		sh.mu.Unlock()
	}
	s.relayCrash = false
}

func (s *System) Drain(max int) {
	for i := 0; i < max; i++ {
		s.Relay()
		if len(s.broker.q) == 0 {
			return
		}
		ev := s.broker.q[0]
		s.broker.q = s.broker.q[1:]
		if err := s.Handle(ev); err != nil {
			s.broker.q = append(s.broker.q, ev)
		}
	}
}

func (s *System) Handle(ev Event) error {
	switch ev.Topic {
	case "OrderCreated":
		return s.reserve(ev)
	case "InventoryReserved":
		return s.confirm(ev)
	case "InventoryRejected":
		return s.reject(ev)
	case "ReleaseRequested":
		return s.release(ev)
	default:
		return fmt.Errorf("unknown topic %q", ev.Topic)
	}
}

// reserve is the inventory step. The inbox row is committed with the stock
// movement, so a duplicate or a timeout retry cannot reserve twice.
func (s *System) reserve(ev Event) error {
	s.inventory.mu.Lock()
	defer s.inventory.mu.Unlock()
	if s.inventory.inbox[ev.ID] {
		return nil
	}
	p := ev.Payload.(CreatedPayload)
	s.inventory.inbox[ev.ID] = true
	if s.inventory.stock < p.Qty {
		s.inventory.push(Event{ID: "rejected-" + p.OrderID, Topic: "InventoryRejected",
			Payload: RejectedPayload{OrderID: p.OrderID}})
		return nil
	}
	s.inventory.stock -= p.Qty
	s.inventory.held[p.OrderID] = p.Qty
	s.inventory.push(Event{ID: "reserved-" + p.OrderID, Topic: "InventoryReserved",
		Payload: ReservedPayload{OrderID: p.OrderID}})
	if s.reserveTimeouts[p.OrderID] {
		delete(s.reserveTimeouts, p.OrderID)
		return ErrTimeout
	}
	return nil
}

// confirm charges the payment and advances the order. The order ID is the
// payment idempotency key, so a retry after a timeout is free.
func (s *System) confirm(ev Event) error {
	s.orders.mu.Lock()
	defer s.orders.mu.Unlock()
	if s.orders.inbox[ev.ID] {
		return nil
	}
	p := ev.Payload.(ReservedPayload)
	o, ok := s.orders.orders[p.OrderID]
	if !ok {
		return fmt.Errorf("order %s not found", p.OrderID)
	}
	if o.Status == Created {
		o.Status = Reserved
	}
	if o.Status != Reserved {
		s.orders.inbox[ev.ID] = true
		return nil
	}
	if s.declines[p.OrderID] {
		delete(s.declines, p.OrderID)
		o.Status = Cancelled
		s.orders.inbox[ev.ID] = true
		s.orders.push(Event{ID: "release-" + o.ID, Topic: "ReleaseRequested",
			Payload: ReservedPayload{OrderID: o.ID}})
		return nil
	}
	if !s.orders.payments[o.ID] {
		s.orders.payments[o.ID] = true
	}
	if s.payTimeouts[o.ID] {
		delete(s.payTimeouts, o.ID)
		return ErrTimeout
	}
	o.Status = Paid
	s.orders.inbox[ev.ID] = true
	return nil
}

func (s *System) reject(ev Event) error {
	s.orders.mu.Lock()
	defer s.orders.mu.Unlock()
	if s.orders.inbox[ev.ID] {
		return nil
	}
	p := ev.Payload.(RejectedPayload)
	if o, ok := s.orders.orders[p.OrderID]; ok && o.Status == Created {
		o.Status = Cancelled
	}
	s.orders.inbox[ev.ID] = true
	return nil
}

// release is the compensation step: it returns the held quantity exactly
// once, keyed by the reservation row.
func (s *System) release(ev Event) error {
	s.inventory.mu.Lock()
	defer s.inventory.mu.Unlock()
	if s.inventory.inbox[ev.ID] {
		return nil
	}
	p := ev.Payload.(ReservedPayload)
	if qty, ok := s.inventory.held[p.OrderID]; ok {
		delete(s.inventory.held, p.OrderID)
		s.inventory.stock += qty
	}
	s.inventory.inbox[ev.ID] = true
	return nil
}

// Reconcile repairs what at-least-once delivery alone cannot: lost tail
// events, a payment recorded before the status update, and a cancelled order
// whose reservation was never released. Every repair is idempotent.
func (s *System) Reconcile() {
	s.orders.mu.Lock()
	snap := make([]Order, 0, len(s.orders.orders))
	for _, o := range s.orders.orders {
		snap = append(snap, *o)
	}
	paid := make(map[string]bool, len(s.orders.payments))
	for id := range s.orders.payments {
		paid[id] = true
	}
	s.orders.mu.Unlock()
	var repairs []Event
	for _, o := range snap {
		switch {
		case o.Status == Created && s.isHeld(o.ID):
			repairs = append(repairs, Event{ID: "reserved-" + o.ID, Topic: "InventoryReserved", Payload: ReservedPayload{o.ID}})
		case o.Status == Created:
			repairs = append(repairs, Event{ID: "created-" + o.ID, Topic: "OrderCreated", Payload: CreatedPayload{o.ID, o.SKU, o.Qty}})
		case o.Status == Reserved && paid[o.ID]:
			s.orders.mu.Lock()
			s.orders.orders[o.ID].Status = Paid
			s.orders.mu.Unlock()
		case o.Status == Reserved && s.isHeld(o.ID):
			repairs = append(repairs, Event{ID: "reserved-" + o.ID, Topic: "InventoryReserved", Payload: ReservedPayload{o.ID}})
		case o.Status == Cancelled && s.isHeld(o.ID):
			repairs = append(repairs, Event{ID: "release-" + o.ID, Topic: "ReleaseRequested", Payload: ReservedPayload{o.ID}})
		}
	}
	s.orders.mu.Lock()
	for _, ev := range repairs {
		s.orders.push(ev)
	}
	s.orders.mu.Unlock()
}

func (s *System) isHeld(orderID string) bool {
	s.inventory.mu.Lock()
	defer s.inventory.mu.Unlock()
	_, ok := s.inventory.held[orderID]
	return ok
}

// Diff returns every broken invariant; an empty slice means convergence.
func (s *System) Diff() []string {
	var out []string
	s.inventory.mu.Lock()
	held := 0
	for _, qty := range s.inventory.held {
		held += qty
	}
	if s.inventory.stock < 0 || s.inventory.stock+held != s.inventory.initial {
		out = append(out, fmt.Sprintf("stock=%d held=%d want=%d", s.inventory.stock, held, s.inventory.initial))
	}
	s.inventory.mu.Unlock()
	s.orders.mu.Lock()
	for _, o := range s.orders.orders {
		if o.Status == Paid && (!s.orders.payments[o.ID] || !s.isHeld(o.ID)) {
			out = append(out, "paid invariant broken: "+o.ID)
		}
		if o.Status == Cancelled && s.isHeld(o.ID) {
			out = append(out, "cancelled invariant broken: "+o.ID)
		}
	}
	s.orders.mu.Unlock()
	return out
}

实测:故障场景全部收敛

命令:go test ./internal/orderflow/ -count=1 -v,并额外跑 go test -race ./internal/orderflow/ -count=1。环境为 macOS darwin/arm64、Go 1.26.0。完整输出在 .local/reports/be-arch/be-arch-dist-13-2-cross-shard-order-consistency-test-run.txt。

=== RUN   TestCrossShardOrderFlow/baseline
    orderflow_test.go:68: paid=40 cancelled=0 stock=960 payments=40 diff=0
=== RUN   TestCrossShardOrderFlow/oversell
    orderflow_test.go:68: paid=5 cancelled=5 stock=0 payments=5 diff=0
=== RUN   TestCrossShardOrderFlow/decline_and_crash
    orderflow_test.go:68: paid=37 cancelled=3 stock=963 payments=37 diff=0
ok  	archlab/internal/orderflow	0.247s

基线、重复投递、库存超时、支付超时和 relay 崩溃这五个场景的读数完全相同:40 个订单全部 PAID,库存从 1000 降到 960,支付记录 40 条,不变量差异为 0。重复投递没有把库存扣两次,提交后超时没有产生第二条预占,支付超时没有产生第二笔支付。超卖场景用 10 个订单抢 5 件库存,结果是 5 个 PAID、5 个 CANCELLED、库存 0,没有负库存。支付拒绝叠加重复投递和 relay 崩溃时,37 个 PAID、3 个 CANCELLED、库存 963,37 条支付记录对应 37 个成功订单,3 个被拒订单没有支付记录,差异仍为 0。

-race 输出 ok archlab/internal/orderflow 1.279s,说明这些互斥锁路径没有数据竞争。需要强调:这是单进程确定性实验,broker 的重复和超时是注入值,不是真实 Kafka 或网络分区;它证明的是状态机、幂等键和对账逻辑,不证明生产环境的吞吐与故障概率。

常见错误与边界

  • 把 outbox 当成恰好一次。outbox 只保证“业务提交了,消息最终会发”,重复投递是常态,消费端必须有 inbox。
  • 用消息 ID 做支付幂等键,却在重试时换了一个新消息 ID。幂等键要绑定业务操作,比如订单号,不能绑定投递次数。
  • 支付超时后直接判失败并退款。对方可能已经扣款,正确做法是带幂等键重试或查询支付状态,再决定补偿。
  • 只修主链路,不写对账。丢失的尾事件、扣款后崩溃、取消后释放丢失,都需要对账任务兜底;对账本身也必须幂等。

小结

跨库下单的一致性不是“引入一个消息队列”就结束了,而是四个机制叠在一起:本地事务写 outbox 保证发布,inbox 去重保证消费幂等,支付幂等键保证不重复扣款,Saga 补偿和对账保证中间态最终收敛。本节用 6 个故障场景加 1 个基线验证了同一组不变量:不超卖、状态一致、取消干净、支付唯一,所有场景的差异都为 0。到这里,卷三的机制已经跑完一轮。下一节不再加新组件,而是把这一卷仍然没有证明的假设——共识性能、跨单元冲突、演练覆盖和组织成本——整理成一份可以验收的交接表,交给卷四。

阅读导航:上一节:订单系统的分布式改造:从单库到分片 · 下一节:进入卷四:演进与治理 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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