《后端架构设计·演进与治理》3.1 服务注册、健康检查与实例生命周期

从 arch-lab 订单服务一次 3 秒网络抖动导致 12 个实例被全部剔除的事故出发,区分 TTL 租约、主动健康检查与流量状态,说明剔除阈值、恢复观察窗口和成功次数如何抑制抖动,给出标准库 Go 注册表实现与真实测试输出,并列出实例生命周期的常见边界。

一次 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 客户端负载均衡:算法、局部性与重试放大 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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