土法炼钢兴趣小组的算法知识备份

【系统架构设计百科】服务发现与注册:动态拓扑的基础设施

文章导航

分类入口
architecture
标签入口
#service-discovery#Consul#Eureka#Nacos#DNS#health-check

目录

某电商平台的订单服务需要调用库存服务扣减库存。在传统部署模式下,库存服务跑在三台固定机器上,IP 地址写在配置文件里:10.0.1.101:808010.0.1.102:808010.0.1.103:8080。某天凌晨三点,运维团队做了一次机房迁移,三台机器的 IP 全部变了。第二天早上订单服务开始报错——所有库存扣减请求都超时了。紧急修改配置、重新部署、重启服务,整个过程花了 40 分钟,造成了几十万元的订单损失。

这不是极端场景。在容器化和云原生时代,服务实例的生命周期以秒计算:Kubernetes 的滚动更新每隔几秒就会销毁旧 Pod、启动新 Pod;弹性扩缩容在流量高峰时每分钟新增几十个实例,低谷时又缩回去。如果还在用静态 IP 配置,运维团队会被淹死在配置变更里。

服务发现(Service Discovery)解决的就是这个问题:在服务实例的 IP 地址和端口不断变化的情况下,调用方如何找到被调用方的可用实例?

这个问题看似简单,但展开之后涉及一系列工程取舍:注册中心用 CP 还是 AP?健康检查应该多频繁?客户端缓存会不会导致流量打到已下线的实例?DNS 能不能替代专门的注册中心?服务网格出现之后,应用层还需要关心服务发现吗?

本文从服务发现的两种基本模式讲起,逐一拆解 Consul、Eureka、Nacos 三大主流注册中心的架构设计,然后讨论 DNS 服务发现的局限性、健康检查的工程挑战、服务网格中的发现机制,最后通过一个完整的工程案例串联这些知识点。


一、静态配置为什么撑不住

1.1 传统方式:配置文件里的 IP 列表

在微服务出现之前,服务之间的调用关系相对简单。一个典型的做法是把被调用方的地址列表写在配置文件或环境变量里:

# application.yml
inventory-service:
  hosts:
    - 10.0.1.101:8080
    - 10.0.1.102:8080
    - 10.0.1.103:8080

调用方启动时读取这个列表,用轮询或随机策略选择一个地址发送请求。这种方式在以下条件下是可行的:

1.2 四个失效场景

当系统进入微服务和容器化阶段,上述前提条件全部被打破:

场景一:弹性扩缩容。 流量高峰时自动扩容 20 个实例,低谷时缩回 3 个。新增实例的 IP 不在配置文件里,调用方完全不知道它们的存在,扩容等于白做。

场景二:滚动更新。 Kubernetes 做滚动发布时,旧 Pod 被终止、新 Pod 被创建,IP 地址完全不同。如果配置文件不更新,调用方会持续向已终止的 Pod 发送请求。

场景三:故障转移。 一台机器宕机,新实例在另一台机器上拉起。IP 变了,但配置文件里还是旧地址。调用方的请求打到一个不存在的地址上,直到超时才会失败。

场景四:多环境管理。 开发、测试、预发布、生产四套环境,每套环境的服务实例地址都不同。手动维护四套配置文件,出错概率随环境数量线性增长。

1.3 核心需求提炼

从上述场景中可以提炼出服务发现的四个核心需求:

  1. 服务注册(Registration):新实例启动后,能自动将自己的地址信息注册到一个集中式的存储中。
  2. 服务发现(Discovery):调用方能从这个集中式存储中查询到被调用方的所有可用实例地址。
  3. 健康检查(Health Check):能自动检测实例是否存活,将不健康的实例从可用列表中剔除。
  4. 变更通知(Change Notification):当实例列表发生变化时,能及时通知调用方更新本地缓存。

这四个需求共同构成了服务注册中心(Service Registry)的基本功能。


二、服务发现的两种模式

服务发现的实现分为两种基本模式:客户端发现(Client-Side Discovery)和服务端发现(Server-Side Discovery)。两者的核心差异在于”谁来决定请求发往哪个实例”。

2.1 客户端发现模式

在客户端发现模式中,调用方直接查询服务注册中心获取实例列表,然后由调用方自己执行负载均衡逻辑,选择一个实例发送请求。

graph LR
    A[服务消费者] -->|1. 查询实例列表| B[服务注册中心]
    B -->|2. 返回实例列表| A
    A -->|3. 负载均衡选择| C[服务提供者实例 A]
    A -.->|备选| D[服务提供者实例 B]
    A -.->|备选| E[服务提供者实例 C]
    C -->|注册| B
    D -->|注册| B
    E -->|注册| B

工作流程:

  1. 服务提供者启动时,向注册中心注册自己的地址(IP + 端口)和元数据(版本号、权重等)。
  2. 服务消费者向注册中心查询目标服务的实例列表。
  3. 消费者在本地缓存实例列表,并使用负载均衡算法(轮询、加权随机、一致性哈希等)选择一个实例。
  4. 消费者直接向选中的实例发送请求。
  5. 注册中心通过推送或轮询机制,将实例列表的变更通知消费者。

典型实现: Netflix Eureka + Ribbon(现已被 Spring Cloud LoadBalancer 替代)、Nacos + 自研客户端、gRPC 内置的 name resolver。

优势:

劣势:

2.2 服务端发现模式

在服务端发现模式中,调用方将请求发送到一个中间层(通常是负载均衡器或 API 网关),由中间层查询注册中心并转发请求到具体实例。

graph LR
    A[服务消费者] -->|1. 发送请求| F[负载均衡器/网关]
    F -->|2. 查询实例| B[服务注册中心]
    B -->|3. 返回实例列表| F
    F -->|4. 转发请求| C[服务提供者实例 A]
    F -.->|备选| D[服务提供者实例 B]
    C -->|注册| B
    D -->|注册| B

