微服务是一种分布式的方式,通过微服务可以将业务拆分,使业务职责单一化,业务解耦。微服务通常都是集群部署,服务之间的通信需要通过RPC完成。集群需要通过服务治理去管理,服务治理主要管理:接口方法和服务之间的映射关系、负载均衡、健康检测、服务续约、服务发现、容灾容错等。
一、RPC
其中RPC主要是通过TCP传输协议和高效的序列化反序列完成。高性能的TCP传输手动主要是通过IO的多路复用和零拷贝,最典型的NIO框架就是netty,而IO的多路复用主要手段有select、poll、epoll,其中epoll是liunx主要的io多路复用模型的手段,也是性能最高的。并且RPC一般还支持插件拓展的spi机制。
由此可见RPC的一套流程是通过TCP向特定的地址传输序列化好的二进制数据,然后通过反序列化将数据还原。RPC的职责就是能够帮助我们解决系统拆分后的通信问题,并且能让我们像调用本地方法一样去调用远程方法。我们不需要因为这个方法是远程调用就需要编写很多与业务无关的代码,RPC 帮助我们屏蔽网络编程细节。而屏蔽网络编程、序列化等一系列细节操作一般是使用动态代理去做的。
动态代理
RPC 会自动给接口生成一个代理类,当我们在项目中注入接口的时候,运行过程中实际绑定的是这个接口生成的代理类。这样在接口方法被调用的时候,它实际上是被生成代理类拦截到了,这样我们就可以在生成的代理类里面,加入远程调用逻辑。常用的动态代理技术有:JDK动态代理、Javassist、Byte Buddy、cglib等
io多路复用:
dubbo使用的TCP框架是netty 是io多路复用模型,相比springcloud的http性能上更胜一筹。io多路复用模型主要有以下几种:
select:用户态每次都将连接的文件描述符放在一数组中,每次调用select时,都需要将整个数组拷贝到内核中,让内核去轮询文件描述符,之后内核会将数组中有数据的文件描述符标记,然后返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历找出被标记的文件描述符。
poll:原理和select一样,只是poll是维护着链表文件描述符个数没有上限,而select最多只能1024个
epoll:主要通过三种方式提升了IO多路复用模型的性能
- 内核中保存一份文件描述符集合,集合是一个红黑树结构,无需用户每次都重新传入,只需告诉内核修改的部分即可,减少了select和poll来回拷贝文件描述符方式的消耗。
- 内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒。
- 内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合。
零拷贝(图片转自微信公众号:小林coding):
零拷贝最主要的两种方式有mmap和sendfile,其中sendfile是性能最好最常用的方式。传统的拷贝是需要经历这样的流程:用户进程希望在网络中发送数据时。
1、首先需要去磁盘读出这些数据这就需要发送read请求,需要从用户态切换到内核态才能完成这个命令
2、然后内核态中利用DMA技术将磁盘的数据拷贝到内核态缓存中,然后再将内核态的缓存的数据拷贝到用户态缓存
3、紧接着又需要将这些数据从用户态缓存拷贝到内核态缓存中,这时用户态又切换到了内核态
这其中进行了四次用户态和内核态的切换,四次数据的拷贝。其实发送这个过程完全没必要把数据拷贝在用户态和内核态来回拷贝的。有两种零拷贝的方式解决了这些问题
-
mmap:系统调用函数会直接把内核缓冲区里的数据映射到用户空间,这块缓存数据变成了用户态和内核太共享的区域,这样操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。但这还不是最理想的零拷贝,因为仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,而且仍然需要 4 次上下文切换
-
sendfile:sendfile相对mmap就更加暴力和高效,首先通过 DMA 将磁盘上的数据拷贝到内核缓冲区,然后直接就将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝。这个过程只需要两次内核态和用户态的切换和2 次的数据拷贝
序列化:
RPC的数据传输是依靠TCP完成,TCP传输的数据是二进制数据,这时就需要将方法中传输的对象进行序列化和反序列化,序列化主要考虑的几个点:安全性、兼容性、性能、序列化后体积大小。
目前比较知名的有Json、Hessian、Protobuf、Kryo。像dubbo的默认序列化方式是Hessian,springcloud的feign是Json,gRPC是Protobuf。
SPI
RPC的拓展插件功能。在 Java 里面,JDK 有自带的 SPI服务发现机制,它可以动态地为某个接口寻找服务实现。使用 SPI 机制需要在 Classpath 下的 META-INF/services 目录里创建一个以服务接口命名的文件,这个文件里的内容就是这个接口的具体实现类。实际很少会使用JDK的SPI功能,原因是:1、JDK 标准的 SPI 会一次性加载实例化扩展点的所有实现。 JDK 启动的时候都会一次性全部加载META-INF/service 下文件里面的实现类。如果有的扩展点实现初始化很耗时或者如果有些实现类并没有用到, 那么会很浪费资源。2、如果扩展点加载失败,会导致调用方报错,而且这个错误很难定位。
市面上比较出名的是Dubbo自己的SPI机制,Dubbo的SPI机制可以按需去拓展插件,几乎将所有的功能组件做成基于 SPI 实现,并且默认提供了很多可以直接使用的扩展点,如序列化的方式、传输协议等。
当然springcloud也有自己的SPI机制,只不过是通过注入的方法去实现,主要是通过Condition按需注入实现类似的SPI机制
二、服务治理
服务治理主要有:服务发现、服务续约、健康检测、负载均衡、容灾容错等。主要是通过将服务注册到注册中心实现这些功能。比较出名的注册中心有:Eureka、nacos、zookeeper等
服务发现
服务发现主要是维护调用接口和服务提供方地址的映射,可以帮助我们定位调用服务提供方的IP地址和维护集群的IP地址动态变化。有了服务发现我们就可以从集群中获取相应接口的IP集合通过负载均衡的方式选择其中的ip地址进行调用,这也PRC的服务发现机制。服务发现通过以下两种方式实现:
1、服务注册:在服务提供方启动的时候,将自己的节点消息(IP地址、端口、服务接口等)注册到注册中心。
2、服务订阅:在服务调用方启动的时候,去注册中心查找并订阅服务提供方的 IP,然后缓
存到本地,并用于后续的远程调用。
3、服务续约:定时的从注册中心拉取其他节点的信息,更新本地缓存。
目前比较流行的服务发现的框架有zookeeper、Eureka、nacos。一个分布式系统不可能同时满足C(一致性)、A(可用性)和P(分区容错性),其中zookeeper是cp,Eureka是AP。
其中eureka的自我保护模式能在短时间内丢失过多的客户端时(可能发送了网络故障),那么这个节点将进入自我保护模式,不再注销任何微服务,当网络故障回复后,该节点会自动退出自我保护模式。在大规模集群服务发现系统的时候,舍弃强一致性,更多地考虑系统的健壮性,最终一致性才是分布式系统设计中更为常用的策略
健康检测
在PRC进行负载均衡调用服务的时候通常需要是需要知道哪些IP地址的节点可以哪些节点不可用,这些信息需要注册中心对注册的服务节点进行健康检测,最常用的方法就是定时的心跳检测,通过建立的TCP连接上发送类似ping的请求。根据服务节点的回复情况可以分为以下三种状态:
1、康状态:建立连接成功,并且心跳探活也一直成功。
2、亚健康状态:建立连接成功,但是心跳请求连续失败。
3、死亡状态:建立连接失败。
心跳检测的频率不同的框架都不一样,像Eureka就是30秒一次心跳,90秒内未收到续约,就会将服务剔除
负载均衡
微服务框架或者RPC框架都是会带有负载均衡的功能,也是集群必备的能力。一般都是从注册中心上拉取服务提供方的信息缓存在本地后,当调用相应的接口服务时,会从对应接口的服务提供方节点的集合中选择一个可以的节点进行调用,具体调用哪个节点会有不一样的选择策略,例如:轮询、随机、权重轮询、最少活跃调用数、一致性hash等。
例如:springcloud的Ribbon,就是springcloud服务消费和负载均衡的核心组件。Ribbon是基于http和tcp的负载均衡组件,eureka利用ribbon实现了服务消费,Ribbon通过配置的服务列表进行轮询访问,但与eureka集成使用时,服务列表会被eureka的服务列表所重写,拓展成从注册中心获取服务列表(eureka的服务列表就是从注册中心拉取的服务节点的信息数据)。同时将自身的Iping功能替换成eureka的NIWSDiscoveryPing功能,由eureka去确认哪些服务可用,因此Ribbon利用了eureka注册中心服务发现、服务注册、服务续约和健康检测的机制,完成发现可用服务的节点地址及健康信息,然后利用自身的负载策略进行服务的消费。
熔断限流降级
熔断限流降级是微服务分布式系统必备功能,它主要应对,访问流量过大、节点负载过高和业务时间过长所引起的诸多问题。通过熔断、降级可以保护系统因业务处理时间过长导致过多接口服务超时的级联故障问题,限流保护系统避免流量访问过大,节点负载过高的问题。
对应熔断限流降级业界最经典的莫过于Springcloud的Hystrix,Hystrix实现了一整套熔断限流降级的思想。
接下来隆重介绍一下Hystrix:
Hystrix对请求有两种隔离策略:线程隔离和信号量。还有限流手段:隔离、熔断、降级
线程隔离:
Hystrix会开启线程池去执行请求,这个线程池就如普通的线程池一样有核心线程数、最大线程数、任务队列、线程空闲时间等,请求会被扔到线程池里面执行,当超过核心线程数时就会进行线程的扩展,超过最大线程数时就会扔到队列中排队。和普通的线程池的机制一摸一样的。默认情况下都是一个类内容的所有方法的请求流量共用一个线程池。
信号量隔离:限流
和线程隔离不一样,信号量隔离可以限制一个对应服务接口调用的最大并发请求量。根据信号量去控制最大的并发数量可以达到限流的目的
熔断降级:
Hystrix可以开启服务接口调用的超时功能默认是1000毫秒,可以设置超时发生时是否中断,这个设置只有线程隔离策略有效,默认是超时时中断执行,Hystrix还会根据固定时间间隔内调用接口的成功率去判断是否开启熔断和后续探测的半熔断,默认熔断和半熔断规则如下:
1、熔断:
当在时间窗内请求后端服务失败数量超过一定比例(默认50%可以使用circuitBreaker.errorThresholdPercentage设置比例)断路器会切换到熔断状态(Open),需要注意的是时间窗内请求数量要超过一定值才进行失败数量的统计(默认20个),假如默认值下只有19个请求就算是全部都失败了也不会开启熔断(可以通过circuitBreaker.requestVolumeThreshold设置请求数量)。断路器开启后所有请求会直接失败触发相应的降级方法。
2、半熔断:
断路器保持在开启状态一段时间后(默认5秒,可以通过circuitBreaker.sleepWindowInMilliseconds设置熔断休眠时间),自动切换到半熔断状态(HALF-OPEN)。 这时会放过一个请求根据返回情况去设置状态, 如果请求成功, 断路器会关闭熔断状态(CLOSED), 否则重新切换到熔断状态(OPEN)。Hystrix根据规则发现端服务不可用时, Hystrix会直接切断请求链, 避免发送大量无效请求影响系统吞吐量, 并且断路器有自我检测并恢复的能力。
在熔断过程中,所有的请求会被触发相应的降级方法。
分布式链路跟踪:
在分布式系统的RPC调用时,可能会涉及多个远程服务的调用。假设分布式的应用系统由 4 个子服务组成,4 个服务的依赖关系为 A->B->C->D,如果调用服务出现异常,光打印日志是往往是不能快速定位问题的。这时就需要使用分布式链路跟踪了。比较著名分的布式链路跟踪组件是就是sleuth和zipking。
分布式链路跟踪有 Trace 与 Span 的概念,Trace 就是一个服务调用链路,每次分布式都会产生一个 Trace,每个 Trace 都有它的唯一标识即 TraceId,在分布式链路跟踪系统中,就是通过 TraceId 来区分每个 Trace 。
Span 就是代表了一调用链路中的一段链路,也就是说 Trace 是由多个 Span 组成的。在一个Trace 下,每个 Span 也都有它的唯一标识 SpanId,而 Span 是存在父子关系的。
比如:在 A->B->C->D 的情况下,在整个调用链中,正常情况下会产生 3个 Span,分别是 Span1(A->B)、Span2(B->C)、Span3(C->D),这时 Span3
的父 Span 就是 Span2,而 Span2 的父 Span 就是 Span1
再例如:
在sleuth中会产生这样的日志:[order-service,96f95a0dd81fe3ab,852ef4cfcdecabf3,false]
1、第一个值,是项目配置的spring.application.name的值
2、第二个值,96f95a0dd81fe3ab ,sleuth生成的一个ID,就是Trace ID,用来标识一条请求链路,一条请求链路中包含一个Trace ID,多个Span ID。
3、第三个值,852ef4cfcdecabf3、就是spanId。
4、第四个值:false,是否要将该信息输出到zipkin服务中来收集和展示。
路由网关和配置中心:
路由网关的作用类似nginx haproxy等,所有的请求在达到集群前会先通过网关进行url过滤、权限验证、负载均衡等,路由网关也是通过服务发现功能将注册中心所有的节点IP信息拉取到本地,然后通过配置过滤url、进行负载均衡等。路由网关在业界比较出名的有zuul和gateway,其中gateway使用的nio网络编程技术,在性能上比zuul更加的出色。
配置中心则是将系统的配置集中一起,例如:spring yml、网关的配置的更改、网关限流策略的变更等各种配置,然后各个节点通过TCP的长连接将配置中心相应的配置拉取下来实现热加载和统一管理的效果。市面比较流行的是 apollo