在早期的单点系统中,一个API只由一个服务器提供,客户端直接根据确定的IP访问这个服务。随着用户规模的增长,单凭一个服务器已不能支撑其庞大的流量,这就需要多台服务器组成一个集群,共同支持起一个API的流量。但是客户端并不知晓有哪些真实服务器可以提供所需要的API,也不知晓应该向哪个API提供者发起请求,这就需要一个组件来完成服务发现(发现有哪些服务提供者)和负载均衡(向哪个服务提供者发起请求)。

服务发现和负载均衡是服务端架构中非常基本的问题,涉及的领域比较广,本篇文章以我个人的见识介绍并分析这些问题,希望能抛砖引玉,以小见大,从而让读者对服务端架构设计有更深刻的理解。

负载均衡支持获取用户真实IP 负载均衡 服务发现_java

经典的服务端架构

开门见山,我们直接看看现在流行的nginx+tomcat服务端架构,然后围绕着nginx来说明如何实现服务发现与负载均衡。


负载均衡支持获取用户真实IP 负载均衡 服务发现_负载均衡支持获取用户真实IP_02

流程包括:

  1. 客户端(Client,可以是计算机、平板或手机等)发出来自外网的请求,经过DNS域名解析后,得到一个IP,通过IP请求API网关。
  2. nginx作为API网关(API Gateway),对请求进行限流、缓存后,将非静态数据的请求经由负载均衡器转发给特定的真实服务器,这个真实服务器只能通过内网访问,对于客户端是不可见的。
  3. tomcat作为web服务器,响应来自API网关的请求。
  4. redis和mysql作为分布式存储系统,mq作为分布式消息中间件,为业务服务器提供数据存储服务。

接下来会对这些流程进行更细致的讨论。

请求的网络结构

逻辑上的请求路径,并不表明物理网络上的数据传输路径也是如此。架构图并非网络结构图,不包含路由器、防火墙、交换机等设备,不能反映出一个请求在网络链路中的真实情况。大多数情况下,理解逻辑结构就能满足要求了,网络结构是运维人员应该思考的,但是程序员仍然应该清楚地知道诸如服务器之间并非直接物理相连而是需要经过交换机或路由器转发等问题,这些概念往往对软件设计也有启发作用。


负载均衡支持获取用户真实IP 负载均衡 服务发现_java_03

下图可以更直观地看出在局域网同一个广播域内的数据包传输线路:


负载均衡支持获取用户真实IP 负载均衡 服务发现_负载均衡_04

当我们在说服务器时,一般有两种含义,一个是指Linux操作系统中监听特定端口并提供服务的进程,另一个是指这个进程所处的物理机或虚拟机。没有规定API网关、业务服务器和数据库服务器不能部署在一个物理机上,一般在架构图中,用进程表示一个服务,至于怎么部署并没有做出约定,但在做高可用的规划中,需要避免一个集群完全部署到一台物理机中。不同语境下“服务器”所指的含义不同,在这个图中,表示不同的物理机。

如果是客户端直接访问业务服务器Server1,数据包路线为:路由器-交换机-server1-交换机-路由器,一共4跳。

如果是客户端通过nginx代理间接访问业务服务器Server1,数据包路线为:路由器-交换机-nginx-交换机-server1-交换机-nginx-交换机-路由器,一共8跳。

增加一层代理,网络传输线路足足增加了4跳,这还只是在同一广播域下,如果不在同一广播域,甚至不在同一机房,那么从nginx到server这一看上去直连的请求,其实可能经过交换机、防火墙、路由器等多个网络设备,无一不增加了流量负担和响应时延。在后面将要讨论的高可用问题时,还会提到,每增加一层代理就会导致自身成为瓶颈点。

像API网关这样的请求代理不是越多越好,服务端架构也不是层级越深越高端,不要随便添加代理。


负载均衡支持获取用户真实IP 负载均衡 服务发现_nginx_05

单体架构转微服务架构后,会大量增加服务之间的通信成本,这是微服务不可避免的弱点。我很奇怪,如果是不满足于单体架构的整体开发整体发布的弊端的话,拆解为粗粒度的模块便是,为什么要走向另一个极端微服务呢。

API网关是必需的

但是一个演进式规模化的架构,必须要有一个API网关来代理下层的访问,必要时甚至有多层网关的设计。

