一次拆完就上线的演练
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_RESERVED | hold 等于订单数量 | 无扣款 | 继续扣款或补偿 |
PAID | hold 已转 committed | 扣款等于订单金额 | 提交并履约 |
FULFILLED | committed 等于订单数量 | 扣款等于订单金额 | 只允许退款 |
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 门禁与混沌演练的发布 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。