典型实现: AWS ELB + AWS Cloud Map、Kubernetes Service(kube-proxy)、Nginx + Consul Template、Envoy(服务网格场景)。

优势:

劣势:

2.3 两种模式的选择依据

选择客户端发现还是服务端发现,取决于以下因素:

考量维度 客户端发现 服务端发现
延迟敏感度 更优(直连) 多一跳
多语言支持 需要多语言 SDK 语言无关
运维复杂度 SDK 升级需全量部署 集中管理
容灾能力 本地缓存可用 依赖 LB 高可用
典型场景 内部 RPC 调用 对外暴露 API

实践中,很多系统同时使用两种模式:内部服务间 RPC 用客户端发现(追求低延迟),对外 API 用服务端发现(追求简洁性和统一管理)。


三、Consul 深度解析

Consul 是 HashiCorp 开源的服务发现与配置管理工具,采用 Go 语言编写,以单一二进制文件分发,部署简单。它在服务发现领域的核心竞争力是多数据中心支持和丰富的健康检查机制。

3.1 架构概览

Consul 的架构分为两层:Server 节点和 Client 节点。

graph TB
    subgraph 数据中心 DC1
        S1[Server 1 - Leader]
        S2[Server 2 - Follower]
        S3[Server 3 - Follower]
        S1 <-->|Raft 共识| S2
        S1 <-->|Raft 共识| S3
        C1[Client Agent] -->|RPC| S1
        C2[Client Agent] -->|RPC| S2
        App1[服务实例 A] --- C1
        App2[服务实例 B] --- C2
        C1 <-->|Gossip - LAN| C2
        C1 <-->|Gossip - LAN| S1
    end
    subgraph 数据中心 DC2
        S4[Server 4 - Leader]
        S5[Server 5 - Follower]
        C3[Client Agent] -->|RPC| S4
        App3[服务实例 C] --- C3
    end
    S1 <-->|Gossip - WAN| S4

Server 节点: 负责存储服务注册信息和键值数据。Server 之间通过 Raft 共识协议保持数据一致性。每个数据中心有一个 Leader,所有写操作都由 Leader 处理。推荐每个数据中心部署 3 或 5 个 Server 节点。

Client 节点: 轻量级代理进程,部署在每个服务实例所在的机器上。Client 负责将服务注册请求转发给 Server,执行本地健康检查,并缓存查询结果。Client 之间通过 Gossip 协议(基于 SWIM)传播成员信息。

3.2 Raft 共识协议

Consul 的 Server 节点使用 Raft 协议保证数据的强一致性。Raft 的核心流程如下:

  1. Leader 选举: 所有 Server 节点启动后进入 Follower 状态。如果一个 Follower 在选举超时时间内没有收到 Leader 的心跳,它就切换为 Candidate 状态,发起投票请求。获得多数票的 Candidate 成为 Leader。
  2. 日志复制: 所有写操作(服务注册、注销、KV 写入)由 Leader 接收,写入本地日志后广播给 Follower。当多数 Follower 确认收到日志条目后,Leader 将该条目标记为已提交。
  3. 一致性读取: 默认模式下,Consul 的读操作也由 Leader 处理,确保读到最新数据。可以通过设置 stale 模式允许从 Follower 读取,牺牲一致性换取更低延迟和更高吞吐。
// Consul 注册服务示例(Go 客户端)
package main

import (
    "fmt"
    "log"

    consulapi "github.com/hashicorp/consul/api"
)

func main() {
    config := consulapi.DefaultConfig()
    config.Address = "127.0.0.1:8500"

    client, err := consulapi.NewClient(config)
    if err != nil {
        log.Fatalf("创建 Consul 客户端失败: %v", err)
    }

    // 注册服务
    registration := &consulapi.AgentServiceRegistration{
        ID:      "inventory-service-001",
        Name:    "inventory-service",
        Port:    8080,
        Address: "10.0.1.101",
        Tags:    []string{"v2.1.0", "dc1"},
        Meta: map[string]string{
            "version": "2.1.0",
            "region":  "cn-east-1",
        },
        Check: &consulapi.AgentServiceCheck{
            HTTP:                           "http://10.0.1.101:8080/health",
            Interval:                       "10s",
            Timeout:                        "3s",
            DeregisterCriticalServiceAfter: "30s",
        },
    }

    err = client.Agent().ServiceRegister(registration)
    if err != nil {
        log.Fatalf("服务注册失败: %v", err)
    }
    fmt.Println("服务注册成功")

    // 发现服务
    services, _, err := client.Health().Service("inventory-service", "", true, nil)
    if err != nil {
        log.Fatalf("服务发现失败: %v", err)
    }

    for _, entry := range services {
        fmt.Printf("实例: %s:%d\n", entry.Service.Address, entry.Service.Port)
    }
}

3.3 Gossip 协议

Consul 使用两层 Gossip 协议:

LAN Gossip: 同一个数据中心内的所有节点(Server + Client)组成一个 LAN Gossip 池。每个节点周期性地随机选择几个其他节点,交换成员列表信息。这种方式可以在几秒内将成员变更传播到整个数据中心,即使有上千个节点。LAN Gossip 基于 SWIM(Scalable Weakly-consistent Infection-style Process Group Membership Protocol)协议,通过 Ping、Indirect Ping 和 Suspect 机制检测节点故障。

WAN Gossip: 不同数据中心的 Server 节点之间组成 WAN Gossip 池。WAN Gossip 的传播频率更低(因为跨数据中心的网络延迟更高),但能保证每个数据中心的 Server 知道其他数据中心的 Server 地址,从而支持跨数据中心的服务发现请求。

3.4 健康检查机制

Consul 支持多种健康检查方式:

检查类型 说明 典型用法
HTTP 定期向指定 URL 发送 GET 请求,2xx 为健康 REST 服务
TCP 尝试建立 TCP 连接 数据库、缓存
gRPC 调用 gRPC Health Checking Protocol gRPC 服务
Script 执行脚本,退出码 0 为健康 自定义检查逻辑
TTL 服务主动上报心跳,超时未上报则标记为不健康 不方便被动探测的场景
Alias 关联到另一个服务的健康状态 Sidecar 代理

DeregisterCriticalServiceAfter 参数特别值得注意:当一个服务的健康检查持续处于 Critical 状态超过指定时间后,Consul 会自动将其从注册表中删除。这避免了”僵尸服务”长期占用注册表的问题。

3.5 多数据中心

Consul 的多数据中心设计是其区别于 Eureka 和 Nacos 的核心优势。每个数据中心有独立的 Server 集群和独立的 Raft 日志。数据中心之间通过 WAN Gossip 互相发现,通过 RPC 转发跨数据中心的查询请求。

这种设计意味着:


四、Eureka 深度解析

Eureka 是 Netflix 开源的服务注册与发现组件,是 Spring Cloud Netflix 技术栈的核心组件之一。它的设计哲学与 Consul 截然不同——Eureka 选择了 AP 模型(可用性优先),在网络分区时宁可返回过期数据,也不愿拒绝服务。

4.1 架构设计

Eureka 的架构非常简单:Eureka Server 集群 + Eureka Client。

graph TB
    subgraph Eureka Server 集群
        ES1[Eureka Server 1]
        ES2[Eureka Server 2]
        ES3[Eureka Server 3]
        ES1 <-->|Peer Replication| ES2
        ES2 <-->|Peer Replication| ES3
        ES1 <-->|Peer Replication| ES3
    end
    EC1[Eureka Client - 服务 A] -->|注册/心跳| ES1
    EC2[Eureka Client - 服务 B] -->|注册/心跳| ES2
    EC3[Eureka Client - 服务 C] -->|注册/心跳| ES3
    EC1 -->|获取注册表| ES1
    EC2 -->|获取注册表| ES2

Server 之间的关系: Eureka Server 之间是对等复制(Peer Replication),没有 Leader/Follower 的区分。每个 Server 都可以接收注册请求,然后将注册信息异步复制到其他 Server。这意味着在短时间内,不同 Server 上的注册信息可能不一致,但最终会收敛。

Client 行为: Eureka Client 启动时向一个 Server 注册,然后每 30 秒发送一次心跳续约(Renew)。同时,Client 每 30 秒从 Server 拉取一次完整的注册表,缓存在本地。

4.2 AP 模型的取舍

Eureka 选择 AP 模型的核心理由是:在服务发现场景中,返回一个可能略微过期的实例列表,比拒绝服务要好得多。

设想一个场景:注册中心的三个节点之间发生了网络分区。在 CP 模型(如 Consul 的 Raft)下,少数派节点无法完成写操作,也可能拒绝读操作——这意味着部分消费者无法获取服务实例列表,导致调用链中断。在 AP 模型下,每个 Eureka Server 独立提供服务,即使数据不完全一致,消费者仍然可以获取到实例列表,只不过某些新注册的实例可能暂时缺失。

Netflix 的工程实践表明,在大多数情况下,一个略微过期的实例列表造成的影响(少数请求打到已下线的实例,被重试机制覆盖)远小于完全无法获取实例列表造成的影响(整个调用链断裂)。

4.3 自我保护模式

自我保护模式(Self-Preservation Mode)是 Eureka 最具争议也最容易被误解的特性。

触发条件: 当 Eureka Server 在最近一分钟内收到的心跳续约数量低于预期值的 85% 时,进入自我保护模式。

行为变化: 进入自我保护模式后,Eureka Server 不再剔除因心跳超时而标记为过期的服务实例。

设计意图: 自我保护模式的目的是应对网络分区场景。当 Server 和大量 Client 之间的网络断开时,Server 会收不到这些 Client 的心跳。如果此时按照正常逻辑剔除这些 Client,注册表会被清空,导致所有调用方都找不到服务实例。自我保护模式通过”宁可保留可能已下线的实例,也不冒险清空注册表”来降低这种风险。

争议: 自我保护模式在实践中经常导致困惑。开发者在本地环境中频繁重启服务时,Eureka 可能进入自我保护模式,导致已下线的服务实例长时间残留在注册表中。很多团队会选择在开发环境关闭自我保护模式:

# Eureka Server 配置
eureka:
  server:
    enable-self-preservation: false
    eviction-interval-timer-in-ms: 5000
// Eureka Client 注册与发现示例(Spring Boot)
// application.yml
// eureka:
//   client:
//     service-url:
//       defaultZone: http://eureka1:8761/eureka/,http://eureka2:8762/eureka/
//     registry-fetch-interval-seconds: 15
//   instance:
//     lease-renewal-interval-in-seconds: 10
//     lease-expiration-duration-in-seconds: 30

@SpringBootApplication
@EnableDiscoveryClient
public class OrderServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderServiceApplication.class, args);
    }
}

@RestController
public class OrderController {

    private final DiscoveryClient discoveryClient;
    private final RestTemplate restTemplate;

    public OrderController(DiscoveryClient discoveryClient,
                           @LoadBalanced RestTemplate restTemplate) {
        this.discoveryClient = discoveryClient;
        this.restTemplate = restTemplate;
    }

    @GetMapping("/order/{id}")
    public Order getOrder(@PathVariable String id) {
        // 方式一:手动获取实例列表
        List<ServiceInstance> instances =
            discoveryClient.getInstances("inventory-service");
        if (instances.isEmpty()) {
            throw new RuntimeException("无可用的库存服务实例");
        }
        ServiceInstance instance = instances.get(0);
        String url = String.format("http://%s:%d/inventory/%s",
            instance.getHost(), instance.getPort(), id);

        // 方式二:通过 @LoadBalanced RestTemplate 自动发现
        String inventory = restTemplate
            .getForObject("http://inventory-service/inventory/" + id,
                          String.class);

        return new Order(id, inventory);
    }
}

