阶段二 · 服务治理与一致性

服务发现与负载均衡

一句话总结

服务发现解决"我要调用的服务在哪里"的问题,负载均衡解决"同一个服务有多个实例时选哪个"的问题.
在 K8s 时代,这两件事大部分由平台自动完成--但理解底层机制能帮你做出正确的架构决策.

从一个问题出发

在单体时代,所有逻辑在一个进程内,调用另一个模块就是函数调用.拆成微服务后:

  • 订单服务要调用用户服务--用户服务跑在哪台机器,哪个端口?
  • 用户服务有 3 个副本--调哪一个?
  • 其中一个副本刚发布新版本正在重启--怎么避免把请求发过去?

这就是服务发现和负载均衡要解决的核心问题.

服务发现的两层模型

在 Kubernetes 04 篇中我们讨论过--服务发现有两个层次:

层次 职责 K8s 实现 应用层实现
名称解析 服务名 → IP 地址 CoreDNS(svc.namespace.svc.cluster.local) Consul / Nacos / etcd
负载均衡 一个 IP → 多个后端 Pod kube-proxy(iptables/IPVS) 客户端 LB(gRPC resolver)

K8s 原生 vs 应用层注册中心

如果你的服务全部跑在 K8s 里,大多数场景不需要额外的注册中心--K8s Service + CoreDNS 已经提供了服务发现.

只有当你需要跨集群,跨云,非 K8s 环境混合部署,或需要更细粒度的流量控制时,才考虑 Consul / Nacos 等应用层方案.

K8s 原生服务发现

ClusterIP Service 回顾

K8s 04 篇的核心知识--这里只做关联总结:

  1. 创建 Service 得到一个虚拟 IP(ClusterIP)
  2. CoreDNS 将 order-service.default.svc.cluster.local 解析为该 ClusterIP
  3. kube-proxy 在每个节点维护 iptables/IPVS 规则,将 ClusterIP 流量转发到后端 Pod
  4. 当 Pod 挂了 / 未就绪时,自动从 Endpoints 移除

对于大多数 HTTP/REST 服务,这就够了--ClusterIP 做 L4 负载均衡,每个连接随机分配到一个 Pod.

gRPC 长连接的负载均衡问题

K8s 04 篇和 02 篇都提到过--gRPC 使用 HTTP/2 长连接,ClusterIP 的 L4 负载均衡失效:

问题:
gRPC Client ──[TCP 连接]──→ ClusterIP ──→ Pod A
Pod B(空闲)
Pod C(空闲)

kube-proxy 只在 TCP 连接建立时选择一次后端
后续所有 RPC 复用同一连接 → 全打到 Pod A

解决方案:
1. Headless Service + 客户端 LB(gRPC 内置 round_robin)
2. L7 代理(Envoy / Istio)按请求级别负载均衡

Headless Service + 客户端负载均衡

# Headless Service(ClusterIP: None)
apiVersion: v1
kind: Service
metadata:
name: user-service
spec:
clusterIP: None # 不分配虚拟 IP
selector:
app: user-service
ports:
- port: 50051

Headless Service 的 DNS 查询直接返回所有 Pod IP(A 记录),而非 ClusterIP.客户端可以自己决定连接哪个 Pod.

