服务地址总在变化,调用方怎样找到它:服务发现、健康检查与故障摘除

从支付服务调用钱包服务的完整路径出发,讲清注册中心、DNS、客户端发现、健康检查、故障摘除、本地缓存与 Kubernetes Service 的职责和失败边界。

支付服务调用钱包服务时,代码里通常只写一个逻辑名称:wallet-service。实际接收请求的是某个进程,例如 10.24.7.18:8080。这个地址可能因为发布、扩容、容器重建、节点故障或机房切换而改变。调用方如果把 IP 写进配置,每次实例变化都要改配置并重启;地址更新慢一点,请求就会继续打向已经退出的进程。

服务发现把逻辑名称解析成当前可用的实例集合。它看起来像一张动态通讯录,生产实现却要处理一组带时间的问题:新实例什么时候可以接流量,宕机实例多久会消失,注册中心不可用时客户端用什么地址,健康检查误判后怎样避免雪崩,已经建立的连接会不会继续访问被摘除实例。

本文回答的问题是:实例集合不断变化时,调用方怎样得到一个足够新、可以安全使用的服务视图,并在视图过期和实例故障时控制影响。注册中心部分参考 Nacos 服务发现文档与 Consul 服务发现文档;容器环境以 Kubernetes 的 Service、EndpointSlice和容器探针为依据。限流、熔断和降级只讲与实例选择相接的边界,完整策略留到稳定性治理文章。

先把服务发现链路拆成五个职责

一次调用至少经过服务身份、实例目录、健康判断、实例选择和网络连接。它们经常被某个 SDK 包在一起,却不应该混成一个概念。

职责 输入与输出 常见实现 主要失败
服务命名 业务依赖变成稳定名称 wallet-service、namespace、group 重名、环境串流
实例目录 服务名映射到实例集合 Nacos、Consul、EndpointSlice 地址过期、注册中心不可用
健康判断 实例是否应该接收新请求 心跳、主动探测、readiness 漏摘、误摘、频繁抖动
实例选择 从候选集合选一个目标 轮询、权重、最少请求、就近路由 热点、跨机房调用
数据面连接 向选中地址建立或复用连接 HTTP、gRPC、连接池、代理 旧连接不退出、超时传播

注册中心保存 wallet-service -> {instance-a, instance-b, instance-c},不代表三台机器都能完成一笔扣款。健康检查提供的是某个时刻、某个观察点下的证据;负载均衡还要结合区域、权重、连接状态和请求结果选择实例;调用是否成功仍由业务响应定义。

服务发现的控制面与请求数据面

图中上半部分是控制面:实例注册、续约,注册中心发布成员变化,消费者维护本地视图。下半部分是数据面:支付服务根据本地视图选择地址,直接或经过代理调用钱包服务。注册中心不应该进入每个业务请求的同步路径,否则一次目录查询故障会直接阻塞全部调用。

服务发现也不负责创建服务实例。Deployment、虚拟机编排系统或进程管理器决定运行多少实例;发现系统只描述这些实例当前在哪里、是否应该被消费者看见。自动扩容器把副本数从 10 调到 20 后,新增实例仍要完成启动、注册和就绪,才能进入调用集合。

一、最小模型是一张带租约的动态目录

最小服务记录可以写成:

{
  "service": "wallet-service",
  "instanceId": "wallet-10.24.7.18-8080",
  "address": "10.24.7.18",
  "port": 8080,
  "zone": "az-a",
  "version": "2026.10.4-3",
  "weight": 100,
  "enabled": true
}

service 是调用契约中的逻辑身份;instanceId 标识一次实例注册;地址和端口供数据面连接;zone、version 与 weight 参与流量选择;enabled 允许运维人员先停止新流量,再关闭进程。生产系统还会携带协议、集群、标签和证书身份,但不应把大量频繁变化的业务配置塞进实例元数据。

仅有记录还不够。进程可能被 kill -9,宿主机也可能断电,它们来不及执行注销。动态实例通常绑定租约、心跳或长连接:

register(instance, lease=30s)
renew(instance) every 10s
expire instance when lease is not renewed

租约把“没有收到注销”转成“在期限内没有继续证明存活”。30 秒并不意味着宕机后恰好 30 秒摘除。心跳发送、网络传输、服务端调度、状态复制与客户端收到变更都会增加传播时间。设计时需要预算从实例停止服务到最后一个调用方停止选中它的最长时间。

自注册与第三方注册

自注册让应用在启动时调用注册中心,运行中续约,退出前注销。应用知道自己的协议和元数据,接入简单;注册 SDK 也因此进入业务进程,版本兼容、重连和线程资源都由应用承担。若进程已经开始监听端口但初始化尚未完成,过早注册会收到真实流量。

第三方注册由 sidecar、节点 agent 或编排控制器观察进程,再写入服务目录。应用不需要理解注册中心,适合多语言或遗留程序;观察者必须准确知道应用何时可以接流量。Kubernetes 就采用控制器模式:Deployment 管理 Pod,Service selector 选择 Pod,EndpointSlice controller 根据 Pod 与就绪状态维护后端集合。

两种方式都要明确注册身份的所有者。实例重启后若复用同一个 instanceId,旧会话的延迟注销不能删除新实例;常见做法是让注册记录携带 session、epoch 或连接身份,服务端只允许当前会话续约和注销。地址相同不等于还是同一个进程。

临时实例与持久实例表达不同生命周期

Nacos 的实例模型区分临时服务和持久服务。临时实例的状态依赖运行中客户端,连接释放或心跳超时后会被清理;持久实例由管理流程维护,服务端通过主动检查更新健康状态。二者回答的问题不同:普通应用进程适合随生命周期消失,固定数据库或外部设备的记录可能需要在短暂离线后继续保留。

把应用进程注册成持久实例,会留下大量已经不存在的地址,再依赖健康检查永久标红;把需要人工管理的基础设施当成临时实例,客户端断线又可能删除目录事实。实例类型应该服从资源生命周期,不能只因为某一种模式“看起来不容易丢”。

二、注册、就绪、下线组成一个状态机

新进程能够接受 TCP 连接,不等于已经能执行业务。它可能正在加载规则、建立数据库连接池、预热 JIT、同步缓存或等待密钥。让实例先完成初始化,再进入 READY,可以避免第一批用户替它做冷启动测试。

服务实例从启动、接流量到摘除和退出的状态机

图中区分了三种容易混淆的动作:STARTING 期间进程存在但不接流量;READY 才进入新请求的候选集;DRAINING 停止新请求,同时给在途请求和长连接留下退出窗口。SUSPECT 与 EJECTED 则是故障路径,连续失败达到阈值后摘除,探测恢复并经过稳定窗口后再放回。

上线应先就绪再放量

以钱包服务为例,启动顺序可以是:

  1. 进程监听管理端口,启动探针能够观察初始化进度。
  2. 加载配置和密钥,建立数据库、Redis 与下游连接池。
  3. 执行轻量自检,确认处理请求所需的本地状态已经准备好。
  4. 注册实例但标记为未就绪,或者注册动作延后到初始化完成。
  5. readiness 成功,服务目录把实例加入可选集合。
  6. 负载均衡逐步提高权重,观察错误率和延迟。

若实例一进入 READY 就平均分到全部流量,刚扩容的一台机器可能同时建立大量连接、加载热点数据并编译热代码。慢启动(slow start)会在一段时间内逐步提升权重。它不会修复未初始化完成的问题,只负责平滑已经就绪实例的负载变化。

正常下线先摘流量再停进程

收到发布终止信号后,实例应先进入 DRAINING。服务目录或 readiness 先把它移出新请求集合,负载均衡停止建立新连接;实例继续处理在途请求,在宽限期结束后关闭监听和进程。

T0       mark not-ready / set weight=0
T0+Δ1    consumers receive endpoint update
T0+Δ2    stop accepting new requests
T0+Δ3    wait for in-flight requests and streams
T0+grace close process