4.4 Peer Replication 机制

Eureka Server 之间的数据复制是异步的、尽力而为的(best-effort)。当一个 Server 收到注册、续约或注销请求后,它会将这个操作异步复制到所有已知的 Peer。

关键细节:

4.5 Eureka 2.0 的命运

Netflix 在 2018 年宣布 Eureka 2.0 进入维护模式,不再积极开发。Spring Cloud 社区随后将默认的服务发现组件从 Eureka 切换到了 Consul 和 Nacos 等替代方案。尽管如此,Eureka 1.x 仍然在大量存量系统中稳定运行,其 AP 设计思想对后续的注册中心设计产生了深远影响。


五、Nacos 深度解析

Nacos(Dynamic Naming and Configuration Service)是阿里巴巴开源的服务发现与配置管理平台。它的设计目标是同时覆盖服务发现和配置管理两个领域,并支持在 CP 和 AP 两种一致性模型之间切换。

5.1 架构总览

graph TB
    subgraph Nacos Server 集群
        NS1[Nacos Server 1]
        NS2[Nacos Server 2]
        NS3[Nacos Server 3]
        NS1 <-->|Raft / Distro| NS2
        NS2 <-->|Raft / Distro| NS3
    end
    subgraph 服务提供者
        P1[服务实例 A] -->|注册| NS1
        P2[服务实例 B] -->|注册| NS2
    end
    subgraph 服务消费者
        C1[消费者 X] -->|订阅/查询| NS1
        C2[消费者 Y] -->|订阅/查询| NS3
    end
    NS1 -->|推送变更| C1
    NS3 -->|推送变更| C2

5.2 CP 与 AP 的切换

Nacos 最独特的设计是支持在 CP 和 AP 两种模式之间切换,并且可以在同一个集群中为不同的服务选择不同的模式。

临时实例(Ephemeral Instance): 默认模式,对应 AP 一致性。临时实例通过心跳维持注册状态,如果心跳超时,实例会被自动剔除。Server 之间使用 Distro 协议(一种基于推拉结合的最终一致性协议)同步数据。

持久实例(Persistent Instance): 对应 CP 一致性。持久实例一旦注册,即使心跳停止也不会被自动剔除,需要手动注销。Server 之间使用 Raft 协议(通过 JRaft 实现)保证数据的强一致性。

// Nacos 服务注册示例
Properties properties = new Properties();
properties.setProperty("serverAddr", "nacos1:8848,nacos2:8848,nacos3:8848");
properties.setProperty("namespace", "production");

NamingService naming = NamingFactory.createNamingService(properties);

// 注册临时实例(AP 模式)
Instance ephemeralInstance = new Instance();
ephemeralInstance.setIp("10.0.1.101");
ephemeralInstance.setPort(8080);
ephemeralInstance.setWeight(1.0);
ephemeralInstance.setHealthy(true);
ephemeralInstance.setEphemeral(true); // 临时实例
ephemeralInstance.setClusterName("DEFAULT");
Map<String, String> metadata = new HashMap<>();
metadata.put("version", "2.1.0");
metadata.put("region", "cn-east-1");
ephemeralInstance.setMetadata(metadata);

naming.registerInstance("inventory-service", "DEFAULT_GROUP",
    ephemeralInstance);

// 注册持久实例(CP 模式)
Instance persistentInstance = new Instance();
persistentInstance.setIp("10.0.2.201");
persistentInstance.setPort(3306);
persistentInstance.setEphemeral(false); // 持久实例
naming.registerInstance("mysql-primary", "INFRA_GROUP",
    persistentInstance);

// 订阅服务变更
naming.subscribe("inventory-service", "DEFAULT_GROUP", event -> {
    NamingEvent namingEvent = (NamingEvent) event;
    List<Instance> instances = namingEvent.getInstances();
    System.out.println("实例列表变更: " + instances);
});

// 查询健康实例
List<Instance> healthyInstances = naming.selectInstances(
    "inventory-service", "DEFAULT_GROUP", true);
for (Instance inst : healthyInstances) {
    System.out.printf("可用实例: %s:%d (权重: %.1f)%n",
        inst.getIp(), inst.getPort(), inst.getWeight());
}

5.3 Distro 协议

Distro 是 Nacos 自研的 AP 一致性协议,用于临时实例的数据同步。它的核心设计如下:

  1. 责任分片: 每个 Nacos Server 节点负责一部分服务的写操作。客户端的注册请求如果发到了非责任节点,会被转发到责任节点。
  2. 增量同步: 责任节点处理完注册请求后,通过增量推送的方式将变更同步到其他节点。
  3. 全量校验: 节点之间定期进行全量数据校验(通过比较数据摘要),修复因网络抖动导致的数据不一致。
  4. 新节点加入: 新加入的 Server 节点会从已有节点拉取全量数据进行初始化。

5.4 推拉结合的通知机制

Nacos 的服务变更通知采用推拉结合的模式:

这种推拉结合的设计,在保证低延迟通知的同时,提供了数据最终一致性的保障。

5.5 Nacos 2.x 的改进

Nacos 2.x 做了两个重要的架构升级:

  1. gRPC 长连接替代 HTTP 短轮询: 客户端与服务端之间建立 gRPC 双向流,心跳和推送都走这条长连接。相比 1.x 的 HTTP 短轮询,连接开销大幅降低,推送延迟从秒级降到毫秒级。
  2. 连接级别的健康检测: 利用 gRPC 连接的存活状态判断客户端是否在线,减少了独立心跳请求的数量。当连接断开时,Server 可以立即感知到客户端下线。

