拆 Order 与 Inventory 之前
1.2 的边界评审给出一个候选:order-api、order-worker 和 payment 留在订单侧,inventory 与 notify 放到另一侧。真正动手前,arch-lab 先做了一次进程内演练:同一套场景分别跑在单体、同步拆分和事件拆分三种形态下,观察失败时订单与库存各自停在哪里。演练暴露出共享表读取、预占事务跨服务、远程失败无法回滚三个问题;不解决它们,拆出去只会把本地事务换成分布式不一致。
共享数据先对齐
| 数据 | 写入方 | 拆分前读取方 | 拆分后允许的读取方式 |
|---|---|---|---|
orders | Order | Order | Order 自己 |
stock | Inventory | Order 直接读表 | Inventory API 或带版本的只读投影 |
reservations | Inventory | Order 直接读表 | 预占结果事件或查询 API |
共享写必须消失,共享读必须有契约。订单服务展示“可售数量”时,如果继续读库存表,数据库迁移、字段改名、索引调整都会重新耦合两个团队;更糟的是,它可能看到未提交或已过期的库存。拆分后的读模型要带版本或时间戳,让订单侧知道数据有多旧。写路径只能有一个 owner:stock 和 reservations 由 Inventory 写,Order 只能通过预占命令改变它们;相关决策应写进数据所有权 ADR,而不是留在代码注释里。
同步调用只是把事务拉长
同步拆分下,Order 先创建 CREATED 订单,再调用 Inventory 预占。远程调用失败时,本地订单不能假装没发生:它已经提交,只能保留 CREATED 并等待重试或补偿。单体模式下,预占失败会删除订单行,因为两个写操作在同一个事务里;这个差异是拆分带来的真实成本,不是实现瑕疵。
| 维度 | 单体事务 | 同步拆分 | 事件拆分 |
|---|---|---|---|
| 失败时订单状态 | 无订单行 | CREATED,等待补偿 | CREATED,等待事件 |
| 重试安全性 | 不需要 | 依赖预占幂等 | 依赖消费幂等 |
同步调用适合“用户必须立刻知道预占结果”的场景,代价是下单链路的尾延迟被库存服务绑定。事件拆分把预占移出用户请求,但订单在确认前处于中间态,前端必须显示“处理中”,超时与取消规则也要重新定义。两种方式的选择可以对照同步调用与耦合 和事件、命令与消息 。
回滚开关要能拒绝
演练里的 SwitchMode 不是随时可拨的开关。只要 outbox 里还有未投递事件,或有订单停在 CREATED,切回单体就会让在途状态失去处理者。正确顺序是:停止新流量、排空 outbox、确认没有 CREATED 订单、再切换部署形态。
回滚不是“把开关拨回去”,而是让两种形态在数据上都能被解释;开关与数据兼容可对照功能开关与数据兼容 。
可运行演练
下面的包用内存表模拟订单与库存,三种模式共用同一个 PlaceOrder。单体模式在预占失败时删除订单;同步模式保留 CREATED;事件模式先写 outbox,broker 恢复后由 DeliverOutbox 投递并应用。预占按订单号幂等,重复投递不会二次扣减;正文只列事件投递的核心用例,单体原子性、同步重试、库存拒绝和回滚拦截由同包其余测试覆盖。
// file: internal/platform/split/rehearsal.go
// tier: L1
// verify: go test ./internal/platform/split/ -count=1
package split
import (
"errors"
"fmt"
"sync"
)
// Status is the arch-lab order state machine.
type Status string
const (
StatusCreated Status = "CREATED"
StatusInventoryReserved Status = "INVENTORY_RESERVED"
StatusPaid Status = "PAID"
StatusFulfilled Status = "FULFILLED"
StatusCancelled Status = "CANCELLED"
)
var (
ErrOutOfStock = errors.New("split: out of stock")
ErrDependencyUnavailable = errors.New("split: inventory dependency unavailable")
ErrRollbackBlocked = errors.New("split: rollback blocked")
)
// Stock is the inventory table. Reserve is idempotent per order id.
type Stock struct {
mu sync.Mutex
available map[string]int
reserved map[string]bool
}
// NewStock copies initial stock and rejects negative quantities.
func NewStock(initial map[string]int) (*Stock, error) {
s := &Stock{available: make(map[string]int, len(initial)), reserved: make(map[string]bool)}
for sku, qty := range initial {
if sku == "" || qty < 0 {
return nil, fmt.Errorf("stock: invalid sku or quantity %q", sku)
}
s.available[sku] = qty
}
return s, nil
}
// Available returns current stock for sku.
func (s *Stock) Available(sku string) int {
s.mu.Lock()
defer s.mu.Unlock()
return s.available[sku]
}
// Reserve decrements stock once per order and returns a deterministic id.
func (s *Stock) Reserve(orderID, sku string, qty int) (string, error) {
if orderID == "" || sku == "" || qty <= 0 {
return "", fmt.Errorf("stock: invalid reservation")
}
s.mu.Lock()
defer s.mu.Unlock()
if s.reserved[orderID] {
return "res-" + orderID, nil
}
if s.available[sku] < qty {
return "", ErrOutOfStock
}
s.available[sku] -= qty
s.reserved[orderID] = true
return "res-" + orderID, nil
}
// Order is the public order aggregate used in the rehearsal.
type Order struct {
ID string
SKU string
Qty int
Status Status
ReservationID string
}
// Mode selects the deployment shape under test.
type Mode string
const (
ModeMonolith Mode = "monolith"
ModeSplitSync Mode = "split_sync"
ModeSplitEvent Mode = "split_event"
)
func (m Mode) valid() bool {
return m == ModeMonolith || m == ModeSplitSync || m == ModeSplitEvent
}
// Event is an outbox/inbox message.
type Event struct {
Type string
OrderID string
SKU string
Qty int
ReservationID string
}
const (
EventOrderCreated = "order.created"
EventInventoryReserved = "inventory.reserved"
EventInventoryRejected = "inventory.rejected"
)
// Harness runs the same scenarios under three deployment shapes.
type Harness struct {
mu sync.Mutex
mode Mode
stock *Stock
orders map[string]*Order
outbox []Event
brokerDown bool
inventoryDown bool
}
// NewHarness validates the mode and stock dependency.
func NewHarness(mode Mode, stock *Stock) (*Harness, error) {
if stock == nil || !mode.valid() {
return nil, fmt.Errorf("split: invalid harness")
}
return &Harness{mode: mode, stock: stock, orders: make(map[string]*Order)}, nil
}
// PlaceOrder is idempotent per order id. In monolith mode a failed reservation
// leaves no row; in split modes a failed remote step leaves CREATED.
func (h *Harness) PlaceOrder(id, sku string, qty int) (Order, error) {
h.mu.Lock()
defer h.mu.Unlock()
if id == "" || sku == "" || qty <= 0 {
return Order{}, fmt.Errorf("split: invalid order")
}
if o, ok := h.orders[id]; ok {
if h.mode == ModeSplitSync && o.Status == StatusCreated {
if err := h.reserveLocked(o); err != nil {
return *o, err
}
}
return *o, nil
}
o := &Order{ID: id, SKU: sku, Qty: qty, Status: StatusCreated}
h.orders[id] = o
switch h.mode {
case ModeMonolith:
if err := h.reserveLocked(o); err != nil {
delete(h.orders, id)
return Order{}, err
}
case ModeSplitSync:
if err := h.reserveLocked(o); err != nil {
return *o, err
}
case ModeSplitEvent:
h.outbox = append(h.outbox, Event{Type: EventOrderCreated, OrderID: id, SKU: sku, Qty: qty})
}
return *o, nil
}
func (h *Harness) reserveLocked(o *Order) error {
if h.inventoryDown {
return ErrDependencyUnavailable
}
id, err := h.stock.Reserve(o.ID, o.SKU, o.Qty)
if errors.Is(err, ErrOutOfStock) {
o.Status = StatusCancelled
return err
}
if err != nil {
return err
}
o.Status, o.ReservationID = StatusInventoryReserved, id
return nil
}
// DeliverOutbox moves order.created events to the inventory consumer. A down
// broker keeps them queued; a duplicate delivery sees a non-CREATED status
// and is ignored. At-least-once delivery plus a status guard is idempotent.
func (h *Harness) DeliverOutbox() int {
h.mu.Lock()
defer h.mu.Unlock()
if h.brokerDown {
return 0
}
delivered := 0
next := h.outbox[:0]
for _, ev := range h.outbox {
o := h.orders[ev.OrderID]
if o == nil || o.Status != StatusCreated {
delivered++
continue
}
if h.inventoryDown {
next = append(next, ev)
continue
}
id, err := h.stock.Reserve(ev.OrderID, ev.SKU, ev.Qty)
switch {
case err == nil:
o.Status, o.ReservationID = StatusInventoryReserved, id
case errors.Is(err, ErrOutOfStock):
o.Status = StatusCancelled
default:
next = append(next, ev)
continue
}
delivered++
}
h.outbox = next
return delivered
}
// SwitchMode is the rollback switch. Returning to the monolith is blocked
// while events are undelivered or orders are still CREATED.
func (h *Harness) SwitchMode(next Mode) error {
h.mu.Lock()
defer h.mu.Unlock()
if !next.valid() {
return fmt.Errorf("split: unknown mode %q", next)
}
if next == ModeMonolith && h.mode != ModeMonolith {
if len(h.outbox) > 0 {
return fmt.Errorf("%w: %d undelivered events", ErrRollbackBlocked, len(h.outbox))
}
for _, o := range h.orders {
if o.Status == StatusCreated {
return fmt.Errorf("%w: order %s still CREATED", ErrRollbackBlocked, o.ID)
}
}
}
h.mode = next
return nil
}
// Order returns a copy of one order.
func (h *Harness) Order(id string) (Order, bool) {
h.mu.Lock()
defer h.mu.Unlock()
o, ok := h.orders[id]
if !ok {
return Order{}, false
}
return *o, true
}
// Fail injects broker and inventory outages.
func (h *Harness) Fail(broker, inventory bool) {
h.mu.Lock()
defer h.mu.Unlock()
h.brokerDown, h.inventoryDown = broker, inventory
}
// file: internal/platform/split/rehearsal_test.go
// tier: L1
// verify: go test ./internal/platform/split/ -count=1
package split
import "testing"
func newHarness(t *testing.T, mode Mode, stock map[string]int) (*Harness, *Stock) {
t.Helper()
s, err := NewStock(stock)
if err != nil {
t.Fatalf("NewStock: %v", err)
}
h, err := NewHarness(mode, s)
if err != nil {
t.Fatalf("NewHarness: %v", err)
}
return h, s
}
func TestEventDelivery(t *testing.T) {
h, stock := newHarness(t, ModeSplitEvent, map[string]int{"sku": 1})
h.Fail(true, false)
o, err := h.PlaceOrder("o1", "sku", 1)
if err != nil || o.Status != StatusCreated {
t.Fatalf("place: err=%v status=%s", err, o.Status)
}
if got := h.DeliverOutbox(); got != 0 {
t.Fatalf("delivered=%d while broker down", got)
}
h.Fail(false, false)
if got := h.DeliverOutbox(); got != 1 {
t.Fatalf("delivered=%d, want 1", got)
}
if id, err := stock.Reserve("o1", "sku", 1); err != nil || id != "res-o1" {
t.Fatalf("idempotent reserve id=%s err=%v", id, err)
}
o, _ = h.Order("o1")
t.Logf("status=%s reservation=%s stock=%d", o.Status, o.ReservationID, stock.Available("sku"))
if o.Status != StatusInventoryReserved || stock.Available("sku") != 0 {
t.Fatalf("status=%s stock=%d", o.Status, stock.Available("sku"))
}
}
本机实测
module archlab,Go 1.26.0,macOS darwin/arm64。命令与原始输出如下:
$ cd .local/checks/be-arch-gov-ch01
$ gofmt -l .
$ go vet ./...
$ go test ./internal/platform/split/ -run '^(TestMonolithAtomicity|TestSplitSyncFailureAndRetry|TestEventDelivery|TestEventRejection|TestRollbackSwitch)$' -count=1 -v
=== RUN TestMonolithAtomicity
rehearsal_test.go:36: status=INVENTORY_RESERVED stock=0
--- PASS: TestMonolithAtomicity (0.00s)
=== RUN TestSplitSyncFailureAndRetry
rehearsal_test.go:57: retry status=INVENTORY_RESERVED reservation=res-o1 stock=0
--- PASS: TestSplitSyncFailureAndRetry (0.00s)
=== RUN TestEventDelivery
rehearsal_test.go:81: status=INVENTORY_RESERVED reservation=res-o1 stock=0
--- PASS: TestEventDelivery (0.00s)
=== RUN TestEventRejection
rehearsal_test.go:94: status=CANCELLED stock=0
--- PASS: TestEventRejection (0.00s)
=== RUN TestRollbackSwitch
rehearsal_test.go:118: order=o2 status=INVENTORY_RESERVED stock=0
--- PASS: TestRollbackSwitch (0.00s)
PASS
ok archlab/internal/platform/split 0.261s
读数对应三条边界。单体模式下,库存不足的订单不落库,库存保持 2;同步模式下,库存依赖不可用时订单停在 CREATED、库存仍为 1,恢复后重试变成 INVENTORY_RESERVED、库存归零;事件模式下,broker 关闭时投递数为 0,恢复后投递 1 条并完成预占,重复预占返回同一 res-o1 且不二次扣减;库存为 0 时事件消费把订单置为 CANCELLED;回滚在 outbox 未排空时被拒绝,排空后才允许切回单体。全量 go test ./... -count=1 -v 通过,末尾为 ok archlab/internal/platform/split 0.452s。这些数字来自单进程内存表,不包含真实数据库事务、broker 持久化、网络分区和消费者重启;上线前必须用真实依赖重跑,并观察 outbox 积压、重复投递率和订单中间态时长。
常见错误与边界
- 同步调用失败就删本地订单。远程预占可能已经提交,删除本地行会留下无法对账的库存;应保留
CREATED并用幂等重试或补偿关闭。 - 事件发出就算完成。订单要等消费结果才能离开
CREATED,前端、超时和取消规则都要覆盖中间态。 - 忽略重复投递。预占必须按订单号幂等,消费必须用状态守卫,否则 at-least-once 会变成重复扣减。
小结
Order 与 Inventory 的拆分难点不在服务骨架,而在三条数据缝:共享表读取、跨服务预占事务、回滚时的在途状态。本节用同一套场景把单体、同步和事件三种形态放在一起跑,失败时订单和库存的位置一目了然:单体没有残留行,同步留下 CREATED,事件留下 outbox 与中间态。同步改事件并不会消除一致性,只是把等待从请求链路移到消费链路;事务性 outbox
保证事件不丢,幂等预占和状态守卫保证重复投递不重复扣减,回滚开关则必须等状态收敛后才能拨动。真实上线还需要把这些假设放到真实 broker、数据库和故障注入下验证。
阅读导航:上一节:1.2 拆分粒度:业务能力、数据所有权与变更频率 · 下一节:2.1 同步调用与异步事件:组合而不是二选一 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。