理由之一是保护API提供者免于暴露在外网之下。如果客户端能够直接访问API真实服务器,那就意味着服务器的IP不再透明,就算API设计得可以阻止流量攻击,但是服务器上的其他进程就没有这么幸运了。这也是基于最小知识原则,向用户暴露更小范围的服务,会让系统更安全。

理由之二是隔离内部组织结构的变化。API由一个宿主计算机移动到另一个宿主计算机时,IP会变化,这意味着其用户必须及时改变请求地址。这种情况下,如果用户仍然访问原IP,将导致请求失败,就算是更改DNS的域名IP映射,也要等待IP缓存过期后才能访问正确的服务。使用API网关可以隐藏真实服务器的组织结构细节(代理的下一层节点对它来说都可称之为真实服务器,或者是被代理服务器),就像桥接模式一样,能够在保持客户端请求逻辑不变的情况下,任意改变真实服务器的组织结构而不影响客户端。

理由之三是服务发现与智能负载均衡。一个满足高容量高吞吐的API都是多进程部署的,通过横向堆叠服务器,形成一个集群对外提供服务。客户端并不知晓每个服务的IP和端口,也不知晓每个服务的健康状态。API网关提供了这样的能力,它配置和管理API与其对应服务器的关联,并定期进行健康状态检查,如果发现某个服务宕机,将短暂地放弃这个服务。最重要的是,它可以根据每个服务的负载情况和响应时延,智能地选择一个服务来均衡负载。“智能”在于负载均衡策略可编程,以及能准确判断下游服务的真实负载。

理由之四是集中化控制。众所周知,一个API的请求过程需要夹杂各种非功能性的处理策略,比如身份认证、参数校验、协议转换、流量控制、日志记录、结果处理等等。如果这些逻辑都施加在真实服务器上,那么设计一个API的过程会变得很复杂,同样的事情要避免每个服务器都重做一遍。

以上的理由都是刚需,这也是为什么nginx这么火的原因了。

关于速度,也是在API网关选型中很重要的一点,但是我认为nginx是火在开源上,而不是速度上。

很多人认为nginx速度快是因为使用了I/O多路复用技术,这年头还有不使用这个技术的网络服务器吗?tomcat用了,zuul也用了。快是相对来说的,相对tomcat是很快,因为nginx虽然也是web服务器,但是不需要真的做业务,只把真正耗时的部分交给下游服务器完成,它自身当然快了。据测试,nginx比开启了多核cpu绑定预热后的zuul快不了多少。

nginx是用c语言实现的,是基于应用层的负载均衡,他的速度受到所在服务器性能的制约,要比工作在网络层的LVS慢一些,要比硬件负载均衡器f5慢很多。

API网关是个融合怪

增加一层代理会让请求响应变慢以及产生新的瓶颈,但是这层代理又不可或缺,为了对得起它的身份,只能让这层代理做越来越多的事情,不断增加新功能,最终变成融合怪。

下到日志记录,上到负载均衡,大事小事脏活累活,API网关全包了。


负载均衡支持获取用户真实IP 负载均衡 服务发现_负载均衡_06

这看上去违反单一职责原则,不过好在其内部实现还是职责分明的,一个过滤器负责一项任务。对于开源的API网关,还可以进行定制化开发,添加贴合业务的更具体的功能,反正它的功能已经很多了。

DNS,原始的负载均衡

域名系统(Domain Name System,DNS)是为Internet用户解决域名IP映射的系统。就像拜访朋友要先知道别人家怎么走一样,Internet上当一台主机要访问另外一台主机时,必须首先获知其地址,TCP/IP中的IP地址是由四段以“.”分开的数字组成(此处以IPv4的地址为例,IPv6的地址同理),记起来总是不如名字那么方便,所以,就采用了域名系统来管理名字和IP之间的对应关系。

DNS的技术是标准的,几乎所有的技术栈都支持它,并且几乎不会出现错误。DNS由不同层次的节点服务器组成,并且每一个服务器都进行力所能及的缓存,这有点像去中心化的区块链技术。

书本上是这样教的,但是只知道这些是不够的,其实DNS还是一个客户端侧的负载均衡系统。

