《后端架构设计·架构基础与边界》8.1 绞杀者模式与增量迁移

库存模块的迁移不是把代码搬到新目录,而是让同一份库存始终只有一本账。本篇用 arch-lab 的 SKU 分桶路由、读路径双跑和可执行的退出判据,说明绞杀者模式落到有状态资源上时如何避免双扣、如何回退,以及什么时候才允许删掉旧路径,并给出可运行的 Go 实现与测试。

迁移的单位不是服务,是状态

站内 绞杀者模式 已经把绞杀者的通用套路讲清楚了:在旧系统前面放一层 facade,按入口把流量逐步引到新实现。那一套在不持有状态的服务上成立,因为请求走哪边只影响这一次调用。库存不是这样:每次扣减都会改变后续所有请求能看到的事实。路由键选错,不是“这次请求慢一点”,而是同一件商品在两边各卖了一次。所以本篇不重复模式定义,只回答一个工程问题:有状态模块的迁移路由应该按什么分桶,双跑能跑什么、不能跑什么,以及什么证据足够支撑删掉旧代码。演进式架构 说演进要能被度量,这一节把度量落成代码。

arch-lab 的迁移现场

arch-lab 的旧库有一张 legacy_inventory 表,reserve 直接在事务里执行 UPDATE ... SET available = available - $1。新模块 internal/inventory 使用独立 schema,预留记录带状态和归属,对外只有 Reserve、Release、Available 三个动作。迁移目标是让每个 SKU 在任意时刻只有一本权威账,而不是让新代码“大概能跑”。整个迁移分三个动作:写路径按 SKU 路由到唯一一侧;读路径在返回旧结果的同时影子调用新侧;退出判据连续满足后,删掉路由表和旧实现。第三个动作是删代码,不是把开关拨到 100%——开关留在那里,旧路径就永远有人会拨回去。

路由键决定成败

候选路由键看起来都合理,后果差别很大:

路由键迁移期后果
请求 ID同一笔重试可能落到另一侧,预留无法释放
订单号同一 SKU 的新旧订单分属两本账,可用量无法合并
客户 ID整个客户被一起切换,爆炸半径最大
SKU同一商品的扣减始终落在一侧,可逐商品回滚

按 SKU 分桶的代价是放量粒度粗:一个爆款 SKU 可能占大部分流量。但它换来一个更强的不变量——同一 SKU 的写只发生在一侧。流量比例可以慢慢调,账本分裂了却很难合并。哈希用 FNV-1a,不引入随机数,也不依赖进程启动时间。同一 SKU 在任何实例、任何重启后都得到同一个桶,路由表因此不需要在实例间同步。NewPercent 从 0 调到 100 时,受影响的 SKU 集合单调增加,不会出现上周走新、这周又回旧的情况。定向放量可以在这张表上加名单,但不要同时叠加两套互相打架的规则。

回退是契约问题,不是异常处理

新实现返回错误时是否回退,取决于错误语义,而不是错误看起来严不严重。ErrOutOfStock 是业务结论,回退到旧实现只会把同一个“没库存”再问一遍;ErrUnavailable 才表示这一侧暂时不可用。即便如此,超时也不能直接回退:新实现可能已经扣了库存,只是回包丢了,此时旧实现再扣一次就是双扣。

所以 Branch 的契约必须写死一句话:返回错误即表示没有产生任何预留。做不到这一点的实现,不能接进路由表。代码里额外的 ctx.Err() == nil 用来区分“下游不可用”和“调用方已经放弃”:调用方超时后回退没有意义,只会浪费一次旧库写入。Release 同样需要路由。预留 ID 由创建它的那一侧生成,新实现写入时加 nv- 前缀,迁移器按前缀回源。不要试图从 ID 格式猜归属,更不要在两侧都尝试释放——第二次释放会返回 not found,但第一次可能已经改掉了状态。

双跑只对读

写路径双跑在有状态资源上是危险的:旧实现扣一次、新实现再扣一次,库存直接少一倍。读路径不同,Available 不改变状态,比对新旧两侧的返回值是安全的。代码里的影子调用始终返回旧值,差异只进计数器;这样即使新实现算错,用户看到的仍然是当前权威结果。影子查询也要有独立的短超时和熔断。示例为了聚焦路由逻辑省略了这层,但生产实现不能让影子调用拖慢主路径:影子是观测手段,不是依赖。

代码

先看领域侧的最小契约,再看路由器和它的测试。

// file: internal/inventory/inventory.go
// tier: L1
// verify: go test ./internal/migration/
package inventory

import (
	"context"
	"errors"
)

var (
	ErrUnavailable = errors.New("inventory unavailable")
	ErrOutOfStock  = errors.New("out of stock")
)

