《MMO 服务器开发》2.3 对照:etcd / NATS / Consul 在本机的真跑选型

同一套注册-发现-健康检查,在 etcd、NATS、Consul 上各实现一遍并真连本机中间件跑出数字:注册延迟、发现延迟、心跳中断后的下线收敛时间。三种模型(租约、心跳陈旧判定、原生 TTL 检查)的差异不在毫秒,而在谁负责判定存活这件事上,据此给出可判定的选型结论。

前两节我们默认用 etcd 做服务发现,但选型从来不是「哪个更对」,而是「哪个更合」。这一节是本卷唯一的三中间件对照:把同一套注册-发现-健康检查,在 etcd、NATS、Consul 上各实现一遍,真连本机主控起的三个容器,跑出可比较的数字,再给出可判定的选型结论。

先说清楚口径:下面所有数字都来自本机同一台机器(macOS / Apple Silicon),三个中间件都跑在 Docker 里、端口与用户自己的实例错开。它们是同机相对量级,绝对数值不可移植——换一台机器、换一次网络,绝对值都会变。真正能迁移的是结论里那些不随机器变的性质。

2.3.1 三种模型的根本差异

选型之前先把三者的「模型」摆清楚。它们看着都能注册服务,但底层机制完全不同:

维度etcdNATSConsul
定位强一致 KV + Watch轻量消息总线服务目录 + 健康检查
注册载体带 TTL 的键心跳 subject 消息服务注册 + 健康检查对象
存活判定租约过期(中间件判)心跳陈旧(订阅方判)TTL 检查过期(中间件判)
变更通知Watch 推事件订阅即时收到阻塞查询 / DNS
一致性线性一致(Raft)无强一致,至少一次线性一致(Raft)
原生健康三态无无有(passing / warning / critical)
额外能力CAS 事务、Leader 选举海量 pub/sub、请求应答DNS 接口、多数据中心

一句话概括三者的分歧:etcd 把「存活」做成了租约,NATS 把「存活」留给了订阅方自己算,Consul 把「存活」做成了产品内置的健康检查。 谁判存活、在哪判,决定了它们各自适合什么位置。

2.3.2 NATS:心跳 + 陈旧判定