这里必须给变更传播留时间。调用方有本地缓存、DNS 有 TTL、代理有 Endpoint 配置、连接池还有已建立连接。进程刚注销就立即退出,注销请求本身即使成功,也不能证明所有消费者已经停止使用旧地址。

HTTP/2 和 gRPC 的长连接尤其容易被忽略。摘除通常只影响“下一次选择实例”,已经复用的连接仍可能继续发新 stream。代理或服务端需要发送连接排空信号,例如 GOAWAY,客户端也要在连接失效后重新解析目标。服务发现更新和连接生命周期必须接起来。

三、消费者怎样拿到实例变化

消费者可以每次调用前查询注册中心,但这会把目录的延迟和可用性放进业务主路径。常见 SDK 在后台查询或订阅,把结果保存为进程内服务视图:

final class ServiceView {
    long revision;
    Instant receivedAt;
    List<Instance> readyInstances;
}

Instance choose(String service, RequestContext ctx) {
    ServiceView view = localViews.get(service);
    List<Instance> candidates = filters.apply(view.readyInstances, ctx);
    return loadBalancer.pick(candidates);
}

一次请求只读本地快照。后台线程负责与注册中心保持连接、接收变更并原子替换整个列表,业务线程不应该看到修改到一半的集合。revision 用于拒绝倒序通知,receivedAt 表示这份视图已经多久没有刷新。

查询、轮询与订阅

一次查询适合命令行、启动检查或生命周期很短的任务。长时间运行的服务更适合订阅变更。Nacos 的订阅机制会在实例列表、权重或健康状态变化后推送新的发现视图,客户端更新本地缓存并通知监听器。推送减少空轮询,但仍要处理断线、重连和漏事件。

安全的订阅协议通常是“先取得某个 revision 的完整快照,再接收后续变更”。仅靠增量事件容易在重连时漏掉一段;仅靠周期全量查询会增加注册中心负载,也让摘除延迟受轮询周期限制。实践中可以推送变化,同时低频做全量校准。

监听回调不能执行慢业务。若回调线程在更新连接池时阻塞数秒,后续服务变化会排队,客户端看到的目录越来越旧。回调适合验证 revision、构造不可变视图并快速交换引用;连接预热和指标计算放到独立线程池。

空列表不是一个普通更新

收到 wallet-service = [] 可能有三种含义:钱包服务真的全部下线,权限或命名空间配置错了,注册中心在故障中返回了异常空结果。无条件用空集合覆盖本地可用视图,会把控制面抖动放大成全量业务失败;永远拒绝空集合,又会在服务确实下线时继续请求旧实例。

处理方式依赖业务风险。普通无状态查询可以短时间保留最后一次非空视图,并给它设置最大陈旧时间;资金操作更关注错误目标和重复执行,过期超过阈值后可以停止发起新扣款,进入“处理中”并等待恢复。SDK 应记录空更新来源、revision 和持续时间,不能悄悄吞掉变化。

四、客户端发现、代理发现与 DNS

服务发现的差异主要在谁持有实例集合、谁做负载均衡。三种常见结构各有一套故障边界。

模式 实例视图在哪里 请求多一跳 主要优点 主要代价
客户端发现 每个调用进程 否 少一跳,可按请求语义选实例 SDK、多语言一致性、旧客户端
代理或服务端发现 网关、sidecar、LB 是 策略集中,应用只认识稳定地址 代理容量、额外跳转、控制面发布
DNS 发现 DNS 缓存与解析器 取决于后端 协议通用,遗留应用容易接入 TTL、缓存层次、记录能力有限

客户端发现让调用方掌握选择权

客户端从 Nacos、Consul 或 Kubernetes API 获得实例列表,本地做 zone 过滤、权重和负载均衡。它能把请求哈希到同一实例,也能在某地址连接失败后立即尝试另一个候选。代价是每种语言都要正确实现订阅、缓存、重试和指标,升级策略时还会存在多个 SDK 版本。

