服务发现

本页目录

服务名是稳定的,实例地址却会随扩缩容、重启和迁移改变。服务发现维护“这个逻辑服务当前有哪些候选实例”,负载均衡再从候选中选择连接目标。发现一组地址与判断它们能否处理业务,是两个相关但不同的职责。

实例从启动到退出

实例生命周期:注册成功并不等于已经可以接流量 启动并初始化 加载配置,建立必要依赖 1 注册实例与健康状态 身份、地址、版本和租约分开维护 2 就绪后进入候选集合 客户端/代理更新路由与连接 3 下线时停止新流量 撤销就绪,等待传播并排空在途请求 4 发现状态有传播延迟,旧连接的排空需要额外处理。

进程存活只表示程序还在运行;就绪表示可以接收某类业务流量。服务已经监听端口但缓存尚未加载,或者数据库迁移尚未完成时,可能存活却不就绪。下线也应先停止新流量,再给在途请求与连接留出排空机会。

注册表中的地址仍可能陈旧。调用方需要连接失败处理、重试预算与更新机制,不能把注册中心返回 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。

快照 20 之后,从 21 接续历史,覆盖建连窗口 读取快照 r=20 候选:A、B 期间发生更新 21 删 A;22 加 C Watch 起点 21 回放后候选:B、C 历史已压缩:重新获取快照,再从新 revision + 1 接续 同一 revision 的全部相关事件应用完成,才推进本地恢复检查点。

下面模拟缓存更新,事件已按 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"}
# revision 23 修改了其他前缀,当前 Watch 没有对应事件,这是合法空洞。
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

# 错误用法:同一 revision 按行更新检查点,会把第二行当成重复。
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 必须衔接。从建立监听时的现在开始,会漏掉中间变化;所需历史已压缩时必须重新取快照。

客户端选择与代理选择

模式地址由谁更新,目标由谁选择主要代价
客户端发现与负载均衡调用方 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、负载均衡、连接池、控制面、数据面