抛去教条性质的解释,从巨石应用到微服务应用,耦合度是其中最大的变化。或是将多个模块中重复的部分进行拆分,或是纯粹为了拆分膨胀的单体应用,这些拆分出来的部分独立成一个服务单独部署与维护,便是微服务了。
拆分后自然而然会催生出一些必要的需求:
从目前常见网站架构的宏观角度看,微服务处在中间的层次。红框圈出的部分都属于微服务的范畴。包括最基础的rpc框架、注册中心、配置中心,以及更广义角度的监控追踪、治理中心、调度中心等。
从微服务自身角度来看,则大致会包含以下这些模块:
是不是只要套上微服务框架就算是一个微服务了呢?虽然这样有了微服务的表,但却没有微服务的实质,即”微“。微服务化的前提是服务拆分到足够”微“,足够单一职责,当然拆分程度与服务边界都需要结合业务自行把握。
广义的服务拆分即包含了应用拆分,也包含了数据拆分。
应用拆分后需要引入微服务框架来进行服务通信与服务治理,这也就是传统定义上的微服务。
数据拆分后同样需要引入一系列手段来进行保障,由于不是与微服务强相关的话题,在此只做简单阐述:
在我们对微服务架构有了整体的认识,并且具备了服务化的前提后,一个完整的微服务请求需要涉及到哪些内容呢?这其中包括了微服务框架所具备的三个基本功能:
首先我们面临的第一个问题是,如何发布服务和引用服务。具体一点就是,这个服务的接口名是啥,有哪些参数,返回值是什么类型等等,通常也就是接口描述信息。
常见的发布和引用的方式包括:
一般来讲,不管使用哪种方式,服务端定义接口与实现接口都是必要的,例如:
@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";
}
}
这种常使用HTTP或者HTTPS协议调用服务,相对来说,性能稍差。
首先服务端如上定义接口并实现接口,随后服务提供者可以使用类似restEasy这样的框架通过servlet的方式发布服务,而服务消费者直接引用定义的接口调用。
除此之外还有一种类似feign的方式,即服务端的发布依赖于springmvc controller,框架只基于客户端模板化http请求调用。这种情况下需接口定义与服务端controller协商一致,这样客户端直接引用接口发起调用即可。
XML
使用私有rpc协议的都会选择xml配置的方式来描述接口,比较高效,例如dubbo、motan等。
同样服务端如上定义接口并实现接口,服务端通过server.xml将文件接口暴露出去。服务消费者则通过client.xml引用需要调用的接口。
但这种方式对业务代码入侵较高,xml配置有变更时候,服务消费者和服务提供者都需要更新。
IDL
IDL是接口描述语言,常用于跨语言之间的调用,最常用的IDL包括Thrift协议以及gRpc协议。例如gRpc协议使用Protobuf来定义接口,写好一个proto文件后,利用语言对应的protoc插件生成对应server端与client端的代码,便可直接使用。
但是如果参数字段非常多,proto文件会显得非常大难以维护。并且如果字段经常需要变更,例如删除字段,PB就无法做到向前兼容。
一些tips
不管哪种方式,在接口变更的时候都需要通知服务消费者。消费者对api的强依赖性是很难避免的,接口变更引起的各种调用失败也十分常见。所以如果有变更,尽量使用新增接口的方式,或者给每个接口定义好版本号吧。
在使用上,大多数人的选择是对外Restful,对内Xml,跨语言IDL。
一些问题
在实际的服务发布与引用的落地上,还会存在很多问题,大多和配置信息相关。例如一个简单的接口调用超时时间配置,这个配置应该配在服务级别还是接口级别?是放在服务提供者这边还是服务消费者这边?
在实践中,大多数服务消费者会忽略这些配置,所以服务提供者自身提供默认的配置模板是有必要的,相当于一个预定义的过程。每个服务消费者在继承服务提供者预定义好的配置后,还需要能够进行自定义的配置覆盖。
但是,比方说一个服务有100个接口,每个接口都有自身的超时配置,而这个服务又有100个消费者,当服务节点发生变更的时候,就会发生100*100次注册中心的消息通知,这是比较可怕的,就有可能引起网络风暴。
假设你已经发布了服务,并在一台机器上部署了服务,那么消费者该怎样找到你的服务的地址呢?
也许有人会说是DNS,但DNS有许多缺陷:
其实在分布式系统中,有个很重要的角色,叫注册中心,便是用于解决该问题。
注册中心的实现主要需要考虑以下这些问题:
一个老旧的命题,即分布式系统中的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方法来获取服务变更。
在上面,服务消费者已经正确引用了服务,并发现了该服务的地址,那么如何向这个地址发起请求呢?要解决服务间的远程通信问题,我们需要考虑一些问题:
简单来说,就是客户端是怎么处理请求?服务端又是怎么处理请求的?
先从客户端来说,我们创建连接的时机可以是从注册中心获取到节点信息的时候,但更多时候,我们会选择在第一次请求发起调用的时候去创建连接。此外,我们往往会为该节点维护一个连接池,进行连接复用。
如果是异步的情况下,我们还需要为每一个请求编号,并维护一个请求池,从而在响应返回时找到对应的请求。当然这并不是必须的,很多框架会帮我们干好这些事情,比如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。具体情况需要自行定夺。
在超时设置的时候,对于同步与异步的接口也是有区分的。对于同步接口,超时设置的值不仅需要考虑到下游接口,还需要考虑上游接口。而对于异步来说,由于接口已经快速返回,可以不用考虑上游接口,只需考虑自身在异步线程里的阻塞时长,所以超时时间也放得更宽一些。
请求调用永远不能保证成功,那么当请求失败时候,服务消费者可以如何进行容错呢?通常容错机制分为以下这些:
另外,还有很多形形色色的容错机制,大多是基于自己的业务特性定制的,主要是在重试上做文章,例如每次重试等待时间都呈指数增长等。
第三方框架也都会内置默认的容错机制,例如Ribbon的容错机制就是由retry以及retry next组成,即重试当前实例与重试下一个实例。这里要多说一句,ribbon的重试次数与重试下一个实例次数是以笛卡尔乘积的方式提供的噢!
熔断
上一节将的容错机制,主要是一些重试机制,对于偶然因素导致的错误比较有效,例如网络原因。但如果错误的原因是服务提供者自身的故障,那么重试机制反而会引起服务恶化。这时候我们需要引入一种熔断的机制,即在一定时间内不再发起调用,给予服务提供者一定的恢复时间,等服务提供者恢复正常后再发起调用。这种保护机制大大降低了链式异常引起的服务雪崩的可能性。
在实际应用中,熔断器往往分为三种状态,打开、半开以及关闭。引用一张martinfowler画的原理图:
在普通情况下,断路器处于关闭状态,请求可以正常调用。当请求失败达到一定阈值条件时,则打开断路器,禁止向服务提供者发起调用。当断路器打开后一段时间,会进入一个半开的状态,此状态下的请求如果调用成功了则关闭断路器,如果没有成功则重新打开断路器,等待下一次半开状态周期。
断路器的实现中比较重要的一点是失败阈值的设置。可以根据业务需求设置失败的条件为连续失败的调用次数,也可以是时间窗口内的失败比率,失败比率通过一定的滑动窗口算法进行计算。另外,针对断路器的半开状态周期也可以做一些花样,一种常见的计算方法是周期长度随着失败次数呈指数增长。
具体的实现方式可以根据具体业务指定,也可以选择第三方框架例如Hystrix。
隔离往往和熔断结合在一起使用,还是以Hystrix为例,它提供了两种隔离方式:
降级同样大多和熔断结合在一起使用,当服务调用者这方断路器打开后,无法再对服务提供者发起调用了,这时候可以通过返回降级数据来避免熔断造成的影响。
降级往往用于那些错误容忍度较高的业务。同时降级的数据如何设置也是一门学问。一种方法是为每个接口预先设置好可接受的降级数据,但这种静态降级的方法适用性较窄。还有一种方法,是去线上日志系统/流量录制系统中捞取上一次正确的返回数据作为本次降级数据,但这种方法的关键是提供可供稳定抓取请求的日志系统或者流量采样录制系统。
参考:面试官:什么是熔断?什么是服务降级?
另外,针对降级我们往往还会设置操作开关,对于一些影响不大的采取自动降级,而对于一些影响较大的则需进行人为干预降级。
限流就是限制服务请求流量,服务提供者可以根据自身情况(容量)给请求设置一个阈值,当超过这个阈值后就丢弃请求,这样就保证了自身服务的正常运行。
阈值的设置可以针对两个方面考虑,一是QPS即每秒请求数,二是并发线程数。从实践来看,我们往往会选择后者,因为QPS高往往是由于处理能力高,并不能反映出系统"不堪重负"。
除此之外,我们还有许多针对限流的算法。例如令牌桶算法以及漏桶算法,主要针对突发流量的状况做了优化。第三方的实现中例如guava rateLimiter就实现了令牌桶算法。在此就不就细节展开了。
限流更多的起到一种保障的作用,但如果服务提供者已经出现问题了,这时候该怎么办呢?
这时候就会出现两种状况。一是本身代码有bug,这时候一方面需要服务消费者做好熔断降级等操作,一方面服务提供者这边结合DevOps需要有快速回滚到上一个正确版本的能力。
更多的时候,我们可能仅仅碰到了与代码无强关联的单机故障,一个简单粗暴的办法就是自动重启。例如观察到某个接口的平均耗时超出了正常范围一定程度,就将该实例进行自动重启。当然自动重启需要有很多注意事项,例如重启时间是否放在晚上,以及自动重启引起的与上述节点摘除一样的问题,都需要考虑和处理。
在事后复盘的时候,如果当时没有保护现场,就很难定位到问题原因。所以往往在一键回滚或者自动重启之前,我们往往需要进行现场保护。现场保护可以是自动的,例如一开始就给jvm加上打印gc日志的参数-XX:+PrintGCDetails,或者输出oom文件-XX:+HeapDumpOnOutOfMemoryError,也可以配合DevOps自动脚本完成,当然手动也可以。一般来说我们会如下操作:
除了以上这些措施,通过调度流量来避免调用到问题节点上也是非常常用的手段。
当服务提供者中的一台机器出现问题,而其他机器正常时,我们可以结合负载均衡算法迅速调整该机器的权重至0,避免流量流入,再去机器上进行慢慢排查,而不用着急第一时间重启。
如果服务提供者分了不同集群/分组,当其中一个集群出现问题时,我们也可以通过路由算法将流量路由到正常的集群中。这时候一个集群就是一个微服务分组。
而当机房炸了、光缆被偷了等IDC故障时,我们又部署了多IDC,也可以通过一些方式将流量切换到正常的IDC,以供服务继续正常运行。切换流量同样可以通过微服务的路由实现,但这时候一个IDC对应一个微服务分组了。除此之外,使用DNS解析进行流量切换也是可以的,将对外域名的VIP从一个IDC切换到另一个IDC。