微服务是什么?抛去教条性质的解释,从巨石应用到微服务应用,耦合度是其中最大的变化。
或是将多个模块中重复的部分进行拆分,或是纯粹为了拆分膨胀的单体应用,这些拆分出来的部分独立成一个服务单独部署与维护,便是微服务了。
拆分后自然而然会催生出一些必要的需求:
从本地方法调用的关系衍变成远程过程调用的关系,那么可靠的通信功能是首要的。
随着拆分工作的推进,资源调度关系会变得错综复杂,这时候需要完善的服务治理。
调用关系网的整体复杂化还会给我们带来更大的风险,即链式反应导致服务雪崩的可能性,所以如何保障服务稳定性也是微服务架构中需要考虑的。
这点就不是内需而算是自我演进了,服务化后,如果能结合容器化、Devops 技术实现服务运维一体化,将大大降低微服务维护的成本,不管是现在还是将来。
微服务是什么样的
从目前常见网站架构的宏观角度看,微服务处在中间的层次。红框圈出的部分都属于微服务的范畴。
包括最基础的 RPC 框架、注册中心、配置中心,以及更广义角度的监控追踪、治理中心、调度中心等。
从微服务自身角度来看,则大致会包含以下这些模块:
服务注册与发现
RPC 远程调用
路由与负载均衡
服务监控
服务治理
服务化的前提
是不是只要套上微服务框架就算是一个微服务了呢?虽然这样有了微服务的表,但却没有微服务的实质,即“微”。
微服务化的前提是服务拆分到足够”微“,足够单一职责,当然拆分程度与服务边界都需要结合业务自行把握。
广义的服务拆分即包含了应用拆分,也包含了数据拆分。应用拆分后需要引入微服务框架来进行服务通信与服务治理,这也就是传统定义上的微服务。
数据拆分后同样需要引入一系列手段来进行保障,由于不是与微服务强相关的话题,在此只做简单阐述:
分布式 ID
新表优化
数据迁移与数据同步
SQL 调用方案改造
切库方案
数据一致性
具体的微服务请求背后
在我们对微服务架构有了整体的认识,并且具备了服务化的前提后,一个完整的微服务请求需要涉及到哪些内容呢?
这其中包括了微服务框架所具备的三个基本功能:
服务的发布与引用
服务的注册与发现
服务的远程通信
服务的发布与引用
首先我们面临的第一个问题是,如何发布服务和引用服务。具体一点就是,这个服务的接口名是啥,有哪些参数,返回值是什么类型等等,通常也就是接口描述信息。
常见的发布和引用的方式包括:
RESTful API/声明式 Restful API
XML
IDL
一般来讲,不管使用哪种方式,服务端定义接口与实现接口都是必要的,例如:
@exa(id = "xxx")
public interface testApi {
@PostMapping(value = "/soatest/{id}")
String getResponse(@PathVariable(value = "id") final Integer index, @RequestParam(value = "str") final String Data);
}
具体实现如下:
public class testApiImpl implements testApi{
@Override
String getResponse(final Integer index, final String Data){
return "ok";
}
}
首先服务端如上定义接口并实现接口,随后服务提供者可以使用类似 restEasy 这样的框架通过 Servlet 的方式发布服务,而服务消费者直接引用定义的接口调用。
除此之外还有一种类似 Feign 的方式,即服务端的发布依赖于 SpringMVC Controller,框架只基于客户端模板化 HTTP 请求调用。
这种情况下需接口定义与服务端 Controller 协商一致,这样客户端直接引用接口发起调用即可。
同样服务端如上定义接口并实现接口,服务端通过 server.xml 将文件接口暴露出去。服务消费者则通过 client.xml 引用需要调用的接口。
但这种方式对业务代码入侵较高,XML 配置有变更时候,服务消费者和服务提供者都需要更新。
例如 gRpc 协议使用 Protobuf 来定义接口,写好一个 proto 文件后,利用语言对应的 protoc 插件生成对应 server 端与 client 端的代码,便可直接使用。
但是如果参数字段非常多,proto 文件会显得非常大难以维护。并且如果字段经常需要变更,例如删除字段,PB 就无法做到向前兼容。
在实践中,大多数服务消费者会忽略这些配置,所以服务提供者自身提供默认的配置模板是有必要的,相当于一个预定义的过程。
每个服务消费者在继承服务提供者预定义好的配置后,还需要能够进行自定义的配置覆盖。
但是,比方说一个服务有 100 个接口,每个接口都有自身的超时配置,而这个服务又有 100 个消费者,当服务节点发生变更的时候,就会发生 100*100 次注册中心的消息通知,这是比较可怕的,就有可能引起网络风暴。
服务的注册与发现
假设你已经发布了服务,并在一台机器上部署了服务,那么消费者该怎样找到你的服务的地址呢?
也许有人会说是 DNS,但 DNS 有许多缺陷:
维护麻烦,更新延迟
无法在客户端做负载均衡
不能做到端口级别的服务发现
其实在分布式系统中,有个很重要的角色,叫注册中心,便是用于解决该问题。
使用注册中心寻址并调用的过程如下:
服务启动时,向注册中心注册自身,并定期发送心跳汇报存活状态。
客户端调用服务时,向注册中心订阅服务,并将节点列表缓存至本地,再与服务端建立连接(当然这儿可以 lazy load)。发起调用时,在本地缓存节点列表中,基于负载均衡算法选取一台服务端发起调用。
当服务端节点发生变更,注册中心能感知到后通知到客户端。
注册中心的实现主要需要考虑以下这些问题:
自身一致性与可用性
注册方式
存储结构
服务健康监测
状态变更通知
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 的处理
传输协议
序列化方式
简单来说,就是客户端是怎么处理请求?服务端又是怎么处理请求的?
先从客户端来说,我们创建连接的时机可以是从注册中心获取到节点信息的时候,但更多时候,我们会选择在第一次请求发起调用的时候去创建连接。此外,我们往往会为该节点维护一个连接池,进行连接复用。
如果是异步的情况下,我们还需要为每一个请求编号,并维护一个请求池,从而在响应返回时找到对应的请求。当然这并不是必须的,很多框架会帮我们干好这些事情,比如 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 就实现了令牌桶算法。在此就不就细节展开了。
限流更多的起到一种保障的作用,但如果服务提供者已经出现问题了,这时候该怎么办呢?
这时候就会出现两种状况:一是本身代码有 Bug,这时候一方面需要服务消费者做好熔断降级等操作,一方面服务提供者这边结合 DevOps 需要有快速回滚到上一个正确版本的能力。
更多的时候,我们可能仅仅碰到了与代码无强关联的单机故障,一个简单粗暴的办法就是自动重启。
例如观察到某个接口的平均耗时超出了正常范围一定程度,就将该实例进行自动重启。
当然自动重启需要有很多注意事项,例如重启时间是否放在晚上,以及自动重启引起的与上述节点摘除一样的问题,都需要考虑和处理。
在事后复盘的时候,如果当时没有保护现场,就很难定位到问题原因。所以往往在一键回滚或者自动重启之前,我们往往需要进行现场保护。
现场保护可以是自动的,例如:
一开始就给 jvm 加上打印 gc 日志的参数 -XX:+PrintGCDetails
或者输出 oom 文件 -XX:+HeapDumpOnOutOfMemoryError
也可以配合 DevOps 自动脚本完成,当然手动也可以
一般来说我们会如下操作:
打印堆栈信息,jstak -l 'java进程PID'
打印内存镜像,jmap -dump:format=b,file=hprof 'java进程PID'
保留 gc 日志,保留业务日志
除了以上这些措施,通过调度流量来避免调用到问题节点上也是非常常用的手段。
当服务提供者中的一台机器出现问题,而其他机器正常时,我们可以结合负载均衡算法迅速调整该机器的权重至 0,避免流量流入,再去机器上进行慢慢排查,而不用着急第一时间重启。
如果服务提供者分了不同集群/分组,当其中一个集群出现问题时,我们也可以通过路由算法将流量路由到正常的集群中。这时候一个集群就是一个微服务分组。
而当机房炸了、光缆被偷了等 IDC 故障时,我们又部署了多 IDC,也可以通过一些方式将流量切换到正常的 IDC,以供服务继续正常运行。
切换流量同样可以通过微服务的路由实现,但这时候一个 IDC 对应一个微服务分组了。
除此之外,使用 DNS 解析进行流量切换也是可以的,将对外域名的 VIP 从一个 IDC 切换到另一个 IDC。
作者:fredalxin
编辑:陶家龙
出处:https://fredal.xin/
在公众号菜单中可自行获取专属架构视频资料,包括不限于 java架构、python系列、人工智能系列、架构系列,以及最新面试、小程序、大前端均无私奉献,你会感谢我的哈
往期热门文章:
1,架构的本质:如何打造一个有序的系统?
2,分布式高可靠之负载均衡,今天看了你肯定会
3,分布式数据之缓存技术,一起来揭开其神秘面纱
4,分布式数据复制技术,今天就教你真正分身术
5,数据分布方式之哈希与一致性哈希,我就是个神算子
6 ,分布式存储系统三要素,掌握这些就离成功不远了
7 ,想要设计一个好的分布式系统,必须搞定这个理论
8 ,分布式通信技术之发布订阅,干货满满
9,分布式通信技术之远程调用:RPC
10 ,秒杀系统每秒上万次下单请求,我们该怎么去设计