NATS 核心没有租约,也没有中心注册表。节点只是定期往一个自己的 subject 上发心跳,谁订阅谁就知道;「下线」不是中间件给的,而是订阅方按「多久没收到心跳」自己推出来的。下面这段是 L2,真连本机 NATS(nats://127.0.0.1:34222):

// snippet: 心跳发布与陈旧判定(节选自 internal/discovery/nats.go)
// 节点侧:起一个协程定期发心跳,首拍立即发。
func (r *NatsRegistry) Register(ctx context.Context, rec NodeRecord) error {
	s := &natsSession{addr: rec.Addr, stop: make(chan struct{}), state: rec.State}
	r.sessions[rec.ID] = s
	r.publish(rec.ID, rec.Addr, rec.State) // 首拍:立刻可被发现
	go func() {
		tk := time.NewTicker(r.interval)
		defer tk.Stop()
		for {
			select {
			case <-tk.C:
				s.mu.Lock()
				st := s.state
				s.mu.Unlock()
				r.publish(rec.ID, rec.Addr, st)
			case <-s.stop:
				return
			}
		}
	}()
	return nil
}

// 监视器侧:扫一遍,超过 ttl 没心跳的判 offline。
func (m *NatsMonitor) sweep() {
	tk := time.NewTicker(m.interval)
	defer tk.Stop()
	for {
		select {
		case <-tk.C:
			now := time.Now()
			m.mu.Lock()
			for id, t := range m.seen {
				if now.Sub(t) > m.ttl { // ttl = 3 * interval
					mem := m.states[id]
					mem.State = StateOffline
					m.states[id] = mem
					delete(m.seen, id)
				}
			}
			m.mu.Unlock()
		case <-m.stop:
			return
		}
	}
}

NATS 的好处是轻:注册就是发一条消息,没有任何写放大,扇出也天然便宜。代价是健康判定变成了每个订阅方的本地逻辑——你设的 ttl 是几倍心跳,决定了你多快能把死节点判下线;而节点自己也没法「撤销」注册,只能等心跳停。

2.3.3 Consul:原生服务注册 + TTL 检查

Consul 把服务目录和健康检查做成了产品。注册服务时挂一个 TTL 检查,然后周期上报 PassTTL / WarnTTL / FailTTL,Consul 自己维护三态——passing 对应健康、warning 对应降级、critical 对应下线,映射是一对一的。下面这段是 L2,真连本机 Consul(http://127.0.0.1:38500):

// snippet: 服务注册 + TTL 检查 + 三态映射(节选自 internal/discovery/consul.go)
// 注册:挂一个 TTL 检查,初始 passing。
reg := &consulapi.AgentServiceRegistration{
	ID:      consulSvcID(rec.ID),
	Name:    consulServiceName,
	Address: host,
	Port:    port,
	Check: &consulapi.AgentServiceCheck{
		CheckID: s.checkID,
		TTL:     r.ttl.String(),
		Status:  consulapi.HealthPassing,
	},
}
if err := r.cli.Agent().ServiceRegister(reg); err != nil {
	return err
}

// 上报:三态直接映射到 Consul 的三档健康。
func (r *ConsulRegistry) report(checkID string, state HealthState) error {
	agent := r.cli.Agent()
	switch state {
	case StateHealthy:
		return agent.PassTTL(checkID, "healthy")
	case StateDegraded:
		return agent.WarnTTL(checkID, "degraded")
	case StateOffline:
		return agent.FailTTL(checkID, "offline")
	}
	return fmt.Errorf("consul: unknown state %d", state)
}

Consul 的便利是「健康态是原生的」:不用自己维护时间戳,critical 就是离线;而且它自带 DNS 和 HTTP 接口,别的进程连客户端库都不用引就能发现服务。代价是模型更重——一个服务要注册成一份目录记录,还要维护检查对象,比「发一条消息」啰嗦。

2.3.4 同一套基准,三种后端

三套实现都跑通了,接下来是公平对照。下面这个基准程序用同一组动作(注册、改状态、判下线)驱动三种后端,tier: B,go run 可以直接跑:

// file: cmd/compare/main.go
// tier: B
// verify: go run ./cmd/compare
package main

import (
	"context"
	"fmt"
	"sort"
	"time"

	consulapi "github.com/hashicorp/consul/api"
	"github.com/nats-io/nats.go"
	clientv3 "go.etcd.io/etcd/client/v3"

	"aegis/internal/discovery"
)

// 同一组动作、三种后端:注册、改状态、判下线。
type backend struct {
	name     string
	register func(context.Context, discovery.NodeID) error
	setState func(context.Context, discovery.NodeID, discovery.HealthState) error
	suspend  func(discovery.NodeID)
	snapshot func() map[discovery.NodeID]discovery.Member
}

func waitState(ctx context.Context, snap func() map[discovery.NodeID]discovery.Member,
	id discovery.NodeID, want discovery.HealthState, d time.Duration) error {
	deadline := time.Now().Add(d)
	for time.Now().Before(deadline) {
		if m, ok := snap()[id]; ok && m.State == want {
			return nil
		}
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-time.After(2 * time.Millisecond):
		}
	}
	return fmt.Errorf("timeout waiting %s=%s", id, want)
}

func pct(ds []time.Duration, p int) time.Duration {
	cp := append([]time.Duration(nil), ds...)
	sort.Slice(cp, func(i, j int) bool { return cp[i] < cp[j] })
	return cp[(p*(len(cp)-1))/100]
}

func main() {
	ctx := context.Background()
	mctx, mcancel := context.WithCancel(ctx)
	defer mcancel()

	etcdCli, err := clientv3.New(clientv3.Config{Endpoints: []string{"http://127.0.0.1:32379"}, DialTimeout: 3 * time.Second})
	if err != nil {
		panic(err)
	}
	reg := discovery.NewRegistry(etcdCli, "aegis:test:ch02:", 2*time.Second)
	emon := discovery.NewHealthMonitor(etcdCli, "aegis:test:ch02:")
	emon.Run(mctx)

	ncli, err := nats.Connect("nats://127.0.0.1:34222", nats.Timeout(3*time.Second))
	if err != nil {
		panic(err)
	}
	nreg := discovery.NewNatsRegistry(ncli, 600*time.Millisecond)
	nmon, err := discovery.NewNatsMonitor(ncli, 600*time.Millisecond)
	if err != nil {
		panic(err)
	}

	cncli, err := consulapi.NewClient(&consulapi.Config{Address: "127.0.0.1:38500", Scheme: "http"})
	if err != nil {
		panic(err)
	}
	creg := discovery.NewConsulRegistry(cncli, 2*time.Second)
	cmon := discovery.NewConsulMonitor(cncli)
	cmon.Run(mctx)

	backends := []backend{
		{"etcd",
			func(c context.Context, id discovery.NodeID) error {
				return reg.Register(c, discovery.NodeRecord{ID: id, Addr: "127.0.0.1:9001", Weight: 100})
			},
			func(c context.Context, id discovery.NodeID, s discovery.HealthState) error { return reg.SetState(c, id, s) },
			reg.Suspend, emon.Snapshot},
		{"nats",
			func(c context.Context, id discovery.NodeID) error {
				return nreg.Register(c, discovery.NodeRecord{ID: id, Addr: "127.0.0.1:9002", Weight: 100})
			},
			func(c context.Context, id discovery.NodeID, s discovery.HealthState) error { return nreg.SetState(c, id, s) },
			func(id discovery.NodeID) { _ = nreg.Deregister(context.Background(), id) }, nmon.Snapshot},
		{"consul",
			func(c context.Context, id discovery.NodeID) error {
				return creg.Register(c, discovery.NodeRecord{ID: id, Addr: "127.0.0.1:9003", Weight: 100})
			},
			func(c context.Context, id discovery.NodeID, s discovery.HealthState) error { return creg.SetState(c, id, s) },
			creg.Suspend, cmon.Snapshot},
	}

	const n = 20
	fmt.Println("register->discoverable p50 / state-change->observed p50")
	for _, b := range backends {
		time.Sleep(150 * time.Millisecond)
		regLat := make([]time.Duration, 0, n)
		for i := 0; i < n; i++ {
			id := discovery.NodeID(fmt.Sprintf("%s-reg-%02d", b.name, i))
			t0 := time.Now()
			if err := b.register(ctx, id); err != nil {
				panic(err)
			}
			if err := waitState(ctx, b.snapshot, id, discovery.StateHealthy, 5*time.Second); err != nil {
				panic(err)
			}
			regLat = append(regLat, time.Since(t0))
		}
		discID := discovery.NodeID(b.name + "-disc")
		if err := b.register(ctx, discID); err != nil {
			panic(err)
		}
		_ = waitState(ctx, b.snapshot, discID, discovery.StateHealthy, 5*time.Second)
		discLat := make([]time.Duration, 0, n)
		for i := 0; i < n; i++ {
			want := discovery.StateDegraded
			if i%2 == 1 {
				want = discovery.StateHealthy
			}
			t0 := time.Now()
			_ = b.setState(ctx, discID, want)
			_ = waitState(ctx, b.snapshot, discID, want, 5*time.Second)
			discLat = append(discLat, time.Since(t0))
		}
		fmt.Printf("%-7s register p50=%-10v   state-change p50=%v\n", b.name, pct(regLat, 50), pct(discLat, 50))
	}

	fmt.Println("heartbeat-stop->offline (3 runs, median)")
	for _, b := range backends {
		var runs []time.Duration
		for i := 0; i < 3; i++ {
			id := discovery.NodeID(fmt.Sprintf("%s-crash-%d", b.name, i))
			if err := b.register(ctx, id); err != nil {
				panic(err)
			}
			_ = waitState(ctx, b.snapshot, id, discovery.StateHealthy, 5*time.Second)
			t0 := time.Now()
			b.suspend(id)
			if err := waitState(ctx, b.snapshot, id, discovery.StateOffline, 15*time.Second); err != nil {
				panic(err)
			}
			runs = append(runs, time.Since(t0))
		}
		fmt.Printf("%-7s convergence median=%v\n", b.name, pct(runs, 50))
	}

	// 清理本章写入的数据。
	dctx, dcancel := context.WithTimeout(ctx, 3*time.Second)
	defer dcancel()
	_, _ = etcdCli.Delete(dctx, "aegis:test:ch02:", clientv3.WithPrefix())
	for _, m := range cmon.Snapshot() {
		_ = cncli.Agent().ServiceDeregister("aegis-ch02-svc-" + string(m.ID))
	}
}

三次真跑的典型输出:

register->discoverable p50 / state-change->observed p50
etcd    register p50=6.33ms   state-change p50=6.57ms
nats    register p50=4.73ms   state-change p50=4.70ms
consul  register p50=7.52ms   state-change p50=5.24ms
heartbeat-stop->offline (3 runs, median)
etcd    convergence median=2.477s
nats    convergence median=2.386s
consul  convergence median=1.990s

把三次运行的区间整理成表(etcd TTL=2s;NATS 心跳 600ms、下线阈值 1.8s;Consul TTL=2s):

指标etcdNATSConsul
注册→可发现 p504.9–10.9 ms4.6–9.6 ms6.5–10.8 ms
状态变更→观测 p502.5–6.6 ms2.3–7.1 ms3.9–8.0 ms
心跳中断→下线(中位)~2.48 s~2.39 s~1.99 s

读这张表要克制。前两行三个后端的差距都在个位数毫秒量级,落在运行间的抖动里——单机上,写一条键、发一条消息、注册一个服务,都只是一次本地往返,谁也没比谁快出一个数量级。所以「选哪个」不该看这几毫秒。真正有信息量的是第三行:下线收敛时间几乎就等于你配置的 TTL,etcd 和 Consul 因为要等租约/检查过期而略超 TTL,NATS 因为还叠了一层清扫周期(ttl 到 ttl+interval)略多一点。想更快判死,只能调小 TTL——代价是心跳更频繁、误判风险更高。

选型结论

把数字和模型合起来看,结论是可以判定的:

  1. 需要「选主 + 事务 + 强一致读」→ etcd。 前两节的注册表、Leader 选举、fencing token 全都依赖 etcd 的租约与 CAS 事务,这是 NATS 和 Consul 都给不了的。集群的控制面(节点注册、路由目录、所有权)放这里。
  2. 需要「高扇出实时消息」→ NATS。 跨服聊天、战场事件广播这类一条发、多条收的场景,NATS 的 pub/sub 天然合适;但别把它的心跳当成所有权的事实源——订阅方算出来的「健康」不是强一致的。
  3. 需要「开箱即用的健康目录 + DNS」→ Consul。 如果你已经在用 Consul 做基础设施服务目录,或者希望别的进程用 DNS/HTTP 就能发现服务、不想引客户端库,Consul 的原生健康检查省事。
  4. 推荐组合:etcd 做控制面、NATS 做数据面。 两者职责不重叠:etcd 保证「谁是谁、谁在任」这件事强一致,NATS 负责把消息高效地扇出去。Consul 则在「已有 Consul 基础设施」时才作为控制面的替代。

一句话收束:三种中间件的注册延迟都在同一量级,选型的胜负手不在毫秒,而在「谁负责判定存活」——把这件事交给谁,就要接受谁的失败语义。 下一章进入服务间通信本身:有了目录,两个进程该怎么说话、超时预算怎么分。

阅读导航:上一节:2.2 Leader 选举与服务所有权 · 下一节:3.1 协议与超时预算 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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