为什么需要服务注册与发现?一般来说,服务集群会部署在不同的机房和不同的机器上,监听不同的端口。当客户端收给服务端发送请求,怎么知道应该发送给哪个机器?这就需要用到“注册中心”。
针对服务注册与发现,可以分为下面几个步骤(服务上线):
需要注意的是,服务端必须要等待一段时间才能下线。因为从它通知注册中心自己要下线,到客户端收到通知,是有一段延时的,这段延时就是服务端要等待的最小时间。
在正常情况下,服务端下线都需要通知注册中心。那么万一服务端宕机了呢?在这种情况下,服务端是没办法通知注册中心的,注册中心自然也就不会通知客户端。那么客户端就会继续把请求发送给服务端,而这些请求显然都会失败。因此,为了提高可用性,需要让注册中心尽快发现服务端已经崩溃了,而后通知客户端。所以问题的关键就在于 注册中心怎么判断服务端已经崩溃了。
简单地说, 如果注册中心和服务端之间的心跳断了,就认为服务端已经崩溃了。但是,需要考虑一个特殊情况,如果注册中心和服务端之间的网络出现偶发性的抖动,那么心跳也会失败。此时服务端并没有崩溃。
影响到可用性的关键点就是注册中心需要尽快发现服务端宕机。如果服务端突然宕机,那么服务端是来不及通知注册中心的。所以注册中心需要有一种检测机制来判断服务端有没有崩溃。在服务端崩溃的情况下,要及时通知客户端,不然客户端就会继续把请求发送到已经崩溃的节点上,这种检机制就是心跳。当注册中心发现和服务端的心跳失败了,那么它就应该认为服务端可能已经崩溃了,就立刻通知客户端停止使用该服务端;但是这种失败可能是偶发性的失败,比如说因为网络偶尔不稳定造成的。所以注册中心要继续保持心跳。如果几次心跳都失败了,那么就可以认为服务端已经彻底不可用了。但是如果心跳再次恢复了,那么注册中心就要再次告诉客户端这个服务端是可用的。
如果心跳失败了要不要继续重试,是立刻重试还是间隔重试,重试的话试几次? 一般来说,在心跳失败之后如果不进行重试就直接判定服务端崩溃,那么就难以处理偶发性网络不通的问题。而如果要重试,比如重试三次,而且重试间隔是十秒钟,那么注册中心确定服务端崩溃就需要三十秒。在这三十秒内,客户端估计有成千上万的请求尝试发到崩溃的服务端,结果都失败了。
如果不考虑重试间隔的话,就难以避开偶发性的失败。比如说注册中心和服务端之间网络抖动,那么第一次心跳失败之后,你立刻重试多半也是失败的,因为此时网络很可能还是不稳定。所以比较好的策略是立刻重试几次,如果都失败了就再间隔一段时间继续重试。所有的重试机制实际上也是要谨慎考虑重试次数和重试间隔的,确保在业务可以接受的范围内重试成功。不过再怎么样,从服务端崩溃到客户端知道,中间总是存在一个时间误差的,这时候就需要客户端来做容错了。
从服务端崩溃到客户端最终知道是有一段延时的。在这段延时内,客户端还是会把请求发送到已经崩溃的服务端节点上。在服务端节点崩溃之后,到注册中心发现,再到客户端收到通知,是存在一段延时的,在这段延时内,客户端发送请求给这个服务端节点都会失败。这个时候需要客户端来做一些容错。
延时怎么计算:最坏的情况下,延时等于 服务端和注册中心心跳间隔 加上 注册中心通知客户端的时间。大多数时候,注册中心通知客户端都是很快的,在毫秒级以内。因此可以认为服务端和注册中心的心跳间隔就是这个延时。
一般的策略是客户端在发现调不通之后,应该尝试换另外一个节点进行重试。如果客户端上的服务发现组件或者负载均衡器能够根据调用结果来做一些容错的话,那么它们应该要尝试将这个节点挪出可用节点列表,在短时间内不要再使用这个节点了。后面再考虑将这个节点挪回去。
选 CP 还是选 AP 的问题。C:Consistency,数据一致性;A:Availability,服务可用性;P:Partition-tolerance,分区容错性。
一个分布式系统不可能同时满足数据一致性、服务可用性和分区容错性这三个基本需求,最多只能同时满足其中的两个。选择 CP 就是选了一致性和分区容错性,而选择 AP 就相当于选了可用性和分区容错性。(参考: 微服务的使用场景和架构设计方案 )
P 分区容错性是肯定要选的,那么剩下的就是选 C(一致性) 还是选 A(可用性) 了。在注册中心选型里面,一致性和可用性相比,可用性更加重要,所以应该选 AP。在选择 AP 的情况下,客户端就可能拿到错误的可用节点列表。如果客户端将请求发到错误的可用节点上,就会出现错误,此时客户端自然可以执行容错,换一个可用节点重试。
Eureka 和 Nacos 都是使用的 AP 模式; ZooKeeper使用的是CP模式,适合体量小、集群规模不大的业务场景。
负载均衡其实就是要解决一个问题: 我该把请求发给哪个服务端?
其中一类算法是 静态负载均衡算法,例如:轮询和加权轮询、随机和加权随机,哈希和一致性哈希这些负载均衡算法。这些算法适用于请求都差不多、请求数量也足够多的情况,它们能够挑选出比较合适的节点。
还有一类算法,是 动态负载均衡算法,或者说是实时检测负载均衡算法。这一类算法依赖于实时判断所有候选节点的状态,并且从里面挑选出最合适的节点。这一类算法包含最少连接数、最少活跃请求数、最快响应时间等算法。
但是每个节点的实际处理能力可能并不一样,于是就有了一个加权的版本,就是加权轮询,此时就不再是节点轮流,而是 根据权重来轮流。比如一个节点的权重是另外一个节点的两倍,那么最终这个节点被选中的次数也会是另外一个节点的两倍。
图中节点1的权重是其他两个节点的三倍,所以相应地被选中的机会也是三倍。
随机就是随便挑选一个节点作为目标节点,加权随机 则是利用不同的权重来设置选中的概率。权重越大,那么被选中的机会也就越大。
哈希算法就是选取请求里面某几个参数来计算一个哈希值,然后除以节点数量取余。这个过程几乎和随机一样,区别就在于随机算法里面用的是随机数,这里用的是根据参数计算出来的哈希值。哈希算法的选取会严重影响负载均衡的效果。假如说你计算哈希值的算法不太好,就容易导致某几个节点上负载特别高,而其他节点的负载就比较低。所以要尽可能保证哈希值计算出来的结果是均匀的。
一致性哈希负载均衡引入了一个哈希环的概念,服务端节点会落在环的某些位置上。客户端根据请求参数,计算一个哈希值,这个哈希值会落在哈希环的某个位置。从这个位置出发,顺时针查找,遇到的第一个服务端节点就是目标节点。
注意,在一致性哈希负载均衡算法里面,并不要求服务端节点是均匀分散在哈希环上的。(实际上,我们是希望所有的节点负载是均衡的,但是不同节点之间的间隔可以是不均匀的。)
最少连接数基于一个基本假设:如果一个服务端节点上的连接数越多,那么这个节点的负载就越高。因此在做负载均衡的时候就是看一下客户端和各个节点的连接数量,从中挑选出连接数数量最少的节点。最少连接数算法的缺陷在于,连接数并不能代表节点的实际负载,尤其是在连接多路复用的情况下。
比如这张示意图里,理论上来说新来的请求就会落到服务端节点 1 上,而后连接数变成 11。实际上在连接复用的情况下,客户端可能连续发 10 个请求到服务端节点 1 上,才会创建一个新连接。那么在这种情况下,服务端节点 1 的负载会比其他两个节点高很多。
最少活跃数算法是用 当前活跃请求数 来代表服务端节点的负载。所谓的活跃请求,就是已经接收但是还没有返回的请求。客户端会维持一个自己发过去但是还没返回的请求数量,然后每次挑选活跃请求最少的那个服务端节点。
和上面“最少连接数”类似,活跃请求数量也不能真正代表服务端节点的负载。比如图中服务端节点1虽然只有10个请求,但是万一这10个请求都是较复杂的请求(例如大商家、大买家或者千万粉丝UP主的请求),那么服务端节点1的负载也会显著高于其他两个节点。
最快响应时间算法用的是响应时间来代表服务端节点的负载。最快响应时间算法就是客户端维持每个节点的响应时间,而后每次挑选响应时间最短的。响应时间和前面的两个指标比起来,是一种综合性的指标,所以用响应时间来代表服务端节点负载要更加准确。但是在实现上,要注意响应时间的时效性。一般来说统计响应时间时应该只用近期请求的响应时间,并且越近的响应时间,权重应该越高。换句话说,就是采集的响应时间效用应该随着时间衰减。
最少连接数、最少活跃请求数和最快响应时间,都可以看作是选择了单一的指标来代表一个节点的负载,在实际工作中可以利用这个思路来设计自己的负载均衡算法。比如说在 CPU 密集型的应用里面可以设计一个负载均衡算法,每次筛选 CPU 负载最低的节点,但是难点是需要考虑 怎么采集到所有服务端节点的 CPU 负载数据。
这三个算法还有一个问题,就是它们都是客户端来采集数据的。 不同的客户端就可能采集到不同的数据,如下图所示,因为客户端 1 本身并不知道客户端 2 上还有 30 个连接,因此它选择了服务端节点 1。而实际上它应该选择服务端节点 2。
那怎么解决这两个问题呢?答案是让服务端上报指标,而不是客户端采集。思路是服务端在返回响应的时候顺便把服务端上的一些信息一并返回。这种思路需要微服务框架支持从服务端往客户端回传链路元数据。
在实际项目中,还可以尝试根据业务设计一个独一无二的负载均衡算法,即便使用的是最简单的轮询之类的算法,也不用担心。因为目前大规模应用的就是这种简单的算法,那些花里胡哨的算法实际上落地的并不多。
所有负载均衡算法都需要考虑请求本身。
【问题】某公司用的是轮询来作为负载均衡,不过因为轮询没有实际查询服务端节点的负载,所以难免会出现偶发性的负载不均衡的问题。比如说之前发现线上的响应时间总体来说是非常均匀的,但是每隔一段时间就会出现响应时间特别慢的情况。而且时间间隔是不固定的,慢的程度也不一样。后来经过排查之后,发现是因为当一个大请求落到一个节点的时候,它会占据大量的内存和 CPU。如果这时候再有请求打到同一个节点上,这部分请求的响应时间就会非常慢。
【解决方案分析】
(业务拆分角度)这个大请求其实是一个大的批量请求,可以改为限制一批最多只能取100个,然后分为多批处理。
(隔离角度)可以稍微改一下负载均衡算法,不再是单纯的轮询了,改为每天计算一批大客户,这部分大客户的请求会在负载均衡里面被打到专门的几个节点上。虽然大客户的请求依旧很慢,但是至少别的客户不会再受到他们的影响了。
需要注意,加权类的算法都要考虑权重的设置和调整,实际上在工作中可以考虑根据调用结果来动态调整权重。那么应该怎么设置权重或者怎么调整权重呢?思路如下:
在性能非常苛刻的时候,我们会考虑使用本地缓存。但是使用本地缓存可能会出现很严重的数据一致性问题,比如同一个 key 对应的请求,可能会被打到不同的节点上。这就会造成两个问题,一是缓存未命中;二是不同节点都要缓存同样的数据,导致内存浪费和数据一致性问题。
在这种情况下,可以把类似的请求都让同一个节点来处理,比如对相同用户数据的请求都打到同一个节点上,也就是可以使用哈希或者一致性哈希。如果考虑到节点可能上线、下线的情况,那么一致性哈希负载均衡就是最优选择。可以尝试将一致性哈希负载均衡算法和本地缓存结合在一起,以提高缓存命中率,并且降低本地缓存的总体内存消耗。
比如说针对用户的本地缓存,可以使用用户 ID 来计算哈希值,那么可以确保同一个用户的本地缓存必然在同一个节点上。