《晨曦大陆》从单服扩到集群,第一个新问题不是「怎么把消息发过去」,而是「怎么知道对面还活着」。一个网关要把登录请求转给世界服,它必须先知道世界服有哪些进程、各自地址是什么、哪一个现在能接流量。这些信息不能写死在配置里——进程会重启、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——这就是崩溃场景,也是整节最该被固化成测试的那条路径。
本节小结
- 存活有三层,别混用。 租约是中间件视角,心跳是节点自证,健康态是业务视角。
healthy/degraded由节点自报,offline只能靠租约过期推导——崩溃的进程发不出「我挂了」。 - 续约成功不用做事,通道关闭才是信号。
KeepAlive的返回值是个通道,for range它,退出即代表租约失效;这是把「网络断了」翻译成「我该下线了」的唯一落点。 - 三态视图要保留 offline 墓碑。 收到 Delete 事件时标状态而不是删记录,否则掉线历史就丢了。
一句话收束:服务发现真正难的不是「发现」,而是「确认对方已经死了」——这只能靠租约超时,而不是靠对方主动告别。 下一节把同一套租约机制用在更难的地方:让一群对等节点里,任何时刻只有一个能当 Leader。
阅读导航:上一节:目录 · 下一节:2.2 Leader 选举与服务所有权 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。