调用方数量很大时,每个进程都与注册中心保持订阅连接,控制面必须承受连接和推送放大。客户端还可能同时重连,形成惊群。SDK 需要指数退避、随机抖动和本地缓存,让注册中心恢复时的流量分散开。

代理发现集中治理

应用只调用本机 sidecar、集群 Service VIP 或统一负载均衡器,代理持有后端视图。服务网格把发现、TLS、重试和指标放在 sidecar 或节点代理中,多语言应用获得相近行为。问题转移到代理:配置下发是否及时,代理过载如何隔离,控制面失联时使用哪一版配置。

集中不等于只有一台代理。数据面代理必须横向扩展或贴近调用方部署,否则它会成为额外的单点和带宽瓶颈。控制面可以暂时不可用,只要代理仍有一份有效配置;数据面代理自身不可用则会立即影响请求。

DNS 给稳定名称,不保证瞬时更新

DNS 可以让 wallet.service.example 返回一个 VIP,也可以返回多个 A/AAAA 记录;SRV 记录还能携带端口。它适合标准化程度高的客户端和跨语言环境,但地址变化受 TTL、操作系统缓存、JVM DNS 缓存、应用连接池与中间递归解析器共同影响。

TTL 到期只允许缓存重新查询,不会主动关闭已经建立的 TCP 连接。客户端如果启动时解析一次并永久保存 IP,再短的 DNS TTL 也没有用。使用 DNS 发现时应验证真实运行时:解析结果缓存多久,连接失败是否重新解析,多地址是否都会尝试,负缓存怎样处理。

Consul 同时提供 DNS 与 HTTP API;Consul 官方文档说明标准 DNS 和部分健康 API 会基于检查结果返回实例。使用 DNS 不代表没有健康目录,只是消费者通过 DNS 协议读取目录视图。

五、健康检查要回答“应该采取什么动作”

“健康”如果只做成一个布尔值,很容易让所有故障都触发同一种动作。进程启动慢、暂时过载、线程死锁和下游数据库故障,处理方式并不相同。

Startup、readiness 与 liveness

Kubernetes 将探针分成三个用途:startup probe为慢启动提供独立窗口,成功之前不执行 liveness 和 readiness;readiness 失败会让 Pod 停止接收 Service 流量;liveness 失败会重启容器。

探针 它回答的问题 失败动作 不宜检查
startup 初始化是否已经完成 超过启动窗口后重启 短暂下游抖动
readiness 当前是否适合接收新请求 从流量集合摘除 所有非必要外部依赖
liveness 进程是否无法自行恢复 重启进程 数据库暂时超时、流量过高

把数据库连通性放进 liveness 很危险。数据库发生一分钟抖动,所有应用实例可能同时判定自己“死亡”并重启,原本只影响依赖访问的故障扩大为连接风暴和整体冷启动。liveness 应聚焦本进程无法自行恢复的状态,例如事件循环永久卡死;readiness 可以更接近“能否正确接新请求”,仍要防止共享依赖故障导致全部实例同时摘除。

健康接口本身要足够便宜。每秒探测一次,1000 个实例就会产生持续流量;若每次健康检查都查询数据库、Redis 和多个下游,它会制造额外负载,并在依赖变慢时占满业务线程池。探针最好使用独立管理端口或线程资源,返回预先维护的状态摘要。

主动探测与被动观测看到不同故障

注册中心或负载均衡器可以主动发送 TCP、HTTP 或 gRPC health check。它能在没有业务流量时发现实例宕机,检查路径却可能与真实请求不同:探针来自不同网络区域、只访问浅层接口,也没有真实请求体和权限。

被动健康根据业务调用结果判断。某实例最近连续连接失败、超时或返回特定 5xx,调用方可以临时降低权重或摘除。它贴近真实数据面,却只代表这个调用方观察到的路径;客户端自身网络故障也可能让它误判所有服务端实例。