六、三大注册中心对比

对比维度 Consul Eureka Nacos
一致性模型 CP(Raft) AP(Peer Replication) AP(Distro)/ CP(Raft),可切换
语言 Go Java Java
健康检查 HTTP、TCP、gRPC、Script、TTL 客户端心跳(TTL) 客户端心跳 + 服务端主动探测
多数据中心 原生支持(WAN Gossip) 不支持(需自建) 不支持(需自建)
配置管理 支持(KV Store) 不支持 支持(核心功能)
负载均衡 外部集成(如 Fabio) 客户端(Ribbon) 客户端(内置权重随机)
Watch/Push 机制 Long Polling / Blocking Query 客户端定时拉取 Push(gRPC/UDP)+ Pull
实例类型 持久化 临时(心跳过期删除) 临时 + 持久,可选
CAP 偏好 一致性优先 可用性优先 可切换
适用场景 多数据中心、多语言异构 Spring Cloud 生态 阿里系 / Dubbo 生态
Spring Cloud 集成 成熟 原生 成熟
性能(万级实例) 中等
社区活跃度 维护模式

选型建议

选 Consul 的场景:

选 Eureka 的场景:

选 Nacos 的场景:


七、DNS 服务发现的局限

DNS(Domain Name System)是互联网最古老的”服务发现”机制。用域名代替 IP 地址,本质上就是一种服务发现。自然地,有人会问:能不能直接用 DNS 来做微服务的服务发现?

答案是:可以,但有严重限制。

7.1 DNS 用于服务发现的方式

最直接的做法是使用 DNS SRV 记录(Service Record)。SRV 记录不仅可以返回 IP 地址,还可以返回端口号和优先级:

_inventory._tcp.example.com.  IN SRV 10 60 8080 inv1.example.com.
_inventory._tcp.example.com.  IN SRV 10 40 8080 inv2.example.com.
_inventory._tcp.example.com.  IN SRV 20 100 8080 inv3.example.com.

Consul 和 Kubernetes 都支持通过 DNS 接口查询服务实例。Kubernetes 的 CoreDNS 为每个 Service 创建一个 A 记录或 SRV 记录,Pod 可以通过域名访问 Service。

7.2 五个局限性

局限一:TTL 缓存延迟。 DNS 的设计基于”域名到 IP 的映射变化频率很低”这个假设。DNS 记录有 TTL(Time To Live),客户端和中间 DNS 服务器会缓存记录直到 TTL 过期。如果 TTL 设为 30 秒,那么一个实例下线后,最长需要 30 秒才能被消费者感知。如果把 TTL 设得很短(如 1 秒),DNS 服务器的查询压力会急剧上升。

局限二:没有健康检查。 标准 DNS 不具备健康检查能力。即使一个服务实例已经挂了,只要 DNS 记录没有更新,查询者仍然会拿到这个实例的地址。Consul DNS 和 CoreDNS 通过集成外部健康检查弥补了这一不足,但这已经超出了标准 DNS 的范畴。

局限三:缺少元数据。 标准 DNS 记录只能携带 IP、端口和少量的权重信息。服务发现场景经常需要的元数据——版本号、部署环境、区域标签、协议类型——都无法通过 DNS 记录传递。

局限四:连接级别的负载均衡问题。 对于长连接场景(gRPC、WebSocket),DNS 解析通常只在连接建立时发生一次。连接建立之后,即使 DNS 记录更新了,已有连接仍然指向旧的实例。这会导致负载不均衡:新实例启动后,已有的长连接不会迁移过来。

局限五:Java 的 DNS 缓存问题。 JVM 默认会永久缓存 DNS 解析结果(networkaddress.cache.ttl 默认值为 -1)。在容器化环境中,这意味着 Pod IP 变化后,Java 应用可能永远无法感知到新地址。需要显式设置:

// 在 JVM 启动参数中设置 DNS 缓存 TTL
java.security.Security.setProperty("networkaddress.cache.ttl", "10");
java.security.Security.setProperty("networkaddress.cache.negative.ttl", "5");

7.3 DNS 的合理使用场景

尽管有上述限制,DNS 在以下场景中仍然是合理的服务发现方式:


八、健康检查的工程挑战

健康检查是服务发现机制的核心环节。一个设计不良的健康检查可能比没有健康检查更糟糕——它会导致正常的实例被误判为不健康(假阳性,False Positive)或不健康的实例被误判为正常(假阴性,False Negative)。

8.1 假阳性:误杀健康实例

原因一:网络抖动。 注册中心与服务实例之间的网络出现短暂中断,导致健康检查请求超时。如果超时次数达到阈值,实例会被标记为不健康并从注册表中剔除。但实际上服务本身是正常的,只是网络瞬断。

原因二:GC 停顿。 Java 应用在执行 Full GC 时会出现 Stop-The-World 停顿,可能持续数百毫秒甚至数秒。如果健康检查的超时时间小于 GC 停顿时间,检查会失败。

原因三:CPU 资源争抢。 在容器化环境中,多个容器共享宿主机 CPU。当宿主机 CPU 负载过高时,健康检查线程可能无法及时响应检查请求。

缓解策略:

// 实现健康检查的防抖动逻辑(Go)
type HealthChecker struct {
    consecutiveFailures int
    failureThreshold    int    // 连续失败多少次才标记为不健康
    mu                  sync.Mutex
}

func (hc *HealthChecker) Check(target string) bool {
    hc.mu.Lock()
    defer hc.mu.Unlock()

    healthy := doHTTPCheck(target)

    if healthy {
        hc.consecutiveFailures = 0
        return true
    }

    hc.consecutiveFailures++
    // 连续失败 N 次才判定为不健康,避免网络抖动导致的误判
    if hc.consecutiveFailures >= hc.failureThreshold {
        return false
    }
    // 未达到阈值,仍然视为健康
    return true
}

