前两节我们默认用 etcd 做服务发现,但选型从来不是「哪个更对」,而是「哪个更合」。这一节是本卷唯一的三中间件对照:把同一套注册-发现-健康检查,在 etcd、NATS、Consul 上各实现一遍,真连本机主控起的三个容器,跑出可比较的数字,再给出可判定的选型结论。
先说清楚口径:下面所有数字都来自本机同一台机器(macOS / Apple Silicon),三个中间件都跑在 Docker 里、端口与用户自己的实例错开。它们是同机相对量级,绝对数值不可移植——换一台机器、换一次网络,绝对值都会变。真正能迁移的是结论里那些不随机器变的性质。
2.3.1 三种模型的根本差异
选型之前先把三者的「模型」摆清楚。它们看着都能注册服务,但底层机制完全不同:
| 维度 | etcd | NATS | Consul |
|---|---|---|---|
| 定位 | 强一致 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):
| 指标 | etcd | NATS | Consul |
|---|---|---|---|
| 注册→可发现 p50 | 4.9–10.9 ms | 4.6–9.6 ms | 6.5–10.8 ms |
| 状态变更→观测 p50 | 2.5–6.6 ms | 2.3–7.1 ms | 3.9–8.0 ms |
| 心跳中断→下线(中位) | ~2.48 s | ~2.39 s | ~1.99 s |
读这张表要克制。前两行三个后端的差距都在个位数毫秒量级,落在运行间的抖动里——单机上,写一条键、发一条消息、注册一个服务,都只是一次本地往返,谁也没比谁快出一个数量级。所以「选哪个」不该看这几毫秒。真正有信息量的是第三行:下线收敛时间几乎就等于你配置的 TTL,etcd 和 Consul 因为要等租约/检查过期而略超 TTL,NATS 因为还叠了一层清扫周期(ttl 到 ttl+interval)略多一点。想更快判死,只能调小 TTL——代价是心跳更频繁、误判风险更高。
选型结论
把数字和模型合起来看,结论是可以判定的:
- 需要「选主 + 事务 + 强一致读」→ etcd。 前两节的注册表、Leader 选举、fencing token 全都依赖 etcd 的租约与 CAS 事务,这是 NATS 和 Consul 都给不了的。集群的控制面(节点注册、路由目录、所有权)放这里。
- 需要「高扇出实时消息」→ NATS。 跨服聊天、战场事件广播这类一条发、多条收的场景,NATS 的 pub/sub 天然合适;但别把它的心跳当成所有权的事实源——订阅方算出来的「健康」不是强一致的。
- 需要「开箱即用的健康目录 + DNS」→ Consul。 如果你已经在用 Consul 做基础设施服务目录,或者希望别的进程用 DNS/HTTP 就能发现服务、不想引客户端库,Consul 的原生健康检查省事。
- 推荐组合:etcd 做控制面、NATS 做数据面。 两者职责不重叠:etcd 保证「谁是谁、谁在任」这件事强一致,NATS 负责把消息高效地扇出去。Consul 则在「已有 Consul 基础设施」时才作为控制面的替代。
一句话收束:三种中间件的注册延迟都在同一量级,选型的胜负手不在毫秒,而在「谁负责判定存活」——把这件事交给谁,就要接受谁的失败语义。 下一章进入服务间通信本身:有了目录,两个进程该怎么说话、超时预算怎么分。
阅读导航:上一节:2.2 Leader 选举与服务所有权 · 下一节:3.1 协议与超时预算 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。