较稳妥的组合是:控制面健康决定全局候选集合,客户端的被动结果维护一层短期本地隔离。全局摘除需要更多证据,本地连接失败则可以立即避开该地址几秒,减少同一进程反复撞墙。

检查深度决定误判方向

TCP 端口能连接,只证明内核正在监听;HTTP /health 返回 200,可能只证明管理线程还活着;执行一次只读业务查询能覆盖更多依赖,也更慢、更容易受共享故障影响。检查越深,越接近业务能力,也越可能把外部依赖问题算到当前实例头上。

可以按动作倒推检查深度:用于重启进程的证据要保守;用于暂停新流量的 readiness 可以更敏感;用于报警和诊断的深度检查不一定参与自动摘除。一个接口同时服务这三种用途,阈值和故障处理很难合理。

六、故障摘除需要阈值、滞回与恢复窗口

一次超时不足以证明实例已坏。网络丢包、GC、连接池排队或调用方自身过载都可能造成单次失败。如果每次失败立刻全局摘除、一次成功立刻恢复,实例会在健康与不健康之间来回跳,连接和流量持续震荡。

一个简单状态机可以使用连续结果和时间窗口:

void onResult(InstanceState s, CallResult r, Instant now) {
    if (r.isTransportFailure() || r.isTimeout()) {
        s.consecutiveFailures++;
        if (s.consecutiveFailures >= 3) {
            s.ejectUntil = now.plusSeconds(15);
        }
        return;
    }

    s.consecutiveFailures = 0;
}

这段代码只表达本地临时摘除,不能直接照搬到生产。还要区分可归因于实例的失败:连接拒绝、连接超时通常与目标或网络有关;业务参数错误、余额不足这类 4xx 不应降低实例健康;全体实例同时超时更像共享依赖或调用方过载。

半开探测控制恢复流量

摘除窗口结束后不要立即恢复全部权重。先允许少量探测请求,连续成功后逐步恢复;一旦再次失败,回到摘除状态并延长等待。这个过程与熔断器的半开状态相似,但作用域可以是单个实例,而不是整个下游服务。

恢复阈值应比摘除阈值更严格,形成滞回(hysteresis)。例如连续 3 次失败进入 EJECTED,需要连续 5 次探测成功才恢复 READY。它减少临界状态附近的抖动。阈值应结合调用量和时间窗口,低流量实例等待 5 个真实请求可能要很久,需要主动探测补充证据。

摘除过多会把剩余实例压垮

10 台实例中有 2 台变慢,把它们摘除后,其余实例每台负载上升 25%。若错误来自整体流量超过容量,继续摘除会让更多实例过载,最后一个都不剩。Nacos 的健康保护思想就是提醒这个风险:健康比例过低时,是否继续严格过滤要在“请求可能失败”和“剩余实例被集中压垮”之间选择。

不能靠永远返回不健康实例解决过载。调用方还需要并发限制、排队上限、超时预算和降级。摘除只改变流量分配,不会创造容量。

七、注册中心故障时,业务是否还能调用

注册中心是控制面。业务进程已经拿到实例视图后,短暂失联不必停止数据面调用;否则一次 Nacos 或 Consul 抖动会让所有服务同时不可用。客户端通常保留最近一次成功视图,并后台重连。

Nacos SDK 运行时说明提到本地快照与 failover view:远端不可用时,部分读取可以回退到上一次成功数据;这份数据是 last known view,不是权威当前状态。恢复能力来自缓存,风险也来自缓存。

本地缓存要有年龄和来源

只保存实例数组不够,还要保存 revision、获取时间、来源和校验结果:

{
  "service": "wallet-service",
  "revision": 18421,
  "receivedAt": "2026-10-04T12:01:03Z",
  "source": "nacos-push",
  "instances": ["10.24.7.18:8080", "10.24.8.11:8080"]
}

注册中心断开 5 秒时继续使用通常合理;断开 6 小时后,地址可能已经被回收给别的工作负载。最大陈旧时间不能只由 SDK 给一个通用默认值。内部只读查询可以容忍更久,支付扣款应在视图过期后进入保护状态,同时通过固定的应急视图、代理或人工切换恢复。