func doHTTPCheck(target string) bool {
    client := &http.Client{Timeout: 3 * time.Second}
    resp, err := client.Get(target + "/health")
    if err != nil {
        return false
    }
    defer resp.Body.Close()
    return resp.StatusCode >= 200 && resp.StatusCode < 300
}

其他缓解手段:

8.2 假阴性:放过不健康实例

原因一:浅层健康检查。 最常见的健康检查是向 /health 端点发送 HTTP GET 请求,只要返回 200 就认为健康。但服务可能处于以下状态:HTTP 端口正常响应,但数据库连接池已耗尽,无法处理任何实际请求。

原因二:依赖服务不可用。 服务 A 依赖服务 B,服务 B 已宕机。服务 A 的 /health 返回 200(因为它自己还活着),但实际上任何需要调用服务 B 的请求都会失败。

缓解策略:

// Spring Boot Actuator 的深度健康检查
@Component
public class InventoryHealthIndicator implements HealthIndicator {

    private final DataSource dataSource;
    private final RedisTemplate<String, String> redisTemplate;

    public InventoryHealthIndicator(DataSource dataSource,
                                     RedisTemplate<String, String> redisTemplate) {
        this.dataSource = dataSource;
        this.redisTemplate = redisTemplate;
    }

    @Override
    public Health health() {
        Map<String, Object> details = new HashMap<>();

        // 检查数据库连接
        try (Connection conn = dataSource.getConnection()) {
            PreparedStatement ps = conn.prepareStatement("SELECT 1");
            ps.executeQuery();
            details.put("database", "UP");
        } catch (SQLException e) {
            details.put("database", "DOWN: " + e.getMessage());
            return Health.down().withDetails(details).build();
        }

        // 检查 Redis 连接
        try {
            redisTemplate.opsForValue().get("health-check-key");
            details.put("redis", "UP");
        } catch (Exception e) {
            details.put("redis", "DOWN: " + e.getMessage());
            return Health.down().withDetails(details).build();
        }

        // 检查线程池是否健康
        ThreadPoolExecutor executor = getBusinessThreadPool();
        int activeCount = executor.getActiveCount();
        int maxPoolSize = executor.getMaximumPoolSize();
        double utilization = (double) activeCount / maxPoolSize;
        details.put("threadPoolUtilization", String.format("%.1f%%",
            utilization * 100));

        if (utilization > 0.9) {
            return Health.down()
                .withDetail("reason", "线程池利用率超过 90%")
                .withDetails(details)
                .build();
        }

        return Health.up().withDetails(details).build();
    }
}

8.3 健康检查的层次模型

一个成熟的健康检查体系应该分为三个层次:

层次 检查内容 频率 用途
存活检查(Liveness) 进程是否存活,能否响应请求 高频(5-10 秒) 快速发现宕机实例
就绪检查(Readiness) 服务是否准备好接受流量 中频(10-30 秒) 控制流量引入/摘除
深度检查(Deep Health) 依赖的数据库、缓存、消息队列是否正常 低频(30-60 秒) 发现隐性故障

Kubernetes 的 livenessProbereadinessProbe 正是基于这种层次模型设计的。存活探针失败会导致 Pod 被重启,就绪探针失败会导致 Pod 从 Service 的 Endpoints 列表中摘除(但不会重启)。

# Kubernetes Pod 健康检查配置
apiVersion: v1
kind: Pod
metadata:
  name: inventory-service
spec:
  containers:
  - name: inventory
    image: inventory-service:2.1.0
    ports:
    - containerPort: 8080
    livenessProbe:
      httpGet:
        path: /health/liveness
        port: 8080
      initialDelaySeconds: 15
      periodSeconds: 10
      failureThreshold: 3
      timeoutSeconds: 3
    readinessProbe:
      httpGet:
        path: /health/readiness
        port: 8080
      initialDelaySeconds: 10
      periodSeconds: 5
      failureThreshold: 2
      timeoutSeconds: 3
    startupProbe:
      httpGet:
        path: /health/liveness
        port: 8080
      initialDelaySeconds: 5
      periodSeconds: 5
      failureThreshold: 30

九、服务网格中的服务发现

服务网格(Service Mesh)的出现,从根本上改变了服务发现的实现方式。在服务网格架构中,服务发现逻辑从应用代码中完全剥离,下沉到基础设施层。

9.1 Sidecar 代理与控制平面

以 Istio + Envoy 为例,服务发现的流程如下:

graph TB
    subgraph 控制平面
        Istiod[Istiod - Pilot]
        K8sAPI[Kubernetes API Server]
        K8sAPI -->|Watch Endpoints| Istiod
    end
    subgraph 数据平面 - Pod A
        AppA[服务 A] -->|localhost:port| EnvoyA[Envoy Sidecar A]
    end
    subgraph 数据平面 - Pod B
        EnvoyB[Envoy Sidecar B] -->|localhost:port| AppB[服务 B]
    end
    Istiod -->|xDS 推送| EnvoyA
    Istiod -->|xDS 推送| EnvoyB
    EnvoyA -->|路由请求| EnvoyB

流程说明:

  1. Istiod(Pilot 组件)Watch Kubernetes API Server 的 Service 和 Endpoints 资源变化。
  2. 当 Endpoints 发生变化(Pod 创建/销毁)时,Istiod 将最新的服务实例列表通过 xDS 协议推送给所有 Envoy Sidecar。
  3. 服务 A 向服务 B 发送请求时,请求首先被 Envoy Sidecar A 拦截。
  4. Envoy A 根据本地缓存的实例列表和负载均衡策略,选择一个服务 B 的实例,将请求转发过去。
  5. 应用代码完全不需要引入任何服务发现 SDK。

9.2 xDS 协议

xDS 是 Envoy 定义的一组动态配置协议,核心包括:

xDS 支持两种交互模式:

// Envoy xDS 中 EDS 返回的 ClusterLoadAssignment 结构示例
// 展示如何定义服务实例列表
package main

import (
    "fmt"

    endpoint "github.com/envoyproxy/go-control-plane/envoy/config/endpoint/v3"
    core "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
)

func buildEndpoints() *endpoint.ClusterLoadAssignment {
    return &endpoint.ClusterLoadAssignment{
        ClusterName: "inventory-service",
        Endpoints: []*endpoint.LocalityLbEndpoints{
            {
                // 区域感知路由
                Locality: &core.Locality{
                    Region: "cn-east-1",
                    Zone:   "cn-east-1a",
                },
                Priority: 0,
                LbEndpoints: []*endpoint.LbEndpoint{
                    {
                        HostIdentifier: &endpoint.LbEndpoint_Endpoint{
                            Endpoint: &endpoint.Endpoint{
                                Address: &core.Address{
                                    Address: &core.Address_SocketAddress{
                                        SocketAddress: &core.SocketAddress{
                                            Address: "10.0.1.101",
                                            PortSpecifier: &core.SocketAddress_PortValue{
                                                PortValue: 8080,
                                            },
                                        },
                                    },
                                },
                            },
                        },
                        HealthStatus: core.HealthStatus_HEALTHY,
                        LoadBalancingWeight: wrapperspb.UInt32(80),
                    },
                },
            },
        },
    }
}

func main() {
    endpoints := buildEndpoints()
    fmt.Printf("集群: %s, 端点数: %d\n",
        endpoints.ClusterName,
        len(endpoints.Endpoints[0].LbEndpoints))
}

9.3 服务网格 vs 传统注册中心

维度 传统注册中心 服务网格
服务发现逻辑位置 应用内(SDK) Sidecar 代理
语言依赖 需要各语言 SDK 语言无关
升级方式 重新部署应用 滚动更新 Sidecar
额外延迟 无(直连) 有(两跳 Sidecar)
资源消耗 高(每个 Pod 一个 Envoy)
功能范围 服务发现 + 健康检查 发现 + 负载均衡 + 熔断 + mTLS + 可观测性
适用规模 中小规模 大规模微服务

服务网格的核心优势是将服务发现、负载均衡、熔断、重试、认证等横切关注点(Cross-Cutting Concerns)统一下沉到基础设施层,使应用代码只需关注业务逻辑。代价是增加了资源消耗(每个 Pod 一个 Sidecar)和运维复杂度(需要维护控制平面)。


十、工程案例:电商平台服务发现体系演进

10.1 背景

某电商平台从单体架构拆分为 60 多个微服务,部署在 Kubernetes 集群上。初期使用 Kubernetes Service(ClusterIP)+ CoreDNS 作为服务发现方案。随着业务增长,遇到了以下问题:

  1. DNS 缓存导致流量打到已下线的 Pod。 Java 应用的 DNS 缓存 TTL 过长,滚动更新时频繁出现连接超时。
  2. 缺乏灰度发布能力。 Kubernetes Service 只能做简单的轮询负载均衡,无法按版本、区域等维度做精细路由。
  3. 跨集群调用。 业务扩展到两个 Kubernetes 集群(两个可用区),跨集群的服务发现需要手动维护。

10.2 方案选型

经过评估,团队决定引入 Nacos 作为统一的服务注册中心,同时保留 Kubernetes Service 作为基础设施层的兜底。

选择 Nacos 的理由:

10.3 架构设计

graph TB
    subgraph K8s 集群 A - 可用区 1
        NacosA1[Nacos Server] 
        NacosA2[Nacos Server]
        NacosA3[Nacos Server]
        NacosA1 <-->|Raft/Distro| NacosA2
        NacosA2 <-->|Raft/Distro| NacosA3
        PodA1[订单服务 Pod] -->|注册/发现| NacosA1
        PodA2[库存服务 Pod] -->|注册/发现| NacosA2
    end
    subgraph K8s 集群 B - 可用区 2
        NacosB1[Nacos Server]
        NacosB2[Nacos Server]
        NacosB3[Nacos Server]
        PodB1[订单服务 Pod] -->|注册/发现| NacosB1
        PodB2[库存服务 Pod] -->|注册/发现| NacosB2
    end
    NacosA1 <-->|Nacos Sync| NacosB1

关键设计决策:

  1. 双集群独立部署 Nacos。 每个可用区部署独立的 Nacos 集群(3 节点),通过 Nacos Sync 组件实现跨集群的服务实例同步。
  2. 临时实例用于微服务。 所有微服务实例以临时实例注册,心跳超时自动剔除。
  3. 持久实例用于基础设施。 MySQL 主从地址、Redis 集群地址以持久实例注册,需要手动切换。
  4. 区域亲和路由。 消费者优先调用同可用区的实例,跨区调用作为降级方案。

10.4 注册与反注册流程

服务实例的生命周期管理是一个需要仔细处理的工程问题,特别是优雅关停(Graceful Shutdown)和反注册(Deregistration)。

// 优雅关停与反注册的 Spring Boot 实现
@Component
public class GracefulShutdownHandler {

    private final NamingService namingService;
    private final String serviceName;
    private final String ip;
    private final int port;

    public GracefulShutdownHandler(NamingService namingService,
                                    @Value("${spring.application.name}") String serviceName,
                                    @Value("${server.address:}") String ip,
                                    @Value("${server.port}") int port) {
        this.namingService = namingService;
        this.serviceName = serviceName;
        this.ip = ip;
        this.port = port;
    }

