2.3 注册发现:etcd 服务注册与下线

契约定完,调用方要能实时找到服务方。我们选 etcd 做注册中心,这一篇把键设计、租约参数、watch 消费、下线摘除的完整生命周期写出来,参数全部给实测值。etcd 的键空间设计与官方文档的语义一致,本篇只加一层:微服务场景下的参数取舍与两次事故的复盘。注册发现与负载均衡的配合在 2.4 篇展开,本篇只管「位置信息」这一段。

场景:注册中心要扛住什么

场景 要求 数字
实例上线 流量在注册后 1s 内可见 watch 传播 300ms 内
实例宕机 失联 15s 内摘除 租约 TTL 15s
etcd 抖动 调用方本地缓存兜底 缓存可撑 30s
全量规模 17 服务 × 8 实例 136 个 key

四个要求对应四个设计点,下面逐条给做法。规模上限也要心里有数:136 个 key 对 etcd 是小菜一碟,真正要防的是事件速率。全量重放时 etcd 吐事件 300 条/秒,单线程消费 3ms 一条完全跟得上;但如果服务数翻十倍,watch 侧必须加消费队列与背压,这是 1.2 篇说的「40 个服务换托管注册中心」的量化依据。注册发现与 LB 的分工边界见 go-micro-cps-guide 的对照章节。

etcd 集群参数也提前定死,避免后面反复调:

参数 取值 理由
节点数 3,跨可用区 多数派容 1 区故障
磁盘 SSD,单节点 ≤ 2GB 写放大敏感
heartbeat / election 100ms / 1s 默认值,调慢拉长失联
快照 每日 2 份传 COS 5.2 恢复脚本依赖

方案:键设计与生命周期

键结构定死:gov/services/{name}/v={ver}/inst/{instId},value 是 JSON(地址、权重、灰度标签)。把版本编进 key 而不是 value,灰度路由(4.1)与 LB(2.4)都只靠 key 前缀过滤,不用解析 value。

阶段 动作 参数
注册 Grant 租约 15s,Put 挂租约 续约 5s 一次
续约 独立 goroutine,失败 3 次退出进程 超时 2s
摘除 租约过期自动删 key TTL 15s 到期
下线 优雅停机:先摘流量再退 发 FINISH 事件

版本号的语义要固定:v 跟 2.2 的 proto 契约版本走,同一契约的 bugfix 不换号,避免「每个小版本一个 v」把 key 空间撑爆。实例 id 用容器 hostname 加短 hash,重名直接拒注册,防同机双进程撞 key。key 前缀统一 gov/,与配置类节点隔离,避免注册事件与配置变更事件混在一条 watch 流里。

// 注册核心流程(govkit 封装后服务侧一行调用)
lease, _ := cli.Grant(ctx, 15)
defer cli.Revoke(ctx, lease.ID) // 优雅下线:主动撤销
cli.Put(ctx, key, jsonAddr)      // 挂 lease 的 Put
go renewLoop(ctx, cli, lease.ID) // 每 5s 续约,三连败退出

优雅下线的顺序很重要:发 FINISH 事件让 watch 侧摘流量,等 2 个续约周期(10s)确认无新流量进来,再 Revoke 退租。反过来先 Revoke 会有一小段时间旧实例还挂旧连接,新流量切走但 in-flight 请求被打断。

配置与代码:watch 侧怎么做

watch 侧是注册发现里最容易写错的半边。我们的消费模型:

ch := cli.Watch(ctx, "gov/services/", etcd.WithPrefix())
for ev := range ch {
    updateLocalCache(ev)  // diff 出增删,更新内存实例表
    resolver.Push()       // 通知 gRPC resolver 重新解析
}

三个实现要点:watch 断连后必须带 PrevKv 重放补漏,否则网络抖动期间丢的事件就永久丢了;实例表加读锁,更新用 copy-on-write 整表替换,避免 gRPC 选择实例时读到半更新状态;每次更新记录一个单调 revision,卡住 30s 无事件就打 warn 并告警到 4.3 看板。