大多数域名注册商都支持对同一主机添加多条A记录,也就是说DNS可以将一个域名映射到多个IP。客户端请求域名解析时,DNS返回一个随机顺序的IP列表,客户端把列表的第一个IP作为请求IP去访问主机,这就完成了简单的负载均衡。

负载均衡支持获取用户真实IP 负载均衡 服务发现_负载均衡支持获取用户真实IP_07

但是DNS的服务发现和负载均衡机制是很差的。

域名的DNS条目有一个 TTL,表示该条目在这个时间内是有效的。当我们在DNS服务器上更新域名所指向的IP时,客户端或其他DNS节点服务器由于缓存了这个条目所以并不会得到即时更新,我们不得不假定客户端至少在TTL所指示的时间内持有旧的IP。这会导致很多种糟糕的情况,比如源主机的IP改变或者出现宕机而不可用时,客户端依然会访问过时的主机。

另一方面DNS负载均衡采用的是简单的轮询算法,不能根据地域返回离用户较近的IP,不能反映服务器当前的运行状态,不能为性能较好的服务器多分配请求,甚至会出现用户请求集中访问某一台服务器上的情况。

如果你只有单个主机,那么DNS直接引用这个主机就可以了,否则更好的做法是,将DNS解析后的IP指向一个负载均衡器,由这个负载均衡器再做路由分发。

硬编码是最基本的服务发现

你可能想知道,DNS提供了服务发现,前提是需要知道DNS服务器的地址,而客户端又是怎么知道DNS服务器的地址呢?这个问题换一种说法是,客户端如何发现提供了服务发现功能的服务器。

问题的答案来自于我们的原始习惯:写死,也就是将服务器地址硬编码到程序中。


负载均衡支持获取用户真实IP 负载均衡 服务发现_服务器_08

首先集群服务先部署上去,并明确知道所有进程的IP和端口,然后在程序中写死这些IP和端口,比如在xml/yml/properties这些配置文件中写明:“xx注册中心,集群地址列表为:xxx”。一般列表中第一个IP作为首选IP,其他的IP作为备选IP,当客户端要访问服务时,先使用首选IP服务器获取数据,如果能正常访问,下次继续用,否则将采用备选IP,直到用尽所有的备选IP,最终返回失败。由于集群中的不同服务是分开部署的,同时宕机的可能性几乎为零,这种策略可以满足高可用的要求。

为什么业界选择的硬编码服务发现方案都是不断请求首选服务器直到不可用才切换下一个,而不是循环选择。我能想到的原因是,硬编码方案主要适用于集中式服务器的发现,这些服务器所提供的服务是可异步且轻量级的,不需要负载均衡,反而请求相同的服务器有利于TCP连接复用,在逻辑简单的同时略微提升性能。

现在大多数集群式部署的中间件都采用动态的注册中心来发现服务。“动态”体现在不需要硬编码配置,而是Server provider(服务提供者)在启动时向registry(注册中心)注册自己的服务ip映射关系,在运行过程中间隔性维持心跳,在关闭时销毁映射关系。而至于注册中心的地址,当然是写死啦。动态也体现在服务检活上,注册中心给每个条目维持一个存活性状态记录,如果条目对应的服务器失联(可以是网络故障或者服务器宕机),则从候选服务中移除该条目,直到它恢复时再上线,这就保证了用户的请求始终可以得到正确的响应。虽然看上去灵活,但这些动态注册中心集群的背后,是需要客户端直接引用注册中心集群地址的。

RocketMq的consumer通过namesrv发现broker服务,而namesrv集群的地址还是会附加在客户端侧。

Eureka的eureka Client通过eureka server发现provider,而eureka server集群地址会固定在配置文件中:

eureka.client.service-url.defaultZone=http://1,http://2

nacos的nacos Client通过nacos server获取配置数据,而nacos server集群地址会固定在配置文件中:

<dubbo:registry protocol="nacos" address="192.168.0.1:8080,192.168.0.2:8080,192.168.0.3:8080"/>

负载均衡支持获取用户真实IP 负载均衡 服务发现_nginx_09

能不能彻底避免硬编码呢?我还没有找到这样的方法,就算再找一个注册中心,将这些集群地址注册进去,最终客户端还是要知道这个核心注册中心地址才行。问题的关键是,当我们需要避免硬编码时,究竟是要避免什么,是要避免配置频繁变动导致的服务频繁发布,如果这个配置不经常变动,或者变动的频率远远低于它自然升级的频率,是不是就不用在意那么多了。

