《游戏服务端编程实践》11.3 服务注册与发现机制

拆解临时节点与 TTL 续约、持久节点和健康检查的责任归属,比较客户端负载均衡与服务端代理,说明本地缓存、推送时效、摘除抖动,以及 DNS、K8s Service 与注册中心的分工,并用 Go 实现带 TTL 的注册表与订阅,测试续约、过期摘除和并发快照。

一、注册中心解决的是“现在谁可用”

服务发现要回答的问题很具体:调用方在发起请求的那一刻,应该把请求发给哪个地址。配置文件回答不了这个问题,因为实例会扩缩容、会发布、会故障;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。
  • 续约失败后立即删除连接,没有给在途请求留出完成时间。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist