后端系统工程 16:服务发现中的旧地址
系列导航:上一篇 15 配置发布与客户端收敛 | 本篇 16 | 下一篇 17 从持久日志构建最小消息队列
服务发现的地址集总是落后于事实。一个提供者进程被暂停、被杀死,或者与注册中心断开,注册中心要等租约过期才删除它的地址;删除事件再经过 watch 流到达消费者,消费者才停止向它发请求。这段时间里,消费者手里的地址已经指向一个不能服务的进程。服务发现做不到“没有旧地址”,能做的是把旧地址的存活时间算清楚,并给这段时间准备失败预算。
本篇用真实 etcd 3.4 实例、真实提供者进程和真实消费者进程测量这个窗口:提供者被 SIGSTOP(进程在,续租停)、被 kill -9、被优雅终止时,旧地址各存活多久;同一窗口内,带本地剔除与重试的消费者和不带的消费者各有多少次失败。另外测试三件和“不漏事件”有关的事:watch 断线后按 revision 续订、历史被压缩后全量重新同步、etcd 自身重启。负向对照两个:断线后从“现在”开始 watch 的消费者,以及恢复后不检查租约的提供者。代码在 examples/16-service-discovery/,输出在 evidence/16/output.txt。
需求与不变量
提供者启动时注册地址,运行期间保持注册,退出或失联后地址被移除。消费者维护一份地址集,并用它调用提供者。要求如下:
- 注册中心里的每个地址都绑定一个租约,租约过期或撤销时地址被删除,删除不依赖提供者配合。
- 消费者的地址集等于“某个 revision 时刻的全量快照 + 之后按 revision 顺序到达的全部事件”。断线、重启、压缩都不能让它跳过事件而不自知。
- 提供者从暂停中恢复后,必须能发现自己的注册已经消失,并重新注册。
- 旧地址窗口内的失败尽量由消费者本地吸收,不全部暴露给上层调用方。
教学参数:租约 TTL 3s,续租间隔 1s;消费者每 50ms 发起一次逻辑调用,单次尝试超时 300ms,失败地址本地剔除 2s,最多在另一个地址上重试 1 次。生产系统的 TTL 通常更长,窗口按比例放大;这里取小值只为缩短实验时间。
组件与状态
图 16-1:状态归属与持久化位置
flowchart LR
subgraph E["etcd 3.4.30 单成员(127.0.0.1:47160,数据 /tmp/b2-etcd-16)"]
K[("/svc/demo/<name> → {addr, lease}<br/>绑定租约 TTL=3s")]
LS[(租约表)]
end
P["provider.py(每个一进程)<br/>内存:当前租约 ID<br/>HTTP GET /hello"] -->|grant / put / 每 1s keepalive / SIGTERM 时 revoke| E
C["consumer.py<br/>内存:地址集、last_revision、剔除表"] -->|启动:range 前缀,记 revision| E
C -->|watch start_revision = last_revision+1| E
C -->|HTTP/1.1 长连接调用| P
Python 标准库没有 gRPC,所有调用都经 etcd 的 gRPC-gateway 走 JSON。etcd 3.4 的网关路径是 /v3/*(同时保留 /v3beta/*),key 和 value 是字节数组,在 JSON 中要 base64 编码(etcd v3.4 gRPC gateway)。int64 字段以字符串出现,零值字段直接省略。省略零值这一点在续租时很重要:对一个已不存在的租约调用 /v3/lease/keepalive,网关返回的结果里没有 TTL 字段,etcdgw.keepalive 把它读成 0,提供者据此判断租约已经丢失。
消费者先 range 再 watch。range 返回的响应头带有读取时刻的 revision,watch 从 revision+1 开始。etcd 文档对 start_revision 的说明是:起点包含在内;不填时从 watch 创建响应头的 revision 之后开始(etcd v3.4 API)。填与不填的差别正是第一个负向对照要暴露的问题。
旧地址窗口
租约在 TTL 内没有收到续租就过期,过期时删除所有绑定的 key,每个 key 产生一个 delete 事件(同一文档)。从提供者失去服务能力到消费者删除地址,窗口由四项组成:
1 | |
watch 延迟一项,etcd 文档的说法是:KV 调用立即生效,watch 的返回有无上界的延迟;健康集群中事件大约在 10ms 后出现,但不健康的集群里事件可能永远不到(etcd v3.4 API 保证)。第一项最大接近 TTL,最小约为 TTL 减去续租间隔。
sigstop_stale_window 对提供者 p1 做 3 轮 SIGSTOP,三个消费者同时观察:A 带剔除与重试,B 两者都不带,C 是后面要用的负向对照,此时行为与 A 相同。
1 | |
(原输出是一行,这里按轮次折行。)按上式拆分,三轮的租约剩余寿命分别是 2757、2702、2401ms,实测窗口比它多出 464、491、494ms。这部分包含过期检测、删除提交与 watch 投递,实验没有把它再细分;kill -9 那一次多出 218ms,说明它不是固定值,而在零到半秒之间变化。三个消费者看到删除的时刻相差不超过 2ms,watch 投递本身在这里不是主要成本。
被暂停的进程不会拒绝连接。内核仍然替它完成 TCP 握手,请求写进缓冲区后没有任何回应,调用方只能等到超时,所以错误类型全是 timeout。B 在每轮窗口内对 p1 有 6–7 次调用失败,每次都直接落到上层;A 只尝试失败 2 次:第一次超时后把 p1 剔除 2s,并在另一个地址上重试成功,2s 后剔除到期再试一次又失败,此后注册中心的删除已经到达。
kill -9 时情形不同:
1 | |
进程已经不在,长连接被内核重置,新连接被拒绝,每次失败只花一个往返时间。B 在同样长的窗口内失败 19 次,比暂停时多出两倍有余,原因是失败得快,单位时间内的尝试次数多。“进程崩溃”和“进程卡住”对注册中心是同一件事,都要等租约过期;对调用方则是两种代价,一种是快速失败、次数多,另一种是每次都占满超时时间。
优雅退出是唯一不需要等待的情况。提供者收到 SIGTERM 时先撤销租约:
1 | |
撤销后 3ms 内删除到达消费者。部署系统停止实例时若先发 SIGTERM、留出撤销与排空的时间,旧地址窗口就从 TTL 量级降到毫秒量级。TTL 只是兜底,用来覆盖进程来不及做任何事的情况。
本地剔除与注册中心删除
图 16-2:一次逻辑调用在旧地址窗口内的处理(A 的策略)
sequenceDiagram
participant U as 上层调用方
participant C as 消费者 A
participant P1 as p1(SIGSTOP)
participant P2 as p2
participant E as etcd
U->>C: 调用 /hello(幂等)
C->>P1: 尝试 1:连接成功(内核握手),等待响应
Note over C,P1: 300ms 超时,attempt_fail
C->>C: 剔除 p1 两秒
C->>P2: 尝试 2(重试上限 1)
P2-->>C: 200
C-->>U: 成功(多花约 300ms)
Note over E: 租约到期,删除 key
E-->>C: watch DELETE 事件,地址集移除 p1
Note over C: 剔除只在本进程有效;删除对所有订阅者生效
本地剔除依据的是消费者自己观察到的失败,速度快,作用范围只有这一个进程,判断也可能出错:一次网络抖动就可能把健康地址剔除两秒。注册中心删除依据的是租约,速度慢,但对所有订阅者一致,而且只有提供者自己(或撤销操作)能阻止它发生。两者应该并存:剔除用来在租约过期前降低失败率,删除保证地址最终从所有消费者处消失。剔除不应写回注册中心,否则一个网络位置不好的消费者就能把健康实例从全体消费者那里摘掉。
重试只对幂等操作安全。实验中的 /hello 是只读请求;对写操作,超时意味着结果未知,第 13 篇已经讨论过这类请求不能自动重试。有了重试,失败预算也要换一种算法:A 把窗口内的失败从 6–7 次降到 0 次调用方可见失败,代价是这些调用多花一个尝试超时,并给其他提供者带来额外请求。窗口内的重试流量大约等于“旧地址份额 × 调用速率”。旧地址占多数时,比如一次发布中大部分实例同时暂停,重试会把剩下的实例压垮;这时应限制重试比例,本实现没有做这层预算。
恢复后的租约检查
SIGSTOP 结束后,提供者的续租线程醒来,下一次续租得到的是“租约不存在”:
1 | |
恢复后 4–10ms 内发现租约丢失,用新租约重新注册,消费者在 10–14ms 内重新加入该地址。time.sleep 依据单调时钟,进程暂停期间时钟照常前进,所以恢复后续租线程立即醒来,不会再睡满一个间隔。
负向对照的提供者只会对旧租约 ID 反复续租:
1 | |
它的 HTTP 服务完全正常,4s 内续租失败 4 次,地址既不在注册中心,也不在任何消费者的地址集里。实例在运行、日志在刷,却收不到任何流量。这类故障很难从实例本身的健康检查中发现,因为实例本身确实是健康的。
不漏事件:revision 续订与压缩
watch 的保证是:事件按 revision 有序,不重复,在可用历史窗口内不会丢掉中间的一段;断开的 watch 可以从断开前最后收到的 revision 之后重建,前提是该 revision 仍在历史窗口内(etcd v3.4 API 保证)。消费者 A 记录最后处理的 revision,续订时从它加 1 开始;C 是负向对照,续订时不带 start_revision,也不重新 range。
实验让 A 和 C 主动断开 watch(SIGUSR1 关闭 watch 套接字,模拟一次网络中断),断开期间优雅停止 p3(产生删除)、启动 p5(产生新增),然后恢复:
1 | |
A 从 17 续订,收到 revision 17 的删除和 18 的新增,地址集与注册中心一致。C 的 watch 从“现在”开始,这两个事件都发生在它的起点之前,于是它保留着已经删除的 p3(47164),看不到新加入的 p5(47166)。恢复 6s 后 p3 仍在 C 的地址集里,期间 C 对 p3 失败 3 次,次数被 2s 剔除限制住了。这种旧地址没有租约兜底:p3 的 key 已经删除,以后不会再有它的删除事件,C 只能靠本地剔除反复探测它,直到进程重启。旧地址窗口不再由 TTL 决定,而是无限长。
续订的前提是历史还在。etcd 会压缩旧 revision 以控制存储,从已压缩的 revision 创建 watch 时,响应带回可用的最小 revision(compact_revision),watcher 被取消,用同一个起点重建也会失败(etcd v3.4 API)。实验在 A 断开期间做两次变更,然后手工压缩到当前 revision:
1 | |
A 请求从 19 续订,网关返回 canceled:true, compact_revision:"20"。A 丢弃增量路径,重新 range 全量,用快照替换地址集,补上新增的 p6、移除已删除的 p5,再从新 revision 加 1 继续 watch。全量同步时不能在旧集合上合并,必须整体替换:被压缩掉的那段历史里,可能有删除事件已经无从得知。
图 16-3:消费者订阅状态机
stateDiagram-v2
[*] --> Syncing: 启动
Syncing --> Watching: range 成功,last_rev = 响应头 revision
Watching --> Watching: 事件到达,last_rev = max(mod_revision)
Watching --> Broken: 流结束 / 套接字错误 / etcd 不可达
Broken --> Watching: 重建 watch(start = last_rev + 1),0.5s 退避
Watching --> Syncing: canceled 且带 compact_revision(起点已压缩)
note right of Broken
负向对照:不带 start_revision 重建
→ 断线期间的事件永久丢失
end note
注册中心自身重启
最后一个用例用 kill -9 停掉 etcd,5s 后重启。5s 比 TTL 长:
1 | |
注册中心不可用期间,A 用内存里的地址集完成 100 次调用,没有失败。数据面并不依赖注册中心实时在线;如果消费者在注册中心不可达时清空地址集,一次注册中心故障就会扩大成全部调用失败。etcd 恢复后,三个提供者的续租都在原租约上恢复成功,没有出现 lease_lost,A 从 revision 21 续订,A、B 的地址集与注册中心一致。停机时间超过 TTL 而租约没有过期,这是本地 3.4.30 单成员实例上的观察结果。v3.4 的用户文档没有说明重启或换主时租约如何计时;较新版本的源码接口注释提到,新晋升的 leader 会为全部租约重新计算 TTL(etcd server/v3/lease,v3.7.2)。3.4 的源码没有核对,多成员集群换主的情形也没有实验,这一条不能当作 3.4 的承诺来用。
三种方案的旧地址窗口
| 方案 | 旧地址窗口的组成 | 失败暴露方式 | 不在本实验中 |
|---|---|---|---|
| 租约 + watch 推送(本实现) | 租约剩余寿命(≤ TTL)+ 过期检测(实测 0.22–0.49s)+ watch 投递;优雅退出时只有撤销与投递(实测 3ms) | 窗口内调用失败;断线不带 revision 续订时窗口无限 | 多成员集群、分区 |
| 客户端主动健康探测 | 探测间隔 × 连续失败阈值 + 探测超时 | 每个客户端独立判断,结论可能不一致;探测流量随“客户端 × 实例”增长 | 未实验 |
| DNS + TTL 缓存 | 记录 TTL + 各级缓存的剩余寿命 + 应用自身的解析缓存 | 无删除通知,只能等缓存过期 | 未实验 |
DNS 的 TTL 是记录可以被缓存的最长时间,0 表示只用于当前事务、不得缓存,取值范围是 0 到 2³¹−1 秒(RFC 1035 §3.2.1、RFC 2181 §8)。TTL 只规定上限,解析器提前丢弃缓存是允许的;但上游没有任何机制通知下游“这条记录已失效”,所以旧地址窗口的上界是逐级缓存剩余寿命之和,而应用进程里的解析缓存往往不受 DNS TTL 约束。主动探测的窗口可以做得很短,代价是探测流量和判断不一致。租约方案的长处是删除事件集中产生、所有订阅者看到同一个 revision 序列,短处是窗口下限由 TTL 决定,而 TTL 不能小于续租间隔加上一次网络抖动。
实践中这几种方案往往叠加使用:租约负责成员资格,本地剔除负责窗口内的失败,优雅退出负责把计划内下线的窗口压到毫秒级。
用例与证据
| 用例 | 类型 | 观察值(evidence/16/output.txt) |
|---|---|---|
normal_register_and_watch |
正常 | 3 个地址,A 对每个地址都调用成功,0 次调用方失败 |
graceful_stop_revokes_lease |
正常 | 撤销后 3ms 删除到达 |
sigstop_stale_window |
故障 | 窗口 2892–3221ms;B 失败 6–7 次,A 为 0 |
sigcont_detects_lease_loss |
恢复 | 4–10ms 发现丢失,新租约重新注册 |
negative_no_reregister |
负向对照 | 服务正常但不可见 |
kill9_stale_window |
故障 | 窗口 2823ms;B 失败 19 次,A 为 0 |
watch_resume_from_revision |
恢复 | 从 17 续订,地址集一致 |
negative_resume_from_now |
负向对照 | 保留已删除的 p3,看不到 p5 |
compaction_forces_resync |
边界 | compact_revision=20,全量同步后一致 |
etcd_restart_recovery |
恢复 | 停机期间 100 次成功调用;恢复后一致 |
环境:etcd 3.4.30(单成员独立实例)、Python 3.12.3,4 核(evidence/16/env.txt)。各进程的事件日志在 evidence/16/*.log,证据边界写在 evidence/16/LIMITS.md。这台机器的临时端口范围是 32768–61000,覆盖了实验使用的 4716x 端口。首次试跑时,消费者短连接留下的客户端 TIME_WAIT 占住了一个提供者端口,导致绑定失败;消费者改用 HTTP/1.1 长连接后,最终运行中没有再出现这个问题。
结论边界
实验支持的结论:在 TTL 3s、续租间隔 1s 的设置下,暂停或崩溃的提供者在 2.8–3.3s 后被所有消费者删除,窗口可以用“租约剩余寿命 + 半秒以内的检测与投递”估算;优雅退出把窗口降到毫秒级;本地剔除加一次重试,能把窗口内的调用方可见失败降到 0,代价是延迟和额外流量;提供者恢复后必须检查租约并重新注册;消费者续订必须带 revision,起点被压缩时必须全量替换。
没有证明的部分:多成员 etcd 集群、leader 切换与网络分区下的租约和 watch 行为;etcd 重启后租约不过期是否是 3.4 的保证;DNS 与主动探测两种方案的实测窗口。watch 断开是消费者主动关闭套接字模拟的,没有覆盖真实中间设备丢包和半开连接。
第 15、16 两篇处理的都是“状态从一个权威位置复制到许多客户端”,客户端只保存派生副本。下一篇 17 从持久日志构建最小消息队列 换一个方向:由系统自己保存持久日志,确认、落盘和消费进度各自对应日志中的哪个位置。






