一、注册中心解决的是“现在谁可用”
服务发现要回答的问题很具体:调用方在发起请求的那一刻,应该把请求发给哪个地址。配置文件回答不了这个问题,因为实例会扩缩容、会发布、会故障;DNS 能回答一部分,但缓存和 TTL 常常让结果滞后。注册中心的价值不是保存一份服务名单,而是维护一份“带存活语义的当前视图”。
游戏服务端对这份视图尤其敏感。战斗服扩容后,网关要尽快把新玩家导过去;某个战斗服进程假死时,网关要尽快停止向它转发。注册中心如果只负责注册、不负责摘除,名单会越来越长,故障实例会一直分到流量,最终表现为“服务明明重启了,玩家还是连不上”。
二、临时节点、TTL 与健康检查
注册中心通常提供两类节点。临时节点与客户端会话绑定,会话断开后自动删除;持久节点需要显式注销,适合手工维护的配置或长期存在的逻辑服务。TTL 模型则让实例定期续约,超过 TTL 未续约就视为失效。两者解决的是同一个问题:如何在没有可靠通知的情况下判断实例已经死亡。
| 模型 | 失效判据 | 优点 | 风险 |
|---|---|---|---|
| 临时节点 | 会话断开 | 摘除快,语义直接 | 网络分区时可能误删 |
| TTL 续约 | 超过 TTL 未续约 | 实现简单,跨语言 | TTL 长短难调 |
| 持久节点 | 显式注销 | 不依赖心跳 | 实例崩溃后残留 |
| 外部健康检查 | 探针失败 | 能发现假死 | 检查周期与阈值要调 |
健康检查由谁做,必须明确。常见分工是:实例自己续约(证明进程还活着),注册中心只做 TTL 判定;编排系统或旁路探针做业务健康检查(证明它能处理请求),失败后主动注销。最糟糕的是三方都以为别人会摘除,结果故障实例一直留在名单里。
三、客户端负载均衡与服务端代理
客户端负载均衡让调用方拉取实例列表,在本地选择目标;服务端代理则在调用方和实例之间放一个代理,由代理转发并做负载均衡。两者不是简单的性能取舍,而是把“发现、选择、重试、熔断”放在哪一层的问题。
| 方式 | 发现位置 | 优点 | 代价 |
|---|---|---|---|
| 客户端负载均衡 | 调用方进程内 | 少一跳,策略灵活 | 每个语言都要实现一套 |
| 服务端代理 | 独立代理进程 | 调用方简单,策略统一 | 多一跳,代理成为瓶颈 |
| K8s Service | 集群网络层 | 与编排集成,免改代码 | 策略受限于 Service/Ingress |
| DNS | 解析层 | 通用、无侵入 | 缓存导致摘除滞后 |
游戏网关到战斗服这类长连接场景,通常更适合客户端负载均衡:网关本来就要维护连接和玩家路由,多一次代理转发只会增加延迟和故障点。运营后台到无状态服务则可以用代理或 K8s Service,把重试和熔断交给基础设施。
四、DNS、K8s Service 与注册中心的分工
三者经常被混为一谈。DNS 是名字到地址的解析,缓存由操作系统、浏览器和 DNS 服务器共同决定;K8s Service 是集群内的虚拟 IP 和转发规则,依赖 kube-proxy 或数据面;注册中心是应用层的实例目录,能表达权重、元数据、版本和健康状态。选择哪一种,取决于你需要多细的语义。
一个常见的误用是把 DNS 当成实时注册中心:实例已经下线,但调用方本地缓存仍然解析到旧 IP,直到 TTL 过期。另一个误用是在 K8s 里再自建一套注册中心,却不关闭 Service 的负载均衡,导致流量被转发两次。正确做法是先确定哪一层负责发现,其他层只做补充,不要让两个系统同时决定目标地址。
五、失败场景:TTL 过长与过短
某次版本发布后,战斗服实例被滚动重启。注册中心的 TTL 设为 5 分钟,实例退出时没有主动注销,网关在接下来的 5 分钟里持续把新玩家分配到已经停止的进程。玩家看到的是“匹配成功但一直加载”,监控看到的是“连接数正常、在线人数下降”。TTL 过长把一次正常的发布放大成了五分钟的故障。
把 TTL 调到 2 秒后,问题换了一个方向。机房网络抖动导致续约请求短暂失败,注册中心一次性摘除了大量健康实例,网关把流量集中到剩余实例,剩余实例被压垮,形成雪崩。TTL 过短让网络抖动变成了容量事故。
合理的做法是三层配合:实例正常退出时主动注销;TTL 设置为“能容忍两到三次续约失败”的量级;摘除后不要立即删除连接,而是先停止新流量,等待在途请求完成。对于长连接,网关还应该在实例恢复后逐步放量,而不是瞬间把全部新玩家导过去。
六、Go 实现:TTL 注册表、续约与订阅
下面的 registry.go 用注入时钟实现可测试的 TTL 注册表。Register 写入实例并设置过期时间,Renew 只续约仍然存活的实例,List 返回存活快照并在同一把锁内清理过期项,Watch 把注册、续约、注销和过期事件推给订阅者。
// file: ch11/registry/registry.go
// tier: L1
// verify: go test ./ch11/registry/
package registry
import (
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
)
var (
ErrInvalidTTL = errors.New("registry: ttl must be positive")
ErrEmptyService = errors.New("registry: empty service")
ErrEmptyID = errors.New("registry: empty instance id")
ErrEmptyAddr = errors.New("registry: empty address")
ErrNotFound = errors.New("registry: instance not found")
)
type EventKind string
const (
EventRegister EventKind = "register"
EventRenew EventKind = "renew"
EventDeregister EventKind = "deregister"
EventExpire EventKind = "expire"
)
// Instance 是一次注册的完整描述;ExpiresAt 由注册中心写入,调用方传值会被覆盖。
type Instance struct {
Service string
ID string
Addr string
Metadata map[string]string
ExpiresAt time.Time
}
type Event struct {
Kind EventKind
Instance Instance
At time.Time
}
// Watcher 的通道满时事件会被丢弃;客户端发现缺口后应重新 List 全量对齐。
type Watcher struct {
id uint64
service string
ch chan Event
reg *Registry
closeOnce sync.Once
}
func (w *Watcher) C() <-chan Event {
return w.ch
}
func (w *Watcher) Close() {
w.closeOnce.Do(func() { w.reg.unwatch(w) })
}
// Registry 是带 TTL 的进程内注册表,读写与过期清理共用一把锁。
type Registry struct {
mu sync.Mutex
ttl time.Duration
now func() time.Time
instances map[string]Instance
watchers map[uint64]*Watcher
nextWatcher uint64
}
func New(ttl time.Duration) (*Registry, error) {
return NewWithClock(ttl, time.Now)
}
// NewWithClock 允许测试注入时钟,TTL 行为不需要真实等待。
func NewWithClock(ttl time.Duration, now func() time.Time) (*Registry, error) {
if ttl <= 0 {
return nil, ErrInvalidTTL
}
if now == nil {
now = time.Now
}
return &Registry{
ttl: ttl,
now: now,
instances: make(map[string]Instance),
watchers: make(map[uint64]*Watcher),
}, nil
}
func key(service, id string) string {
return service + "\x00" + id
}
// Register 写入新实例;同 ID 重复注册视为覆盖,并重新计算过期时间。
func (r *Registry) Register(inst Instance) (Instance, error) {
service := strings.TrimSpace(inst.Service)
id := strings.TrimSpace(inst.ID)
addr := strings.TrimSpace(inst.Addr)
switch {
case service == "":
return Instance{}, ErrEmptyService
case id == "":
return Instance{}, ErrEmptyID
case addr == "":
return Instance{}, ErrEmptyAddr
}
inst.Service, inst.ID, inst.Addr = service, id, addr
inst.Metadata = cloneMetadata(inst.Metadata)
r.mu.Lock()
defer r.mu.Unlock()
now := r.now()
inst.ExpiresAt = now.Add(r.ttl)
r.instances[key(service, id)] = inst
r.notifyLocked(Event{Kind: EventRegister, Instance: cloneInstance(inst), At: now})
return cloneInstance(inst), nil
}
// Renew 只续约仍然存活的实例;已经过期的实例不能靠续约复活。
func (r *Registry) Renew(service, id string) (Instance, error) {
service = strings.TrimSpace(service)
id = strings.TrimSpace(id)
r.mu.Lock()
defer r.mu.Unlock()
k := key(service, id)
inst, ok := r.instances[k]
if !ok {
return Instance{}, fmt.Errorf("%w: %s/%s", ErrNotFound, service, id)
}
now := r.now()
if !inst.ExpiresAt.After(now) {
delete(r.instances, k)
r.notifyLocked(Event{Kind: EventExpire, Instance: cloneInstance(inst), At: now})
return Instance{}, fmt.Errorf("%w: %s/%s expired", ErrNotFound, service, id)
}
inst.ExpiresAt = now.Add(r.ttl)
r.instances[k] = inst
r.notifyLocked(Event{Kind: EventRenew, Instance: cloneInstance(inst), At: now})
return cloneInstance(inst), nil
}
func (r *Registry) Deregister(service, id string) error {
service = strings.TrimSpace(service)
id = strings.TrimSpace(id)
r.mu.Lock()
defer r.mu.Unlock()
k := key(service, id)
inst, ok := r.instances[k]
if !ok {
return fmt.Errorf("%w: %s/%s", ErrNotFound, service, id)
}
delete(r.instances, k)
r.notifyLocked(Event{Kind: EventDeregister, Instance: cloneInstance(inst), At: r.now()})
return nil
}
// List 返回存活实例快照,并在同一次加锁中清理所有已过期实例。
func (r *Registry) List(service string) []Instance {
service = strings.TrimSpace(service)
r.mu.Lock()
defer r.mu.Unlock()
now := r.now()
out := make([]Instance, 0, len(r.instances))
for k, inst := range r.instances {
if !inst.ExpiresAt.After(now) {
delete(r.instances, k)
r.notifyLocked(Event{Kind: EventExpire, Instance: cloneInstance(inst), At: now})
continue
}
if service == "" || inst.Service == service {
out = append(out, cloneInstance(inst))
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Service != out[j].Service {
return out[i].Service < out[j].Service
}
return out[i].ID < out[j].ID
})
return out
}
// Watch 订阅后续事件;初始全量仍由调用方先 List 一次获得。
func (r *Registry) Watch(service string, buffer int) (*Watcher, error) {
service = strings.TrimSpace(service)
if buffer < 0 {
buffer = 0
}
r.mu.Lock()
defer r.mu.Unlock()
r.nextWatcher++
w := &Watcher{id: r.nextWatcher, service: service, ch: make(chan Event, buffer), reg: r}
r.watchers[w.id] = w
return w, nil
}
func (r *Registry) unwatch(w *Watcher) {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.watchers[w.id]; !ok {
return
}
delete(r.watchers, w.id)
close(w.ch)
}
// Sweep 主动清理过期实例并返回数量;后台 Run 会周期性调用它。
func (r *Registry) Sweep() int {
r.mu.Lock()
defer r.mu.Unlock()
now := r.now()
removed := 0
for k, inst := range r.instances {
if inst.ExpiresAt.After(now) {
continue
}
delete(r.instances, k)
removed++
r.notifyLocked(Event{Kind: EventExpire, Instance: cloneInstance(inst), At: now})
}
return removed
}
func (r *Registry) notifyLocked(ev Event) {
for _, w := range r.watchers {
if w.service != "" && ev.Instance.Service != w.service {
continue
}
select {
case w.ch <- ev:
default:
}
}
}
func cloneInstance(in Instance) Instance {
out := in
out.Metadata = cloneMetadata(in.Metadata)
return out
}
func cloneMetadata(in map[string]string) map[string]string {
if in == nil {
return nil
}
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = v
}
return out
}
实现上有四个关键点。第一,时钟通过 NewWithClock 注入,测试可以推进虚拟时间,不需要 time.Sleep,也就没有抖动导致的偶发失败。第二,List 在返回快照前清理过期实例,因此“读”本身就会触发自动摘除;生产环境可以另起一个定时器周期性调用 Sweep,把摘除延迟控制在扫描间隔内。第三,所有返回值都做深拷贝,调用方修改 Metadata 或切片不会污染注册表。第四,订阅者通道满时事件被丢弃而不是阻塞注册流程;发现缺口的客户端应重新 List 做全量对齐。
TTL 的续约必须由实例主动发起,而不是由注册中心反向探测。反向探测需要注册中心知道每种服务的健康语义,扩展成本高;主动续约则把责任放回实例,注册中心只做超时判定。两者可以叠加:实例续约证明进程存活,旁路探针证明业务可用,任一失败都触发摘除。
七、测试覆盖了哪些边界
实验包中的 registry_test.go 覆盖了六类行为:
- 注册后过期时间等于当前时间加 TTL,续约后按新的当前时间重新计算。
- 实例在 TTL 内可见,到达过期时刻后
List返回空,Renew返回ErrNotFound。 List返回的切片和Metadata是深拷贝,外部修改不影响注册表。- 订阅者按顺序收到
register、renew、expire事件,按服务过滤的订阅者收不到其他服务事件。 - 注销后再次注销或续约都返回
ErrNotFound。 - 八个 goroutine 并发注册、续约、查询和注销,最终实例数量稳定,没有数据竞争导致的 panic。
八、运行测试
cd .local/checks/gs-practice-lab && gofmt -l ch11/registry && go vet ./ch11/registry/ && go test ./ch11/registry/ -count=1
ok gspractice/ch11/registry 0.890s
gofmt -l 无输出,go vet 通过。并发测试使用 8 个 goroutine、每个 200 次循环;耗时是本机一次运行结果,只说明测试通过。
小结
注册中心维护的是带存活语义的当前视图,而不是一份静态名单。临时节点、TTL 续约、持久节点和外部健康检查各有失效判据,必须明确由谁负责摘除。客户端负载均衡适合长连接和高频调用,服务端代理适合统一策略;DNS、K8s Service 和注册中心不要同时决定目标地址。TTL 过长会让故障实例继续接流量,过短会让网络抖动变成容量事故,正确做法是主动注销、容忍数次续约失败、先停新流量再等待在途请求。实现上注入时钟、读写加锁、返回深拷贝、订阅非阻塞,才能既测试得动又扛得住并发。
常见错误与边界
- TTL 设得过长,实例崩溃或发布后仍持续接收新流量。
- TTL 设得过短,网络抖动触发大面积误摘,剩余实例被压垮。
- 只注册不注销,滚动发布后名单里残留大量已停止的实例。
- 把 DNS 缓存当成实时发现,摘除延迟取决于客户端和递归 DNS 的 TTL。
- 在 K8s 里同时启用 Service 负载均衡和自建注册中心,流量被转发两次。
- 订阅通道无界或阻塞发送,慢消费者拖住整个注册与续约流程。
List直接返回内部 map 或共享Metadata,调用方修改导致并发读写 panic。- 续约失败后立即删除连接,没有给在途请求留出完成时间。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。