扩容完成不等于路由完成
第 5.2 节把订单从 4 个分片迁到 5 个分片,数据校验通过,切换命令也执行了。十分钟后告警出现:一部分支付回调返回“订单不存在”,少量请求在旧分片上重试后创建了重复的预占记录。数据没有丢,但旧分片仍然收到写请求,因为调用方缓存里的路由表还是扩容前的版本。
错误直觉是“控制面发布新路由,所有客户端立刻生效”。实际至少有三种延迟叠加:控制面写入元数据存储的时间、客户端拉取或订阅到新版本的时间、客户端连接池与本地缓存刷新的时间。任何一层没有追上,请求就会按旧路由发出。扩容窗口内的路由正确性,不能假设“最终会一致”,必须假设“旧版本还会持续一段时间”,并让旧分片能识别并纠正这种请求。
站内 服务发现 和 Gossip 协议 分别讨论过注册中心与传播机制;本节只聚焦一件事:路由表带版本之后,陈旧请求怎样收敛,以及扩容窗口内允许错多少次。
元数据传播的三层延迟
第一层是控制面到元数据存储。迁移完成后,路由表从版本 41 写成版本 42,写入本身可能经过共识或主从复制,耗时从毫秒到秒级。第二层是元数据存储到客户端,常见做法是长轮询、watch 或定期拉取;即使 watch 很快,网络抖动和重连也会让个别客户端落后。第三层是客户端内部:本地 map 的刷新周期、连接池里指向旧分片的连接、DNS 缓存,都可能让一次请求继续使用旧路由。
三层延迟不能简单相加成“最坏 30 秒”,因为它们是分布而非固定值。工程上更可靠的做法是给路由表一个单调递增的版本号,让每一层都能判断“这份元数据是不是比我现在用的新”。版本号相等或更旧的更新必须被拒绝,否则一个延迟到达的旧消息会把已经修复的缓存改回旧值。
控制面版本 42 ──写入──> 元数据存储 ──watch/轮询──> 网关缓存 ──本地刷新──> 请求
│ │ │
└── 版本单调递增 ──────┴── 拒绝旧版本 ─────────┘
版本号只解决“不要回退”,不解决“还没追上”。请求仍然可能带着版本 41 的路由到达已经不属于它的分片。此时正确的响应不是“订单不存在”,而是“这个键已经搬到别处”,让调用方失效缓存并重试。
陈旧路由必须可识别
分片收到一个自己不再拥有的键时,有三种可能的处理方式。第一种返回 ErrNotFound,调用方会认为订单真的不存在,可能触发重复创建,这是最危险的。第二种返回一个可重试的通用错误,调用方不知道应该刷新路由,重试仍然打到旧分片,形成无效循环。第三种返回 ErrMoved 并带上正确分片或让调用方重新解析,调用方失效本地缓存、重新解析一次,再重试。
ErrMoved 与 ErrNotFound 必须是不同的错误类型,不能靠错误字符串判断。订单查询、支付回调、库存预占都要区分这两种语义:查不到可能是数据还没复制完,也可能是键搬走了;前者应该等待或重试,后者应该换分片。把两者混在一起,等于把路由问题伪装成业务问题。
重试要有上界。一次陈旧路由通常只需要一次权威解析就能修复;如果解析服务也不可用,继续重试只会放大故障。路由组件应记录 stale_hits、resolve_calls、failures 三个计数,并把“重试次数耗尽”作为明确的失败,而不是无限循环。熔断器可以根据 stale_hits 的速率判断元数据传播是否异常。
扩容窗口的错误预算
错误预算把“允许多少错”变成可计算的数。假设订单创建接口的 SLO 是 99.95%,扩容窗口内总请求 1000 万,那么允许失败 5000 次。陈旧路由本身不是失败:只有当权威解析也失败、或重试次数耗尽时,请求才失败。设陈旧请求占比为 stale_rate,解析成功率为 resolve_rate,预期失败数为:
projected_failures = total_requests × stale_rate × (1 - resolve_rate)
如果 stale_rate = 0.002(0.2%),resolve_rate = 0.999,则 1000 万请求里有 2 万次陈旧路由,其中约 20 次解析失败,远低于 5000 的预算。反过来说,若 stale_rate = 0.05、resolve_rate = 0.95,预期失败 25000 次,已经超出预算,扩容必须暂停或先修复解析服务。
这里有一个实现细节:99.95% 的预算不能直接用浮点算。(1 - 99.95/100) × 10,000,000 在某些浮点表示下会得到 4999.999…,向下取整变成 4999,少算一次。代码里把 SLO 转成“万分之一”为单位的整数(99.95% → 9995 个基点),预算用整数除法计算,结果稳定为 5000。这个差异很小,但预算系统一旦出现“差一”,排查成本远大于写对整数运算的成本。
可运行实现
下面的包包含三部分:带版本保护的本地路由缓存、带 ErrMoved 重试的路由器,以及基于整数基点的错误预算。路由器在缓存命中时直接使用;分片返回 ErrMoved 时失效缓存并做一次权威解析;解析失败或次数耗尽才计为失败。
// file: internal/platform/routing/router.go
// tier: L2
// verify: go test ./internal/platform/routing/ -count=1
package routing
import (
"errors"
"fmt"
"math"
"sync"
)
// Shard identifies one storage shard.
type Shard string
// ErrRouteUnavailable means the router exhausted its resolver attempts.
var ErrRouteUnavailable = errors.New("route unavailable")
// ErrMoved means the contacted shard no longer owns the key. The caller should
// invalidate its cached route and retry.
var ErrMoved = errors.New("key moved")
// Directory is a versioned local route cache. Apply rejects versions that are
// not newer, so delayed metadata cannot overwrite a fresher table.
type Directory struct {
mu sync.RWMutex
version int64
entries map[string]Shard
}
// NewDirectory returns an empty cache at version 0.
func NewDirectory() *Directory {
return &Directory{entries: make(map[string]Shard)}
}
// Apply atomically replaces the table when version is newer than the current
// version.
func (d *Directory) Apply(version int64, entries map[string]Shard) error {
d.mu.Lock()
defer d.mu.Unlock()
if version <= d.version {
return fmt.Errorf("routing: version %d not newer than %d", version, d.version)
}
next := make(map[string]Shard, len(entries))
for k, v := range entries {
next[k] = v
}
d.entries = next
d.version = version
return nil
}
// Put caches one resolved route.
func (d *Directory) Put(key string, shard Shard) {
d.mu.Lock()
defer d.mu.Unlock()
d.entries[key] = shard
}
// Invalidate drops one cached route after a moved error.
func (d *Directory) Invalidate(key string) {
d.mu.Lock()
defer d.mu.Unlock()
delete(d.entries, key)
}
// Lookup returns the cached route for key.
func (d *Directory) Lookup(key string) (Shard, bool) {
d.mu.RLock()
defer d.mu.RUnlock()
shard, ok := d.entries[key]
return shard, ok
}
// Version returns the applied metadata version.
func (d *Directory) Version() int64 {
d.mu.RLock()
defer d.mu.RUnlock()
return d.version
}
// Resolver returns the authoritative shard for a key.
type Resolver interface {
Resolve(key string) (Shard, error)
}
// ResolverFunc adapts a function to Resolver.
type ResolverFunc func(string) (Shard, error)
// Resolve calls f.
func (f ResolverFunc) Resolve(key string) (Shard, error) {
return f(key)
}
// Metrics counts routing outcomes.
type Metrics struct {
CacheHits int64
ResolveCalls int64
StaleHits int64
Failures int64
}
// Router resolves keys with a local cache and a bounded resolver.
type Router struct {
dir *Directory
resolver Resolver
maxResolve int
mu sync.Mutex
metrics Metrics
}
// NewRouter returns a router. maxResolve is the number of authoritative lookups
// allowed for one request, including retries after ErrMoved.
func NewRouter(dir *Directory, resolver Resolver, maxResolve int) (*Router, error) {
if dir == nil {
return nil, fmt.Errorf("routing: nil directory")
}
if resolver == nil {
return nil, fmt.Errorf("routing: nil resolver")
}
if maxResolve <= 0 {
return nil, fmt.Errorf("routing: maxResolve must be positive, got %d", maxResolve)
}
return &Router{dir: dir, resolver: resolver, maxResolve: maxResolve}, nil
}
// GetFunc performs the actual read against a shard. It returns ErrMoved when
// the shard does not own the key.
type GetFunc func(shard Shard) (string, error)
// Fetch routes key and executes get. A moved error invalidates the cache entry
// and triggers one authoritative resolve, so a stale route costs one extra hop
// but converges.
func (r *Router) Fetch(key string, get GetFunc) (string, error) {
for attempt := 0; attempt <= r.maxResolve; attempt++ {
shard, ok := r.dir.Lookup(key)
if ok {
r.inc(&r.metrics.CacheHits)
} else {
if attempt == r.maxResolve {
break
}
r.inc(&r.metrics.ResolveCalls)
resolved, err := r.resolver.Resolve(key)
if err != nil || resolved == "" {
continue
}
shard = resolved
r.dir.Put(key, shard)
}
value, err := get(shard)
if err == nil {
return value, nil
}
if errors.Is(err, ErrMoved) {
r.inc(&r.metrics.StaleHits)
r.dir.Invalidate(key)
continue
}
r.inc(&r.metrics.Failures)
return "", fmt.Errorf("routing: fetch %q from %s: %w", key, shard, err)
}
r.inc(&r.metrics.Failures)
return "", fmt.Errorf("routing: fetch %q after %d resolves: %w", key, r.maxResolve, ErrRouteUnavailable)
}
func (r *Router) inc(counter *int64) {
r.mu.Lock()
*counter++
r.mu.Unlock()
}
// Metrics returns a snapshot.
func (r *Router) Metrics() Metrics {
r.mu.Lock()
defer r.mu.Unlock()
return r.metrics
}
// Budget expresses the error budget available during one expansion window.
type Budget struct {
TotalRequests int64
SLOPercent float64
StaleRate float64
ResolveRate float64
}
// Validate checks ranges so a typo cannot silently produce a meaningless
// budget.
func (b Budget) Validate() error {
if b.TotalRequests <= 0 {
return fmt.Errorf("budget: total requests must be positive, got %d", b.TotalRequests)
}
if b.SLOPercent <= 0 || b.SLOPercent >= 100 {
return fmt.Errorf("budget: SLO percent must be in (0,100), got %v", b.SLOPercent)
}
if b.StaleRate < 0 || b.StaleRate > 1 {
return fmt.Errorf("budget: stale rate must be in [0,1], got %v", b.StaleRate)
}
if b.ResolveRate < 0 || b.ResolveRate > 1 {
return fmt.Errorf("budget: resolve rate must be in [0,1], got %v", b.ResolveRate)
}
return nil
}
// sloBasisPoints converts a percentage to hundredths of a percent. It keeps
// the budget arithmetic in integers so 99.95% yields exactly 5 basis points of
// allowed error rather than a float near 4999.99 failures.
func (b Budget) sloBasisPoints() int64 {
return int64(math.Round(b.SLOPercent * 100))
}
// AllowedFailures is the error budget for the window.
func (b Budget) AllowedFailures() int64 {
if b.TotalRequests <= 0 || b.SLOPercent <= 0 || b.SLOPercent >= 100 {
return 0
}
return b.TotalRequests * (10000 - b.sloBasisPoints()) / 10000
}
// ProjectedFailures assumes every stale request needs one authoritative
// resolve and only failed resolves become errors.
func (b Budget) ProjectedFailures() int64 {
stale := math.Round(float64(b.TotalRequests) * b.StaleRate)
return int64(math.Round(stale * (1 - b.ResolveRate)))
}
// WithinBudget reports whether the plan fits the window.
func (b Budget) WithinBudget() bool {
return b.ProjectedFailures() <= b.AllowedFailures()
}
// BreakerThreshold is the failure rate that consumes the whole budget. A
// circuit breaker should trip before the observed rate reaches it.
func (b Budget) BreakerThreshold() float64 {
return float64(10000-b.sloBasisPoints()) / 10000
}
实现里有两处取舍。第一,Fetch 的循环次数是 maxResolve + 1,但权威解析最多调用 maxResolve 次;这样“缓存命中但已陈旧”不会额外消耗解析次数,而解析服务持续失败时有明确上界。第二,指标计数用互斥锁保护,因为并发请求会同时更新;计数不在业务路径上做复杂聚合,只做自增,避免路由层成为新的瓶颈。
本机实测:一次陈旧跳转与预算算术
测试覆盖四个场景:缓存指向旧分片、解析服务不可用、旧版本更新被拒绝、64 个并发请求。预算用 1000 万请求窗口、SLO 99.95%、陈旧率 0.2%、解析成功率 99.9% 计算,再换一组超预算参数对照。命令与原始输出:
$ go test ./internal/platform/routing/ -v -count=1
=== RUN TestFetchRetriesAfterMoved
routing_test.go:39: value=payload-1001 resolves=1 cache_hits=1 stale_hits=1 failures=0
--- PASS: TestFetchRetriesAfterMoved (0.00s)
=== RUN TestFetchGivesUpAfterMaxResolve
routing_test.go:63: resolve_calls=2 failures=1
--- PASS: TestFetchGivesUpAfterMaxResolve (0.00s)
=== RUN TestDirectoryRejectsStaleVersion
routing_test.go:89: version guard kept=shard-a final_version=8
--- PASS: TestDirectoryRejectsStaleVersion (0.00s)
=== RUN TestFetchConcurrent
routing_test.go:119: concurrent fetches=64 cache_hits=0 resolves=64
--- PASS: TestFetchConcurrent (0.00s)
=== RUN TestBudgetArithmetic
routing_test.go:129: allowed_failures=5000 projected_failures=20 within=true breaker=0.000500
routing_test.go:142: bad_allowed=5000 bad_projected=25000 within=false
--- PASS: TestBudgetArithmetic (0.00s)
PASS
ok archlab/internal/platform/routing 0.277s
读数分两部分。路由部分,陈旧缓存先命中一次旧分片,收到 ErrMoved 后失效并解析一次,最终读到正确值;指标是 1 次缓存命中、1 次陈旧命中、1 次解析、0 次失败。解析服务不可用时,maxResolve=2 得到 2 次解析调用和 1 次失败,说明重试有上界而不是无限循环。版本保护测试中,版本 7 的旧更新被拒绝,缓存仍指向 shard-a,版本 8 才生效。并发测试的 64 个请求全部成功,没有数据竞争。
预算部分,1000 万请求、99.95% SLO 给出允许失败 5000 次;0.2% 陈旧率产生 2 万次陈旧请求,99.9% 解析成功率把预期失败压到 20 次,在预算内;熔断阈值是 0.0005,即失败率达到万分之五就应该熔断。对照参数 5% 陈旧率、95% 解析成功率产生 25000 次预期失败,超出预算 5 倍,扩容必须暂停。测试耗时是单机内存操作的结果,不代表生产路由延迟。
扩容窗口的决策表
扩容期间不要等“错误率超标”才反应。下表把观测值直接映射到动作,阈值应在演练中用真实流量校准,而不是照抄示例。
| 观测 | 判据 | 动作 | 退出条件 |
|---|---|---|---|
stale_hits 比例 | 超过预设陈旧率上限 | 暂停切换,检查元数据传播 | 连续两个窗口低于上限 |
| 解析失败率 | 预期失败接近预算 | 扩容解析服务或降低切换速度 | 预期失败低于预算 50% |
| 重试耗尽 | 出现 ErrRouteUnavailable | 熔断该路由,回退旧版本 | 解析成功率恢复 |
| 版本回退 | Apply 拒绝旧版本 | 记录来源,排查是否有重复发布 | 版本单调递增 |
| 旧分片写入 | 切换后仍收到写请求 | 旧分片返回 ErrMoved,不落库 | 旧分片写入归零 |
最后一行是扩容窗口的硬边界:旧分片可以接收请求、可以返回重定向,但不能继续提交写入。如果旧分片在切换后仍然落库,路由再正确也会产生双写,之后只能靠对账收敛。
常见错误与边界
第一,把 ErrMoved 当成 ErrNotFound。分片不再拥有某个键时返回“找不到”,调用方可能创建重复订单;必须用独立错误类型表达“键搬走了”,并让调用方刷新路由。
第二,重试没有上界。陈旧路由重试一次通常就能修复;解析服务故障时无限重试会把故障放大到整个调用链。重试次数、超时和熔断阈值要一起设定,不能只设其中一个。
第三,接受相等或更旧的版本。版本号必须严格递增;version <= current 的更新一律拒绝。否则一个延迟到达的旧消息会把新路由覆盖回去,且很难从日志里发现。
第四,只统计服务端 5xx,不统计路由失败。ErrRouteUnavailable 可能被包装成业务错误或客户端超时,如果不单独计数,错误预算会低估真实失败,熔断器也不会触发。
小结
扩容后的路由正确性取决于“旧版本还会存在一段时间”这个前提。元数据版本保证缓存不回退,ErrMoved 让陈旧请求能识别并换分片,有界重试把单次陈旧跳转变成一次额外开销而不是无限循环。错误预算把陈旧率、解析成功率和 SLO 连成一个可计算的判据:1000 万请求、99.95% SLO 只允许 5000 次失败,0.2% 陈旧率在 99.9% 解析成功率下只预期失败 20 次,而 5% 陈旧率会放大到 25000 次。下一章从这些“允许错多少”的工程判据回到一致性模型本身,讨论线性一致、顺序一致与因果一致各自承诺了什么。
阅读导航:上一节:再平衡:迁移、双写与客户端路由 · 下一节:线性一致、顺序一致与因果一致 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。