《后端架构设计·演进与治理》13.1 arch-lab 从模块化单体到两服务

从 arch-lab 订单系统把库存与支付拆成两个进程内服务的一次演练切入,对比共享库、单体事务与端口补偿三种方案,给出带状态机、幂等预占、崩溃恢复和跨服务审计的 Go 实现,并用固定时钟与失败注入跑通支付失败补偿和两处崩溃点恢复的完整测试。

一次拆完就上线的演练

arch-lab 的订单系统原本是一个模块化单体:Order、InventoryReservation、Payment 三个聚合在同一个进程里,靠本地事务保证“预占库存、扣款、改状态”要么全成要么全败。大促前,团队决定把库存和支付拆出去,理由很直接:库存有大促峰值,支付有独立的风控与合规窗口,两者的扩容节奏不同。

第一版拆得很省事:两个新服务仍然连同一个数据库,订单服务直接 UPDATE inventory SET stock = stock - ?,支付服务回写 payment 表。演示环境一切正常,压测时却出现了库存扣成负数、订单已支付但预占记录被另一个服务释放的情况。问题不在 SQL,而在于“拆了进程却没拆数据所有权”,两个服务对同一行数据各自解释事务边界。

第二版改成端口与适配器:订单服务只持有订单状态,库存服务只持有库存与预占,支付服务只持有扣款记录;跨服务调用全部经过接口,失败用补偿而不是回滚。本节把这个版本做成进程内可运行的实验:两个端口、一个状态机、固定时钟和两处崩溃注入点,并用审计函数检查“订单状态、预占数量、扣款金额”三者是否一致。第 1 章讨论过绞杀者迁移 的切换顺序,第 6 章讨论过事务边界与 outbox ;这里只回答拆完之后,进程内的最小正确性长什么样。

三种拆分姿势的成本

方案数据边界失败语义主要风险适用阶段
共享库,直接读写没有边界依赖数据库事务任一方改表即破坏对方只适合过渡期只读
共享库,各自封装表面边界仍是一个本地事务锁竞争、部署耦合拆分前最后一步
端口加补偿每服务独立存储本地事务加补偿与重试需要幂等、恢复与审计目标形态

共享库的成本不是“多一次连接”,而是把两个服务的发布、锁和故障域重新绑在一起。支付服务一次慢查询会持有库存行的锁,库存服务一次 DDL 会让支付发布回滚。端口加补偿则把失败显式化:预占成功、扣款失败时,订单进入 CANCELLED 并释放库存;扣款成功、落状态前崩溃时,恢复流程必须能识别“钱已扣、单未完成”,并且不重复扣款。

代价也很明确:每一步都要回答“重复执行会怎样”。预占按订单号幂等,扣款按订单号幂等,提交预占是“把 hold 变成 committed”的幂等操作。缺少其中任何一个,补偿就会在重试时制造第二笔扣款或第二次扣库存。

状态机与三条不变量

状态机只有一条主链:CREATED → INVENTORY_RESERVED → PAID → FULFILLED,两个终止分支是 CANCELLED 与 REFUNDED。每次迁移只允许表里列出的边,任何跳跃都返回错误。状态本身不携带远程数据,只携带订单自己的字段;库存数量与扣款金额分别由两个端口回答。

状态库存应满足支付应满足违反后的动作
CREATED无 hold无扣款重试或取消
INVENTORY_RESERVEDhold 等于订单数量无扣款继续扣款或补偿
PAIDhold 已转 committed扣款等于订单金额提交并履约
FULFILLEDcommitted 等于订单数量扣款等于订单金额只允许退款
CANCELLED无 hold无有效扣款终态,人工复核

三条不变量是审计函数的全部依据:库存守恒(现存量加 hold 等于初始量减 committed)、状态与预占一致、状态与扣款一致。它们都不是单个服务能独立检查的,因为每条都跨越两个存储。这也是拆分后必须新增的测试类型:不是再测一遍“下单成功”,而是测“失败与崩溃之后,三份数据仍然对得上”。

可运行实现

下面的代码是本节实验的全部实现:订单服务只依赖两个端口,端口方法都按订单号幂等。实验是单 goroutine 的,生产环境需要 per-order 锁或数据库唯一约束,文末的边界一节会说明原因。

// file: internal/evolve/twoservice.go
// tier: L1
// verify: go test ./internal/evolve/ -count=1 -run TestPlace
//
// Single-goroutine experiment. Production needs a per-order lock or an
// idempotency key with a unique constraint; see the boundary notes.
package evolve

import (
	"errors"
	"fmt"
	"time"
)

type State string

const (
	Created           State = "CREATED"
	InventoryReserved State = "INVENTORY_RESERVED"
	Paid              State = "PAID"
	Fulfilled         State = "FULFILLED"
	Cancelled         State = "CANCELLED"
	Refunded          State = "REFUNDED"
)