type Reservation struct {
	ID, OrderID, SKU, Status string
	Quantity                 int
}

type Request struct {
	ReservationID, OrderID, SKU string
	Quantity                    int
}

// Branch 是新旧两侧都必须满足的最小契约。返回错误即表示没有产生
// 任何预留,这是允许回退的前提。
type Branch interface {
	Reserve(ctx context.Context, req Request) (Reservation, error)
	Release(ctx context.Context, reservationID string) error
	Available(ctx context.Context, sku string) (int, error)
}

这个文件只定义类型、错误和接口,没有任何存储细节。接口里三个方法都返回错误,且错误必须可分类:路由器和退出判据都依赖 errors.Is,而不是字符串匹配。

// file: internal/migration/router.go
// tier: L1
// verify: go test ./internal/migration/
package migration

import (
	"context"
	"errors"
	"fmt"
	"hash/fnv"
	"strings"
	"sync/atomic"

	"archlab/internal/inventory"
)

type Route string

const (
	RouteLegacy Route = "legacy"
	RouteNew    Route = "new"
)

type Config struct {
	NewPercent int  // 按 SKU 分桶后的放量比例,0–100
	ShadowRead bool // 只对查询双跑;写入永远单路由
}

type Stats struct {
	Legacy, New, Fallbacks, ShadowChecks, ShadowDiffs atomic.Int64
}

type Router struct {
	legacy, newer inventory.Branch
	cfg           Config
	stats         Stats
}

func NewRouter(legacy, newer inventory.Branch, cfg Config) (*Router, error) {
	switch {
	case legacy == nil || newer == nil:
		return nil, errors.New("migration: both branches are required")
	case cfg.NewPercent < 0 || cfg.NewPercent > 100:
		return nil, errors.New("migration: NewPercent out of range")
	}
	return &Router{legacy: legacy, newer: newer, cfg: cfg}, nil
}

// Decide 的路由键是 SKU,不是订单号或请求 ID。库存是有状态资源,
// 同一个 SKU 的扣减必须落在同一本账上,否则两侧各扣一次。
func (r *Router) Decide(sku string) Route {
	if sku == "" || r.cfg.NewPercent <= 0 {
		return RouteLegacy
	}
	if r.cfg.NewPercent >= 100 {
		return RouteNew
	}
	h := fnv.New32a()
	_, _ = h.Write([]byte(sku))
	if int(h.Sum32()%100) < r.cfg.NewPercent {
		return RouteNew
	}
	return RouteLegacy
}

func (r *Router) Reserve(ctx context.Context, req inventory.Request) (inventory.Reservation, error) {
	if r.Decide(req.SKU) == RouteNew {
		res, err := r.newer.Reserve(ctx, req)
		if err == nil {
			r.stats.New.Add(1)
			return res, nil
		}
		// 只有“确定没有副作用”的不可用错误才允许回退。超时可能是
		// 新实现已经扣了库存但没来得及回包,此时回退会双扣。
		if errors.Is(err, inventory.ErrUnavailable) && ctx.Err() == nil {
			r.stats.Fallbacks.Add(1)
			return r.legacy.Reserve(ctx, req)
		}
		return inventory.Reservation{}, err
	}
	r.stats.Legacy.Add(1)
	return r.legacy.Reserve(ctx, req)
}

// Release 必须回到创建预留的那一侧。新实现写入时用 "nv-" 前缀标记
// 归属,迁移器据此路由,不去猜 ID 的格式。
func (r *Router) Release(ctx context.Context, reservationID string) error {
	if strings.HasPrefix(reservationID, "nv-") {
		return r.newer.Release(ctx, reservationID)
	}
	return r.legacy.Release(ctx, reservationID)
}

// Available 是允许双跑的读路径:返回旧实现的结果,只统计差异。
func (r *Router) Available(ctx context.Context, sku string) (int, error) {
	got, err := r.legacy.Available(ctx, sku)
	if err != nil || !r.cfg.ShadowRead {
		return got, err
	}
	r.stats.ShadowChecks.Add(1)
	if want, serr := r.newer.Available(ctx, sku); serr != nil || want != got {
		r.stats.ShadowDiffs.Add(1)
	}
	return got, nil
}

type ExitCriteria struct {
	MinNewRequests, MaxFallbackBps, MaxShadowDiffBps int64
}

// Ready 是退出判据,不是仪表盘;false 时返回全部未满足条目。
func (s *Stats) Ready(c ExitCriteria) (bool, []string) {
	var out []string
	add := func(ok bool, format string, args ...any) {
		if !ok {
			out = append(out, fmt.Sprintf(format, args...))
		}
	}
	add(s.New.Load() >= c.MinNewRequests, "new-path requests %d < %d", s.New.Load(), c.MinNewRequests)
	total := s.New.Load() + s.Fallbacks.Load()
	add(total > 0 && s.Fallbacks.Load()*10000/total <= c.MaxFallbackBps, "fallback rate above %d bps", c.MaxFallbackBps)
	checks := s.ShadowChecks.Load()
	add(checks > 0 && s.ShadowDiffs.Load()*10000/checks <= c.MaxShadowDiffBps, "shadow diff above %d bps", c.MaxShadowDiffBps)
	return len(out) == 0, out
}

Decide 是纯函数,给定 SKU 和配置就有确定结果,测试可以反复调用一千次验证稳定性。Reserve 只在不可用且调用方仍存活时回退。Available 在影子差异时只加计数,不改变返回值。Ready 把“能不能退出”变成可断言的布尔值,而不是一张需要人解读的看板。

// file: internal/migration/router_test.go
// tier: L1
// verify: go test -race ./internal/migration/ -v
package migration

import (
	"context"
	"errors"
	"fmt"
	"testing"

	"archlab/internal/inventory"
)

type fake struct {
	id    string
	err   error
	avail int
	calls int
}

func (f *fake) Reserve(context.Context, inventory.Request) (inventory.Reservation, error) {
	f.calls++
	if f.err != nil {
		return inventory.Reservation{}, f.err
	}
	return inventory.Reservation{ID: f.id, Status: "RESERVED"}, nil
}

func (f *fake) Release(context.Context, string) error { f.calls++; return f.err }

func (f *fake) Available(context.Context, string) (int, error) { f.calls++; return f.avail, f.err }

func newTestRouter(t *testing.T, percent int) (*Router, *fake, *fake) {
	legacy, newer := &fake{id: "lg-r", avail: 10}, &fake{id: "nv-r", avail: 10}
	r, err := NewRouter(legacy, newer, Config{NewPercent: percent, ShadowRead: true})
	if err != nil {
		t.Fatal(err)
	}
	return r, legacy, newer
}

func TestDecideStableAndSpread(t *testing.T) {
	r, _, _ := newTestRouter(t, 50)
	first, newCount := r.Decide("sku-42"), 0
	for i := 0; i < 1000; i++ {
		if r.Decide("sku-42") != first {
			t.Fatalf("route changed at call %d", i)
		}
		if r.Decide(fmt.Sprintf("sku-%03d", i)) == RouteNew {
			newCount++
		}
	}
	t.Logf("new routes: %d/1000", newCount)
	if newCount < 400 || newCount > 600 {
		t.Fatalf("new route count = %d, want 400..600", newCount)
	}
}

func TestReserveFallsBackOnlyOnUnavailable(t *testing.T) {
	ctx, req := context.Background(), inventory.Request{ReservationID: "r-1", SKU: "sku-1"}
	r, legacy, newer := newTestRouter(t, 100)

	newer.err = inventory.ErrOutOfStock
	if _, err := r.Reserve(ctx, req); !errors.Is(err, inventory.ErrOutOfStock) {
		t.Fatalf("err = %v, want ErrOutOfStock", err)
	}
	if legacy.calls != 0 {
		t.Fatal("fallback must not run after stock-out")
	}

	newer.err = inventory.ErrUnavailable
	if res, err := r.Reserve(ctx, req); err != nil || res.ID != "lg-r" {
		t.Fatalf("res=%+v err=%v, want legacy fallback", res, err)
	}
	if got := r.stats.Fallbacks.Load(); got != 1 {
		t.Fatalf("fallbacks = %d, want 1", got)
	}
}

func TestShadowReadAndReleaseRouting(t *testing.T) {
	r, legacy, newer := newTestRouter(t, 50)
	legacy.avail, newer.avail = 7, 5
	if got, err := r.Available(context.Background(), "sku-1"); err != nil || got != 7 {
		t.Fatalf("got=%d err=%v, want legacy 7", got, err)
	}
	if c, d := r.stats.ShadowChecks.Load(), r.stats.ShadowDiffs.Load(); c != 1 || d != 1 {
		t.Fatalf("checks=%d diffs=%d, want 1/1", c, d)
	}
	before := newer.calls
	_ = r.Release(context.Background(), "nv-r-1")
	if newer.calls != before+1 {
		t.Fatalf("release did not route to new branch")
	}
}

func TestExitCriteriaRequiresEvidence(t *testing.T) {
	r, _, _ := newTestRouter(t, 50)
	criteria := ExitCriteria{MinNewRequests: 100, MaxFallbackBps: 100, MaxShadowDiffBps: 50}
	if ok, reasons := r.stats.Ready(criteria); ok || len(reasons) != 3 {
		t.Fatalf("ok=%v reasons=%v, want three blockers", ok, reasons)
	}
	s := &r.stats
	s.New.Store(10_000)
	s.Fallbacks.Store(10)
	s.ShadowChecks.Store(1_000)
	s.ShadowDiffs.Store(3)
	if ok, reasons := s.Ready(criteria); !ok {
		t.Fatalf("not ready: %v", reasons)
	}
	s.ShadowDiffs.Store(10)
	if ok, _ := s.Ready(criteria); ok {
		t.Fatal("must block on shadow drift")
	}
}