etcd 集群本身的运维要点也列在这:3 节点跨可用区、SSD 盘、heartbeat-interval 加 election-timeout 保持默认 100ms/1s 不动(调慢会拉长失联窗口);etcdctl endpoint status 加 etcdctl check health 进每日巡检脚本,任一节点 lag 超 10s 告警。注册中心是整条链路的地基,地基的监控优先级高于所有业务服务,这条写进了 4.3 的告警分级。

租约参数 15s 的来历:太短(如 5s)时 etcd 一次选主抖动就触发全量误下线,1.3 篇那次 etcd 事故我们见过 40s 多数派失联;太长(如 60s)故障实例挂半小时。15s 加 5s 续约是抖动容忍与故障恢复速度的折中,写进框架默认值。注册中心与负载均衡的分工边界在 go-micro-cps-guide 里也有对照:注册只管位置,健康度交给探活。

演练:kill 一台的完整时序

把「实例宕机」这条验收(R1)走一遍实测时序,10s 摘除的口径就从这来:

时刻 事件 位置
T+0 实例被 kill etcd 侧
T+0~15 租约 15s 到期 etcd 自动删 key
T+15.3 watch 事件到达调用方 各实例本地
T+15.3+ε resolver 重解析,流量切走 调用方

理论上限 15.3s,但调用方实例表对「连续 2 次探活失败」也会主动摘除(2.4 篇的探活),实际摘除中位数约 10s——探活间隔 5s,两次失败 10s 就摘,比等租约快。两种机制并行,谁先命中谁生效,这就是 R1 写 10s 的依据。

演练的另一个版本是「etcd 整体不可用 40s」:调用方本地实例表缓存 30s 撑住,第 31s 起调用方拒绝新解析但旧连接继续服务,40s 后 etcd 恢复,watch 补漏 2s 内追上。这个演练每季度跑一次,脚本在 5.2 的容灾清单里,结果直接写进当季的稳定性报告。

踩坑:四个实战教训

第一,watch 重连风暴。一次 etcd 升级后 watch 全量重连,136 个 key 的事件洪峰把 9 台 CVM 的内存瞬时打高 40%,恢复后残留了 2 分钟长尾。对策:重连加 100ms 随机退避,实例表更新走批量合并不逐条推。

第二,健康检查误杀。早期把「服务无响应」也走租约,一次 GC 停顿 8s 导致续约超时误下线 3 个健康实例,后来注册只信进程存活,健康度交给 LB 探活(2.4)。

第三,etcd 自身无备份。磁盘坏过一次,快照靠人工想起来才做的,之后把 etcd snapshot 每日 2 份传 COS,恢复脚本进 5.2 容灾清单。

第四,key 数量膨胀。灰度标签早期塞 value 里,后改 key,一次迁移写脚本全量重放 key,迁移期间 watch 事件 3 倍,提前在低峰窗口执行。

教训 现象 修复 现在的纪律
重连风暴 内存尖峰 40% 退避加批量合并 watch 重连必带抖动
误下线 GC 8s 续约超时 注册只信存活 健康度走探活
无备份 磁盘坏需重建 每日 2 份快照传 COS 快照自动加校验
key 迁移 事件洪峰 3 倍 低峰窗口执行 全量重放要排期

快照的校验也要做:传 COS 后跑 etcdctl snapshot status 核对键数量,一次误把旧快照传上去,恢复后少了 2 个服务注册,靠校验才发现。

小结

注册发现的生命周期就五步:注册挂 15s 租约、5s 续约、租约到期自动摘除、优雅下线先摘流量再退租、watch 带 PrevKv 防漏。参数(15/5/30/3)全部有实测依据,写进 govkit 默认值,服务侧零配置。摘除走双轨:租约 15s 与探活 10s 并行,先命中先生效,R1 的 10s 口径由此而来。etcd 自身的快照、巡检、健康告警是地基的维护动作,缺一不可。下一篇 2.4 把实例表接进客户端负载均衡,流量才能真正匀开。

延伸阅读