4、客户端服务发现模式:原理与实现
好,咱们今天聊聊客户端服务发现。这个模式,说白了就是让调用方自己去问注册中心:「嘿,我要的服务在哪儿?」然后自己决定连哪台机器。
我刚开始做微服务那会儿,用的就是这种模式。当时团队不大,服务也就十几个,用起来挺顺手。后来规模大了,才慢慢发现它的优缺点。今天我把这些经验掰开揉碎了讲给你听。
4.1 什么是客户端服务发现?
先看一张逻辑图,你脑子里就有画面了:
┌─────────────┐ ┌──────────────┐
│ 服务消费者 │ │ 服务注册中心 │
│ (客户端) │ │ (例如Consul) │
└──────┬──────┘ └──────┬───────┘
│ │
│ 1. 启动时注册 │
│ ──────────────────>│
│ │
│ 2. 查询服务实例 │
│ ──────────────────>│
│ │
│ 3. 返回实例列表 │
│ <──────────────────│
│ │
│ 4. 直接调用 │
│ ──────────────────>│
│ 服务提供者 │
└─────────────────────┘
流程其实就四步:
- 服务提供者启动时,把自己的IP和端口告诉注册中心
- 服务消费者需要调用时,去注册中心拉取可用实例列表
- 注册中心返回一堆地址(比如
192.168.1.10:8080、192.168.1.11:8080) - 消费者自己选一个,直接发起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都有),那可能服务端服务发现模式更适合你。这个我们下一章再聊。
嗯,客户端服务发现模式就讲到这里。核心就一句话:让客户端自己去找、自己去选、自己去调。简单、直接、高效,但也需要你多操点心。