旧视图的另一个风险是实例身份复用。如果只相信 IP:Port,旧地址后来被分配给另一个服务,调用方可能把敏感请求发给错误进程。mTLS 服务身份校验、网络策略和目标端的服务名校验能把这种错误变成握手失败,而不是错误调用成功。

注册中心恢复会引发集中重连

大量客户端同时发现连接断开,又按固定一秒间隔重试,会给正在恢复的注册中心制造周期性洪峰。退避应包含随机抖动:

delay = random(0, min(maxDelay, base * 2^attempt))

重连成功后还要重新建立订阅并获取完整快照。客户端不能假定断线期间没有变化,也不能把自己的旧列表反向写回注册中心“修复”目录。权威状态来自服务端与仍然存活的发布者。

八、负载均衡与连接池决定发现结果能否生效

拿到三台实例后,轮询只是最简单的选择。实例规格不同可以使用权重;请求耗时差异大时可以参考 active requests;多机房部署通常先选同 zone,容量不足或故障时再跨 zone。算法需要基于可观测信号,不能把注册元数据中的一个 weight 当成永久真相。

一致性哈希不是普通 RPC 的默认答案

按用户 ID 做一致性哈希可以提高本地缓存命中,让同一用户倾向同一实例。实例增减后仍有一部分用户迁移,热点用户也可能把单台机器打满。无状态支付 RPC 更适合均匀分配;只有实例持有可重建的本地状态,并且粘性能带来明确收益时,才值得使用哈希路由。

会话状态若只能留在一台应用实例内,服务发现会被迫维护强粘性。实例故障后状态仍然丢失。更可靠的做法通常是让关键状态进入数据库、缓存或令牌,让任意健康实例能够继续处理;本地缓存只是性能优化。

地址被摘除后还要清理连接

客户端连接池可能按 host:port 保存长连接。服务视图删除某地址时,应把对应连接标为 draining,停止分配新请求;在途请求完成或超时后再关闭。直接关闭全部连接会打断正常请求,永远保留又会绕过摘除。

反过来,新地址加入后也不必立即预建最大连接数。几百个调用进程同时向新实例建满连接,会让它刚 READY 就承受握手风暴。按真实需求渐进建连,与前面的慢启动权重配合更稳妥。

九、Kubernetes Service 把发现拆成稳定入口与后端集合

Kubernetes 中 Pod 是临时资源,重建后 IP 可以改变。Service API提供稳定名称和访问入口,EndpointSlice 保存当前后端。控制器根据 Service selector 和 Pod 状态更新 EndpointSlice,数据面实现再把流量转给后端 Pod。

apiVersion: v1
kind: Service
metadata:
  name: wallet-service
spec:
  selector:
    app: wallet
  ports:
    - name: grpc
      port: 9090
      targetPort: 9090
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: wallet
spec:
  replicas: 3
  template:
    metadata:
      labels:
        app: wallet
    spec:
      containers:
        - name: wallet
          image: example/wallet:2026.10.4
          readinessProbe:
            httpGet:
              path: /ready
              port: 8081
            periodSeconds: 5
            failureThreshold: 2

普通 ClusterIP Service 的 DNS 名称通常解析到稳定虚拟 IP,客户端不直接看到每个 Pod 地址;kube-proxy 或其他数据面依据 Service 与 EndpointSlice 编程转发规则。Headless Service 不分配 ClusterIP,DNS 可以直接返回 ready endpoint 的地址,客户端承担更多负载均衡与连接更新责任。

EndpointSlice 是可分片的后端集合。Kubernetes 官方文档说明,一个 Service 可以关联多个 EndpointSlice,客户端若直接 watch API,必须合并全部 slice,不能只读取其中一个。旧 Endpoints API 在后端规模和双栈信息上有局限,新的客户端应使用 EndpointSlice。

