《MMO 服务器开发》2.1 注册与健康检查

服务发现的核心不是选哪个中间件,而是把「存活」讲清楚:节点用 TTL 租约注册、靠心跳续约维持,etcd 在租约过期时自动删键。本节从零实现注册、续约与健康三态(健康 / 降级 / 下线),并用 Watch 驱动的监视器真连本机 etcd 跑通全部断言。

《晨曦大陆》从单服扩到集群,第一个新问题不是「怎么把消息发过去」,而是「怎么知道对面还活着」。一个网关要把登录请求转给世界服,它必须先知道世界服有哪些进程、各自地址是什么、哪一个现在能接流量。这些信息不能写死在配置里——进程会重启、IP 会变、某个实例可能只是过载了而不是挂了。

上一章我们把单服拆成了集群拓扑,这一章给集群装上「神经系统」:让每个进程能把自己登记出去,让别的进程能发现它,并在它悄悄死掉时把这一条记录清掉。这一节只做最基础的一件事——注册与健康检查,用本机的 etcd 从零实现并真跑。先交代本节与站内既有文章的分工:

既有文章负责「架构方案与取舍」本书本节负责「从零实现 + 真跑」
/game-server-service-discovery-and-health-check-architecture/服务发现的整体分层、注册中心选型与健康检查策略落地一个可编译的 Registry,把租约、续约、三态每一步跑出来
/game-server-service-governance-architecture-design/服务治理的边界:限流、熔断、路由与配置分发给出注册记录与健康态的最小数据结构,作为治理的输入

2.1.1 存活的三层语义:租约、心跳、健康态

「节点还活着」这句话在分布式系统里没有唯一答案,它其实是三件事叠在一起:

  • 租约(lease):中间件给注册键设一个 TTL,到期不续就自动删除。这是「中间件视角」的存活。
  • 心跳(heartbeat):节点定期告诉中间件「我还在」,从而续租。这是「节点视角」的自证。
  • 健康态(health):即使进程活着,它也可能过载、只读、部分功能不可用。这是「业务视角」的可用性。

这三层必须分开。把「进程活着」当成「能接流量」,是集群里最常见的误判。看一下时序:

t0  scene-01 启动  → 注册:建 10s 租约,写键 nodes/scene-01
t1  正常心跳       → 每 ~3s 续租一次,键始终存在
t2  实例过载       → 自报 degraded:键还在,但权重调低、停止派发新流量
t3  进程被 kill -9 → 心跳断,租约到期,etcd 自动删键 → 其它节点看到 offline
t4  进程恢复       → 重新注册,键重现,回到 healthy

关键在 t3:没有人主动去通知「我挂了」,因为进程已经死了、发不出消息。离线只能靠租约过期推导出来。所以健康三态里,healthy 和 degraded 是节点自报的,offline 是中间件推出来的——这个不对称是整节的设计原点。

2.1.2 从零实现:租约注册与心跳续约