集群与高可用

集群就是将相同的代码部署到多个进程中,这些进程集合在一起作为一个整体对外提供服务。根据这个定义,集群自然就离不开服务发现机制了,有的集群自带了服务发现能力,有的集群依赖于第三方的服务发现软件。

高可用(HA)指的是服务的有效可用时长(非故障时间)占总时长的比例高。网络是不可靠的,硬件也会出问题,故障是不可避免的,但是优秀的服务端架构设计应当能够尽可能地降低故障率,控制风险范围。

那么集群就意味着高可用吗?不一定,这要取决于所选择的负载均衡策略。有一种定向路由策略,它把特定的请求参数经过哈希换算,定向到特定的服务器节点上,由且仅由这台服务器负责处理该请求,这种策略并不能对高可用有帮助。比如在redis服务集群中,redis把存储空间划分为16384份,每个主节点保持一部分,当减少或增加机器时,它们会重新分配存储空间并保持均匀。当用户发起一个GET key请求,redis节点接收后计算key所在的存储空间(槽)的位置,如果就在本机上,直接查询对应的数据,否则返回一个moved重定向错误,告诉客户端正确的节点在哪,客户端再去目标节点获取key的数据。如果只有主节点没有从节点,一旦目标节点宕机,节点所负责的存储空间将失效,但其他节点并不负责这块的数据,也没有这块的数据,最终导致服务不可用。

这些表面上看上去是相同的进程,却做着不同的事情,负载不同区域数据的读写,是称不上高可用的(但是可以解决高并发以及单机的内存和磁盘瓶颈问题)。

所以,高可用的关键在于,集群中每个节点是否都是可替代的,也就是存在冗余。集群只有开启了主从复制,增加几倍的节点数量来当从节点,才存在冗余,才是高可用的。一个例外是当节点是无状态的,节点之间不需要数据的共享,那么也无需主从复制。比如业务服务器,很少见到业务服务器还开启主从复制的,因为它不核心的数据都是临时计算,核心的数据都交由下游存储系统,没什么可复制的,所以它的节点随时可以互相替代,这样的无状态集群天然是高可用的。

从节点(或者是备节点)如果仅仅作为风险预备方案就有点浪费了,它可以在双机双工和读写分离中发挥剩余价值。双机双工就是要求互为备份的两台服务器同时工作,以平衡负载,如果一方出现故障,则由正常的一方接管流量。存储系统的读写分离要求从节点在提供备份的同时,也提供读服务,但是这样做是有代价的,要么忍受每次写入数据时主节点强同步从节点的时延,要么忍受数据不一致的可能性。或者在客户端和服务器集群之间多增加一层中间件,根据同步完成度做动态路由。


负载均衡支持获取用户真实IP 负载均衡 服务发现_负载均衡支持获取用户真实IP_10

如果这层代理不能简单地移到客户端或服务器侧,读写分离可能并不值得。

nginx的高可用与负载均衡

nginx实现了对下游流量的代理和负载均衡,那么其本身又是如何实现负载均衡和高可用呢?有的说法是给nginx再加一层负载均衡代理LVS,但这只不过是把问题抛给了LVS,没回答根本问题。

nginx的高可用可以通过Keepalived高可用软件实现。Keepalived是寄生于nginx主机的一个监护进程(注意不是独立的一层),用来管理并监控Nginx和LVS等集群系统中各个服务节点的状态。Keepalived是通过VRRP协议(虚拟路由冗余协议)实现的解决高可用问题的软件,VRRP引入虚拟IP的概念,保证当个别节点宕机时,整个网络可以不间断地运行,解决静态路由的单点故障问题。Keepalived对节点宕机的判断条件是节点死亡,所以你应该保证nginx节点异常时即时地关闭,以保证被Keepalived察觉到。

也可以使用LVS再做一层负载均衡,因为nginx工作在OSI模型的第7层而LVS工作在第4层,抗负载能力比nginx强很多,对内存和CPU资源消耗比较低,也比它更稳定。这样实现之后,LVS代理了nginx集群的流量,保证了nginx的高可用和负载均衡。

当流量更多时,可以利用DNS将域名映射到多个数据中心的nginx或lvs集群的虚拟IP上。