readiness 摘除也有传播窗口

Pod readiness 失败后,状态要经过 kubelet、API Server、EndpointSlice controller 和节点数据面,才会停止转发。不同组件的观察时间并不相同;已经建立的连接也可能继续存在。因此滚动发布仍需要 terminationGracePeriodSeconds、preStop 或应用自身 drain,不能把 readiness 当成瞬时全局开关。

Kubernetes 的存活探针也不会理解业务幂等。它可以重启死锁进程,但请求是否已扣款、客户端是否会重试,仍由 RPC 结果、幂等键和状态查询协议处理。服务发现解决地址,不能消除远程调用的结果未知。

十、支付服务调用钱包服务的完整路径

现在把机制放回一次真实调用。初始状态如下:钱包服务在 az-a 有 A、B 两台实例,在 az-b 有 C;支付服务 P1 订阅 wallet-service,本地视图 revision 为 18421;每笔扣款携带唯一 trade_order_no,钱包按它幂等。

正常请求

  1. P1 从本地视图筛选 READY 且同 zone 的 A、B。
  2. 负载均衡器根据当前 active requests 选择 A。
  3. 连接池复用到 A 的 gRPC 连接,发送 Deduct(trade_order_no, amount)。
  4. A 在钱包账本中按单号执行幂等扣款并返回业务结果。
  5. P1 记录目标实例、目录 revision、连接耗时、RPC 耗时和结果类型。

请求主路径没有查询注册中心。注册中心只在后台维护 P1 的候选集合;单号幂等则负责网络超时后的安全重试,两者不能互相替代。

A 进程崩溃

A 来不及注销。P1 复用连接时收到断开,把这次结果记为 transport failure,并在本地短暂摘除 A。请求是否能自动改投 B,要看接口语义:连接建立前失败通常可以重试;请求可能已经到达 A 时,P1 不能因为换了实例就假定扣款未发生,仍要使用同一 trade_order_no 重试或查询结果。

与此同时,心跳或 Kubernetes 状态发现 A 失联,控制面从服务视图移除 A,revision 增加到 18422并推送。P1 用新快照替换旧列表,A 的连接进入 draining。其他调用方收到更新的时间可能稍晚,它们依靠自己的连接失败和本地摘除缩短影响。

B 只是短暂过载

B 的进程和探针仍正常,部分业务请求开始超时。被动检测让 P1 降低 B 的本地权重,而不是立即要求注册中心全局删除它。若所有调用方都看到同样趋势,监控会显示 B 的队列、CPU 或下游连接池异常,平台可以将其置为 not-ready。

如果 A 已故障、B 又因过载被所有人摘除,同 zone 将没有候选。P1 是否跨 zone 调用 C,需要预先定义。跨 zone 会增加延迟和故障域耦合,但对支付链路而言,返回“处理中”并异步确认可能比同步跨区重试更安全。选择取决于钱包的跨区数据一致性和业务 SLO,服务发现本身无法做出这个决定。

注册中心同时不可用

P1 保留 revision 18422,继续调用 B 或 C,并对视图年龄报警。新启动的支付实例如果从未获得过钱包地址,不能凭空发现下游;可以由持久化快照、DNS 稳定入口或代理提供启动兜底。把旧地址写死在代码里会绕过后续变更和身份校验,不应作为默认应急方案。

这条故障链说明了三层证据:控制面告诉客户端“当前公布了哪些实例”,主动和被动健康告诉负载均衡器“哪些实例暂时适合选择”,业务单号与状态查询告诉支付流程“这次扣款究竟发生了什么”。少一层,故障都会以另一种形式重新出现。

十一、怎样观测和验证服务发现

只监控注册中心进程存活不够。服务发现问题经常表现为少量客户端使用旧列表、某个 zone 没有实例、下线进程仍收到请求,整体成功率可能只下降几个百分点。

控制面指标