下面这段是 L2,真连本机主控起的 etcd(http://127.0.0.1:32379),go test 可以直接跑。它把注册、续约、三态、下线全部做出来,测试数据统一写在独立前缀 aegis:test:ch02: 下:

// file: internal/discovery/registry.go
// tier: L2
// verify: go test ./internal/discovery/ -run TestEtcd -v
package discovery

import (
	"context"
	"encoding/json"
	"fmt"
	"strings"
	"sync"
	"time"

	clientv3 "go.etcd.io/etcd/client/v3"
)

// NodeID 是集群节点标识,形如 "scene-01"、"gateway-03"。
type NodeID string

// HealthState 是节点的健康三态。注意三态来源不同:healthy / degraded
// 由节点自报,offline 由租约过期推导——节点崩溃后没法再自报,只能靠
// 租约自动过期把它判死。
type HealthState uint8

const (
	StateHealthy  HealthState = iota + 1 // 正常,承接全部流量
	StateDegraded                        // 存活但受限,应停止派发新流量
	StateOffline                         // 租约过期,键被 etcd 自动删除
)

// String 便于日志与断言输出。
func (s HealthState) String() string {
	switch s {
	case StateHealthy:
		return "healthy"
	case StateDegraded:
		return "degraded"
	case StateOffline:
		return "offline"
	default:
		return "unknown"
	}
}

// NodeRecord 是写进 etcd 的注册记录。State 只会是 healthy 或 degraded:
// 节点还活着才写得了记录,offline 是记录消失后才推导出来的。
type NodeRecord struct {
	ID     NodeID      `json:"id"`
	Addr   string      `json:"addr"`
	Weight int         `json:"weight"`
	State  HealthState `json:"state"`
}

// Member 是注册表的读侧视图:记录 + etcd 分配的租约 ID。
type Member struct {
	NodeRecord
	LeaseID int64
}

// Registry 把一批节点注册到 etcd,用 TTL 租约 + 心跳续约维护存活。
type Registry struct {
	cli      *clientv3.Client
	prefix   string
	ttl      int64
	mu       sync.Mutex
	sessions map[NodeID]*session
}

type session struct {
	lease  clientv3.LeaseID
	cancel context.CancelFunc
}

// NewRegistry 创建挂在 cli 上的注册表,键前缀 prefix(须以 ':' 结尾),租约 TTL 为 ttl。
func NewRegistry(cli *clientv3.Client, prefix string, ttl time.Duration) *Registry {
	return &Registry{cli: cli, prefix: prefix, ttl: int64(ttl.Seconds()),
		sessions: make(map[NodeID]*session)}
}

func (r *Registry) key(id NodeID) string { return r.prefix + "nodes/" + string(id) }

// Register 注册一个节点:建租约、写键、起心跳续约。重复注册同一 ID 报错。
func (r *Registry) Register(ctx context.Context, rec NodeRecord) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	if _, ok := r.sessions[rec.ID]; ok {
		return fmt.Errorf("discovery: node %s already registered", rec.ID)
	}
	if rec.State == 0 {
		rec.State = StateHealthy
	}
	grant, err := r.cli.Grant(ctx, r.ttl)
	if err != nil {
		return fmt.Errorf("discovery: grant lease: %w", err)
	}
	val, _ := json.Marshal(rec)
	if _, err := r.cli.Put(ctx, r.key(rec.ID), string(val), clientv3.WithLease(grant.ID)); err != nil {
		_, _ = r.cli.Revoke(context.Background(), grant.ID)
		return fmt.Errorf("discovery: put: %w", err)
	}
	// 心跳续约:KeepAlive 返回一个通道,etcd 每隔 TTL/3 自动续一次。
	// 通道一旦关闭(网络断、etcd 重启、租约被撤),节点就该认为自己已掉线。
	hbCtx, cancel := context.WithCancel(context.Background())
	kaCh, err := r.cli.KeepAlive(hbCtx, grant.ID)
	if err != nil {
		cancel()
		_, _ = r.cli.Revoke(context.Background(), grant.ID)
		return fmt.Errorf("discovery: keepalive: %w", err)
	}
	r.sessions[rec.ID] = &session{lease: grant.ID, cancel: cancel}
	go func() {
		for range kaCh {
			// 续约成功什么都不用做;通道关闭即代表租约已失效。
		}
	}()
	return nil
}

// SetState 更新节点的自报健康态(healthy <-> degraded)。offline 不能自报。
func (r *Registry) SetState(ctx context.Context, id NodeID, state HealthState) error {
	if state == StateOffline {
		return fmt.Errorf("discovery: offline is derived, not self-reported")
	}
	r.mu.Lock()
	s, ok := r.sessions[id]
	r.mu.Unlock()
	if !ok {
		return fmt.Errorf("discovery: node %s not registered", id)
	}
	resp, err := r.cli.Get(ctx, r.key(id))
	if err != nil || len(resp.Kvs) == 0 {
		return fmt.Errorf("discovery: node %s key vanished", id)
	}
	var rec NodeRecord
	_ = json.Unmarshal(resp.Kvs[0].Value, &rec)
	rec.State = state
	val, _ := json.Marshal(rec)
	_, err = r.cli.Put(ctx, r.key(id), string(val), clientv3.WithLease(s.lease))
	return err
}

// Deregister 主动下线:撤销租约,键立即消失。
func (r *Registry) Deregister(ctx context.Context, id NodeID) error {
	r.mu.Lock()
	s, ok := r.sessions[id]
	if ok {
		delete(r.sessions, id)
	}
	r.mu.Unlock()
	if !ok {
		return fmt.Errorf("discovery: node %s not registered", id)
	}
	s.cancel()
	_, err := r.cli.Revoke(ctx, s.lease)
	return err
}

// Suspend 只停心跳、不撤租约,模拟「进程还在但心跳断了」的崩溃:
// 租约到期后键被自动删除,节点被监视器判 offline。
func (r *Registry) Suspend(id NodeID) {
	r.mu.Lock()
	s, ok := r.sessions[id]
	if ok {
		delete(r.sessions, id)
	}
	r.mu.Unlock()
	if ok {
		s.cancel()
	}
}

// List 拉取当前注册表里的全部节点(一次强一致读)。
func (r *Registry) List(ctx context.Context) ([]Member, error) {
	resp, err := r.cli.Get(ctx, r.prefix+"nodes/", clientv3.WithPrefix())
	if err != nil {
		return nil, err
	}
	out := make([]Member, 0, len(resp.Kvs))
	for _, kv := range resp.Kvs {
		var rec NodeRecord
		if err := json.Unmarshal(kv.Value, &rec); err != nil {
			return nil, err
		}
		out = append(out, Member{NodeRecord: rec, LeaseID: kv.Lease})
	}
	return out, nil
}

