迁移的单位不是服务,是状态
站内 绞杀者模式 已经把绞杀者的通用套路讲清楚了:在旧系统前面放一层 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 分支抽象与双写/双读 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。