《后端架构设计·演进与治理》1.3 订单域的一次拆分演练:边界、共享数据与回退

以 arch-lab 订单域把 Order 与 Inventory 拆成两个服务为例,演练共享表读取、同步调用改事件、outbox 幂等消费与回滚开关;用同一套 Go 场景在单体、同步、事件三种形态下对比失败行为,附原始测试输出与可执行的回退判据。

拆 Order 与 Inventory 之前

1.2 的边界评审给出一个候选:order-api、order-worker 和 payment 留在订单侧,inventory 与 notify 放到另一侧。真正动手前,arch-lab 先做了一次进程内演练:同一套场景分别跑在单体、同步拆分和事件拆分三种形态下,观察失败时订单与库存各自停在哪里。演练暴露出共享表读取、预占事务跨服务、远程失败无法回滚三个问题;不解决它们,拆出去只会把本地事务换成分布式不一致。

共享数据先对齐

数据写入方拆分前读取方拆分后允许的读取方式
ordersOrderOrderOrder 自己
stockInventoryOrder 直接读表Inventory API 或带版本的只读投影
reservationsInventoryOrder 直接读表预占结果事件或查询 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 同步调用与异步事件:组合而不是二选一 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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