4、客户端服务发现模式:原理与实现

好,咱们今天聊聊客户端服务发现。这个模式,说白了就是让调用方自己去问注册中心:「嘿,我要的服务在哪儿?」然后自己决定连哪台机器。

我刚开始做微服务那会儿,用的就是这种模式。当时团队不大,服务也就十几个,用起来挺顺手。后来规模大了,才慢慢发现它的优缺点。今天我把这些经验掰开揉碎了讲给你听。

4.1 什么是客户端服务发现?

先看一张逻辑图,你脑子里就有画面了:

┌─────────────┐      ┌──────────────┐
│  服务消费者  │      │  服务注册中心  │
│  (客户端)    │      │  (例如Consul) │
└──────┬──────┘      └──────┬───────┘
       │                     │
       │  1. 启动时注册      │
       │  ──────────────────>│
       │                     │
       │  2. 查询服务实例    │
       │  ──────────────────>│
       │                     │
       │  3. 返回实例列表    │
       │  <──────────────────│
       │                     │
       │  4. 直接调用        │
       │  ──────────────────>│
       │     服务提供者      │
       └─────────────────────┘

流程其实就四步:

  1. 服务提供者启动时,把自己的IP和端口告诉注册中心
  2. 服务消费者需要调用时,去注册中心拉取可用实例列表
  3. 注册中心返回一堆地址(比如 192.168.1.10:8080192.168.1.11:8080
  4. 消费者自己选一个,直接发起HTTP或RPC调用

嗯,这里要注意:所有的负载均衡逻辑,都在客户端这边。注册中心只负责「告诉你在哪儿」,不负责「帮你转发」。

4.2 核心组件拆解

我个人习惯把客户端服务发现拆成三个核心组件:

组件 职责 我踩过的坑
服务注册表 存储所有存活的服务实例信息 曾经因为TTL设置太短,导致健康实例被误删
健康检查 定期探测实例是否还活着 心跳超时设置不当,引发雪崩
客户端负载均衡器 从实例列表里选一个来调用 默认轮询策略在慢服务场景下会出大问题

4.3 客户端怎么拿到实例列表?

这里有两种做法,我分别说说:

4.3.1 拉模式(Pull)

客户端每隔几秒主动去注册中心拉一次。就像你每隔5分钟刷一次朋友圈。

// 伪代码示例
while (true) {
    List<Instance> instances = registryClient.fetchInstances("order-service");
    localCache.update(instances);
    Thread.sleep(5000); // 每5秒拉一次
}

优点:实现简单,客户端自己控制节奏。
缺点:有延迟。如果服务挂了,最坏要等5秒才知道。

4.3.2 推模式(Push)

注册中心主动通知客户端:「喂,服务列表变了!」

// 伪代码示例
registryClient.subscribe("order-service", new ChangeListener() {
    void onChange(List<Instance> newInstances) {
        localCache.update(newInstances);
        // 立刻生效,不用等轮询
    }
});

优点:实时性高,变更秒级生效。
缺点:需要维护长连接,实现复杂一点。

我的建议:小规模系统用拉模式就够了。如果服务超过50个,或者对实时性要求高,上推模式。我在项目中两种都试过,推模式虽然麻烦点,但线上体验好很多。

4.4 客户端怎么选实例?

拿到列表之后,选哪个?这就是负载均衡策略的事了。常见的有:

  • 随机:简单粗暴,但分布不均匀
  • 轮询:按顺序来,公平但没考虑机器性能
  • 最少连接:谁闲找谁,适合长连接场景
  • 一致性哈希:同一个请求总是打到同一台机器,适合有状态服务

我曾经遇到过一个坑:默认用轮询,结果有一台机器配置低,每次请求都慢,轮询到它的时候整个接口就卡住。后来改成最少连接策略,问题就解决了。

4.5 代码实现示例

我用Spring Cloud举个实际例子,你感受一下:

// 1. 服务提供者注册
@SpringBootApplication
@EnableDiscoveryClient
public class OrderServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderServiceApplication.class, args);
    }
}

// 2. 服务消费者发现并调用
@Service
public class OrderClient {
    
    @Autowired
    private DiscoveryClient discoveryClient;
    
    @Autowired
    private LoadBalancerClient loadBalancer;
    
    public String callOrderService() {
        // 第一步:获取所有实例
        List<ServiceInstance> instances = 
            discoveryClient.getInstances("order-service");
        
        if (instances.isEmpty()) {
            throw new RuntimeException("没有可用的order-service实例");
        }
        
        // 第二步:用负载均衡选一个
        ServiceInstance instance = loadBalancer.choose("order-service");
        
        // 第三步:发起调用
        String url = String.format("http://%s:%s/api/order",
            instance.getHost(), instance.getPort());
        
        return restTemplate.getForObject(url, String.class);
    }
}

小技巧:实际项目中,我一般会把实例列表缓存到本地内存,同时加一个后台线程定时刷新。这样即使注册中心挂了,客户端还能用缓存继续工作一段时间。

4.6 优缺点总结

优点 缺点
架构简单,没有中间代理 客户端逻辑变重,每个语言都要实现一套
延迟低,直连服务提供者 注册中心变更时,客户端感知有延迟
灵活,客户端可以自定义策略 服务实例数量多时,客户端内存占用大

避坑指南:我曾经遇到过一个线上事故——客户端缓存了过期的实例列表,结果一直往一台已经下线的机器发请求。从那以后,我强制要求所有客户端必须实现「被动失效+主动刷新」双重机制。说白了,就是既要监听变更通知,又要定期主动拉取,双保险。

4.7 什么时候选客户端模式?

我个人觉得,以下场景比较适合:

  • 团队能控制所有客户端代码(比如都是Java技术栈)
  • 对延迟要求极高,不想多一跳代理
  • 服务规模中等,几百个以内
  • 需要灵活的负载均衡策略

如果服务规模上千,或者客户端语言五花八门(Java、Go、Python都有),那可能服务端服务发现模式更适合你。这个我们下一章再聊。

嗯,客户端服务发现模式就讲到这里。核心就一句话:让客户端自己去找、自己去选、自己去调。简单、直接、高效,但也需要你多操点心。