2961 字
约 9 分钟
16
S17-X13 · 服务发现与负载均衡

服务发现的本质,是把"我知道一个服务叫什么"翻译成"这一刻应该往哪个 IP:PORT 发请求"。前者在配置里写死就够了,后者每隔几十秒就可能变一次。所有麻烦都来自这个变化:注册中心的缓存延迟、健康检查的探测间隔、摘除与停机之间的空档,任何一环没对齐,都会变成 502 或者打到一个已经不存在的实例上。

1. 要解决的两个问题

实例列表从哪来:进程启动时把 ip:port 报到注册中心(或由平台自动注入),调用方从注册中心拉取。

变化怎么通知:主动推送(watch/long-poll)、定时拉取、或依赖调用方的本地缓存 TTL。三者延迟不同,决定了"实例下线后多久流量才停止打过去"。

这两件事决定了后面所有设计:注册中心选谁、健康检查怎么做、LB 放客户端还是服务端。

2. 客户端发现 vs 服务端发现

维度 客户端发现 服务端发现
谁持有实例列表 调用方进程内缓存 中间的 LB / 网关 / Service
典型实现 Nacos + Spring Cloud LoadBalancer、Consul + gRPC kube-proxy、nginx upstream、云 LB
复杂度归属 落在每个业务应用(SDK、版本升级) 落在基础设施(运维统一升级)
多语言支持 每种语言都要有 SDK 任何语言都只是 HTTP 调用
故障域 某个应用缓存脏 → 只有它出问题 LB 挂了 → 全站出问题

选型没有绝对优劣:基础设施团队强、语言栈统一,客户端发现更省一次网络跳转;语言栈杂、或者跑在 K8s 上,服务端发现更省心。

3. 注册中心的能力对照

能力 Consul Nacos Eureka K8s Service/DNS
一致性取向 CP(Raft,可配 AP 读) AP 为主,可切 CP(2.x 的 Raft 模式) AP 依赖 etcd,AP 读
健康检查 主动探测(TCP/HTTP/脚本)+ TTL 心跳 心跳 + 主动探测 客户端心跳 就绪探针 + Endpoints 控制器
配置中心 有 KV 有,能力更全 无 ConfigMap/Secret
变更通知 watch(阻塞查询) 长轮询推送 客户端定时拉取 watch Endpoints
跨数据中心 原生支持 支持命名空间/集群 不支持 多集群方案

CP/AP 的选择要看后果:注册中心脑裂时,CP 取向会拒绝写入新实例(新 Pod 注册不上,滚动发布卡住),AP 取向会返回陈旧列表(流量打到已下线实例)。多数业务选 AP + 短 TTL + 客户端重试,比选 CP 更实用。

4. 健康检查的三种形态

# 应用侧:把"活着"和"能干活"拆成两个信号
management:
  endpoint.health.probes.enabled: true
  health.readiness-state.enabled: true
形态 谁发起 能发现什么 盲区
心跳(客户端上报) 实例自己 进程活着 依赖全挂仍报健康
主动探测(TCP/HTTP) 注册中心 端口可连、接口可响应 只探一个端点,可能探到缓存健康的假象
就绪探针(readiness) 平台(K8s) 是否可以接流量 只影响 Service 转发,不通知外部注册中心

"注册了但不可用"的窗口就是这三者的时间差:进程已死,心跳还没超时(典型是 3 个周期);或进程活着但数据库连不上,心跳仍然正常。窗口内的流量只能靠调用方超时 + 重试 + 熔断兜底,不能指望注册中心秒级摘除。

5. 负载均衡算法与适用面

算法 原理 适用 短板
轮询 依次分配 实例同质、耗时接近 慢实例被持续压
加权轮询 按权重分配 机型不同、灰度按比例放量 权重靠人工拍
最少连接 选连接数最少的 请求耗时差异大 需共享计数,客户端难做准
一致性哈希 按 key 落到固定节点 有本地缓存、需要命中率 热点 key 压单实例
随机二选一 随机挑两个,取较优 大规模、低开销 只能近似

最少连接在客户端发现模式下很难做准——每个调用方只知道自己发了多少请求,不知道别人的。所以客户端 LB 常用轮询/加权/一致性哈希,最少连接留给服务端 LB。

6. 一致性哈希与扩容迁移量

朴素取模 hash(key) % N 的问题:N 从 4 变 5,几乎所有 key 都要换节点。一致性哈希把节点和 key 都映射到一个环上,key 顺时针找到第一个节点——扩容时只有新节点负责的那一段需要迁移。

虚拟节点是为了解决"节点少时环上分布不均":

// 每个物理节点放 160 个虚拟节点,分布才够均匀
for (int i = 0; i < 160; i++) ring.put(hash(nodeId + "#VN" + i), nodeId);
// 查找用 ceilingEntry,找不到则回绕到 firstEntry

迁移量取决于虚拟节点数量与新增节点的份额:加第 N+1 个节点时,理想情况只迁移总量的 1/(N+1)。但这是理想值——真实迁移量还受 key 分布倾斜影响,热点 key 所在的那一段可能一次搬走大半。

7. 客户端负载均衡的两个坑

# Spring Cloud LoadBalancer:缓存过期时间是第一个坑的根源
spring.cloud.loadbalancer.cache.ttl=2s
spring.cloud.loadbalancer.retry.enabled=true
spring.cloud.loadbalancer.retry.retry-on-all-operations=false

坑一:本地缓存过期导致打到已下线实例。默认 TTL 偏长(按所用版本核对),实例下线后调用方仍拿旧列表。对策是压到秒级,并配合重试——重试必须换实例。

坑二:重试打到同一实例。若重试只是"再选一次"而缓存没刷新,两次都选中同一个死实例,白做还放大延迟。要确认重试走的是 ServiceInstanceListSupplier 的实时列表。

// 自定义 LB 时把 supplier 注入进来,才能拿到实时列表
@Bean
ReactorLoadBalancer<ServiceInstance> lb(LoadBalancerClientFactory f) {
    return new RoundRobinLoadBalancer(f.getLazyProvider("orders", ServiceInstanceListSupplier.class), "orders");
}

这只是骨架,实际要配合重试过滤器实现"排除上次实例",别指望默认行为自动避开。

8. 服务端负载均衡与会话保持

upstream order_api {
    least_conn;
    server 10.0.1.11:8080 max_fails=3 fail_timeout=10s;
    server 10.0.1.12:8080 max_fails=3 fail_timeout=10s backup;
    keepalive 64;
}

max_fails/fail_timeout 是最被低估的两个参数:max_fails=3 fail_timeout=10s 意味着 10 秒内失败 3 次就摘除 10 秒(不是永久摘除)。backup 只在主节点全挂时启用,适合兜底不适合分摊。

会话保持有三条路:粘性 Cookie(ip_hash / sticky)、把 session 外置到 Redis、或干脆无状态用 JWT。网关层做粘性会让扩容失效——新实例分不到流量,因为老会话都粘在旧实例上。

9. 优雅下线:三步不能省

# 1) 先摘除:从注册中心下线,等待调用方缓存刷新
curl -X PUT "http://127.0.0.1:8848/nacos/v1/ns/instance?serviceName=order&ip=${IP}&port=8080&enabled=false"

# 2) 等在途请求结束(等待时长 ≥ 客户端缓存 TTL + 最长请求耗时)
sleep 30

# 3) 再停进程
kill -TERM ${PID}

K8s 里对应的是 preStop + terminationGracePeriodSeconds:

lifecycle:
  preStop:
    exec:
      command: ["sh", "-c", "sleep 10 && curl -sf -X POST http://localhost:8080/actuator/shutdown || true"]
terminationGracePeriodSeconds: 40

顺序错了就是 502 制造机:先杀进程再摘除,注册中心还有心跳、调用方还有缓存,几十秒内全是连接拒绝。

10. DNS 作为服务发现的限制

dig +short order.svc.cluster.local A
dig +short order.internal CNAME

DNS 能用,但有四个硬伤:

  1. TTL 缓存不受你控制:中间解析器会缓存,设 5 秒也可能几分钟才生效。
  2. 没有权重与健康检查:DNS 只会返回 A 记录列表,客户端随机或按顺序取,不问死活。
  3. 没有端口语义:SRV 记录能带端口,但绝大多数客户端不查 SRV。
  4. 连接复用掩盖变化:长连接池不会因为 DNS 变了就重建,摘除后旧连接还在用。

所以 DNS 适合数据库、缓存这类不常变的基础设施,不适合频繁扩缩容的业务实例。

11. 常见坑

  • 实例注册了但端口没起:注册动作在端口监听之前执行,形成几秒的空窗。注册必须放在 ApplicationReadyEvent 之后。
  • 摘除与停止之间没有等待:少了 sleep,等于把在途请求直接掐断。
  • 区域感知缺失导致跨机房流量:同机房实例没优先,一半请求跨机房,延迟与带宽双输。
  • 缓存刷新间隔设得太长:扩缩容看起来"没生效",实际是调用方列表没刷。
  • 健康检查探到的是缓存:/health 返回 200 但数据库已断,探针永远不报警。

12. 排错:五种"打不到/打到死实例"

现象 常见原因 处理
502 集中出现在发布后几分钟 先停进程后摘除 加 preStop 与 sleep,先摘后停
新实例没流量 本地缓存 TTL 长、粘性会话 缩短 TTL,关粘性,检查权重
请求偶发打到已下线 IP 注册中心 AP 模式下的陈旧列表 调用方重试换实例 + 熔断
跨机房延迟高 无区域感知或权重相同 配 region/zone 优先,权重按机房
探针一直健康但接口全 500 探针只查进程存活 就绪探针检查关键依赖,或分离 liveness/readiness

13. 边界:这些场景别用服务发现

场景 更合适的做法 理由
单体应用、固定几台机器 静态 upstream 变化频率低,注册中心多余
数据库、缓存等有状态服务 配置中心 + 人工变更 自动摘除会引发更大故障
跨公网调用第三方 固定域名 + 客户端重试 你控制不了对方的注册中心
极低频调用的内部工具 服务端 LB 或 DNS 客户端 SDK 维护成本更高
需要严格全局限流的入口 网关统一入口 分散在客户端的限流阈值无法全局一致

一句话收束:注册中心负责"列表正确",负载均衡负责"选得合理",优雅下线负责"退出时不伤人",三者缺一环就等着 502。

14. 自测

  1. 客户端发现与服务端发现,复杂度分别落在谁身上?
  2. 注册中心选 CP 时,滚动发布会遇到什么问题?
  3. "注册了但不可用"的窗口由哪几个时间差构成?
  4. 一致性哈希扩容时,迁移量由什么决定,为什么真实值常大于理论值?
  5. 为什么重试逻辑必须"换实例",否则会放大故障?
  6. 优雅下线的三步顺序是什么,颠倒会怎样?
S17-X13 · 服务发现与负载均衡
https://www.clxhxhhr.top/posts/5887/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。