var (
	ErrNotFound = errors.New("evolve: not found")
	ErrInjected = errors.New("evolve: injected failure")
	allowed     = map[State][]State{
		Created:           {InventoryReserved, Cancelled},
		InventoryReserved: {Paid, Cancelled},
		Paid:              {Fulfilled, Refunded},
		Fulfilled:         {Refunded},
	}
)

type Order struct {
	ID, SKU          string
	Qty, AmountCents int
	State            State
	UpdatedAt        time.Time
}

type Inventory interface {
	Reserve(orderID, sku string, qty int) error
	Release(orderID string) error
	Commit(orderID string) error
	Held(orderID string) int
	Committed(orderID string) int
	Check() []string
}

type Payments interface {
	Charge(orderID string, cents int) error
	Refund(orderID string) error
	Captured(orderID string) int
}

type Orders struct {
	orders  map[string]*Order
	inv     Inventory
	pay     Payments
	now     func() time.Time
	CrashAt State
}

func NewOrders(inv Inventory, pay Payments, now func() time.Time) *Orders {
	return &Orders{orders: map[string]*Order{}, inv: inv, pay: pay, now: now}
}

type Request struct {
	ID, SKU          string
	Qty, AmountCents int
}

func (o *Orders) snap(id string) *Order {
	if order := o.orders[id]; order != nil {
		cp := *order
		return &cp
	}
	return nil
}

func (o *Orders) move(id string, to State) error {
	order := o.orders[id]
	if order == nil {
		return ErrNotFound
	}
	for _, next := range allowed[order.State] {
		if next == to {
			order.State, order.UpdatedAt = to, o.now()
			return nil
		}
	}
	return fmt.Errorf("evolve: illegal %s -> %s", order.State, to)
}

func (o *Orders) Place(req Request) (*Order, error) {
	if req.ID == "" || req.Qty <= 0 || req.AmountCents <= 0 {
		return nil, fmt.Errorf("evolve: invalid request %q", req.ID)
	}
	if existing := o.orders[req.ID]; existing != nil {
		cp := *existing
		return &cp, nil
	}
	o.orders[req.ID] = &Order{ID: req.ID, SKU: req.SKU, Qty: req.Qty,
		AmountCents: req.AmountCents, State: Created, UpdatedAt: o.now()}
	if err := o.inv.Reserve(req.ID, req.SKU, req.Qty); err != nil {
		o.cancel(req.ID)
		return o.snap(req.ID), err
	}
	if err := o.move(req.ID, InventoryReserved); err != nil {
		return o.snap(req.ID), err
	}
	if o.CrashAt == InventoryReserved {
		return o.snap(req.ID), ErrInjected
	}
	return o.chargeAndFulfil(req.ID)
}

func (o *Orders) chargeAndFulfil(id string) (*Order, error) {
	order := o.snap(id)
	if order == nil {
		return nil, ErrNotFound
	}
	if err := o.pay.Charge(id, order.AmountCents); err != nil {
		o.cancel(id)
		return o.snap(id), err
	}
	if err := o.move(id, Paid); err != nil {
		return o.snap(id), err
	}
	if o.CrashAt == Paid {
		return o.snap(id), ErrInjected
	}
	if err := o.inv.Commit(id); err != nil {
		return o.snap(id), err
	}
	if err := o.move(id, Fulfilled); err != nil {
		return o.snap(id), err
	}
	return o.snap(id), nil
}

func (o *Orders) cancel(id string) {
	_ = o.pay.Refund(id)
	_ = o.inv.Release(id)
	if order := o.orders[id]; order != nil && order.State != Fulfilled {
		order.State, order.UpdatedAt = Cancelled, o.now()
	}
}

func (o *Orders) Resume(id string) (*Order, error) {
	order := o.snap(id)
	if order == nil {
		return nil, ErrNotFound
	}
	switch order.State {
	case InventoryReserved:
		return o.chargeAndFulfil(id)
	case Paid:
		if err := o.inv.Commit(id); err != nil {
			return o.snap(id), err
		}
		if err := o.move(id, Fulfilled); err != nil {
			return o.snap(id), err
		}
		return o.snap(id), nil
	case Created:
		return nil, fmt.Errorf("evolve: %s has no reservation; retry Place", id)
	default:
		return order, nil
	}
}

func (o *Orders) Audit() []string {
	var out []string
	for id, order := range o.orders {
		held, committed, paid := o.inv.Held(id), o.inv.Committed(id), o.pay.Captured(id)
		switch order.State {
		case InventoryReserved:
			if held != order.Qty || paid != 0 {
				out = append(out, id+": reserved order disagrees with stock or capture")
			}
		case Paid, Fulfilled:
			if committed != order.Qty || paid != order.AmountCents {
				out = append(out, id+": paid order lacks committed stock or capture")
			}
		case Cancelled:
			if held != 0 || paid != 0 {
				out = append(out, id+": cancelled order still holds stock or money")
			}
		}
	}
	return append(out, o.inv.Check()...)
}

测试用 90 行内存适配器实现两个端口,并注入支付失败与两处崩溃点。下面是两个关键用例,完整测试文件在 .local/reports/be-arch-gov/ch13-full.txt 记录的命令下运行:

// file: internal/evolve/twoservice_test.go
// tier: L1
// verify: go test ./internal/evolve/ -count=1 -run TestPlace
func TestPlaceHappyPath(t *testing.T) {
	o, m := fixture(5)
	order := place(t, o, "ord-1", 2)
	if order.State != Fulfilled || m.stock != 3 || m.captured["ord-1"] != 19900 {
		t.Fatalf("state=%s stock=%d captured=%d", order.State, m.stock, m.captured["ord-1"])
	}
	if got := o.Audit(); len(got) != 0 {
		t.Fatalf("audit = %v", got)
	}
}

func TestPlaceCrashResume(t *testing.T) {
	for _, at := range []State{InventoryReserved, Paid} {
		o, m := fixture(5)
		o.CrashAt = at
		order, err := o.Place(Request{ID: "ord-3", SKU: "SKU-1", Qty: 2, AmountCents: 19900})
		if !errors.Is(err, ErrInjected) || order.State != at {
			t.Fatalf("at %s: err=%v state=%v", at, err, order.State)
		}
		resumed, err := o.Resume("ord-3")
		if err != nil || resumed.State != Fulfilled {
			t.Fatalf("at %s: resume err=%v state=%v", at, err, resumed.State)
		}
		if m.stock != 3 || m.captured["ord-3"] != 19900 {
			t.Fatalf("at %s: stock=%d captured=%d", at, m.stock, m.captured["ord-3"])
		}
		if got := o.Audit(); len(got) != 0 {
			t.Fatalf("at %s: audit = %v", at, got)
		}
	}
}

本机实测

环境为 macOS darwin/arm64、Go 1.26.0,命令在 .local/checks/be-arch-gov-ch13 下执行:

$ gofmt -l .
$ go vet ./...
$ go test ./internal/evolve/ -count=1 -v -run TestPlace
=== RUN   TestPlaceHappyPath
--- PASS: TestPlaceHappyPath (0.00s)
=== RUN   TestPlacePaymentFailureCompensates
--- PASS: TestPlacePaymentFailureCompensates (0.00s)
=== RUN   TestPlaceCrashResume
--- PASS: TestPlaceCrashResume (0.00s)
=== RUN   TestPlaceDuplicateIsIdempotent
--- PASS: TestPlaceDuplicateIsIdempotent (0.00s)
=== RUN   TestAuditDetectsLeak
--- PASS: TestAuditDetectsLeak (0.00s)
PASS
ok  	archlab/internal/evolve	0.267s

三个读数值得注意。支付失败用例里,订单停在 CANCELLED,库存回到 5,扣款为 0,审计为空,说明补偿路径没有留下半成品。崩溃用例分别停在 INVENTORY_RESERVED 和 PAID,Resume 之后都到达 FULFILLED,库存只扣一次、扣款只有一笔,说明“预占幂等 + 扣款幂等 + 提交幂等”这三条同时成立。最后一个用例故意把库存改成 9,审计立即报出守恒被破坏,证明审计不是只复述状态,而是真的交叉核对了两份存储。

这些数字只对本机的内存实现成立。真实部署还要验证 HTTP/gRPC 超时、数据库唯一约束、消息重投和跨进程时钟;本节证明的是状态机与补偿顺序,不是网络层的幂等。

常见错误与边界

第一,拆了进程却共享写库。这是最贵的假拆分:发布、锁和故障域仍然耦合,却失去了本地事务的原子性。只要两个服务都能写同一张表,事务边界就已经不存在了。

第二,只在成功路径上幂等。预占幂等、扣款幂等、提交幂等要分别验证;只做其中一个,崩溃恢复仍会重复扣库存或重复扣款。测试必须包含“崩溃在每一步之后”。

第三,把补偿当成回滚。补偿是新的正向操作,会失败、会重试、会被观察;回滚假设世界回到过去,补偿只保证最终一致。补偿失败必须留下可审计的差异,而不是吞掉错误。

第四,忽略单 goroutine 假设。本节代码没有 per-order 锁,两个并发请求可能同时通过重复检查。生产实现要么在订单号上加唯一约束,要么用分段锁;这项边界不能靠“流量不大”带过。

小结

从模块化单体拆到两服务,真正的分界线不是进程数,而是数据所有权和失败语义。共享库把两者都留在了原地;端口加补偿则要求每一步都能重复执行,并要求一个跨服务的审计函数来证明三份数据仍然一致。

本节用 196 行实现和一个内存适配器跑通了成功、支付失败、两处崩溃恢复、重复下单和库存篡改五种场景。它能证明的是进程内的状态机与补偿顺序正确;它不能证明真实网络下的超时、重试和并发安全,后者需要在下一节的发布门禁与混沌演练里继续验证。

阅读导航:上一节:12.3 成本治理机制:预算、评审与责任到团队 · 下一节:13.2 一次带 SLO 门禁与混沌演练的发布 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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