负载均衡支持获取用户真实IP 负载均衡 服务发现_负载均衡支持获取用户真实IP_11

魔改的硬件

使用Keepalived实现高可用,这很好,但是终究是要经过交换机、路由器的,这些不都是单点吗?这就产生有趣的套娃问题,即解决下游节点高可用问题的节点如何实现自身的高可用。流量是一层一层向低层传播,不断接近真实的业务服务器,从理论上看,上层节点所接受的流量比下层节点的总和还要多(不考虑请求合并),但上层节点的同质数量却比下次节点少很多,比如你可能有10个业务服务器,但只有3个nginx负载均衡器,而交换机只有1个。程序员费尽心思给redis,给mysql,给nginx增加冗余,到最后核心交换机宕机,不是全玩完了?

首先高层节点的吞吐性能远比下层节点的吞吐性能高,因为它更接近底层操作系统甚至硬件,一些经典的算法如SSL加速在网络设备上可以直接做成芯片,速度非常快。

其次硬件设备与常规的计算机不同,它可以改造硬件设备组织结构来提高可用性。冗余是物理问题,就最应该用物理方法解决,硬件厂商可以在一个硬件设备上安装冗余电源、双风扇、冗余磁盘阵列等,这些都是普通计算机做不到的。普通计算机只能通过软件方法提升负载和高可用,而硬件可以改造物理结构来提升。

最后,硬件设备也是可以通过堆叠冗余,来实现高可用的,大型的互联网数据中心尤其如此。只是,其中的堆叠方法和奥秘,与我们熟知的软件方法有所不同,背后的原理和实践方法非常复杂,我是一头雾水。不过好在现在的物理网络架构以基础设施的方式向程序员提供,程序员不需要关注其内部细节,只要知道它是一个魔术般的架构即可。

比起担心基础设施的可用性问题,代码中的BUG对可用性威胁更大。


负载均衡支持获取用户真实IP 负载均衡 服务发现_java_12

分布式的方法论

分布式系统需要在一致性、可用性和分区容忍性之间权衡,CAP定理利用数学推导证明这三个最多只能保证其中两个。一致性(Consistency)是指访问多个节点时能获得相同的最新的值;可用性(Availability)表示请求始终能够获得响应;分区容忍性(Partition tolerance)是指当集群中某个节点失去联系时,整个集群依然可以继续提供服务。前两个好理解,分区容忍性好像说了等于白说,因为只有单进程服务才不会分区。

并不是绝对的AP优于CP或者CP优于AP,而是视情况而定。对于在线支付应用,考虑一下如果余额出现不一致,用户会同意吗?而对于商品推荐服务,推荐内容究竟是不是最新的好像并不重要,至少比查询不到内容好很多。

横向扩展优于纵向扩展。横向扩展是指通过增加集群中节点的数量来提升并发量,纵向扩展是指通过提高单个机器的性能来提升并发量。机器的性能是有极限的,且基础性能越高有效提升率越低,而通过增加机器数量却可以获得线性提升。我们通常根据一个架构是否支持线性扩展来判断架构设计的好坏。

本地计算优于远程计算。本地计算不需要跨网络,内存计算不需要跨磁盘,高速缓存命中不需要访问主存,寄存器取值不需要读缓存,总是越接近底层的计算速度越快,我们要让计算最大可能性地接近底层。

异步优于同步。通过一个独立的线程异步发起API调用,用户请求不需要这个调用完成就可以返回,甚至可能不关心这个操作成功与否。 在需要低延迟响应的时候,或者下游服务操作耗时很长的时候,可以改为异步调用的方式。

为什么nginx不使用动态的注册中心

复习一下动态注册中心的优点:自动注册,无需配置,服务检活。很多面向微服务的注册中心如nacos,eureka都是这样做的。nginx虽然可以服务检活,但是没有动态注册功能,只能在nginx.conf中配置upstream来指定有哪些服务提供者。nginx会定时监听配置文件的变化,不需要重新启动即可响应配置,但仍比不上动态注册中心的灵活度,为什么nginx不采用这样的设计呢?

因为nginx设计的核心方向是高性能的HTTP服务器和反向代理服务器,服务发现只是附带功能。反向代理强调对后端服务的零侵入性,隐藏和保护后端服务从而不被前端发现,后端服务不需要知道访问它的用户是真实用户还是一个负载均衡器。这意味着反向代理服务器可以很容易地引入到新的架构中,不是为了迎合微服务的概念而生的。

