一、为什么需要服务注册与发现?
在业务早期的快速迭代中,单体架构发挥了重要作用。 但随着用户的数量和流量的快速上涨,这个单体架构就遇到了成本、效率和稳定性的问题。为了解决这些问题,将单机替换为多机,替换为多机 服务间就需要服务间互相存有实例信息才能进行交互,这个统一管理的地方就是服务注册与发现。
1,成本问题
- 异构工作负载
- 不同的保障级别
首先是成本方面。我们在做所有的事情时都会考虑投入产出比(ROI),所以成本是我们必须考虑的一个问题。对于单体服务在服务器硬件方面的成本,我们需要特别注意异构工作负载和不同保障级别这两个方面的问题。
我们先来看异构工作负载方面的问题。单体服务会包含多种多样的功能模块,有一些是 IO 密集型的模块,比如主要对数据库进行 CRUD 的功能模块;另一些则是计算密集型的模块,比如图片、音频和视频转码相关的功能模块。如果能将 IO 密集型和 CPU 密集型的模块拆分成不同的服务,分开部署到更合适的硬件上,将可以节省大量的机器成本。比如 IO 密集型的模块,我们可以部署在 CPU 性能相对较低的机器上。
另一个问题是不同的保障级别。不同业务等级的保障级别也是不一样的:对于账号模块等核心模块,必须确保资源充足;但是对于非核心模块,保障的资源可以相对少一些。而对于一个单体服务来说,是没有办法对不同的模块实施不同的保障级别的。
2,效率方面
- 串行编译,测试和发布
- 单一的开发语言和生态
研发效率是我们能够高效、舒心工作的基本保障,所以必须要注意单体服务模式导致的串行的编译、测试和发布,以及研发团队只能选择单一的研发语言和生态(一般在进程内跨语言都会有限制)这两个限制。
串行的编译、测试和发布很好理解:多个研发团队会同时开发不同的功能,由于是单体服务,这些功能只能一起编译、测试和发布,非常浪费时间。如果还要进行灰度发布,那么效率将会更低。
另外还有单一的语言和生态限制。要知道,不同的业务需求可能会对应不同的编程语言和生态。如果是单体服务,则很难按业务需求来选择编程语言和相关的生态,这会大大影响研发效率。
3,稳定性方面
- 局部风险会放大到全局
- 业务迭代周期差异大
单体服务带来的稳定性问题,一来局部风险会放大到全局,因为整个单体服务会包括非常多的功能,一个局部非核心功能的崩溃、死锁等各种异常情况,都会影响所有的业务。这样的风险非常大,而且我们没有办法将故障隔离开。
二来业务迭代周期差异大,一般来说,越底层核心的功能,需求就越稳定,因为它的迭代周期会比较长,比如 4 周迭代一次;而越上层的业务功能,需求变更就越频繁,因为它的迭代周期会比较短,比如 1 周迭代一次。由于单体服务不能分开发布,所以在业务功能迭代的时候,底层核心功能也必须频繁地发布,这对于稳定性来说是一个考验。
基于以上原因,我们决定按资源和业务等维度对单体服务进行拆分。
这个时候,我们会遇到一个新的问题:之前所有的功能都在一个服务里面,不同模块和功能之间直接通过本地函数进行调用,拆分为多个服务后,怎么调用其他服务的函数呢?
你肯定能很快想到,通过 REST API 或者 RPC 来进行跨服务的调用。的确,这是个非常好的办法,但是通过 REST API 或者 RPC 都需要知道被调用服务的 IP 和 Port。所以,我们还需要解决一个问题:如果服务 A 需要调用服务 B,那么服务 A 怎么获取被调用服务 B 的 IP 和 Port 呢?这个其实就是服务注册发现的业务场景。
我们先一起来讨论一下可以尝试哪些可行的方式。
首先,最容易想到的方式是配置 IP 和 Port 列表,即直接在服务 A 的配置文件中配置服务 B 的 IP 和 Port,如果服务 B 有多个实例,那么就配置一个列表。
这样的确解决了问题,但是如果服务 C、D、E 等非常多的服务,都需要调用服务 B,那么这些服务都需要维护服务 B 的 IP 和 Port 列表。每一次当服务 B 增加、删除一个实例,或者一个实例的 IP 和 Port 发生改变时,所有调用服务 B 的服务都需要更新配置,这是一个非常繁杂并且容易出错的工作,那么怎么避免这个问题呢?
其实,我们可以将配置 IP 和 Port 列表的方式修改为配置域名和 Port,即在服务 A 的配置文件中不再配置服务 B 的 IP 和 Port 列表,而是配置服务 B 的域名和 Port。这样可以通过域名解析获得所有服务 B 的 IP 列表,让所有的服务 B 都监听同一个 Port。
当服务 B 的实例有变更,不论有多少个服务调用服务 B,只需要修改服务 B 的域名解析就行了,这样就解决了配置分散到各个调用服务,导致配置一致性的问题。
但是如果服务 B 的某个实例出现了崩溃、网络不通等情况时,服务 A 在对服务 B 的域名做 DNS 解析时,会因为我们不能实时感知服务实例的状态变更,依然获得该实例的 IP,从而导致访问错误。
这里我们举一个租房中介的例子来说明一下。假设每一个要租 A 小区房子的人,都需要亲自去 A 小区获得租房的信息,同样,如果还想租 B 小区的房子,也需要亲自去 B 小区获得租房的信息,这是一个非常麻烦的事情。而更麻烦的是,一个小区的租房信息有变化了,之前获得信息的人都不会立刻知道,非常影响我们的租房效率和成功率。
这个时候,租房中介出现了,他每天去各个小区收集租房信息,我们需要租房的时候,直接联系中介就可以获得相关小区的租房信息,并且,中介会记录谁关心哪一个小区的租房信息。如果一个小区的租房信息有变化,中介会主动通知给关心这个小区的人,这样就让租房这件事情变得非常高效了。这里的租房中介,其实就是承担租房信息的注册和发现的功能。
所以,经过前面的讨论,我们可以得出服务注册发现需要解决的两个关键问题:
- 统一的中介存储:调用方在唯一的地方获得被调用服务的所有实例的信息。
- 状态更新与通知:服务实例的信息能够及时更新并且通知到服务调用方。
怎么实现服务注册与发现。
1,选择合适的中介存储、
存储需要有以下几个特点:
- 可用性要求非常高:因为服务注册发现是整个分布式系统的基石,如果它出现问题,整个分布式系统将不可用。
- 性能要求中等:只要设计得当,整体的性能要求还是可控的,不过需要注意的是性能要求会随分布式系统的实例数量变多而提高。
- 数据容量要求低:因为主要是存储实例的 IP 和 Port 等元数据,单个实例存储的数据量非常小。
- API 友好程度:是否能很好支持服务注册发现场景的“发布/订阅”模式,将被调用服务实例的 IP 和 Port 信息同步给调用方。
通过上面的分析,我们可以看到,这些存储系统几乎都能用来作为服务发现的中介存储系统,但是基于整体考虑,MySQL 和 Redis 在高可用性和 API 友好程度上不满足要求,所以更合适的存储系统为 etcd、ZooKeeper 和Eureka。如果你希望在系统出现网络分区的时候,调用方一定不能获取过期的被调用服务实例信息,那么就选择 etcd 和 ZooKeeper,但是在被分区的部分网络中,可能出现因为不能获取被调用服务实例信息,而导致请求失败的情况。
如果你认为获取过期的实例信息,可能比完全不能获取被调用服务的实例信息要好,那么就选择 Eureka。毕竟大部分情况下,信息并没有过期,因为被调用服务的实例配置还没有发生变更,并且就算获得的信息过期了,也只是导致一次请求失败。
2,怎么做服务的状态更新与通知
首先是服务的状态更新,即服务注册:服务的每一个实例每隔一段时间,比如 30 秒,主动向中介存储上报一次自己的 IP 和 Port 信息,同时告诉中介存储这一信息的有效期,比如 90 秒。这样如果实例一直存活,那么每隔 30 秒,它都会将自己的状态信息更新到中介存储。如果实例崩溃或者被 Kill 了,那么 90 秒后,中介存储就会自动将该实例的信息清除,避免了实例信息的不一致。所以这里的数据同步是最终一致性的。
然后是服务的状态通知,即**服务发现:**服务的调用方通过中介存储监听被调用服务的状态变更信息。这里可以采用“发布/订阅”模式,也可以采用轮询模式,比如每30秒去中介存储获取一次。所以这里的数据同步也是最终一致性的。
选择 AP 还是 CP
根据上面的讨论,从服务注册发现的场景来说,我认为Eureka 之类的 AP 系统更符合要求。因为服务发现是整个分布式系统的基石,所以可用性是最关键的设计目标。并且上面介绍的服务,在同步自己的状态到中介存储,以及调用方通过中介存储区获得服务的状态,这两个过程中的数据同步都是最终一致性的。既然服务注册发现系统整体是一个 AP 系统,那么将中介存储设计为 CP 系统,去放弃部分的可用性是不值得的。
到这里,服务注册发现的基本原理就介绍完了。当我们去研究各种各样服务发现的实现方式时,就会发现其实它们都是在解决“如何选择适合的中介存储”和“怎么做服务状态的更新与通知”的问题。当然由于服务发现是非常基础和重要的功能,所以其中的各种实现都是在高性能、高可用性的基础上解决上面的两个问题,做着各自的优化与权衡。