【一线大厂Java面试题解析+后端开发学习笔记+最新架构讲解视频+实战项目源码讲义】
**开源地址:https://docs.qq.com/doc/DSmxTbFJ1cmN1R2dB **
-
服务启动时,向注册中心注册自身,并定期发送心跳汇报存活状态。
-
客户端调用服务时,向注册中心订阅服务,并将节点列表缓存至本地,再与服务端建立连接(当然这儿可以 lazy load)。发起调用时,在本地缓存节点列表中,基于负载均衡算法选取一台服务端发起调用。
-
当服务端节点发生变更,注册中心能感知到后通知到客户端。
注册中心的实现主要需要考虑以下这些问题:
-
自身一致性与可用性
-
注册方式
-
存储结构
-
服务健康监测
-
状态变更通知
①一致性与可用性
一个老旧的命题,即分布式系统中的 CAP(一致性、可用性、分区容错性)。
我们知道同时满足 CAP 是不可能的,那么便需要有取舍。常见的注册中心大致分为 CP 注册中心以及 AP 注册中心。
CP 注册中心:比较典型的就是 Zookeeper、etcd 以及 Consul 了,牺牲可用性来保证了一致性,通过 Zab 协议或者 Raft 协议来保证一致性。
AP 注册中心:牺牲一致性来保证可用性,感觉只能列出 Eureka 了。Eureka 每个服务器单独保存节点列表,可能会出现不一致的情况。
从理论上来说,仅用于注册中心,AP 型是远比 CP 型合适的。可用性的需求远远高于一致性,一致性只要保证最终一致即可,而不一致的时候还可以使用各种容错策略进行弥补。
保障高可用性其实还有很多办法,例如集群部署或者多 IDC 部署等。Consul 就是多 IDC 部署保障可用性的典型例子,它使用了 wan gossip 来保持跨机房状态同步。
②注册方式
有两种与注册中心交互的方式,一种是通过应用内集成 SDK,另一种则是通过其他方式在应用外间接与注册中心交互。
应用内:这应该就是最常见的方式了,客户端与服务端都集成相关sdk与注册中心进行交互。
例如选择 Zookeeper 作为注册中心,那么就可以使用 Curator SDK 进行服务的注册与发现。
应用外:Consul 提供了应用外注册的解决方案,Consul Agent 或者第三方 Registrator 可以监听服务状态,从而负责服务提供者的注册或销毁。
而 Consul Template 则可以做到定时从注册中心拉取节点列表,并刷新 LB 配置(例如通过 Nginx 的 upstream),这样就相当于完成了服务消费者端的负载均衡。
③存储结构
注册中心存储相关信息一般采取目录化的层次结构,一般分为服务-接口-节点信息。
同时注册中心一般还会进行分组,分组的概念很广,可以是根据机房划分也可以根据环境划分。
节点信息主要会包括节点的地址(ip 和端口号),还有一些节点的其他信息,比如请求失败的重试次数、超时时间的设置等等。
当然很多时候,其实可能会把接口这一层给去掉,因为考虑到接口数量很多的情况下,过多的节点会造成很多问题,比如之前说的网络风暴。
④服务健康监测
服务存活状态监测也是注册中心的一个必要功能。在 Zookeeper 中,每个客户端都会与服务端保持一个长连接,并生成一个 Session。
在 Session 过期周期内,通过客户端定时向服务端发送心跳包来检测链路是否正常,服务端则重置下次 Session 的过期时间。
如果 Session 过期周期内都没有检测到客户端的心跳包,那么就会认为它已经不可用了,将其从节点列表中移除。
⑤状态变更通知
在注册中心具备服务健康检测能力后,还需要将状态变更通知到客户端。在 Zookeeper 中,可以通过监听器 Watcher 的 Process 方法来获取服务变更。
服务的远程通信
在上面,服务消费者已经正确引用了服务,并发现了该服务的地址,那么如何向这个地址发起请求呢?
要解决服务间的远程通信问题,我们需要考虑一些问题:
-
网络 I/O 的处理
-
传输协议
-
序列化方式
①网络 I/O 的处理
简单来说,就是客户端是怎么处理请求?服务端又是怎么处理请求的?
先从客户端来说,我们创建连接的时机可以是从注册中心获取到节点信息的时候,但更多时候,我们会选择在第一次请求发起调用的时候去创建连接。此外,我们往往会为该节点维护一个连接池,进行连接复用。
如果是异步的情况下,我们还需要为每一个请求编号,并维护一个请求池,从而在响应返回时找到对应的请求。当然这并不是必须的,很多框架会帮我们干好这些事情,比如 rxNetty。
从服务端来说,处理请求的方式就可以追溯到 Unix 的 5 种 IO 模型了。我们可以直接使用 Netty、MINA 等网络框架来处理服务端请求,或者如果你有十分的兴趣,可以自己实现一个通信框架。
②传输协议
最常见的当然是直接使用 HTTP 协议,使用双方无需关注和了解协议内容,方便直接,但自然性能上会有所折损。
还有就是目前比较火热的 HTTP2 协议,拥有二进制数据、头部压缩、多路复用等许多优良特性。
但从自身的实践上看,HTTP2 要走到生产仍有一段距离,一个最简单的例子,升级到 HTTP2 后所有的 header names 都变成小写,同时不是 case-insenstive 了,这时候就会有兼容性问题。
当然如果追求更高效与可控的传输,可以定制私有协议并基于 TCP 进行传输。私有协议的定制需要通信双方都了解其特性,设计上还需要注意预留好扩展字段,以及处理好粘包分包等问题。
③序列化方式
在网络传输的前后,往往都需要在发送端进行编码,在服务端进行解码,这样主要是为了在网络传输时候减少数据传输量。
常用的序列化方式包括文本类的,例如 XML/JSON,还有二进制类型的,例如 Protobuf/Thrift 等。
在选择序列化的考虑上:
一是性能,Protobuf 的压缩大小和压缩速度都会比 JSON 快很多,性能也更好。
二是兼容性上,相对来说,JSON 的前后兼容性会强一些,可以用于接口经常变化的场景。
在此还是需要强调,使用每一种序列化都需要了解过其特性,并在接口变更的时候拿捏好边界。
例如 jackson 的 FAIL_ON_UNKNOW_PROPERTIES 属性、kryo 的 CompatibleFieldSerializer、jdk 序列化会严格比较 serialVersionUID 等等。
微服务的稳定性
当一个单体应用改造成多个微服务之后,在请求调用过程中往往会出现更多的问题,通信过程中的每一个环节都可能出现问题。
而在出现问题之后,如果不加处理,还会出现链式反应导致服务雪崩。服务治理功能就是用来处理此类问题的。
我们将从微服务的三个角色:注册中心、服务消费者以及服务提供者一一说起。
注册中心如何保障稳定性
注册中心主要是负责节点状态的维护,以及相应的变更探测与通知操作。
一方面,注册中心自身的稳定性是十分重要的。另一方面,我们也不能完全依赖注册中心,需要时常进行类似注册中心完全宕机后微服务如何正常运行的故障演练。
这一节,我们着重讲的并不是注册中心自身可用性保证,而更多的是与节点状态相关的部分。
①节点信息的保障
我们说过,当注册中心完全宕机后,微服务框架仍然需要有正常工作的能力。这得益于框架内处理节点状态的一些机制。
本机内存:首先服务消费者会将节点状态保持在本机内存中。
一方面由于节点状态不会变更得那么频繁,放在内存中可以减少网络开销。另一方面,当注册中心宕机后,服务消费者仍能从本机内存中找到服务节点列表从而发起调用。
本地快照:我们说,注册中心宕机后,服务消费者仍能从本机内存中找到服务节点列表。那么如果服务消费者重启了呢?
这时候我们就需要一份本地快照了,即我们保存一份节点状态到本地文件,每次重启之后会恢复到本机内存中。
②服务节点的摘除
现在无论注册中心工作与否,我们都能顺利拿到服务节点了。但是不是所有的服务节点都是正确可用的呢?
在实际应用中,这是需要打问号的。如果我们不校验服务节点的正确性,很有可能就调用到了一个不正常的节点上。所以我们需要进行必要的节点管理。
对于节点管理来说,我们有两种手段,主要是去摘除不正确的服务节点。
**注册中心摘除机制:**一是通过注册中心来进行摘除节点。服务提供者会与注册中心保持心跳,而一旦超出一定时间收不到心跳包,注册中心就认为该节点出现了问题,会把节点从服务列表中摘除,并通知到服务消费者,这样服务消费者就不会调用到有问题的节点上。
**服务消费者摘除机制:**二是在服务消费者这边拆除节点。因为服务消费者自身是最知道节点是否可用的角色,所以在服务消费者这边做判断更合理,如果服务消费者调用出现网络异常,就将该节点从内存缓存列表中摘除。
当然调用失败多少次之后才进行摘除,以及摘除恢复的时间等等细节,其实都和客户端熔断类似,可以结合起来做。
一般来说,对于大流量应用,服务消费者摘除的敏感度会高于注册中心摘除,两者之间也不用刻意做同步判断,因为过一段时间后注册中心摘除会自动覆盖服务消费者摘除。
③服务节点是可以随便摘除/变更的么
上一节我们讲可以摘除问题节点,从而避免流量调用到该节点上。但节点是可以随便摘除的么?同时,这也包含"节点是可以随便更新的么?"疑问。
频繁变动:当网络抖动的时候,注册中心的节点就会不断变动。这导致的后果就是变更消息会不断通知到服务消费者,服务消费者不断刷新本地缓存。
如果一个服务提供者有 100 个节点,同时有 100 个服务消费者,那么频繁变动的效果可能就是 100*100,引起带宽打满。
这时候,我们可以在注册中心这边做一些控制,例如经过一段时间间隔后才能进行变更消息通知,或者打开开关后直接屏蔽不进行通知,或者通过一个概率计算来判断需要向哪些服务消费者通知。
增量更新:同样是由于频繁变动可能引起的网络风暴问题,一个可行的方案是进行增量更新,注册中心只会推送那些变化的节点信息而不是全部,从而在频繁变动的时候避免网络风暴。
可用节点过少:当网络抖动,并进行节点摘除过后,很可能出现可用节点过少的情况。
这时候过大的流量分配给过少的节点,导致剩下的节点难堪重负,罢工不干,引起恶化。
而实际上,可能节点大多数是可用的,只不过由于网络问题与注册中心未能及时保持心跳而已。
这时候,就需要在服务消费者这边设置一个开关比例阈值,当注册中心通知节点摘除,但缓存列表中剩下的节点数低于一定比例后(与之前一段时间相比),不再进行摘除,从而保证有足够的节点提供正常服务。
这个值其实可以设置的高一些,例如百分之 70,因为正常情况下不会有频繁的网络抖动。当然,如果开发者确实需要下线多数节点,可以关闭该开关。
服务消费者如何保障稳定性
一个请求失败了,最直接影响到的是服务消费者,那么在服务消费者这边,有什么可以做的呢?
①超时
如果调用一个接口,但迟迟没有返回响应的时候,我们往往需要设置一个超时时间,以防自己被远程调用拖死。
超时时间的设置也是有讲究的,设置的太长起的作用就小,自己被拖垮的风险就大,设置的太短又有可能误判一些正常请求,大幅提升错误率。
在实际使用中,我们可以取该应用一段时间内的 P999 的值,或者取 p95 的值*2,具体情况需要自行定夺。
在超时设置的时候,对于同步与异步的接口也是有区分的。对于同步接口,超时设置的值不仅需要考虑到下游接口,还需要考虑上游接口。
而对于异步来说,由于接口已经快速返回,可以不用考虑上游接口,只需考虑自身在异步线程里的阻塞时长,所以超时时间也放得更宽一些。
②容错机制
请求调用永远不能保证成功,那么当请求失败时候,服务消费者可以如何进行容错呢?
通常容错机制分为以下这些:
- FailTry:失败重试。就是指最常见的重试机制,当请求失败后视图再次发起请求进行重试。
这样从概率上讲,失败率会呈指数下降。对于重试次数来说,也需要选择一个恰当的值,如果重试次数太多,就有可能引起服务恶化。
另外,结合超时时间来说,对于性能有要求的服务,可以在超时时间到达前的一段提前量就发起重试,从而在概率上优化请求调用。当然,重试的前提是幂等操作。
-
FailOver:失败切换。和上面的策略类似,只不过 FailTry 会在当前实例上重试。而 FailOver 会重新在可用节点列表中根据负载均衡算法选择一个节点进行重试。
-
FailFast:快速失败。请求失败了就直接报一个错,或者记录在错误日志中,这没什么好说的。
另外,还有很多形形色色的容错机制,大多是基于自己的业务特性定制的,主要是在重试上做文章,例如每次重试等待时间都呈指数增长等。
第三方框架也都会内置默认的容错机制,例如 Ribbon 的容错机制就是由 retry 以及 retry next 组成,即重试当前实例与重试下一个实例。
这里要多说一句,Ribbon 的重试次数与重试下一个实例次数是以笛卡尔乘积的方式提供的噢!
③熔断
上一节将的容错机制,主要是一些重试机制,对于偶然因素导致的错误比较有效,例如网络原因。
但如果错误的原因是服务提供者自身的故障,那么重试机制反而会引起服务恶化。
这时候我们需要引入一种熔断的机制,即在一定时间内不再发起调用,给予服务提供者一定的恢复时间,等服务提供者恢复正常后再发起调用。这种保护机制大大降低了链式异常引起的服务雪崩的可能性。
在实际应用中,熔断器往往分为三种状态,打开、半开以及关闭。引用一张 MartinFowler 画的原理图:
在普通情况下,断路器处于关闭状态,请求可以正常调用。当请求失败达到一定阈值条件时,则打开断路器,禁止向服务提供者发起调用。
当断路器打开后一段时间,会进入一个半开的状态,此状态下的请求如果调用成功了则关闭断路器,如果没有成功则重新打开断路器,等待下一次半开状态周期。
断路器的实现中比较重要的一点是失败阈值的设置。可以根据业务需求设置失败的条件为连续失败的调用次数,也可以是时间窗口内的失败比率,失败比率通过一定的滑动窗口算法进行计算。
另外,针对断路器的半开状态周期也可以做一些花样,一种常见的计算方法是周期长度随着失败次数呈指数增长。
具体的实现方式可以根据具体业务指定,也可以选择第三方框架例如 Hystrix。
④隔离
隔离往往和熔断结合在一起使用,还是以 Hystrix 为例,它提供了两种隔离方式:
信号量隔离:使用信号量来控制隔离线程,你可以为不同的资源设置不同的信号量以控制并发,并相互隔离。当然实际上,使用原子计数器也没什么不一样。
线程池隔离:通过提供相互隔离的线程池的方式来隔离资源,相对来说消耗资源更多,但可以更好地应对突发流量。
⑤降级
降级同样大多和熔断结合在一起使用,当服务调用者这方断路器打开后,无法再对服务提供者发起调用了,这时候可以通过返回降级数据来避免熔断造成的影响。
降级往往用于那些错误容忍度较高的业务。同时降级的数据如何设置也是一门学问。
一种方法是为每个接口预先设置好可接受的降级数据,但这种静态降级的方法适用性较窄。
还有一种方法,是去线上日志系统/流量录制系统中捞取上一次正确的返回数据作为本次降级数据,但这种方法的关键是提供可供稳定抓取请求的日志系统或者流量采样录制系统。
另外,针对降级我们往往还会设置操作开关,对于一些影响不大的采取自动降级,而对于一些影响较大的则需进行人为干预降级。
服务提供者如何保障稳定性
①限流
限流就是限制服务请求流量,服务提供者可以根据自身情况(容量)给请求设置一个阈值,当超过这个阈值后就丢弃请求,这样就保证了自身服务的正常运行。
阈值的设置可以针对两个方面考虑:
-
QPS,即每秒请求数
-
并发线程数
从实践来看,我们往往会选择后者,因为 QPS 高往往是由于处理能力高,并不能反映出系统"不堪重负"。
除此之外,我们还有许多针对限流的算法。例如令牌桶算法以及漏桶算法,主要针对突发流量的状况做了优化。
第三方的实现中例如 guava rateLimiter 就实现了令牌桶算法。在此就不就细节展开了。