测试里的 fake 不碰数据库,但它必须遵守与真实实现相同的错误契约,否则回退分支根本测不到。TestDecideStableAndSpread 同时验证两件事:同一 SKU 不换边,以及 50% 配置下出现足够多的新路径样本。少了任何一个,放量都只是心理安慰。

退出判据

退出判据要回答的是“旧路径还有没有存在的必要”,因此每一条都必须能被代码判定:

指标示例阈值为什么是这个指标
新路径成功请求≥ 50,000样本太少时,比率没有统计意义
回退率≤ 10 bps回退说明契约或容量仍有问题
影子差异率≤ 5 bps读口径未对齐就退出,等于把未知差异留给用户
观察窗口连续 7 天覆盖周末促销和批处理,而不是一次低峰期

阈值是示例值,不是行业标准;每个系统要按自己的流量和风险定。关键在形式:判据必须能在 CI 或发布脚本里执行,能一次输出全部未满足条目,而不是靠人盯着仪表盘说“差不多了”。Ready 返回 []string 而不是单个 error,就是为此。

满足判据后,退出动作是:把 NewPercent 拨到 100,保留一个发布窗口的观察期,然后删除旧实现、路由表和 ShadowRead 配置。只拨开关不删代码,等于把技术债换成常驻配置。

真实运行结果

以下输出来自本机 macOS(darwin/arm64,Go 1.26.0):

$ go test -race ./... -count=1 -v
=== RUN   TestDecideStableAndSpread
    router_test.go:54: new routes: 498/1000
--- PASS: TestDecideStableAndSpread (0.00s)
--- PASS: TestReserveFallsBackOnlyOnUnavailable (0.00s)
--- PASS: TestShadowReadAndReleaseRouting (0.00s)
--- PASS: TestExitCriteriaRequiresEvidence (0.00s)
ok  	archlab/internal/migration	1.492s

最值得看的是分布:50% 配置下,1000 个 SKU 中有 498 个走新路径。它不是精确的 500,因为 FNV 对短字符串的分布并不均匀;对放量来说,±5% 的偏差可以接受,稳定性比均匀性更重要。真正不能接受的是同一 SKU 在两次调用间换边,TestDecideStableAndSpread 专门守住这一点。

ADR-008:库存模块按 SKU 分桶迁移

状态:已接受(2026-10-09)。背景:旧库 legacy_inventory 与新模块需要并行至少一个季度,任何时刻不能出现同一 SKU 双扣。备选 A 是一次性重写并切库,否决理由是切换窗口无法回滚,历史预留数据还要停机迁移。备选 B 是按订单号分流,否决理由是同一 SKU 的可用量被拆到两本账,报表和对账无法合并。备选 C 是双写新旧两侧,否决理由是写失败无法原子回滚,重试会放大双扣风险。决策:写路径按 SKU 哈希单路由,读路径影子比对,退出由 Ready 判据控制。后果:迁移期报表必须合并两本账,需要按 SKU 做库存对账,放量粒度受爆款 SKU 影响;换来的是任意时刻只有一本权威账,以及逐 SKU 回滚的能力。

常见错误与边界

第一,按订单号或请求 ID 分流。它让路由看起来更“均匀”,却把同一个 SKU 的库存拆成两份,双扣只是时间问题。第二,把超时当成可回退错误。超时意味着结果未知,不是失败;回退前必须确认新实现没有产生副作用,否则回退就是第二次写入。第三,写路径也做双跑。读双跑是观测,写双跑是两次真实扣减;除非新侧是只读镜像,否则不要这么做。

第四,用开关代替删除。开关拨到 100% 不等于旧路径消失;只要代码还在,下一次事故就可能有人拨回去,而那条路径已经很久没人测了。

小结

有状态模块的绞杀者迁移,核心不是 facade 长什么样,而是路由键与状态归属是否一致。arch-lab 用 SKU 分桶保证同一商品只有一本账,用“错误即无副作用”的契约限定回退,用只读双跑积累差异证据,用可执行的 Ready 判据决定何时删掉旧路径。下一节处理更细的一层:当新旧两侧必须同时写、同时读时,分支抽象如何把顺序、差异和退出条件写进代码。

阅读导航:上一节:7.3 幂等责任归属:API、领域、消息层的契约 · 下一节:8.2 分支抽象与双写/双读 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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