微服务起步
微服务起步
采用服务来构建程序,获得的收益是软件系统“整体”与“部分”在物理层面的真正隔离,这对构筑可靠的大型软件系统来说无比珍贵,但另一面,微服务架构在复杂性与执行性能方面做出了极大的让步。在一套由多个微服务相互调用才能正常运作的分布式系统中,每个节点都互相扮演着服务的生产者与消费者的多重角色,形成了一套复杂的网状调用关系,此时,至少有以下三个问题是必须考虑的:
-
对消费者来说,外部的服务由谁提供?具体在什么网络位置?
-
对生产者来说,内部哪些服务需要暴露?哪些应当隐藏?应当以何种形式暴露服务?以什么规则在集群中分配请求?
-
对调用过程来说,如何保证每个远程服务都接收到相对平均的流量,获得尽可能高的服务质量与可靠性?
这三个问题的解决方案,在微服务架构中通常被称为“服务发现”,“服务的网关路由“和“服务的负载均衡”
1. 服务发现
微服务中,如何确定目标方法的确切位置,解决该问题的过程被称作“服务发现”。
1.1 服务发现的意义
所有的远程服务调用都是使用全限定名、端口号与服务标识所构成的三元组来确定一个远程服务的精确坐标的。
全限定名代表了网络中某台主机的精确位置,端口号代表了主机上某一个提供了TCP/UDP网络服务的程序,服务标识则代表了该程序所提供的某个具体的方法入口。其中“全限定名、端口号”的含义对所有的远程服务来说都是一致的,而“服务标识”则与具体的应用层协议相关,不同协议具有不同形式的标识。
远程服务标识的多样性,决定了“服务发现”也可以有两种不同的理解,一种是以UDDI为代表的“百科全书式”的服务发现,上至提供服务的企业信息(企业实体、联系地址、分类目录等)下至服务的程序接口细节(方法名称、参数、返回值、技术规范等)都在服务发现的管辖范围之内;另一种是类似于DNS这样“门牌号码式”的服务发现,只满足从某个代表服务提供者的全限定名到服务实际主机IP地址的翻译转换,并不关心服务具体是哪个厂家提供的,也不关心服务有几个方法,各自由什么参数构成,它默认这些细节信息是服务消费者本身已完全了解的,此时服务坐标就可以退化为更简单的“全限定名+端口号”。
当今,后种服务发现占主流地位,本文主要介绍它。随着微服务的逐渐流行、服务的非正常宕机、重启和正常的上线、下线变得越发频繁,仅靠DNS服务器和负载均衡器等基础设施逐渐疲于应对,无法跟上服务变动的步伐了。人们最初是尝试使用ZooKeeper这样的分布式K/V框架,但ZooKeeper毕竟是很底层的分布式工具,还需要用户自己做相当多的工作才能满足服务发现的需求。到了2014年,专门用于服务发现的Eureka宣布开源。到2018年,Spring Cloud Eureka 进人维护模式,Consul和Nacos 很快就从Eureka 手上接过传承的衣钵。
到这个阶段,服务发现框架已经发展得相当成熟,考虑到了几乎方方面面的问题,仅支持通过DNS或者HTTP请求进行符号与实际地址的转换,支持各种各样的服务健康检查方式,还支持集中配置、K/V存储、跨数据中心的数据交换等多种功能,可算是应用自身去解决服务发现的一个顶峰。如今,云原生时代来临,基础设施的灵活性得到大幅度强,最初的使用基础设施来透明化地做服务发现的方式又重新被人们所重视,如何在基础设施和网络协议层面,对应用尽可能无感知、方便地实现服务发现是目前服务发现的主要发展方向。
1.2 可用与可靠
这里要讨论的第一问题是“服务发现”具体是指进行过什么操作?这其实包含三个必需的过程。
- 服务的注册:当服务启动的时候,它应该通过某些形式(如用 API、产生事件消息、在 ZooKeeper/etcd 的指定位置记录、存人数据库,等等)将自己的坐标信息通知到服务注册中心,这个过程可能由应用程序本身来完成,称自注册模式,譬如 Spring Cloud 的 @EnableEurekaClient 注解;也可能由容器编排框架或第三方注册工具来完成,称为第三方注册模式,譬如Kubernetes和Registrator。
- 服务的维护:尽管服务发现框架通常都有提供下线机制,但并没有什么办法保证每次服务都能优雅地下线而不是由于宕机、断网等原因突然失联。所以服务发现框架必须自己保证所维护的服务列表的正确性,以避免告知消费者服务的坐标后,得到的服务却不能使用的尬情况。现在的服务发现框架,往往都能支持多种协议(HTTP、TCP等)、多种方式(长连接心跳、探针、进程状态等)去监控服务是否健康存活,将不健康的服务自动从服注册表中剔除。
- 服务的发现:这里的发现是特指狭义上消费者从服务发现框架中,把一个符号(譬如Eureka中的ServiceID、Nacos中的服务名,或者通用FQDN)转换为服务实际坐标的过程,这个过程现在一般是通过HTTPAPI请求或DNS Lookup操作来完成,也有一些相对少用的方式,譬如Kubernetes也支持注人环境变量来做服务发现。
现以服务发现为样本,展示分布式环境里可用性与一致性的矛盾。
服务发现既要高可用,也要高可靠是由它在整个系统中所处的位置所决定的。服务提供者在服务注册中心中注册、续约和下线自己的真实坐标,服务消费者根据某种符号从服务注册中心获取到真实坐标,无论是服务注册中心、服务提供者还是服务消费者,它们都是系统服务中的一员,相互间的关系应是对等的。
但在真实的系统里,注册中心的地位是特殊的,不能完全视其为一个普通的服务。注册中心不依赖其他服务,但被所有其他服务共同依赖,是系统中最基础的服务,几乎没有可能在业务层面进行容错。这意味着服务注册中心一旦崩溃整个系统都不再可用,因此,必须尽最大努力保证服务发现的可用性。实际用于生产的分布式系统,服务注册中心都是以集群的方式进行部署的,通常使用三个或者五个节点,最多七个来保证高可用,如图所示。
Eureka的选择是优先保证高可用性,相对牺牲系统中服务状态的一致性。Eureka的每个节点间采用异步复制来交换服务注册信息,当有新服务注册进来时,并不需要等待信息在其他节点复制完成,而是马上在该服务发现节点宣告服务可见,只是不保证在其他节点上多长时间后才会可见。同时,当有旧的服务发生变动,如下线或者断网,只会由超时机制来控制何时从哪一个服务注册表中移除,变动信息不会实时同步给所有服务端与客户端。这样的设计使得不论是Eureka的服务端还是客户端,都能够持有自己的服务注册表缓存,并以TTL机制来进行更新,哪怕服务注册中心完全崩溃,客户端仍然可以维持最低限度的可用。
Eureka的服务发现模型适合于节点关系相对固定、服务一般不会频繁上下线的系统,以较小的同步代价换取了最高的可用性。
Consul的选择是优先保证高可靠性,相对牺牲系统服务发现的可用性。Consul采用Raft算法,要求多数节点写人成功后服务的注册或变动才算完成,严格地保证了在集群外部读取到的服务发现结果的一致性;同时采用Gossip协议,支持多数据中心之间更大规模的服务同步。
1.3 注册中心的实现
现在有那么多服务发现框架,哪一款最好?或者说应该加何挑选适合的服务发现框架?当下,直接以服务发现、服务注册中心为目标的组件库,或者间接用来实这个目标的工具主要有以下三类:
- 在分布式K/V存储框架上自己开发的服务发现,典型代表是ZooKeeper、etcd。这些K/V框架提供了分布式环境下读写操作的共识算法。etcd采用的是Raft自算法,ZooKeeper采用的是ZAB算法,它们都是CP的。这些K/V框架的一个共同特点是在整体较高复杂度的架构和算法的外部,维持着为简单的应用接口,只有基本的CRUD和Watch等少量API,所以要在上面完成功能齐全的服务发现,很多基础的能力,譬如服务如何注册、如何做健康检查等都必须自己去实现。
- 以基础设施(主要是指DNS服务器)来实现服务发现,典型代表是SkyDNS,CoreDNS。在Kubernetes1.3之前的版本使用SkyDNS作为默认的DNS服务,其工作原理是从API Server中监听集群服务的变化,然后根据服务生成NS、SRV等DNS记录存放到etcd中,kubelet会设置每个Pod的DNS服务的地址为SkyDNS的地址,需要调用服务时,只需查询DNS把域名转换成PP列表便可实现分布式的服务发现。在Kubernetes1.3之后,SkyDNS不再是默认的DNS服务器,而是由只将DNS记录存储在内存中的KubeDNS代替,到了1.11版,就更推荐采用扩展性很强的CoreDNS。此时可以通过各种插件来决定是否要采用etcd存储、重定向、定制DNS 记录,记录日志,等等。
采用这种方案,是CP还是AP就取决于后端采用何种存储,如果是基于etcd实现,那自然是CP的,如果是基于内存异步复制的方案实现,那就是AP的(仅对DNS服务器本身,不考虑本地DNS缓存的TL刷新)。
以基础设施来做服务发现的好处是对应用透明,任何语言,框架、工具都肯定支持HTTP,DNS,所以完全不受程序技术选型的约束,但坏处是透明的并不一定是简单的,你必须自己考虑如去做客户端负载均衡、如何调用远程方法等这些问题,而且必须遵循或者说受限于这些某础设施本身所采用的实现机制,譬如服务健康检查里,服务的缓存期限就应该由TTL决定、这是DNS协议所规定的,如果想改用KeepAlive长连接来实时判断服务是否存活就相对麻烦。 - 专门用于服务发现的框架和工具,典型代表是Eureka、Consul和Nacos。在这一框架中,你可以自己决定是CP还是AP,譬如CP的Consul、AP的Eureka,还有同时支持CP和AP的Nacos(二取其一)。它们依然是可以被应用程序感知的,所以或多或少还需要考虑程序语言、技术框架的集成问题。
2. 网关路由
网关在计算机网络中很常见,用于表示位于内部区域边缘,与外界进行交互的某个物理或逻辑设备。
2.1 网关的职责
微服务中网关的首要职责就是作为统一的出口对外提供服务,将外部访问网关地址的流量,根据适当的规则路由到内部集群中正确的服务节点之上。微服务中的网关首先应该是个路由器,在满足此前提的基础上,还可以根据需要作为流量过滤器来使用,以提供某些额外的可选职能,臂如安全、认证、授权、限流、监控、缓存,等等。
对于路由这项工作,负载均器与服务网关在实现上是没有什么差别的,很多服务网关本身就是基于老牌的负载均衡器来实现的,譬如基于 Nginx、HAProxy开发的Ingress Controller,基于Netty开发的Zuul 2.0等;但从目的角度来看,负载均衡器与服务网关会有一些区别,具体在于前者是为了根据均衡算法对流量进行平均地路由,后者是为了根据流量中的某种特征进行正确地路由。
对于它的性能与可用性。由于网关是所有服务对外的总出口,是流量必经之地、所以网关的路由性能将导致全局的、系统性的形响,如果经过网关会有1毫秒的性能损失、就意味着整个系统所有服务的响应延迟都会增加1毫秒。网关的性能与它的工作模式和自身实现算法都有关系,但毫无疑问工作模式是最关键的因素。如果能够采用DSR三角传输模式,在实现原理上就决定了性能一定会比代理模式强。不过,因为今天REST和JSON-RPC等基于HTTP协议的服务接口在对外部提供的服务中占绝对主流的地位、所以我们所讨论的服务网关默认都必须支持七层路由,通常默认无法直接进行流量转发,只能采用代理模式。在这个前提约束下,网关的性能主要取决于它如何代理网络请求,也即它们的网络IO模型。
网络I/O 模型:
在套接字接口抽象下,网络IO的出入口就是Socket的读和写,Socket在操作系口中被抽象为数据流,而网络IO可以理解为对流的操作。每一次网络访问,从远程返回的数据会先存放到操作系统内核的缓冲区中,然后从内核的缓冲区复制到应用程序地址空间,所以当发生一次网络请求时,将会按顺序经历“等待数据从远程主机到达缓冲区”和“将数据从缓冲区复制到应用程序地址空间”两个阶段,根据实现这两个阶段不同方法,人们把网络IO模型总结为两类、五种模型;两类是指同步IO与异步I0,五种是指在同步 IO中又划分出阻塞IO、非阻塞O、多路复用IO、信号驱动IO四种细分模型以及异步 IO模型。
这里先解释一下同步和异步、阻塞和非阻塞的概念。同步指调用端在发出请求之后,得到结果之前必须一直等待,与之相对的就是异步,发出请求之后将立即返回,不会马上得到处理结果,结果将通过状态变化和回调来通知调用者。阻塞和非阻塞是针对请求处理过程而言,指在收到调用请求之后,返回结果之前、当前线程是否会被挂起。
下面以“你如何领盒饭“为情景,将之类比解释。
- 异步IO:比如你在美团外卖订了个盒饭,付款之后你自己该干嘛干嘛,饭送到时骑手自然会打电话通知你。异步IO中数据到达缓冲区后、不需要由调用进程主动进行从缓冲区复制数据的操作,而是复制完成后由操作系统向线程发送信号,所以它一定是非阻塞的。
- 同步IO:比如你自己去饭堂打饭,这时可能有如下情形发生:
- 阻塞IO:你去饭堂打饭,发现饭还没做好、只能等待(线程休眠)直到饭做好,这就是被阻寒了。阻塞IO 是最直观的 IO模型,逻辑清晰、较节省CPU资源,但缺点是线程休眠所带来的上下文切换,这是一种需要切换到内核态的重负载操作,不应当频繁进行。
- 非阻塞IO:你去饭堂,发现饭还没做好,你就回去了,然后每隔3分钟来一次饭堂看饭是否做好,一直重复,直到饭做好。非阻塞IO够避免线程休眠,对于一些很快就能返回结果的请求,非阳塞IO可以节省上下文切换的消耗,但是对于较长时间才能返回的请求,非阻塞IO反而白白浪费了CPU资源,所以目前并不太常用。
- 多路复用 IO:多路复用 IO本质上是阻寒 IO 的一种,但它的好处是可以在同一条阻塞线程上处理多个不同端口的监听。仍以去食堂打饭为例,比如你代表整个宿舍去饭堂打饭,去到饭堂,发现饭还没做好,还是继续等待,其中某个舍友的饭好了,你就马上把那份饭送回去,然后继续等待其他舍友的饭做好。多路复用IO是目前高并发网络应用的主流。
- 信号驱动IO:你去到饭堂,发现饭还没做好,但你跟厨师很熟,跟他说饭做好了叫你,然后你就回去了,等收到厨师通知后,你再去饭堂把饭拿回宿舍。这里厨师的通知就是那个“信号”,信号驱动IO与异步IO的区别是“从缓冲区获取数据”这个步骤的处理,前者收到的通知是可以开始进行复制操作了,即要你自己从饭堂拿回宿舍,在复制完成之前线程处于阻塞状态,所以它仍属于同步IO操作,而后者收到的通知是复制操作已经完成,即外卖小哥已经把饭送到了。
网关还有最后一点必须关注的是它的可用性问题。任何系统的网络调用过程中都至少会有一个单点存在,这是由用户只通过唯一的地址去访问系统所决定的。对于更普遍的系统来说,作为后端对外服务代理人角色的网关经常被视为整个系统的人口,在很容易成为网络访问中的单点,这时候它的可用性就尤为重要。由于网关的地址具有唯一性,所以不能像之前服务发现那些注册中心那样,用集群的方式解决问题。在网关的可用性方面,我们应该考虑到以下几点:
- 网关应尽可能轻量,尽管网关作为服务集群统一的出人口,可以很方便地实现安全、认证、授权、限流、监控等功能,但给网关附加这些功能时还是要仔细权衡,取得功能性与可用性之间的平衡,过度增加网关的功能是危险的。
- 网关选型时,应该尽可能选择较成熟的产品实现,譬如Nginx Ingress Controller、KONG、Zuul这些经过长期考验的产品,而不能一味只考虑性能选择最新的产品,性能与可用性之间的平衡也需要权衡。
- 在需要高可用的生产环境中,应当考虑在网关之前部署负载均衡器或者等价路由器(ECMP),让那些更成熟健壮的设施(往往是硬件物理设备)去充当整个系统的入口地址,这样网关也可以进行扩展。
3. 客户端负载均衡器
对于任何一个大型系统,负载均衡器都是必不可少的设施。以前,负载均衡器大多只部署在整个服务集群的前端,负责将用户的请求分流到各个服务进行处理,这种经典的部署形式现在被称为集中式的负载均衡。
随着微服务目渐流行,服务集群收到的请求来源不再局限于外部,而是越来越多的来源于集群内部的某个服务,并由集群内部的另一个服务进行响应,对于这类流量的负载均衡,既有的方案依然是可行的,但针对内部流量的特点,直接在服务集群内部消化掉,肯定是更合理且更受开发者青睐的办法。由此一种全新的、独立位于每个服务前端的、分散式的负载均衡方式逐渐流行起来,即:客户端负载均衡器。那对于此前的集中式负载均衡器也有了一个方便与它对比的名字--“服务端负载均衡器”。
他们的关键区别在于:客户端负载均衡器是和服务实例一一对应的,而且与服务实例并存于同一个进程内。这个特点能为它带来很多好处:
- 负载均衡器与服务之间的信息交换是进程内的方法调用,不存在任何额外的网络开销。
- 不依赖集群边缘的设施,所有内部流量都仅在服务集群的内部循环。
- 分散式的负载均衡器意味着天然避免了集中式的单点问题,它的带宽资将不会像集中式负载均衡器那样敏感。
- 客户端负载均衡器更加灵活,能够针对每一个服务实例单独设置均衡策略等参数。例如访问哪个服务,是否需要具备亲和性,选择服务的策略是随机、轮询等等,都可以单独设置而不影响其他服务。
但是,客户端负载均衡器也不是“银弹”,它也存在不少缺点。
- 它与服务运行于同一个进程内,意味着它的选型受到服务所使用的编程语言的限制,譬如用Go开发的微服务就不太可能搭配Spring Cloud的负载均衡器来使用,而为每种语言都实现对应的能够支持复杂网络情况的负载均衡器是非常难的。客户端负载均衡器的这个缺陷有违于微服务中技术异构不应受到限制的原则。
- 从个体服务来看,由于是共用一个进程,负载均衡器的稳定性会直接影响整个服务进程的稳定性,消耗的CPU、内存等资源也同样影响到服务的可用资源。从集群整体来看,在服务数量达成千乃至上万规模时,客户端负载均衡器消耗的资源总量是相当多的。
- 由于请求的来源可能是集群中任意一个服务节点,而不再是统一来自集中式负载均衡器,使得内部网络安全和信任关系变得复杂,当攻破任何一个服务时,更容易通过该服务突破集群中的其他部分。
- 服务集群的拓扑关系是动态的,每一个客户端均衡器必须持续跟踪其他服务的健康状况,以实现上线新服务、下线旧服务、自动剔除失败服务、自动重连恢复服务等负载均衡器必须具备的功能。由于这些操作都需要通过访问服务注册中心来完成,数量庞大的客户端负载均衡器一直持续轮询服务注册中心,也会带来不小的负担。
3.1 代理负载均衡器
在Java领域,客户端均衡器中最具代表性的产品是Netflix Ribbon和Spring Cloud LoadBalancer,随着微服务的流行,它们在Java微服务中已积聚了相当可观的使用者。直到最近两三年,服务网格(Service Mesh)开始逐渐盛行,另外一种被称为“代理客户负载均衡器” 的客户端负载均衡器变体形式开始引起不同编程语言的微服务开发者的共同关注,它弥补了此前客户端负载均衡器的大多数缺陷。代理均衡器对此前的客户端负载均衡器的改进是将原本嵌入在服务进程中的负载均衡器提取出来,作为一个进程之外,同一Pod之内的特殊服务,放到边车代理中去实现。
虽然代理均衡器与服务实例不再是进程内通信,而是通过网络协议栈进行数据交换,数据要经过操作系统的协议栈,要进行打包拆包、计算校验和、维护序列号等网络数据的收发步骤,比之前的客户端均衡器确实多增加了一系列处理步骤。不过,Kubernetes严格保证了同一个Pod中的容器不会跨越不同的节点,这些容器共享同一个网络名称空间,因此代理均衡器与服务实例的交互,实质上是对本机回环设备的访问,仍然要比真正的网络交互高效且稳定得多。代理均衡器付出的代价较小,但从服务进程中分离出来所获得的收益却是非常显著的。
-
代理均衡器不再受编程语言的限制。开发一个支持Java、Go、Python 等所有微服务应用服务的通用的代理均衡器具有很高的性价比。集中不同编程语言的使用者的力量,更容易打造出能面对复杂网络情况的、高效健壮的负载均衡器。即使退一步说,独立于服务进程的均衡器也不会由于自身的稳定性影响到服务进程的稳定。
-
在服务拓扑感知方面,代理均衡器也更有优势。由于边车代理接受控制平面的统一管理,当服务节点拓扑关系发生变化时,控制平面就会主动向边车代理发送更新服务清单的控制指令,这避免了此前客户端负载均衡器必须长期主动轮询服务注册中心所造成的浪费。
-
在安全性、可观测性上,由于边车代理都是一致的实现,有利于在服务间建立双向mTLS通信,也有利于对整个调用链路给出更详细的统计信息。
总体而言,边车代理这种通过同一个Pod的独立容器实现的负载均衡器是目前处理服务集群内部流量最理想的方式,只是服务网格本身仍是初生事物、还不够成熟,对操作系统、网络和运维方面的知识要求也较高,但有理由相信随着时间的推移,未来这将会是微服务的主流通信方式。
参考自周志明老师的《凤凰架构》一书,有兴趣的小伙伴可以购买阅读,也可以访问官网 https://icyfenix.cn/ 阅读。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· AI 智能体引爆开源社区「GitHub 热点速览」
· 三行代码完成国际化适配,妙~啊~
· .NET Core 中如何实现缓存的预热?