问题缘由
单机时代,传统软件大多是单体/巨石架构(Monolithic)。大家往一个代码仓库提交CODE,这会导致应用膨胀,难以理解和修改,以及扩展受限,无法按需伸缩等诸多问题。单体架构怎么解决多人合作的问题?模块化,对,按功能拆分,模块之间定义编程接口(API),彼此关心功能而不关心实现。
随着时代发展,单机程序遇到了计算力和存储的双重瓶颈,分布式架构应运而生。单体应用通过函数名(标识)便可轻松完成本地函数调用,在分布式系统中,服务(RPC/RESTful API)承担了类似的角色,但请求服务单靠服务名还不够,服务名只是服务能力(服务类型)的标识,还需要指示服务位于网络何处,而部署在云中的服务实例IP是动态分配的,扩缩容、失败和更新则让问题变得更加复杂,静态配置服务实例适应不了新变化,需要更精细化的服务治理能力,为了解决或者说简化这个问题,服务发现作为一种基础能力被抽象和提供,它试图让请求网络服务像调用本地函数一样简单透明。
服务即功能(函数)。只是服务跟网络紧密联系在一起,所有才会出现网络服务这个名词,服务提供者通过网络发布服务,服务使用者通过网络请求服务,分布式系统突破了单机算力和存储的限制,提升了系统稳定性,使得高并发高可用的海量服务成为可能,但这也增加了软件复杂度,引入软件分层、负载均衡、微服务、服务发现/治理、分布式一致性等新的问题和挑战。
服务发现
服务分服务提供者(Service Provider)和服务消费者(Service Consumer),如果要提供海量服务能力,单一的服务实例显然是不够的,如果要提供成千上万种服务,则需要有一个地方记录服务名到服务实例列表的映射,所以,有必要引入一个新的角色:服务中介,服务中介维护一个服务注册表(Service Registry),可以把注册表理解为服务字典,key是服务名,value是服务提供实例列表;服务注册表是联系服务提供者和服务消费者的桥梁,它维护服务提供者的最新网络位置等信息,也是服务发现最核心的部分。
服务启动的时候,把服务信息注册(put)到服务注册表;服务终止的时候,从服务注册表删除(remove)自身的服务信息。
服务消费者在请求服务的时候,先去服务注册表按名查询(get)服务提供者列表,然后从列表里挑选一个服务实例,向该实例请求服务。
大道至简,这便是最简单的服务发现模型,也是服务发现的基本原理,至此,似乎一切都OK,但其实尚有几个问题没有说清楚。
问题和解法
- 第一个问题,服务如果不是正常停止,而是被系统kill掉,它便没有机会通知服务注册表把自身服务信息删除,这样注册表便多了一条指向无效服务实例的信息,而服务消费者却并不知情,怎么办?解决的办法很简单:保活(keepalive),服务提供者定期(比如每隔10秒)给服务中介发送keepalive消息,服务中介收到keepalive消息后更新该服务实例的keepalive timestamp,服务中介定期检查该timestamp,如果超期便把该服务实例从注册表剔除。
- 第二个问题,服务实例列表变化如何通知服务消费者?不外乎两种方法,轮询和pub-sub。轮询是消费者主动询问服务中介服务列表是否变化,如果有变化,则把新的服务列表发送给消费者。如果消费者过多,则服务中介处理轮询的消息会有压力,在服务类别很多,服务列表很大的时候,它甚至会成为瓶颈。pub-sub是服务中介主动通知服务消费者,时效性相比轮询更好,缺点是会占用单独的线程或者连接资源。
- 第三个问题,服务中介如果挂了怎么办?所以我们要解决单点的问题,通常会用集群来对抗这种脆弱性,有很多用于做服务注册表的开源解决方案,比如etcd/zookeeper/consul,本质上使用分布式一致性数据库来保存注册表信息,它既解决读写性能问题又提高了系统稳定性可用性。
- 第四个问题,如果服务消费者每次使用远程服务都需要先查询服务中介获取实例列表,再请求服务,这样效率太低效?对服务中介的压力也不小?通常,客户端会缓存服务实例列表,这样对同名服务的多次请求,便不用重复查询,既减少了延迟又减轻了对服务中介的访问压力。
- 第五个问题,前述的keepalive有间隔,如果在这个间隔内服务实例不可用,那么服务消费者还是不能感知的,所以还是有可能把请求发送到一个无法提供服务的网络远端机器上去,这样自然是没法work。我们无法从根本上杜绝这种情况,系统需要容忍这种错误,但也可以做一些改进,比如向某实例请求服务失败后便拉黑,避免向同一无效服务实例多次派发请求。
- 第六个问题,服务消费者怎么从多个服务实例里选择一个?如何确保同一服务消费者的多次服务请求被分配到固定的服务实例(有时候需要这样)?这其实就是负载均衡的问题,有多种策略,比如rr、优先级、比如加权随机、一致性哈希。
服务发现模式
服务发现主要有两种模式:客户端发现模式(client-side discovery)和服务端发现模式(server-side discovery)。
客户端发现模式
客户端负责查询服务实例列表并决定向哪个实例请求服务,也就是负载均衡策略在客户端实现。该模式包括注册和发现两个部分。
服务实例调用服务中介的注册接口进行实例注册,服务实例通过keepalive做服务续期,服务中介通过健康检查剔除不可用的服务实例。
服务消费者请求服务的时候,先向服务注册表查询服务实例列表,注册表是一个服务数据库,为了提升性能和可靠性,客户端通常会缓存服务列表(缓存用来确保注册表挂了之后还能继续工作),拿到实例列表后客户端基于负载均衡策略挑选一个实例发送服务请求。
优点
- 直接,客户端可以灵活的执行负载均衡策略。
- 去中心化,非网关式,有效避开单点瓶颈和可靠性下降。
- 服务发现直接SDK集成进客户端,这种语言整合程度很好,程序执行性能也很好,排错方便。
缺点
- 客户端与服务注册表耦合,需要为服务客户端使用的每种语言每种框架开发服务发现逻辑。
- 这种侵入式的集成会导致任何服务发现的变化都需要客户端应用程序重新编译和部署,强绑定违背了独立性原则。
- 服务上下线会对调用方有影响,导致服务短暂不可用。
服务端发现模式
发现:服务消费者通过负载均衡器发送服务请求,负载均衡器会查询服务注册表,挑选一个服务实例,并将请求转发到服务实例。
注册:服务注册/注销可以跟上述客户端发现模式一致,也可以通过部署平台的内置服务注册和发现机制完成,即容器化部署平台(docker/k8s)能主动发现服务实例并帮助服务实例完成注册注销。
对比客户端发现模式,使用服务端发现模式的客户端本地不保存服务实例列表,客户端不做负载均衡,这个负载均衡器既承担了服务发现的角色,又承担了网关的角色,所以经常叫API网关服务器。
因为负载均衡器是中心式的,所以它也必须是一个集群,单个实例不足以支撑高并发访问,针对负载均衡器本身的服务发现和负载均衡通常借助DNS。
Http服务器,Nginx、Nginx Plus就是此类服务端发现模式的负载均衡器。
优点
- 服务发现对于服务消费者是透明的,服务消费者与注册表解耦,服务发现功能的更新对客户端无感知。
- 服务消费者只需要向负载均衡器发送请求,不需要为每种服务消费者的编程语言和框架,开发服务发现逻辑SDK。
缺点
- 由于所有请求都要经负载均衡器转发,所以负载均衡器有可能成为新的性能瓶颈。
- 负载均衡器(服务网关)是中心式的,而中心式的架构会有稳定性的隐忧。
- 因为负载均衡器转发请求,所以RT会比客户端直连模式高。
微服务和服务发现
Service Mesh服务网格是服务于微服务应用程序的可配置基础设施层,旨在处理服务之间的大量基于网络的进程间通信。
Service Mesh服务网关解耦调用和通信,在非mesh下,对于协议的感知和服务发现方法的感知需要应用去做,用mesh之后,就只管调用,mesh通过控制面来控制应用的数据流。
Mesh做服务发现其实是客户端发现模式的升级版,基于sidecar和pilot实现,Sidecars,即数据面板(Data Plane),负责发现目标服务实例地址列表并转发请求。Pilots,即控制面板(Control Plane),负责管理服务注册表的所有服务注册信息。
服务注册模式
一个选择是服务实例自注册,即self-registration模式。另一种选择是其它的系统组件来管理服务实例的注册,即third-party registration模式。
自注册模式如前面所述,它足够简单,不需要第三方组件,缺点是必须为服务中用到的每种编程语言与框架实现注册代码。
第三方注册服务实例不会自己完成注册注销,它由另一个叫做Service Registrar的系统组件负责,该组件会轮询部署环境或者跟踪订阅事件去感知服务实例的变化,帮助服务实例完成自动化注册注销。
Third-party registration模式主要的优势在于解耦了服务和服务注册表。不需要为每个语言和框架都实现服务注册逻辑。服务实例注册由一个专用的服务集中实现。缺点是除了被内置到部署环境中,它本身也是一个高可用的系统组件,需要被启动和管理。
其他
如果某个服务对于的服务实例特别多,比如在一些头部公司,一个服务名可能对应几千几万个服务实例,这样,服务变更的查询和对比会很慢,IO的量会大得超过想象,通常,会用version num去解决这个问题。