观测对象 建议指标 能发现什么
注册中心 请求延迟、错误率、leader/成员状态、存储与推送队列 控制面自身故障
服务目录 服务数、实例数、健康/禁用数、变更 revision 异常空列表、实例抖动
客户端订阅 连接状态、最后更新年龄、重连次数、当前 revision 局部客户端落后
健康检查 成功率、检查耗时、状态切换次数、失败原因 误摘、探针过重
传播延迟 注册/摘除到各客户端可见的时间 变更是否满足发布窗口

实例数下降不一定是事故,可能是正常缩容。告警应结合期望副本数、ready 数量和业务流量。某服务从 100 台缩到 80 台没有错误,而从 3 台误摘到 1 台风险很高;绝对值和比例都要看。

数据面指标

每次 RPC 至少记录逻辑服务名、目标实例、zone、目录 revision、视图年龄、选择策略、是否重试以及失败阶段。错误要区分解析失败、无候选、建连失败、TLS 身份失败、请求超时和业务拒绝。全部归为 RPC_ERROR 无法判断应该修注册中心、网络、容量还是业务逻辑。

Trace 可以把 discovery、pick、connect 与 call 分成阶段。大多数请求不需要真的访问注册中心,所以 discovery span 表示读取哪一版本地视图,而不是制造一次远程查询。出现问题时,可以回答“这个调用方当时看到哪几台实例,为什么选择了这一台”。

应该主动演练的故障

在测试或灰度环境中至少验证:

  • 新实例初始化未完成时不会收到业务流量;
  • 正常下线后,在进程退出前新请求已经停止进入;
  • 实例 kill -9 后,影响窗口符合心跳和传播预算;
  • 注册中心不可用时,已有客户端继续工作,新客户端行为符合预案;
  • 推送乱序、重复或异常空列表不会破坏最后有效视图;
  • 单个 zone 全部消失时,系统按策略跨区、降级或停止;
  • 健康接口变慢时,不会占满业务线程池;
  • 摘除一部分实例后,剩余容量不会被自动重试压垮;
  • 长连接在 endpoint 删除后能够排空并重建。

测试需要记录时间线:故障发生时刻、注册中心判定时刻、客户端收到 revision 的时刻、最后一个旧地址请求的时刻。只验证“最终摘除了”无法判断用户会经历多少秒错误。

十二、选型从运行环境和故障边界出发

场景 合适的起点 需要补充的能力
全部运行在单一 Kubernetes 集群 Service + EndpointSlice + readiness drain、跨 zone 策略、应用幂等
虚拟机与容器混合、多语言 Consul DNS/API 或统一代理 本地缓存、健康检查、身份认证
Java 微服务且已有 Nacos 体系 Nacos 订阅 + 客户端负载均衡 SDK 治理、缓存年龄、被动摘除
遗留应用只能配置域名 DNS + 稳定 LB/VIP TTL 验证、连接刷新、LB 健康检查
强流量治理与多语言一致策略 sidecar / service mesh 代理容量、配置发布与调试工具

系统规模很小时,一组稳定地址配合负载均衡器已经足够。引入独立注册中心会增加集群、SDK、权限和运维成本。容器平台已经提供 Service 与 EndpointSlice 时,再让每个应用维护另一套注册目录,需要说明它增加了哪些平台没有的能力;两套健康状态若相互冲突,排障会更难。

落地评审至少要写清这些问题:服务名怎样隔离环境和租户;谁注册、谁注销;实例多久不续约会消失;READY 的业务含义是什么;变更怎样推送并防止倒序;注册中心失联时本地视图能使用多久;空列表如何处理;摘除是否会压垮剩余实例;下线如何排空长连接;一次调用能否追溯到目录 revision 和目标实例。

服务发现最终交付的是一份随时间变化的候选集合。调用方还要判断这份视图是否足够新、目标是否适合当前请求、失败后能否安全换实例。注册中心正常并不等于调用一定成功,注册中心短暂故障也不必让数据面停摆。把控制面状态、数据面观测和业务幂等分别设计,地址变化才会从线上事故变成可管理的正常事件。

参考资料