一次 3 秒抖动为什么清空了实例列表
arch-lab 的订单服务有 12 个实例,注册中心使用 5 秒 TTL 和 3 次连续失败剔除。某个周三下午,订单服务所在可用区到注册中心的网络抖动了 3 秒,12 个实例的续约全部超时;恢复后不到 5 秒,注册表把它们全部判成过期,订单入口瞬间没有可用实例。更糟的是 40 秒后 12 个实例同时重新注册,库存服务的连接池被瞬时流量打满。
这次事故里没有进程崩溃,也没有代码发布。问题出在把“租约没续上”“健康检查失败”“实例应当接流量”三件事混成了一件。站内 服务发现与注册 介绍过注册中心的整体角色,本节把焦点放到单个实例从注册到接流量、再到被剔除和恢复的完整生命周期。
租约、TTL 与健康检查不是一回事
租约回答的是“实例还活着并愿意继续服务吗”,TTL 是这个承诺的有效期。主动健康检查回答的是“实例现在能不能正确处理请求”,它可能由注册中心发起,也可能由负载均衡器在调用路径上观察。两者都通过,实例才应该接流量;任何一方单独通过都不够。入口侧的摘除动作可以对照健康检查与流量摘除 ,它解决的是“注册表已经剔除,为什么还有请求打进来”。
| 信号 | 观察到的事实 | 缺失时的风险 | 典型周期 |
|---|---|---|---|
| 租约续约 | 进程能按时访问注册中心 | 网络分区时误删健康实例 | 5–15 秒 |
| 主动探针 | 端口和依赖探测成功 | 进程活着但业务不可用 | 2–10 秒 |
| 被动结果 | 真实请求的成功率与延迟 | 探针通过但流量已大量失败 | 请求级 |
| 摘除状态 | 实例已从流量快照移除 | 新请求继续打到故障实例 | 秒级传播 |
TTL 不能太短。上面事故中 5 秒 TTL 碰上 3 秒抖动,留给重试的余量只有 2 秒;把 TTL 调到 30 秒、连续 3 次续约失败才过期,注册中心就能扛住一次短暂的网络抖动。代价是实例真正崩溃后,最长要 30 秒才会从注册表消失,因此还需要主动健康检查和入口侧的连接摘除来缩短故障窗口。
剔除与恢复都需要双边迟滞
单边阈值会把抖动放大成事故。只按“一次失败即剔除”,任何一次 GC 停顿或探针超时都会让实例离开;只按“一次成功即恢复”,故障实例会在尚未预热时被重新灌入流量。arch-lab 采用的规则是:连续 EjectAfter 次失败才剔除,剔除后连续 RecoverAfter 次成功、且距离最后一次失败至少 RecoveryWindow,才恢复接流量。
| 观测量 | 判据(示例值) | 动作 | 退出条件 |
|---|---|---|---|
| 连续失败次数 | ≥ 3 | 从 Ready 转为 Ejected | 快照不再包含该实例 |
| 恢复成功次数 | ≥ 2 | 进入恢复观察 | 计数继续累积 |
| 恢复观察窗口 | ≥ 10 秒 | 暂不接流量 | 窗口结束且计数达标 |
| 恢复期间再失败 | 任意一次 | 重置成功计数和观察窗口 | 重新累计 |
| 租约过期 | 超过 TTL 未续约 | 直接删除实例 | 重新注册 |
这张表的关键不是数字,而是每个数字都要有退出条件。剔除阈值决定“多快止损”,恢复窗口决定“多稳回灌”;两者都应由真实探测周期和发布节奏推导,而不是拍一个看起来合理的值。
可运行实现
下面的 Registry 只依赖标准库。它把恢复观察期折叠进 Ejected 状态和成功计数,避免多出一个可能被误用的 Suspect 状态;Register 同时承担首次注册和续约,地址变化会被拒绝,防止同一个 ID 被另一个进程顶替。Snapshot 只输出 Ready 实例并排序,给负载均衡器一个确定的输入。
// file: internal/platform/discovery/registry.go
// tier: L1
// verify: go test ./internal/platform/discovery/ -count=1
package discovery
import "errors"
import "fmt"
import "sort"
import "sync"
import "time"
// State is the traffic state of one registered instance. The recovery
// observation period is folded into Ejected plus a success counter, so there
// is no third state that a caller could mistakenly route traffic to.
type State string
const (
// Ready means the instance receives traffic.
Ready State = "ready"
// Ejected means the instance is removed from the traffic snapshot.
Ejected State = "ejected"
)
// Instance is one registered endpoint plus its health counters.
type Instance struct {
ID, Addr, Zone string
Weight int
TTL time.Duration
State State
Failures, Successes int
LastSeen, EjectedAt time.Time
}
// Endpoint is the traffic-facing view of a Ready instance.
type Endpoint struct {
InstanceID, Addr, Zone string
Weight int
}
// Snapshot is an immutable set of Ready endpoints and its version.
type Snapshot struct {
Version uint64
Endpoints []Endpoint
}
// RegistryConfig controls ejection and recovery hysteresis.
type RegistryConfig struct {
EjectAfter, RecoverAfter int
RecoveryWindow, DefaultTTL time.Duration
}
type Registry struct {
mu sync.Mutex
cfg RegistryConfig
now func() time.Time
instances map[string]Instance
version uint64
}
// NewRegistry builds a registry. Pass nil now to use time.Now.
func NewRegistry(cfg RegistryConfig, now func() time.Time) (*Registry, error) {
if cfg.EjectAfter < 1 || cfg.RecoverAfter < 1 ||
cfg.RecoveryWindow <= 0 || cfg.DefaultTTL <= 0 {
return nil, errors.New("discovery: invalid registry config")
}
if now == nil {
now = time.Now
}
return &Registry{cfg: cfg, now: now, instances: make(map[string]Instance)}, nil
}
// Register adds an instance as Ready, or refreshes the lease of the same
// ID/address pair. A different address for an existing ID is rejected.
func (r *Registry) Register(in Instance) error {
if in.ID == "" || in.Addr == "" {
return errors.New("discovery: instance ID and address are required")
}
if in.Weight <= 0 {
in.Weight = 1
}
if in.TTL <= 0 {
in.TTL = r.cfg.DefaultTTL
}
r.mu.Lock()
defer r.mu.Unlock()
if old, exists := r.instances[in.ID]; exists {
if old.Addr != in.Addr {
return fmt.Errorf("discovery: instance %q changed address", in.ID)
}
old.LastSeen = r.now()
r.instances[in.ID] = old
return nil
}
in.State, in.Failures, in.Successes, in.LastSeen = Ready, 0, 0, r.now()
r.instances[in.ID] = in
r.version++
return nil
}
// Fail records one failed health check.
func (r *Registry) Fail(id string) error {
r.mu.Lock()
defer r.mu.Unlock()
in, ok := r.instances[id]
if !ok {
return fmt.Errorf("discovery: unknown instance %q", id)
}
in.Failures++
in.Successes = 0
if in.State == Ready && in.Failures >= r.cfg.EjectAfter {
in.State, in.EjectedAt = Ejected, r.now()
r.version++
}
if in.State == Ejected {
// A failed probe restarts the recovery observation window.
in.EjectedAt = r.now()
}
r.instances[id] = in
return nil
}
// Succeed records one successful health check.
func (r *Registry) Succeed(id string) error {
r.mu.Lock()
defer r.mu.Unlock()
in, ok := r.instances[id]
if !ok {
return fmt.Errorf("discovery: unknown instance %q", id)
}
in.Failures = 0
switch in.State {
case Ready:
in.Successes = 0
case Ejected:
in.Successes++
if in.Successes >= r.cfg.RecoverAfter && r.now().Sub(in.EjectedAt) >= r.cfg.RecoveryWindow {
in.State, in.Successes, in.EjectedAt = Ready, 0, time.Time{}
r.version++
}
}
r.instances[id] = in
return nil
}
// Expire drops instances whose lease is older than their TTL.
func (r *Registry) Expire() int {
now := r.now()
r.mu.Lock()
defer r.mu.Unlock()
removed := 0
for id, in := range r.instances {
if now.Sub(in.LastSeen) > in.TTL {
delete(r.instances, id)
removed++
}
}
if removed > 0 {
r.version++
}
return removed
}
// Get returns a copy of one instance.
func (r *Registry) Get(id string) (Instance, bool) {
r.mu.Lock()
defer r.mu.Unlock()
in, ok := r.instances[id]
return in, ok
}
// Snapshot returns only Ready endpoints, sorted by instance ID.
func (r *Registry) Snapshot() Snapshot {
r.mu.Lock()
defer r.mu.Unlock()
eps := make([]Endpoint, 0, len(r.instances))
for _, in := range r.instances {
if in.State == Ready {
eps = append(eps, Endpoint{in.ID, in.Addr, in.Zone, in.Weight})
}
}
sort.Slice(eps, func(i, j int) bool { return eps[i].InstanceID < eps[j].InstanceID })
return Snapshot{Version: r.version, Endpoints: eps}
}
实现里有三个刻意选择。第一,Fail 在已剔除状态下会重置 EjectedAt,让恢复窗口从最后一次失败重新计时,避免“失败中恢复”。第二,Expire 与健康状态独立:租约过期的实例即使最后一次探测成功也会被删除。第三,Snapshot 返回副本而不是内部 map,调用方无法绕过状态机直接把实例塞回流量。
// file: internal/platform/discovery/registry_test.go
// tier: L1
// verify: go test ./internal/platform/discovery/ -count=1
package discovery
import "testing"
import "time"
// fakeClock is shared by the registry and balancer tests.
type fakeClock struct{ t time.Time }
func (c *fakeClock) Now() time.Time { return c.t }
func (c *fakeClock) Advance(d time.Duration) { c.t = c.t.Add(d) }
func newRegistry(t *testing.T, cfg RegistryConfig) (*Registry, *fakeClock) {
t.Helper()
clock := &fakeClock{time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC)}
reg, err := NewRegistry(cfg, clock.Now)
if err != nil {
t.Fatalf("NewRegistry: %v", err)
}
return reg, clock
}
func TestRegistryLifecycle(t *testing.T) {
reg, clock := newRegistry(t, RegistryConfig{
EjectAfter: 3, RecoverAfter: 2,
RecoveryWindow: 10 * time.Second, DefaultTTL: 30 * time.Second,
})
if err := reg.Register(Instance{ID: "order-1", Addr: "10.0.0.1:8080", Zone: "az-a"}); err != nil {
t.Fatalf("Register: %v", err)
}
// One bad check followed by a good one is flapping, not failure.
_ = reg.Fail("order-1")
_ = reg.Succeed("order-1")
if got, _ := reg.Get("order-1"); got.State != Ready {
t.Fatalf("single flap state=%s, want ready", got.State)
}
for i := 0; i < 2; i++ {
_ = reg.Fail("order-1")
}
if got, _ := reg.Get("order-1"); got.State != Ready {
t.Fatalf("after 2 failures state=%s, want ready", got.State)
}
if n := len(reg.Snapshot().Endpoints); n != 1 {
t.Fatalf("ready snapshot size=%d, want 1", n)
}
_ = reg.Fail("order-1")
if got, _ := reg.Get("order-1"); got.State != Ejected {
t.Fatalf("after 3 failures state=%s, want ejected", got.State)
}
if n := len(reg.Snapshot().Endpoints); n != 0 {
t.Fatalf("ejected snapshot size=%d, want 0", n)
}
_ = reg.Succeed("order-1")
if got, _ := reg.Get("order-1"); got.State != Ejected {
t.Fatalf("after 1 success state=%s, want ejected", got.State)
}
clock.Advance(5 * time.Second)
_ = reg.Succeed("order-1")
if got, _ := reg.Get("order-1"); got.State != Ejected {
t.Fatalf("before recovery window state=%s, want ejected", got.State)
}
clock.Advance(6 * time.Second)
_ = reg.Succeed("order-1")
if got, _ := reg.Get("order-1"); got.State != Ready {
t.Fatalf("after recovery state=%s, want ready", got.State)
}
if n := len(reg.Snapshot().Endpoints); n != 1 {
t.Fatalf("recovered snapshot size=%d, want 1", n)
}
}
func TestRegistryTTLExpiryAndRenew(t *testing.T) {
reg, clock := newRegistry(t, RegistryConfig{
EjectAfter: 2, RecoverAfter: 1,
RecoveryWindow: time.Second, DefaultTTL: 30 * time.Second,
})
for _, id := range []string{"payment-1", "payment-2"} {
_ = reg.Register(Instance{ID: id, Addr: "10.0.0.3:8080"})
}
clock.Advance(20 * time.Second)
_ = reg.Register(Instance{ID: "payment-1", Addr: "10.0.0.3:8080"})
clock.Advance(11 * time.Second)
if removed := reg.Expire(); removed != 1 {
t.Fatalf("Expire removed=%d, want 1", removed)
}
if _, ok := reg.Get("payment-1"); !ok {
t.Fatal("renewed instance was expired")
}
if _, ok := reg.Get("payment-2"); ok {
t.Fatal("stale instance survived expiry")
}
}
上面是本节实现对应的测试文件。它和实现放在同一个 internal/platform/discovery 包内,测试时钟由 fakeClock 注入,因此整个文件没有 time.Sleep。
本机实测
命令如下,环境为 macOS darwin/arm64、Go 1.26.0。同一 module 执行 go test ./... -count=1 -v 时 10 个用例全部通过,下面只截取本节相关输出;完整原始输出保存在 .local/reports/be-arch-gov/ch03-be-arch-gov-03-1-service-registration-health-lifecycle.txt。
$ cd .local/checks/be-arch-gov-ch03
$ gofmt -l . && go vet ./... && go test ./internal/platform/discovery/ -count=1 -v -run '^TestRegistry'
=== RUN TestRegistryLifecycle
--- PASS: TestRegistryLifecycle (0.00s)
=== RUN TestRegistryTTLExpiryAndRenew
--- PASS: TestRegistryTTLExpiryAndRenew (0.00s)
PASS
ok archlab/internal/platform/discovery 0.231s
两个用例覆盖了本节的核心边界。TestRegistryLifecycle 先验证“一次失败加一次成功”不会剔除,再验证连续 3 次失败才剔除;恢复阶段要求至少 2 次成功且跨过 10 秒窗口,提前成功不会回灌。TestRegistryTTLExpiryAndRenew 用 20 秒后续约、再等 11 秒的方式,验证续约的实例保留、未续约的实例被删除。测试使用注入时钟,没有真实等待,因此 0.00s 只代表逻辑执行时间,不代表生产探测周期。
常见错误与边界
第一,把 TTL 当成健康检查。TTL 只能证明实例还能访问注册中心,不能证明它能查数据库、能扣库存。租约、主动探针和被动成功率要分别采集。
第二,剔除和恢复用同一个阈值。剔除要快,恢复要稳;两边用同一个“连续 N 次”会让恢复过于激进,或者在故障初期止损太慢。
第三,恢复后立即满权重。刚恢复的实例缓存是冷的,连接池也没建立;应先给部分流量,再逐步放开,否则它可能再次被压垮并进入下一轮抖动。
第四,忽略注册中心的传播延迟。快照更新到所有客户端需要时间,剔除动作必须在入口侧同时生效,不能只依赖下一次拉取。
第五,把实例 ID 当成可复用的名字。同一 ID 换地址会让旧连接和新连接混在一起;本实现直接拒绝地址变化,强制使用新的实例 ID。
小结
服务注册表的难点不在存一份地址列表,而在定义实例何时算“在”、何时算“不在”。租约、主动探针和被动结果回答的是不同问题,必须分开采集;剔除与恢复则需要双边迟滞,避免一次抖动触发全量摘除和同时回灌。
本节实现用 TTL 租约、连续失败剔除、连续成功加观察窗口恢复,把生命周期收敛成 Ready 和 Ejected 两个可验证状态。真实环境还要结合入口侧摘除、恢复期权重爬坡和注册中心传播延迟一起验证;这些参数应来自探测周期与故障演练,而不是照搬示例值。
阅读导航:上一节:2.3 契约变更门禁:schema 校验与跨服务兼容测试 · 下一节:3.2 客户端负载均衡:算法、局部性与重试放大 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。