// Close 取消所有本地心跳协程(不撤租约,等 TTL 自然过期)。
func (r *Registry) Close() {
	r.mu.Lock()
	defer r.mu.Unlock()
	for id, s := range r.sessions {
		s.cancel()
		delete(r.sessions, id)
	}
}

// nodeIDFromKey 从 "prefix/nodes/<id>" 里抠出 <id>。
func nodeIDFromKey(prefix, key string) NodeID {
	return NodeID(strings.TrimPrefix(key, prefix+"nodes/"))
}

两个设计点值得停一下。其一,Grant 和 Put 必须分开做:先拿到租约 ID,再把键挂在租约上;如果 Put 失败,要立刻 Revoke 把租约还回去,否则会漏一个空租约。其二,KeepAlive 返回的是一个通道,我们只 for range 它——续约成功不需要任何动作,通道关闭才是信号。这行 for range kaCh {} 看着像空转,其实是整个租约机制的收口:一旦它退出,说明心跳已经无法维持,进程应当把自己标记为不可用。

2.1.3 健康三态与 Watch 驱动的监视器

节点把自己登记出去,别的进程得能实时看到变化。用轮询 List 也能做,但代价是延迟与 QPS 随节点数放大。etcd 的原生做法是 Watch:客户端订阅一个前缀,etcd 在键变化时把事件推过来。监视器先 List 播种一次、记下 revision,再从 rev+1 开始 Watch,既不漏事件也不重放历史。核心的事件分派如下(// snippet:,完整文件见 internal/discovery/watch.go):

// snippet: HealthMonitor.Run 的事件分派(节选自 internal/discovery/watch.go)
switch ev.Type {
case clientv3.EventTypePut:
	var rec NodeRecord
	_ = json.Unmarshal(ev.Kv.Value, &rec)
	mem := Member{NodeRecord: rec, LeaseID: ev.Kv.Lease}
	m.mu.Lock()
	_, existed := m.state[id]
	m.state[id] = mem
	m.mu.Unlock()
	if existed {
		e = Event{Type: EventUpdated, Member: mem} // healthy <-> degraded
	} else {
		e = Event{Type: EventAdded, Member: mem}
	}
case clientv3.EventTypeDelete:
	m.mu.Lock()
	mem, ok := m.state[id]
	if !ok {
		mem = Member{NodeRecord: NodeRecord{ID: id}}
	}
	// 键消失 = 租约过期或主动撤销,一律判 offline。
	// 保留在视图里标 offline(而不是删掉),三态才看得到「谁掉线了」。
	mem.State = StateOffline
	m.state[id] = mem
	m.mu.Unlock()
	e = Event{Type: EventRemoved, Member: mem}
}

这里有一个容易写错的地方:收到 Delete 事件时不要把节点从本地视图里删掉,而要保留它、把状态改成 offline。删掉的话,「谁掉线了」这个信息就丢了——运维想知道的是「scene-01 在 14:02 掉线」,而不是「scene-01 从来没存在过」。三个用例真跑的结果:

=== RUN   TestEtcdRegisterHealthAndOffline
--- PASS: TestEtcdRegisterHealthAndOffline (0.06s)
=== RUN   TestEtcdLeaseExpiry
--- PASS: TestEtcdLeaseExpiry (2.49s)
PASS
ok  	aegis/internal/discovery	3.023s

第一个用例覆盖「注册 → 自报降级 → 主动下线」的完整三态流转;第二个用例验证「不主动撤销、只停心跳」时,租约 1 秒到期后键被自动删除、监视器判 offline——这就是崩溃场景,也是整节最该被固化成测试的那条路径。

本节小结

  1. 存活有三层,别混用。 租约是中间件视角,心跳是节点自证,健康态是业务视角。healthy / degraded 由节点自报,offline 只能靠租约过期推导——崩溃的进程发不出「我挂了」。
  2. 续约成功不用做事,通道关闭才是信号。 KeepAlive 的返回值是个通道,for range 它,退出即代表租约失效;这是把「网络断了」翻译成「我该下线了」的唯一落点。
  3. 三态视图要保留 offline 墓碑。 收到 Delete 事件时标状态而不是删记录,否则掉线历史就丢了。

一句话收束:服务发现真正难的不是「发现」,而是「确认对方已经死了」——这只能靠租约超时,而不是靠对方主动告别。 下一节把同一套租约机制用在更难的地方:让一群对等节点里,任何时刻只有一个能当 Leader。

阅读导航:上一节:目录 · 下一节:2.2 Leader 选举与服务所有权 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标