🍊 客户端负载均衡
🎉 负载均衡(服务端/客户端)
一般我们所说的负载均衡通常都是服务器端负载均衡,服务器端负载均衡又分为两种,一种是硬件负载均衡,还有一种是软件负载均衡。
硬件负载均衡主要通过在服务器节点之前安装专门用于负载均衡的设备,常见的如:F5。
软件负载均衡则主要是在服务器上安装一些具有负载均衡功能的软件来完成请求分发进而实现负载均衡,常见的如:LVS 、 Nginx 。
微服务为负载均衡的实现提供了另外一种思路:把负载均衡的功能以库的方式集成到服务的消费方,不再是由一台指定的负载均衡设备集中提供。这种方案称为软负载均衡客户端负载均衡。常见的如:Spring Cloud中的 Ribbon。
当我们将Ribbon和Eureka一起使用时,Ribbon会到Eureka注册中心去获取服务端列表,然后进行轮询访问以到达负载均衡的作用,客户端负载均衡也需要心跳机制去维护服务端清单的有效性,当然这个过程需要配合服务注册中心一起完成。
🎉 实现Ribbon
📝 底层实现
Ribbon的负载均衡,主要通过LoadBalancerClient来实现的,而LoadBalancerClient具体交给了ILoadBalancer来处理,ILoadBalancer通过配置IRule、IPing等信息,并向EurekaClient获取注册列表的信息,并默认10秒一次向EurekaClient发送“ping”,进而检查是否更新服务列表,最后,得到注册列表后,ILoadBalancer根据IRule的策略进行负载均衡。
而RestTemplate 被@LoadBalance注解后,能过用负载均衡,主要是维护了一个被@LoadBalance注解的RestTemplate列表,并给列表中的RestTemplate添加拦截器,进而交给负载均衡器去处理。
📝 快速集成
在 pom.xml 文件中引入依赖
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.0.6.RELEASE</version> </parent> <properties> <spring-cloud.version>Finchley.SR2</spring-cloud.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- Eureka-Client 依赖 --> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId> </dependency> </dependencies> <dependencyManagement> <dependencies> <!-- SpringCloud 版本控制依赖 --> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>${spring-cloud.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>
然后在启动类里面向Spring容器中注入一个带有@LoadBalanced注解的RestTemplate Bean
import org.springframework.boot.WebApplicationType; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.builder.SpringApplicationBuilder; import org.springframework.cloud.client.loadbalancer.LoadBalanced; import org.springframework.cloud.netflix.eureka.EnableEurekaClient; import org.springframework.context.annotation.Bean; import org.springframework.web.client.RestTemplate; @SpringBootApplication @EnableEurekaClient public class MessageCenterApplication { @Bean @LoadBalanced public RestTemplate restTemplate() { return new RestTemplate(); } public static void main(String[] args) { new SpringApplicationBuilder(MessageCenterApplication.class).web(WebApplicationType.SERVLET).run(args); } }
调用那些需要做负载均衡的服务时,用上面注入的RestTemplate Bean进行调用就可以了
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.client.RestTemplate; @RestController @RequestMapping("/api/v1/center") public class MessageCenterController { @Autowired private RestTemplate restTemplate; @GetMapping("/msg/get") public Object getMsg() { String msg = restTemplate.getForObject("http://message-service/api/v1/msg/get", String.class); return msg; } }
在application.yml配置文件里添加好配置,比如:
spring: application: name: message-service eureka: client: serviceUrl: defaultZone: http://localhost:8761/eureka/
启动的时候,Run As --> Run Configurations->VM arguments,分别使用 8771、8772、8773 三个端口各启动一个MessageApplication应用。
-Dserver.port=8771 -Dserver.port=8772 -Dserver.port=8773
三个服务启动完成后,浏览器输入:http://localhost:8761/
应用启动之后,连续三次请求地址 http://localhost:8781/api/v1/center/msg/get
🎉 Ribbon负载均衡策略
- RoundRobinRule: 轮询策略,Ribbon以轮询的方式选择服务器,这个是默认值。启动的服务会被循环访问。
- RandomRule: 随机策略,也就是说Ribbon会随机从服务器列表中选择一个进行访问。
- BestAvailableRule: 最大可用策略,先过滤出故障服务器后,选择一个当前并发请求数最小的。
- WeightedResponseTimeRule: 带有加权的轮询策略,对各个服务器响应时间进行加权处理,然后在采用轮询的方式来获取相应的服务器。
- AvailabilityFilteringRule: 可用过滤策略,先过滤出故障的或并发请求大于阈值的一部分服务实例,然后再以线性轮询的方式从过滤后的实例清单中选出一个。
- ZoneAvoidanceRule: 区域感知策略,先使用主过滤条件(区域负载器,选择最优区域)对所有实例过滤并返回过滤后的实例清单,依次使用次过滤条件列表中的过滤条件对主过滤条件的结果进行过滤,判断最小过滤数(默认1)和最小过滤百分比(默认0),最后对满足条件的服务器则使用RoundRobinRule(轮询方式)选择一个服务器实例。
例如,message-service的负载均衡策略设置为随机访问RandomRule,application.yml配置如下
message-service: ribbon: NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RandomRule
🎉 脱离Eureka使用Ribbon
如果你不用Eureka,也可以继续使用Ribbon和Feign。
假设不使用Eureka,通过将ribbon.eureka.enabled 属性设置为 false, 可以在Ribbon中禁用Eureka,用@RibbonClient声明了一个"stores"服务,这个时候Ribbon Client 默认会引用一个配置好的服务列表,你可以在application.yml进行配置:
ribbon: eureka: enabled: false stores: ribbon: listOfServers: example.com,google.com
🍊 服务治理
🎉 为什么需要服务治理
公司的系统是由几百个微服务构成的,每一个微服务又有多个实例,服务数量较多,服务之间的相互依赖成网状,所以微服务系统需要服务注册中心来统一管理微服务实例,方便查看每一个微服务实例的健康状态。
🎉 服务治理的解决方案
- 服务注册:服务提供者主动自报家门
服务提供者将自己的服务信息(比如服务名、IP地址等)告诉服务注册中心。
- 服务发现:服务消费者拉取注册数据
当服务消费者需要消费另外一个服务时,服务注册中心需要告诉服务消费者它所要消费服务的实例信息(如服务名、IP地址等)。
- 心跳检测、服务续约和服务剔除
服务注册中心会检查注册的服务是否可用,通常一个服务实例注册后,会定时向服务注册中心提供“心跳”,以表明自己还处于可用的状态。如果一个服务实例停止向服务注册中心提供心跳一段时间后,服务注册中心会认为这个服务实例不可用,会把这个服务实例从服务注册列表中剔除。如果这个被剔除掉的服务实例过一段时间后继续向注册中心提供心跳,那么服务注册中心会把这个服务实例重新加入服务注册中心的列表中。
- 服务下线:服务提供者主动发起下线
🎉 服务治理的技术选型
🎉 Eureka
Eureka Client:负责将这个服务的信息注册到Eureka Server中 Eureka Server:注册中心,里面有一个注册表,保存了各个服务所在的机器和端口号
📝 自我保护机制产生的原因
Eureka的自我保护特性主要用于减少在网络分区或者不稳定状况下的不一致性问题,默认情况下,如果Server在一定时间内没有接收到某个服务实例的心跳(默认周期为30秒),Server将会注销该实例。如果在15分钟内超过85%的节点都没有正常的心跳,那么Eureka就认为客户端与注册中心出现了网络故障,启动自我保护机制。
📝 自我保护机制
- Eureka不再从注册表中移除因为长时间没有收到心跳而过期的服务。
- Eureka仍然能够接受新服务注册和查询请求,但是不会被同步到其它节点上,保证当前节点依然可用。
- 当网络稳定时,当前实例新注册的信息会被同步到其它节点中。
📝 在什么环境下开启自我保护机制
🔥 本地环境
建议关闭自我保护机制。因为在本地开发环境中,EurekaServer端相对来说重启频率不高,但是在EurekaClient端,可能改动代码之后需要重启,频率相对来说比较高;那么EurekaClient端重启之后就不会及时去向EurekaServer端发送心跳包,EurekaServer端就会认为是网络延迟或者其他原因,不会剔除服务,这样的话就会影响开发效率。
🔥 生产环境
建议开启自我保护机制。因为生产环境不会频繁重启服务器,并且EurekaClient端与EurekaServer端存在网络延迟的几率较高,所以需要开启自我保护机制避免误删服务。
Eureka Server端:配置关闭自我保护,并按需配置Eureka Server清理无效节点的时间间隔。
eureka.server.enable-self-preservation # 设为false,关闭自我保护 eureka.server.eviction-interval-timer-in-ms # 清理间隔(单位毫秒,默认是60*1000)
Eureka Client端:配置开启健康检查,并按需配置续约更新时间和到期时间
eureka.instance.lease-renewal-interval-in-seconds # 续约更新时间间隔(默认30秒) eureka.instance.lease-expiration-duration-in-seconds # 续约到期时间(默认90秒)
公司client 的配置:
eureka: instance: prefer-ip-address: true instance-id: ${spring.application.name}:${spring.client.ipAdress}:${server.port} lease-expiration-duration-in-seconds: 30 #服务过期时间配置,超过这个时间没有接收到心跳EurekaServer就会将这个实例剔除 lease-renewal-interval-in-seconds: 10 #服务刷新时间配置,每隔这个时间会主动心跳一次
🎉 Nacos
📝 Nacos的动态更新如何实现?什么是长轮询/如何实现长轮询?配置和服务器之间的配置变更整个方案如何实现?Nacos如何实现配置的变更和对比?
Nacos采用长轮训机制来实现数据变更的同步
Nacos是采用长轮训的方式向Nacos Server端发起配置更新查询的功能。所谓长轮训就是客户端发起一次轮训请求到服务端,当服务端配置没有任何变更的时候,这个连接一直打开,直到服务端有配置或者连接超时后返回。Nacos Client端需要获取服务端变更的配置,前提是要有一个比较,也就是拿客户端本地的配置信息和服务端的配置信息进行比较。
一旦发现和服务端的配置有差异,就表示服务端配置有更新,于是把更新的配置拉到本地。在这个过程中,有可能因为客户端配置比较多,导致比较的时间较长,使得配置同步较慢的问题。于是Nacos针对这个场景,做了两个方面的优化。
- 减少网络通信的数据量,客户端把需要进行比较的配置进行分片,每一个分片大小是3000,也就是说,每次最多拿3000个配置去Nacos Server端进行比较。
- 分阶段进行比较和更新,
第一阶段,客户端把这3000个配置的key以及对应的value值的md5拼接成一个字符串,然后发送到Nacos Server端
进行判断,服务端会逐个比较这些配置中md5不同的key,把存在更新的key返回给客户端。
第二阶段,客户端拿到这些变更的key,循环逐个去调用服务单获取这些key 的value值。
这两个优化,核心目的是减少网络通信数据包的大小,把一次大的数据包通信拆分成了多次小的数据包通信。虽然会增加网络通信次数,但是对整体的性能有较大的提升。最后,再采用长连接这种方式,既减少了pull轮询次数,又利用了长连接的优势,很好的实现了配置的动态更新同步功能。
📝 基于HTTP的长轮询简单实现
web客户端代码
//向后台长轮询消息 function longPolling(){ $.ajax({ async : true,//异步 url : 'longPollingAction!getMessages.action', type : 'post', dataType : 'json', data :{}, timeout : 30000,//超时时间设定30秒 error : function(xhr, textStatus, thrownError) { longPolling();//发生异常错误后再次发起请求 }, success : function(response) { message = response.data.message; if(message!="timeout"){ broadcast();//收到消息后发布消息 } longPolling(); } }); }
web服务器端代码
public class LongPollingAction extends BaseAction { private static final long serialVersionUID = 1L; private LongPollingService longPollingService; private static final long TIMEOUT = 20000;// 超时时间设置为20秒 public String getMessages() { long requestTime = System.currentTimeMillis(); result.clear(); try { String msg = null; while ((System.currentTimeMillis() - requestTime) < TIMEOUT) { msg = longPollingService.getMessages(); if (msg != null) { break; // 跳出循环,返回数据 } else { Thread.sleep(1000);// 休眠1秒 } } if (msg == null) { result.addData("message", "timeout");// 超时 } else { result.addData("message", msg); } } catch (Exception e) { e.printStackTrace(); } return SUCCESS; } public LongPollingService getLongPollingService() { return longPollingService; } public void setLongPollingService(LongPollingService longPollingService) { this.longPollingService = longPollingService; } }
🎉 Nacos、Eureka与Zookeeper区别
相同点:
- 都可以实现分布式注册中心框架
不同点:
- Zookeeper采用CP保证数据的一致性的问题,原理是采用ZAB原子广播协议。当我们ZK领导者宕机或出现了故障,会自动重新实现选举新的领导角色,整个选举的过程中为了保证数据一致性的问题,整个微服务无法实现通讯,可运行的节点必须满足过半机制,整个zk才可以使用,要不然会奔溃。
- Eureka采用AP设计理念架构注册中心,相互注册完全去中心化,也就是没有主从之分,只要有一台Eureka节点存在整个微服务就可以实现通讯。Eureka中会定时向注册中心发送心跳,如果在短期内没有发送心跳,则就会直接剔除。会定时向注册中心定时拉去服务,如果不主动拉去服务,注册中心不会主动推送。
- Nacos中注册中心会定时向消费者主动推送信息 ,这样就会保持数据的准时性。它会向注册中心发送心跳,但是它的频率要比Eureka快。Nacos从1.0版本选择Ap和CP混合形式实现注册中心,默认情况下采用Ap保证服务可用性,CP形式底层采用Raft协议保证数据的一致性问题。默认采用AP方式,当集群中存在非临时实例时,采用CP模式。选择Ap模式,在网络分区的的情况允许注册服务实例。选择CP模式,在网络分区的产生了抖动情况下不允许注册服务实例。
🍊 服务容错保护
🎉 服务雪崩
在微服务架构中,一个请求要调用多个服务是非常常见的。如客户端访问A服务,A服务访问B服务,B服务调用C服务,由于网络原因或者自身的原因,如果B服务或者C服务不能及时响应,A服务将处于阻塞状态,直到B服务C服务响应。此时若有大量的请求涌入,容器的线程资源会被消耗完毕,导致服务瘫痪。服务与服务之间的依赖性,故障会传播,造成连锁反应,会对整个微服务系统造成灾难性的严重后果,这就是服务故障的“雪崩效应”。
在高并发的情况下,一个服务的延迟可能导致所有服务器上的所有资源在数秒内饱和。比起服务故障,更糟糕的是这些应用程序还可能导致服务之间的延迟增加,导致整个系统出现更多级联故障。
造成服务雪崩的原因可以归结为以下三点:
- 服务提供者不可用(硬件故障,程序bug,缓存击穿,用户大量请求等)
- 重试加大流量(用户重试,代码逻辑重试)
- 服务消费者不可用(同步等待造成的资源耗尽)
解决方案:
- 服务隔离:限制调用分布式服务的资源,某一个调用的服务出现问题不会影响到其他服务调用
- 服务熔断:牺牲局部服务,保全整体系统稳定性
- 服务降级:服务熔断以后,客户端调用自己本地方法返回缺省值
所谓的容错处理其实就是捕获异常了,不让异常影响系统的正常运行,正如java中的try catch一样。在微服务调用中,自身异常可自行处理外,对于依赖的服务发生错误,或者调用异常,或者调用时间过长等原因时,为了避免长时间等待,造成系统资源耗尽, 一般上都会通过设置请求的超时时间,如http请求中的ConnectTimeout和ReadTimeout;而微服务提供了Hystrix熔断器,隔离问题服务,防止级联错误的发生。
🎉 Hystrix
Hystrix是一个实现了超时机制和断路器模式的工具类库,用于隔离访问远程系统、服务或第三方库,提升系统的可用性和容错性。
📝 Hystrix容错机制:
- 包裹请求:使用HystrixCommand包裹对依赖的调用逻辑,每个命令在独立线程中执行,这是用到了设计模式“命令模式”。
- 跳闸机制:当某服务的错误率超过一定阈值时,Hystrix可以自动或手动跳闸,停止请求该服务一段时间。
- 资源隔离:Hystrix为每个依赖都维护了一个小型的线程池,如果该线程池已满,发往该依赖的请求就被立即拒绝,而不是排队等候,从而加速判定失败。
- 监控:Hystrix可以近乎实时的监控运行指标和配置的变化。如成功、失败、超时、被拒绝的请求等。
- 回退机制:当请求失败、超时、被拒绝,或当断路器打开时,可以执行回退逻辑。
- 自我修复:断路器打开一段时间后,会自动进入半开状态,断路器打开、关闭、半开的逻辑转换。
📝 熔断器的工作原理
每个请求都会在 hystrix 超时之后返回 fallback,每个请求时间延迟就是近似 hystrix 的超时时间,假设是 5 秒,那么每个请求都要延迟 5 秒后才返回。当熔断器在 10 秒内发现请求总数超过 20,并且错误百分比超过 50%,此时熔断打开。
熔断打开之后,再有请求调用的时候,将不会调用主逻辑,而是直接调用降级逻辑,这个时候就会快速返回,而不是等待 5 秒才返回 fallback。通过断路器,实现了自动发现错误并将降级逻辑切为主逻辑,减少响应延迟。
当断路器打开,主逻辑被熔断后,hystrix 会启动一个休眠时间窗,在这个时间窗内,降级逻辑就是主逻辑;当休眠时间窗到期,断路器进入半开状态,释放一次请求到原来的主逻辑上,如果此次请求返回正常,那么断路器将闭合,主逻辑恢复,如果这次请求依然失败,断路器继续打开,休眠时间窗重新计时。
📝 信号量隔离
请求并发大,耗时短,采用信号量隔离,因为这类服务的返回通常很快,不会占用线程太长时间,而且也减少了线程切换的开销。
每个请求线程通过计数信号进行限制,当信号量大于了最大请求数maxConcurrentRequest时,调用fallback接口快速返回。另外由于通过信号量计数器进行隔离,它只是个计数器,资源消耗小。
信号量的调用是同步的,每次调用都得阻塞调用方的线程,直到有结果才返回,这样就导致了无法对访问做超时处理,只能依靠协议超时,无法主动释放。
🔥 实现方式
@HystrixCommand注解实现线程池隔离,通过配置超时时间,信号量隔离,信号量最大并发,以及回退方法,基于注解就可以对方法实现服务隔离。
// 信号量隔离 @HystrixCommand( commandProperties = { // 超时时间,默认1000ms @HystrixProperty(name = HystrixPropertiesManager. EXECUTION_ISOLATION_THREAD_TIMEOUT_IN_MILLISECONDS, value = "5000"), // 信号量隔离 @HystrixProperty(name = HystrixPropertiesManager. EXECUTION_ISOLATION_STRATEGY, value = "SEMAPHORE"), // 信号量最大并发 @HystrixProperty(name = HystrixPropertiesManager. EXECUTION_ISOLATION_SEMAPHORE_MAX_CONCURRENT_REQUESTS, value = "5") }, fallbackMethod = "selectProductByIdFallBack" ) @Override public Product selectProductById(Integer id) { System.out.println(Thread.currentThread().getName()); return productClient.selectProductById(id); }
📝 线程池隔离
请求并发大,耗时长,采用线程池隔离策略。这样可以保证大量的线程可用,不会由于服务原因一直处于阻塞或等待状态,快速失败返回。还有就是对依赖服务的网络请求涉及超时问题的都使用线程隔离。
🔥 优缺点
优点:
- 使用线程池隔离可以安全隔离依赖的服务,减少所依赖的服务发生故障时的影响。比如A服务发生异常,导致请求大量超时,对应的线程池被打满,这时并不影响在其他线程池中的C、D服务的调用。
- 当失败的服务再次变得可用时,线程池将清理并立即恢复,而不需要一个长时间的恢复。
- 独立的线程池提高了并发性。
缺点:
- 请求在线程池中执行,肯定会带来任务调度、排队个上下文切换带来的CPU开销。
- 因为涉及到跨线程,那么就存在ThreadLocal数据传递的问题,比如在主线程初始化的ThreadLocal变量,在线程池中无法获取。