一、前言
上一篇我们介绍了 RPC 通讯协议,它是实现 RPC 的第一步,接下来我们要讨论一下 RPC 的服务发现(Service Discovery)
二、什么是服务发现?
概念上讲,服务发现就是通过服务唯一标识来获取服务地址的过程,它在 RPC 里扮演了重要角色。下面我用一个点外卖的例子来通俗解释服务发现到底做些什么?它为什么重要?
假设我是一家外卖店的老板,我要考虑的一个问题是:如何让客户能够找到我的店,并且点我的外卖呢?最先想到的是发小广告,客户通过广告里的订餐热线就可以找到我们,这个过程其实就是最简单的服务发现。
这个方案是有效的,但是营运了一段时间后,我发现一些问题:
小广告的传播力有限,投放的精准度也不够,很多人可能随手扔进垃圾桶
客户可能因为丢失卡片或忘记号码而无法下单
一旦留的电话停机了,整个服务不可用
缺货、或者停业还是会接到客户电话
我的生意越来越好,很快开了分店,但是老客户并不知道新店的热线
后面,我听说有一个叫饿了么的点餐平台,抱着试一试态度在上面注册了我的店。没想到这个平台给我带来了大量的订单,而我不再需要到处发小广告,只需要专心做好饭菜、提高服务质量、维护良好的口碑就可以得到稳定的客源。其次,我也不用担心电话停机、缺货、停业、开新店等服务变更带来的麻烦,我只需要在平台上修改服务信息即可。对于消费者来说他们也不需要收集一大堆外卖卡片,只要安装一个 app,就可以找到丰富的美食、并且可以根据评分选择更加优质的服务。这已经是相当高级的服务发现实现,可以看出无论对于提供者还是消费者,服务发现都是至关重要的。
三、服务发现的分类
硬负载
硬负载顾名思义是依靠硬件设备做负载,在调用链路上加一个独立部署的硬件设备(一般就是我们所熟知的F5/LVS/HAproxy集群),通过它们对后端的服务进行发现,对流量进行负载均衡。
+------------+
+----------+ invoke +---------------+ | Services |-+
| Consumer | --------> | Load Balancer | -----> | Providers | |-+
+----------+ +---------------+ +------------+ | |
|-------------+ |
+-------------+
优点
- 存在一个统一的流量集中化节点,可以实现一些全局性的掌控,比如路由、鉴权、安全防控等等
缺点
硬负载设备的成本高,不易维护
在调用主链路上有一定性能损耗
硬负载设备需要实现集群化部署的模式以解决单点故障的问题
软负载
同理,软负载是依靠软件方式进行服务发现和负载均衡,这种方式具有以下特点:
没有了中心化的硬负载设备,把 LB 的功能以 SDK 的模式集成到服务消费方进程里
引入了注册中心(Servcie Registry),用来动态管理所有的服务地址
注册中心不在调用的主链路上,它在旁路
+------------------+
| Service Registry |
+------------------+
/ ^
/ \
Discover Register & Keep Alive
/ \
/ \
v \
+----------+ +----------+
| Consumer | ---- Load Balance & Invoke --> | Provider |
+----------+ +----------+
优点
Consumer 直接调用 Provider,不再有中间节点
不需独立的负载均衡设备,也就不存在成本和运维的问题
缺点
对 Consumer 端有侵入性,存在接入成本
去中心化,所以弱管控
虽然注册中心在旁路,但也是一个关键的基础设施,需要确保高可用
业界常见的服务发现解决方案
硬负载
阿里云的 SLB
AWS 的 ELB
软负载
Eureka
zookeeper/etcd/consul
阿里和蚂蚁的 ConfigServer
这些方案都各有场景,但在 RPC 里我们通常采用软负载来做服务发现
四、Node.js 如何做服务发现?
接口抽象
这里主要讨论 Node.js 接入软负载的一些经验和套路。在典型的软负载模式下包含三个角色:
服务提供者(Service Provider)
服务消费者(Service Consumer)
服务注册中心(Service Registry)
Node.js 主要承担前两种角色,所以我们要做的是开发服务注册中心的客户端 SDK。虽然注册中心有多种实现,但我们可以将其接口抽象为:
服务注册
服务注销
服务订阅
服务去订阅
健康检查(可选)
服务治理相关查询(可选)
由此我们可以创建一个 RegistryBase 基类,它的 API 定义如下:
interface RegistryBase {
async register(config: any): void;
async unRegister(config: any): void;
subscribe(config: any, listener: function): void;
unSubscribe(config: any, listener: function): void;
async close(): void;
}
针对不同的服务端,会有其对应的实现,比如:ZookeeperRegistry、EurekaRegistry 等等。实际例子可以参考 ZookeeperRegistry 的实现
服务发现自己的服务发现
调用注册中心接口本身也需要有一个服务发现的过程,这里感觉有点鸡蛋问题。一般来说这个服务发现我们需要依赖一个更加基础的地址服务(比如:DNS),然后通过轮训或其他策略来更新注册中心的地址列表,最后从中选择一台发起请求,完整的时序图如下:
+--------+ +-----------+ +--------------+
| Client | | DNS | | Registry |
+--------+ +-----------+ +--------------+
| | |
| -- 1. 查询注册中心地址 --> | |
| <--- 返回注册中心地址 ---- | |
| | |
| |
| ---------------- 2. 注册消费者 / 发布者 ----------------> |
| <-------------------- 注册结果反馈 --------------------- |
| |
| |
| ------------------ 3. 订阅服务发布者 ------------------> |
| <-------------------- 订阅结果反馈 --------------------- |
| |
| |
| <----------------- 4. 推送服务地址 --------------------- |
| ----------------------- 反馈收到 ---------------------- |
| |
关于健康检查
服务注册中心不同于一般的动态配置系统,因为服务是有状态的(至少包含可用和不可用两种状态)。在服务发布成功以后,还需要持续通过健康检查来确保服务是可用的。
健康检查的方式一般分两种:
1、通过心跳
服务提供方和注册中心通过定时发送心跳包来维护一个长连接,只要长连接不断,就代表服务可用。
优点
对业务透明,实现也比较简单
可以确保至少网络连接是通的
缺点
粒度较粗,无法检查实际业务是否健康
对于注册中心来说需要维护大量长连接
Zookeeper, 阿里的 ConfigServer 都采用这种方式来做健康检查
2、暴露接口用于定时检查
服务提供方单独暴露一个接口给注册中心来轮训,根据接口的返回状态来判断服务是否可用
优点
业务可以自定健康标准,做更精确的健康检查
不用维护长连接
缺点
- 对业务有一定侵入
K8s 里的 Health Checks 就是这种方式
五、未完待续
服务发现和负载均衡的关系
最后提到负载均衡这个概念是为后面文章做铺垫,因为它很容易和服务发现混淆,在很多简单的场景我们甚至也不去刻意区分它们。但本质上它们的层次和解决的问题是不一样的,简单说服务发现是负载均衡的前提,负载均衡要解决的是拿到服务列表后将流量合理的分配到各个节点上的问题。