服务名是稳定的,实例地址却会随扩缩容、重启和迁移改变。服务发现维护“这个逻辑服务当前有哪些候选实例”,负载均衡再从候选中选择连接目标。发现一组地址与判断它们能否处理业务,是两个相关但不同的职责。
实例从启动到退出
进程存活只表示程序还在运行;就绪表示可以接收某类业务流量。服务已经监听端口但缓存尚未加载,或者数据库迁移尚未完成时,可能存活却不就绪。下线也应先停止新流量,再给在途请求与连接留出排空机会。
注册表中的地址仍可能陈旧。调用方需要连接失败处理、重试预算与更新机制,不能把注册中心返回 healthy 当作本次请求一定成功的证明。
DNS 与 DNS-SD
普通 A/AAAA 查询可得到地址,SRV 能表达目标主机、端口、优先级和权重。DNS-SD 还使用 PTR 浏览服务实例,并通过 SRV/TXT 获取实例位置与属性,不等同于“写几条 SRV”这么窄的定义。RFC 6763 给出了记录组织方式。
_api._tcp.example.com. 30 IN SRV 10 60 8080 api-1.example.com.
_api._tcp.example.com. 30 IN SRV 10 40 8080 api-2.example.com.
这是使用 SRV 表达服务端点的示例。客户端必须支持相应查询与权重语义;普通 HTTP 客户端未必会自动查 SRV。
TTL 限制 DNS 缓存的有效期,但实际流量切换还受客户端缓存、重新解析时机、连接池和长连接影响。TTL=30 秒不保证删除实例 30 秒后再无流量。DNS 协议本身也不定义业务健康检查,健康状态需要额外控制器更新记录。
注册中心:存储原语与完整产品的差别
Consul 提供服务注册和健康相关功能;etcd 提供一致性键值、事务、Lease 和 Watch 等原语,应用需要在其上实现服务注册约定与健康策略。不能把二者都描述成“提交 health URL 就会自动检查”。
一种 etcd 方案将实例键绑定到 lease,活跃实例定期续租,lease 失效后键被移除。Watch 让调用方获得变更;初次读取与后续监听要衔接到正确 revision,避免二者之间漏掉更新。历史已被压缩时,需要重新取得快照并继续监听。接口保证见 etcd API。
租约超时只说明未及时续租,可能来自网络隔离;它仍不能证明旧实例已经停止执行。若发现机制还承担独占任务分配,必须额外考虑 fencing。
快照与 Watch:用 revision 接起来,不靠“尽快开始监听”
假设对服务前缀执行一次完整读取,得到 revision 20 的 {A,B}。读取返回后、Watch 建立前,21 删除 A,22 增加 C。如果从建立 Watch 时的“现在”开始,只收到后续事件,本地缓存就可能永远保留 A、漏掉 C。
应从快照 revision 的下一位置 21 开始 Watch,让服务器回放中间历史,再继续接收新变更;如果 21 已被压缩,重新取得完整快照并衔接,不能把压缩当作“没有变化”。etcd 的起始 revision 与压缩响应见 Watch API。
下面模拟缓存更新,事件已按 revision 分组,同一 revision 的相关事件全部到齐才交给 apply_revision。这里用 put/delete 代表实例加入和移除,不连接 etcd。
class DiscoveryCache:
def __init__(self, revision, snapshot):
self.revision = revision
self.endpoints = dict(snapshot)
def apply_revision(self, revision, events):
if revision <= self.revision:
return False updated = dict(self.endpoints)
for operation, instance, address in events:
if operation == "put":
updated[instance] = address
elif operation == "delete":
updated.pop(instance, None)
else:
raise ValueError("未知事件类型")
self.endpoints, self.revision = updated, revision
return True
cache = DiscoveryCache(20, {"A": "addr-A", "B": "addr-B"})
assert cache.apply_revision(21, [("delete", "A", None)])
assert cache.apply_revision(22, [("put", "C", "addr-C")])
assert cache.endpoints == {"B": "addr-B", "C": "addr-C"}
change = [("delete", "B", None), ("put", "D", "addr-D")]
assert cache.apply_revision(24, change)
assert not cache.apply_revision(24, change)
assert cache.endpoints == {"C": "addr-C", "D": "addr-D"}
assert cache.revision == 24
try:
cache.apply_revision(25, [("delete", "C", None), ("invalid", "E", "addr-E")])
except ValueError:
pass
assert cache.endpoints == {"C": "addr-C", "D": "addr-D"}
assert cache.revision == 24
bad = DiscoveryCache(22, {"B": "addr-B", "C": "addr-C"})
bad.apply_revision(24, [change[0]])
assert not bad.apply_revision(24, [change[1]])
assert "D" not in bad.endpoints
这段实现依赖上游完整、按序投递,不能自行证明 22 到 24 之间没有漏掉本前缀的事件;不要求 revision 逐整数连续,是因为其他 key 的写入也会推进集群 revision。实际客户端需遵守 Watch 的历史与顺序保证,处理断线、取消及压缩错误。etcd Watch 保证
同一事务可以修改多个实例键,具有相同 revision。恢复检查点必须在该 revision 的相关事件全部应用后推进,不能在第一条事件后就持久化“24 已处理”。若开启响应分片,还需要先重组完整内容。事件的修改 revision、响应头中的 revision、进度通知也不能不加区分地当作“本地缓存已经追到的位置”。
此外,大前缀若需要分页读取,应固定同一个快照 revision;否则一页来自 20、另一页来自 22,连初始集合都不是一个一致视图。缓存及恢复检查点若要落盘,也必须共同持久化,或在进程重启时重新拉取快照,避免进度比实际保存的缓存更新。
快照之后的变化,监听从哪里接?
比较缓存时,不只看最终地址列表,还要看它由哪一份快照和哪一段历史构成。下面允许在快照后独立撤销 A、注册 C,再选择从旧快照的下一 revision 回放或只从现在监听。历史压缩会使前一种方式明确失败;正确恢复是重建快照,不是保留旧列表并把错误吞掉。
正在呈现知识画面
快照之后的变化,监听从哪里接?快照 revision 与后续 Watch 必须衔接。从建立监听时的现在开始,会漏掉中间变化;所需历史已压缩时必须重新取快照。
{"id":"discovery-watch-gap","title":"快照之后的变化,监听从哪里接?","summary":"快照 revision 与后续 Watch 必须衔接。从建立监听时的现在开始,会漏掉中间变化;所需历史已压缩时必须重新取快照。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"presets\" role=\"group\" data-i18n-label=\"mode\"><button data-mode=\"resume\" data-i18n=\"resume\"></button><button data-mode=\"now\" data-i18n=\"now\"></button></div><div class=\"metrics\"><div><span data-i18n=\"registry\"></span><strong id=\"registry\"></strong></div><div><span data-i18n=\"cache\"></span><strong id=\"cache\"></strong></div></div><p class=\"formula\" id=\"revision\"></p><div class=\"actions\"><button id=\"snapshot\" data-i18n=\"snapshot\"></button><button id=\"remove\" data-i18n=\"remove\"></button><button id=\"add\" data-i18n=\"add\"></button><button id=\"compact\" data-i18n=\"compact\"></button><button id=\"watch\" data-i18n=\"watch\"></button><button id=\"reset\" data-i18n=\"reset\"></button></div><div class=\"copy\"><p id=\"explanation\" role=\"status\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"need\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"gap\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"ok\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"expired\"></p></div><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n/* Action labels must remain readable across locales and narrow embeds. */\n.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.actions button{overflow-wrap:anywhere;white-space:normal;min-height:58px;padding:8px}\nbutton{overflow-wrap:anywhere}\n@media(min-width:640px){.actions{grid-template-columns:repeat(3,minmax(0,1fr))}}\n\n.metrics span{overflow-wrap:anywhere}\n\n.metrics strong{font-size:20px;min-height:2em}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}let mode='resume',reg,cache,rev,snap,compacted,events;function reset(){reg=new Set(['A','B']);cache=null;rev=20;snap=null;compacted=0;events=[];draw('need');}function draw(note){pressed(mode);set('registry',[...reg].sort().join(', '));set('cache',cache?[...cache].sort().join(', '):'—');set('revision','registry '+rev+' · snapshot '+(snap??'—')+' · compacted ≤ '+compacted);$('#remove').disabled=!reg.has('A');$('#add').disabled=reg.has('C');$('#watch').disabled=snap===null;set('explanation',t(note||'need'));}$('#snapshot').onclick=()=>{cache=new Set(reg);snap=rev;draw('ok');};$('#remove').onclick=()=>{reg.delete('A');events.push({rev:++rev,kind:'remove',key:'A'});draw();};$('#add').onclick=()=>{reg.add('C');events.push({rev:++rev,kind:'add',key:'C'});draw();};$('#compact').onclick=()=>{compacted=rev;draw();};$('#watch').onclick=()=>{const start=mode==='resume'?snap+1:rev+1;if(start<=compacted){draw('expired');return;}for(const e of events.filter(e=>e.rev>=start))e.kind==='remove'?cache.delete(e.key):cache.add(e.key);draw(mode==='now'?'gap':'ok');};document.querySelectorAll('[data-mode]').forEach(e=>e.onclick=()=>{mode=e.dataset.mode;draw();});$('#reset').onclick=reset;reset();","audio":false,"strings":{"add":"注册 C","cache":"客户端缓存","compact":"压缩当前历史","expired":"所需起点已压缩;重新取得快照,不能把它当作没有变化。","gap":"从现在监听无法补回间隙;缓存与注册表可能不同。","heading":"快照之后的变化,监听从哪里接?","intro":"先取得 {A,B} 的快照,再撤销 A、加入 C。选择 Watch 的起点,比较注册表与客户端缓存。","mode":"监听起点","need":"先取快照,再观察两次独立变更。","now":"建立时的现在","ok":"历史已衔接,缓存覆盖当前完整 revision。","registry":"注册表","remove":"撤销 A","reset":"重置","resume":"快照 revision + 1","scope":"单前缀、每个 revision 一条完整事件,固定两个变更;Watch 回放在按钮中一次完成,不模拟连接与分页。缓存更新是本地观察,不代表连接已排空。","scopeLabel":"模型范围与约定","snapshot":"取得完整快照","watch":"建立并回放 Watch"}}
客户端选择与代理选择
| 模式 | 地址由谁更新,目标由谁选择 | 主要代价 |
| 客户端发现与负载均衡 | 调用方 SDK/resolver | 每种语言需要兼容实现与配置 |
| 代理或网关负载均衡 | 数据面代理接收控制面更新 | 多一层资源与故障边界 |
| 平台虚拟地址 | 平台将稳定地址映射到后端 | 应理解连接级分配与长连接行为 |
代理可以多副本部署,因此不是天然单点;客户端直连也不意味着没有控制面依赖。gRPC 将名称解析与负载均衡策略分开,resolver 可以提供地址变化及服务配置,见 Custom Name Resolution。
长连接尤其重要:HTTP/2 或 gRPC 的很多请求复用同一连接,只有建立连接时选择后端的方案可能分布不均。发现新的实例并不自动把已存在连接上的请求迁过去。
删除端点,只改变候选集合,不会收回现有连接
假设实例 A 在 t=0 撤销注册,客户端到 t=2 收到变更,但它与 A 的连接池里仍有一个可用长连接。若客户端只在新建连接时查询候选集合,t=10 的请求仍可能走旧连接;DNS TTL 或 Watch 更新都不能独自阻止它。
| 下线阶段 | 需要改变的状态 | 单做这一步的缺口 |
| 撤销就绪或注册 | 控制面的候选实例集合 | 客户端视图尚未更新 |
| 更新选择器 | 新请求不再选择该实例 | 连接池可能仍复用旧连接 |
| 排空连接与任务 | 停止接纳新工作,让在途任务完成 | 长任务可能超过退出预算 |
| 到达退出 deadline | 执行事先约定的终止或取消策略 | 调用方可能得到失败,需要按语义恢复 |
因此优雅退出应同时约定发现传播、连接排空和任务截止时间。已有请求若已经产生业务效果但响应中断,重试仍需要幂等;“优雅”并不保证所有请求无损完成。发现层提供位置变化,连接和请求层负责把它落实为实际流量变化。
健康信号如何组合
主动检查定期探测,能在无业务流量时发现问题;被动检查观察真实请求,更贴近用户路径;TTL 报告依赖被检查方主动续报。三者都可能误判,通常需要连续失败/成功阈值与抖动抑制。
检查的范围也要克制。若一个共享下游短暂故障使所有实例都被判死,负载均衡可能移除全部候选;若健康接口永远返回 200,又无法发现真正不能服务的实例。应按服务降级能力定义 readiness,而不是机械检查所有依赖。
注册中心失联时怎么办
已有端点缓存能让数据面暂时继续工作,但缓存越久越陈旧。新实例可能无法加入,旧实例可能已消失,安全或策略撤销也可能尚未生效。应按业务选择允许的陈旧程度:继续使用最后已知集合、只移除连续失败端点,或在某些权限敏感场景停止服务。
这体现控制面与数据面的分离:注册中心故障不一定立即中断已有连接,但持续时间越长,实例变化造成的风险越大。验证发现系统时,应同时观察传播延迟、连接更新和实际请求成功率。
参考
关键词:service discovery、DNS-SD、SRV、Lease、Watch、readiness、负载均衡、连接池、控制面、数据面