相反,动态的服务注册中心有很大的缺点,它需要客户端侧的配合。就像前文中反复强调的,客户端需要先知道注册中心的地址,才能注册自己以及服务发现,这个代价就是需要引入一定的客户端代码来完成这部分的逻辑,一些java的类库可以提供帮助,但如果是使用不同的语言,那么开发难度很大。更好的做法是将共同代码移开应用程序,但又不能移出应用程序所在的主机(否则会产生流量浪费与高可用瓶颈,你知道的),最终形成驻留在应用程序周围的代理容器,server mesh就是这样做的。

因此,并不是nginx不采用动态注册中心,而是像eureka这样的注册中心没办法做得像nginx那么好,它不是代理,只是一个远程组件,客户端必然包含这个远程组件的引用,这是免不了的。

代理也能控制反转

可以巧妙地设置代理之间的关联,达到控制反转的效果。这里的代理不仅限于编程上的代理模式。

典型的例子是GSLB(全局负载均衡)技术。目前即便是智能DNS也不能感知后端服务的真实负载情况,只是简单地根据心跳检活而已。只有后端自己才能知道自己的负载情况。GSLB技术的巧妙在于,它将DNS服务器的NS记录(域名解析重定向)定向到数据中心的硬件GSLB设备上,告诉客户端“你的域名解析任务现在交给它了”,而与后端服务器同在一个机房的GSLB设备要感知真实负载当然是小菜一碟了。客户端为了寻找服务器的IP地址,最终问到了服务器上,像极了控制反转。


负载均衡支持获取用户真实IP 负载均衡 服务发现_负载均衡_13

有些时候,我们是平台的使用者,却又可以使用SPI来决定平台策略。

有些时候,我们是软件的使用者,却又可以通过脚本来决定如何使用。

这些都归功于代理,或者称为引用,或者称为指针,或者是其他的名词,它让我们的世界更灵活,更松散耦合。

常见的负载均衡策略

有点跑题了,回到负载均衡的话题,我们看下常见的负载均衡策略。

轮询。循环遍历节点,一个简单且能满足大多数情况的算法。


负载均衡支持获取用户真实IP 负载均衡 服务发现_负载均衡支持获取用户真实IP_14

加权轮询。给每个节点安排一个权重,按照权重循环遍历节点,权重越大,分配的次数越频繁。


负载均衡支持获取用户真实IP 负载均衡 服务发现_负载均衡_15

随机选择。当用户请求到来时,随机而又较为均匀地发到各个服务节点,可以采用伪随机实现。


负载均衡支持获取用户真实IP 负载均衡 服务发现_java_16

IP 哈希。根据客户端的IP,计算一个hash值,将请求固定投放到hash值对应的节点上。


负载均衡支持获取用户真实IP 负载均衡 服务发现_服务器_17

最少连接数。动态选取当前连接数最少的一台服务器来处理当前的请求,尽可能地提高后端服务的利用率。


负载均衡支持获取用户真实IP 负载均衡 服务发现_负载均衡_18

平等法。根据数据大小和最近平均响应时间智能地判断向哪个节点分发请求。

总结

我把我所理解的服务发现和负载均衡相关概念都简单阐述了一下, 看到这里,读者已经很不容易了,这篇文章如果对您有一丝帮助,还请点赞以支持作者。

让我总结一下文中的重要观点。

  1. API网关需要负责服务发现和负载均衡,以及其他必要的功能。
  2. DNS指向负载均衡器,由负载均衡器负责路由分发。
  3. 尽量减少请求深度,每增加一层网络调用都会增加流量开销和引入新的高可用瓶颈。
  4. 如果要解脱单体架构的困境,先尝试划分粗粒度的模块,最后再考虑微服务。
  5. 高可用的手段是冗余,有状态的集群需要开启主从复制才能实现高可用。
  6. 基础设施是复杂的、稳健的,程序员不需要掌握那么多,上云是个好办法。
  7. 在分布式体系中,客户端侧共同代码是不可避免的,但可以使用类库或容器消除重复代码。

思考一下:限流、熔断、降级是API网关实现还是API服务器实现,又是如何实现的?

以上。