// gRPC 客户端使用 dns 解析 + round_robin 策略
conn, _ := grpc.Dial(
"dns:///user-service.default.svc.cluster.local:50051",
grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`),
grpc.WithInsecure(),
)
// 客户端会与所有 Pod 建立连接,每次 RPC 轮询选择

DNS 缓存陷阱

DNS 有 TTL 缓存.Pod 扩缩容后,客户端可能还在用旧的 IP 列表.gRPC 的 dns resolver 默认 30s 刷新一次.

如果你的服务频繁扩缩容(如 HPA),考虑缩短 TTL 或使用更实时的发现机制(如 xDS / Consul watch).

应用层注册中心:Consul

当 K8s Service 不够用时(跨集群,非 K8s 环境,细粒度健康检查),需要应用层注册中心.Consul 是最流行的选择之一.

注册/发现模型

sequenceDiagram
participant SVC as 用户服务实例
participant CONSUL as Consul Server
participant CLIENT as 订单服务

SVC->>CONSUL: 注册(IP:Port + 健康检查配置)
loop 每 10s
CONSUL->>SVC: 健康检查(HTTP/gRPC/TCP)
SVC-->>CONSUL: 200 OK
end
CLIENT->>CONSUL: 查询"user-service"的健康实例
CONSUL-->>CLIENT: [{IP:10.0.1.5, Port:8080}, {IP:10.0.1.6, Port:8080}]
CLIENT->>SVC: 直接调用(客户端负载均衡)

Consul vs K8s Service

维度 K8s Service Consul
范围 单集群内 跨集群,跨数据中心
健康检查 kubelet 探针(pod 级别) 多种方式(HTTP/gRPC/TCP/脚本)
元数据 Labels/Annotations Tags + KV + Service Meta
额外能力 KV 存储,ACL,Service Mesh(Connect)
运维成本 K8s 自带,零成本 需要独立部署 Consul 集群

Nacos:注册中心 + 配置中心一体

国内团队常用 Nacos(阿里开源),同时提供服务发现和配置管理:

能力 Consul Nacos
服务注册发现 支持 支持
配置管理 KV 存储(基础) 完整配置中心(推送,灰度,回滚)
一致性模型 Raft(CP) AP 模式(临时实例)/ CP 模式(持久实例)
生态 HashiCorp 全家桶 Spring Cloud Alibaba / Dubbo

Go 生态中的选择

如果你用 Go + K8s,通常不需要 Nacos(它更偏 Java 生态).Go 微服务框架(go-zero / Kratos)多数直接用 K8s Service 或 etcd/Consul 做发现.

选择注册中心时优先看你的基础设施:K8s 里就用 K8s 原生,非 K8s 环境看团队技术栈.

负载均衡策略

服务端 vs 客户端负载均衡

服务端 LB 客户端 LB
代表 K8s Service / Nginx / Envoy gRPC 内置 / go-zero balancer
工作位置 中间代理节点 调用方进程内
优势 客户端无感知,集中管理 无额外网络跳数,可定制策略
劣势 额外延迟(多一跳),单点风险 客户端复杂度高,需要发现机制
适用 HTTP / 短连接 gRPC / 长连接

常见策略

策略 原理 适用场景
Round Robin 轮询 实例规格一致时的默认选择
Weighted Round Robin 按权重轮询 实例规格不同(新旧机器混合)
Least Connections 选择当前连接数最少的实例 请求处理时间差异大
Random 随机选择 简单场景,大量实例时效果趋近均匀
Consistent Hash 按 key hash 到固定节点 需要会话亲和 / 缓存亲和
P2C(Power of 2 Choices) 随机选 2 个,选负载低的那个 go-zero 默认策略,兼顾均匀和性能

P2C 是最优的通用策略

P2C(两次随机选择取较优)在数学上被证明接近最优:比纯随机均匀得多,比最小连接数开销小得多(不需要全局状态).go-zero 和 gRPC 的 grpclb 都支持这个策略.

gRPC Name Resolver 与 Balancer

gRPC 的服务发现和负载均衡是插件化架构:

┌────────────────────────────────────────────┐
│ gRPC Client │
├────────────────────────────────────────────┤
│ • Name Resolver
(dns/consul/etcd) │
│ • SubChannel Pool │
└────────────────────────────────────────────┘

关系: SC ──→ P1, SC ──→ P2, SC ──→ P3

Name Resolver 负责"发现地址列表",Balancer 负责"选择哪个地址".两者解耦,可以自由组合:

// 自定义 Consul resolver(伪代码)
resolver.Register(&consulResolver{})

conn, _ := grpc.Dial(
"consul:///user-service", // 使用 consul scheme
grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"p2c"}`),
)

健康检查

负载均衡的前提是知道哪些实例是健康的.健康检查有两种模式:

模式 工作方式 代表
主动探测 注册中心/平台定期探测实例 K8s 探针,Consul 健康检查
被动观察 根据实际请求结果判断实例健康 Envoy outlier detection,gRPC 健康检查

生产环境最佳实践:两者结合.

  • 主动探测做粗粒度剔除(实例彻底挂了 → 从列表移除)
  • 被动观察做细粒度降权(实例偶尔超时 → 降低权重/临时隔离)

区分 Liveness 和 Readiness

K8s 探针的核心区别:
Liveness 失败 → 重启容器(进程死锁等致命问题).
Readiness 失败 → 从 Endpoints 移除但不重启(启动中/过载/依赖不可用).
搞反了会导致:

  1. 启动慢的服务不停重启
  2. 过载的服务被杀而非减流量

与 Service Mesh 的关系(预告)

本篇讨论的服务发现和负载均衡,在 Service Mesh 架构中会被 Sidecar(Envoy)接管:

  • 无 Mesh:应用内嵌 resolver + balancer(如 gRPC 内置,go-zero)
  • 有 Mesh:Envoy sidecar 统一处理发现和 LB,应用只需连接 localhost

第 08 篇会详细讲解 Service Mesh 如何将这些能力从应用代码中完全剥离.

快速回顾

  • K8s 原生发现:CoreDNS + ClusterIP 覆盖大部分场景
  • gRPC 长连接需要 Headless Service + 客户端 LB 或 L7 代理
  • 应用层注册中心(Consul/Nacos)用于跨集群,非 K8s,细粒度控制
  • 客户端 LB适合 gRPC 长连接,服务端 LB适合 HTTP 短连接
  • P2C 是最优通用策略(两次随机选择取较优)
  • 健康检查:主动探测 + 被动观察结合使用
  • Service Mesh 会将发现和 LB 下沉到 Sidecar,应用无需关心

动手练习

  1. 验证 ClusterIP DNS:在 K8s 集群中创建一个 3 副本的 Deployment + ClusterIP Service,用 nslookup 验证 DNS 解析
  2. 对比 Headless DNS:将上一步改为 Headless Service(clusterIP: None),对比 DNS 返回结果的区别
  3. gRPC 客户端负载均衡:写一个 gRPC 客户端,使用 dns:/// + round_robin 策略调用 Headless Service,观察请求是否均匀分布到各 Pod
  4. 验证故障自动感知:杀掉一个 Pod,观察 gRPC 客户端是否自动感知并停止向该 Pod 发送请求