    @PreDestroy
    public void onShutdown() {
        try {
            // 第一步:从注册中心主动反注册
            namingService.deregisterInstance(serviceName, ip, port);
            System.out.println("已从注册中心反注册");

            // 第二步:等待消费者感知到实例下线
            // 等待时间应大于消费者的缓存刷新间隔
            Thread.sleep(5000);

            // 第三步:等待正在处理的请求完成
            // Tomcat 的 graceful shutdown 会等待活跃请求完成
            System.out.println("等待活跃请求完成...");

        } catch (Exception e) {
            System.err.println("优雅关停失败: " + e.getMessage());
        }
    }
}
# Kubernetes 配合优雅关停的 Pod 配置
apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
spec:
  replicas: 3
  template:
    spec:
      terminationGracePeriodSeconds: 60
      containers:
      - name: order-service
        image: order-service:3.2.0
        lifecycle:
          preStop:
            exec:
              command:
              - /bin/sh
              - -c
              - |
                # 调用反注册接口
                curl -X DELETE "http://localhost:8080/actuator/service-registry"
                # 等待消费者刷新缓存
                sleep 10
        ports:
        - containerPort: 8080

优雅关停的关键时序:

  1. Kubernetes 发送 SIGTERM 信号给容器。
  2. preStop 钩子执行:调用反注册接口,等待消费者刷新缓存。
  3. 应用收到 SIGTERM 后,停止接受新请求,等待正在处理的请求完成。
  4. 如果在 terminationGracePeriodSeconds(60 秒)内未完成,Kubernetes 发送 SIGKILL 强制终止。

10.5 服务注册模式:自注册 vs 第三方注册

在上述案例中,服务实例自己负责向 Nacos 注册和反注册,这就是自注册模式(Self-Registration Pattern)。还有一种方式是第三方注册模式(Third-Party Registration Pattern)。

自注册模式:

第三方注册模式:

Kubernetes 实际上就是第三方注册模式的典型实现:kubelet 管理 Pod 的生命周期,Endpoints Controller 自动将 Pod 的 IP 注册到对应的 Service 的 Endpoints 列表中。应用代码完全不需要调用任何注册 API。

10.6 效果与数据

方案上线后的效果:

指标 改进前(CoreDNS) 改进后(Nacos)
实例变更感知延迟 10-30 秒 < 1 秒
滚动更新期间错误率 0.3% 0.01%
跨集群调用成功率 需手动配置 99.95%
灰度发布支持 不支持 支持按版本/区域路由

关键经验总结:

  1. JVM DNS 缓存必须显式设置。 生产环境的 Java 应用必须设置 networkaddress.cache.ttl,否则 Pod IP 变化后无法感知。
  2. 健康检查超时要考虑 GC 停顿。 健康检查的超时时间设为 3 秒(覆盖大多数 GC 停顿),连续 3 次失败才标记不健康。
  3. 优雅关停的等待时间要大于消费者缓存刷新间隔。 Nacos 客户端默认每 10 秒拉取一次全量数据,因此反注册后至少等待 10 秒再关停服务。
  4. 监控注册中心本身的可用性。 Nacos 集群的 Leader 选举和数据同步延迟需要纳入监控体系,避免注册中心成为不可见的单点。

参考资料

  1. Chris Richardson. Pattern: Client-side service discovery, Pattern: Server-side service discovery. microservices.io.
  2. HashiCorp. Consul Architecture. consul.io/docs/architecture.
  3. Netflix. Eureka Wiki. github.com/Netflix/eureka/wiki.
  4. Alibaba. Nacos 架构与设计. nacos.io/docs.
  5. Diego Ongaro, John Ousterhout. In Search of an Understandable Consensus Algorithm (Raft). Stanford University, 2014.
  6. Das A, Gupta I, Motivala A. SWIM: Scalable Weakly-consistent Infection-style Process Group Membership Protocol. DSN 2002.
  7. Envoy Project. xDS Protocol. envoyproxy.io/docs.
  8. Kubernetes Documentation. Service. kubernetes.io/docs/concepts/services-networking/service.
  9. Istio Documentation. Traffic Management. istio.io/docs/concepts/traffic-management.
  10. 阿里巴巴中间件团队.《Nacos 架构与原理》. nacos.io.

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-13 · architecture

【系统架构设计百科】配置管理架构:从配置文件到配置中心

配置应该放在代码里、环境变量里、还是配置中心里?本文从 12-Factor App 的配置理念出发,拆解配置的分层模型,深入分析 Apollo、Nacos、etcd 三大配置中心的架构与取舍,讨论动态配置灰度发布、配置加密与审计、Feature Flags 等工程实践。

2026-04-13 · architecture

【系统架构设计百科】架构质量属性:不只是"高可用高性能"

需求评审时写下的'高可用、高性能、高并发',到了架构设计阶段几乎无法落地——因为它们不是可执行的需求。本文从 SEI/CMU 的质量属性理论出发,用 stimulus-response 场景模型把模糊需求变成可量化、可验证的架构约束,并拆解属性之间的冲突与联动关系。

2026-04-13 · architecture

【系统架构设计百科】告警策略:如何避免"狼来了"

大多数团队的告警系统都在制造噪声而不是传递信号。阈值告警看似直观,实则产生大量误报和漏报,值班工程师在凌晨三点被叫醒,却发现只是一次无害的毛刺。本文从告警疲劳的工业数据出发,拆解基于 SLO 的多窗口燃烧率告警算法,深入 Alertmanager 的路由、抑制与分组机制,结合 PagerDuty 的告警疲劳研究和真实工程案例,给出一套可落地的告警策略设计方法。

2026-04-13 · architecture

【系统架构设计百科】复杂性管理:架构的核心战场

系统复杂性是架构腐化的根源——本文从 Brooks 的本质复杂性与偶然复杂性划分出发,结合认知负荷理论与 Parnas 的信息隐藏原则,系统阐述复杂性的来源、度量与控制手段,并给出可操作的架构策略


By .