订单在 3 号分片,库存在 7 号分片
用户 A 的订单按 userID 落在 3 号分片,商品 SKU 的库存按 SKU 落在 7 号分片。下单要同时做三件事:写订单、占库存、扣支付。第一件和第三件可能在订单库,第二件在库存库,没有跨库的本地事务。常见直觉是“用 outbox 就不会不一致”。outbox 解决的是业务写入与消息发布的原子性:订单和 OrderCreated 事件在同一个本地事务里提交,relay 至少投递一次。它不保证消费端只执行一次,也不保证超时后调用方知道对方到底提交没有。重复投递、提交后超时、relay 发完消息后崩溃,都会让“至少一次”变成重复副作用或中间态。要守住不变量,还需要消费端 inbox 去重、支付幂等键、Saga 补偿和对账收敛。订单分片的路由与迁移门在订单系统的分布式改造
已经定义;Saga 的补偿顺序和语义锁在 Saga:补偿、语义锁与不可补偿步骤
有完整讨论。本节只做一件事:把跨库下单跑成进程内 L2 场景,注入故障并断言不变量。
链路、故障与四条不变量
- 订单分片本地事务写
Order(CREATED)和 outboxOrderCreated。 - 库存分片消费
OrderCreated,inbox 去重后扣减可售库存、写预占行和 outboxInventoryReserved;库存不足则写InventoryRejected。 - 订单分片消费
InventoryReserved,用订单号作为支付幂等键扣款,成功置PAID;支付拒绝则置CANCELLED并写ReleaseRequested。 - 库存分片消费
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。到这里,卷三的机制已经跑完一轮。下一节不再加新组件,而是把这一卷仍然没有证明的假设——共识性能、跨单元冲突、演练覆盖和组织成本——整理成一份可以验收的交接表,交给卷四。
阅读导航:上一节:订单系统的分布式改造:从单库到分片 · 下一节:进入卷四:演进与治理 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。