微服务化之后,系统分布式部署,传统单个流程的本地API调用被拆分成多个微服务之间的跨网络调用,由于引入了网络通信、序列化和反序列化等操作,系统发生故障的概率提高了很多。 微服务故障,有些是由于业务自身设计或者编码不当导致,有些是底层的微服务化框架容错能力不足导致。在实际项目中,需要从业务和平台两方面入手,提升微服务的可靠性。 传统单体架构一个完整的业务流程往往在同一个进程内部完成处理,不需要进行分布式协作,它的工作原理如下所示: 传统单体架构本地方法调用 微服务化之后,不同的微服务采用分布式集群部署方式,服务的消费者和提供者通常运行在不同的进程中,需要跨网络做RPC调用,它的工作原理如下所示: 微服务分布式RPC调用 分布式调用之后,相比于传统单体架构的本地方法调用,主要引入了如下潜在故障点:
理想情况下,每个微服务都独立打包和部署,微服务之间天然就支持进程级隔离,但事实上,对于一个大规模的企业IT系统、或者大型网站,是由成百上千个微服务组成的,在实践中,微服务通常是不可能做到百分之百独立部署的,原因如下:
不同的微服务合设在同一个进程之中,就会引入一系列潜在的故障点,例如:
传统情况下,往往使用服务注册中心检测微服务的状态,当检测到服务提供者不可用时,会将故障的服务信息广播到集群所有节点,消费者接收到服务故障通知消息之后,根据故障信息中的服务名称、IP地址等信息,对故障节点进行隔离。它的工作原理如下所示: 微服务状态检测 使用基于心跳或者会话的微服务状态检测,可以发现微服务所在进程宕机、网络故障等问题,但在实际业务中,微服务并非“非死即活”,它可能处于“亚健康状态”,服务调用失败率很高,但又不是全部失败。或者微服务已经处于过负荷流控状态,业务质量受损,但是又没有全部中断。 使用简单的微服务状态检测,很难应对上述这些场景。通过对微服务的运行质量建模,利用微服务健康度模型,根据采集的各种指标对微服务健康度实时打分,依据打分结果采取相应的可靠性对策,可以更有针对性的保障系统的可靠性。 在整个微服务调用过程中,主要会涉及到三类I/O操作:
微服务涉及的主要I/O操作 凡是涉及到I/O操作的,如果I/O操作是同步阻塞模式,例如Java的BIO、文件File的读写操作、数据库访问的JDBC接口等,都是同步阻塞的。只要访问的网络、磁盘或者数据库实例比较慢,都会导致调用方线程的阻塞。由于线程是Java虚拟机比较重要的资源,当大量微服务调用线程被阻塞之后,系统的吞吐量将严重下降。 在微服务中,调用第三方SDK API,也可能会引入新的故障点,例如通过FTP客户端访问远端的FTP服务,或者使用MQ客户端访问MQ服务,如果这些客户端API的容错性设计不好,也会导致调用方的级联故障,这些故障是潜在和隐性的,在设计的时候往往容易被忽视,但它带来的风险和危害是巨大的。 软件可靠性是指在给定时间内,特定环境下软件无错运行的概率。软件可靠性包含了以下三个要素:
微服务的运行质量,除了自身的可靠性因素之外,还受到其它因素的影响,包括网络、数据库访问、其它相关联的微服务运行质量等。微服务的可靠性设计,需要考虑上述综合因素,总结如下: 微服务可靠性设计模型 以Java为例,在JDK 1.4推出JAVA NIO1.0之前,基于JAVA的所有Socket通信都采用了同步阻塞模式(BIO),这种一请求一应答的通信模型简化了上层的应用开发,但是在可靠性和性能方面存在巨大的弊端: 传统Java 同步阻塞I/O模型 采用BIO通信模型的服务端,通常由一个独立的Acceptor线程负责监听客户端的连接,接收到客户端连接之后为客户端连接创建一个新的线程处理请求消息,处理完成之后,返回应答消息给客户端,线程销毁,这就是典型的一请求一应答模型。该架构最大的问题就是不具备弹性伸缩能力,当并发访问量增加后,服务端的线程个数和并发访问数成线性正比,由于线程是JAVA虚拟机非常宝贵的系统资源,当线程数膨胀之后,系统的性能急剧下降,随着并发量的继续增加,可能会发生句柄溢出、线程堆栈溢出等问题,并导致服务器最终宕机。 微服务进行远程通信时,通过使用非阻塞I/O,可以解决由于网络时延大、高并发接入等导致的服务端线程数膨胀或者线程被阻塞等问题。 以Java为例,从JDK1.4开始,JDK提供了一套专门的类库支持非阻塞I/O,可以在java.nio包及其子包中找到相关的类和接口。JDK1.7之后,又提供了NIO2.0类库,支持异步I/O操作。 利用JDK的异步非阻塞I/O,可以实现一个I/O线程同时处理多个客户端链路,读写操作不会因为网络原因被阻塞,I/O线程可以高效的并发处理多个客户端链路,实现I/O多路复用,它的工作原理如下所示: Java非阻塞I/O模型 使用非阻塞I/O进行通信,以Java语言为例,建议策略如下:
微服务对磁盘I/O的操作分为两类:
在实际项目中,最容易被忽视的就是日志操作。不同的日志类库,写日志的机制不同,以Log4j 1.2.X版本为例,当日志队列满之后,有多种策略:
在实际生产环境中,我们就遇到过类似问题,在某些时段,磁盘WIO达到10 持续几秒钟-10几秒钟,然后又恢复正常。WIO较高的时段,需要写接口日志、话单等,由于系统默认采用的是同步等待策略,结果导致通信I/O线程、微服务调度线程等都被阻塞,最终链路因为心跳超时被强制关闭、微服务被大量阻塞在消息队列中导致内存居高不小、响应超时等。 由于偶现的WIO高导致同步写日志被阻塞,继而引起通信线程、微服务调用线程级联故障,定位起来非常困难,平时Code Review也很难被注意到。所以,隐性的磁盘I/O操作,更需要格外关注。 要解决上面的问题,有三种策略:
以JDK1.7为例,它提供了异步的文件I/O操作类库,基于该类库,就不需要担心磁盘I/O操作被阻塞: JDK1.7异步非阻塞文件接口 自己在上层封装异步I/O操作,也比较简单,它的优点是可以实现磁盘I/O操作与微服务之间的线程隔离,但是底层仍然使用的是同步阻塞I/O,如果此时磁盘的I/O比较高,依然会阻塞写磁盘的I/O线程。它的原理如下所示: 应用层封装的异步文件操作 将文件I/O操作封装成一个Task或者Event,投递到文件I/O线程池的消息队列中,根据投递结果,构造I/O操作相关联的Future对象给微服务调用线程。通过向Future对象注册Listener并实现callback接口,可以实现异步回调通知,这样微服务和文件I/O操作就实现了线程隔离。文件I/O操作耗时,并不会阻塞微服务调度线程。 当使用第三方文件I/O操作类库时,需要注意下相关API,尽量使用支持异步非阻塞接口的API,如果没有,则需要考虑是否做上层的异步封装。 部分数据库访问支持非阻塞方式,例如Oracle的OCI,它支持non-blocking模式和blocking模式:阻塞方式就是当调用 OCI操作时,必须等到此OCI操作完成后服务器才返回客户端相应的信息,不管是成功还是失败。非阻塞方式是当客户端提交OCI操作给服务器后,服务器立即返回OCI_STILL_EXECUTING信息,而并不等待服务端的操作完成。对于non-blocking方式,应用程序若收到一个OCI函数的返回值为OCI_STILL_EXECUTING时必须再次对每一个OCI函数的返回值进行判断,判断其成功与否。 可通过设置服务器属性为OCI_ATTR_NONBLOCKING_MODE来实现。 对于Java语言而言,由于JDK本身提供了数据库连接驱动相关的接口定义,JDBC驱动本身就是同步API接口,因此,Java语言的开源ORM框架也都是同步阻塞的,例如MyBatis、Hibernate等。 尽管大部分数据库访问接口是同步阻塞的,但是由于数据库中间件的超时控制机制都比较成熟,因此通过合理设置超时时间,可以避免微服务的数据库访问被长时间挂住。 也可以在应用上层封装异步数据库操作层,实现微服务调度与数据库操作的线程级隔离,原理前文已经介绍过,采用该方式同样存在两点不足:
由于大部分微服务采用同步接口调用,而且多个领域相关的微服务会部署在同一个进程中,很容易发生“雪崩效应”,即某个微服务提供者故障,导致调用该微服务的消费者、或者与故障微服务合设在同一个进程中的其它微服务发生级联故障,最终导致系统崩溃。 为了避免“雪崩效应”的发生,需要支持多种维度的依赖和故障隔离,以实现微服务的HA。 由于网络通信本身通常不是系统的瓶颈,因此大部分服务框架会采用多线程 单个通信链路的方式进行通信,原理如下所示: 多线程-单链路P2P通信模式 正如前面章节所述,由于微服务使用异步非阻塞通信,单个I/O线程可以同时并发处理多个链路的消息,而且网络读写都是非阻塞的,因此采用多线程 单链路的方式进行通信性能本身问题不大。但是从可靠性角度来看,只支持单链路本身又存在一些可靠性隐患,我们从下面的案例中看下问题所在。 某互联网基地微服务架构上线之后,发现在一些时段,经常有业务超时,超时的业务没有固定规律。经定位发现当有较多的批量内容同步、语音和视频类微服务调用时,系统的整体时延就增高了很多,而且存在较突出的时延毛刺。由于这些操作获取的消息码流往往达到数M到数十兆,微服务之间又采用单链路的方式进行P2P通信,导致大码流的传输影响了其它消息的读写效率,增大了微服务的响应时延。 问题定位出来之后,对微服务之间的通信机制做了优化,节点之间支持配置多链路,每个链路之间还可以实现不同策略的隔离,例如根据消息码流大小、根据微服务的优先级等策略,实现链路级的隔离,优化之后的微服务通信机制: 支持多链路隔离 当多个微服务合设运行在同一个进程内部时,可以利用线程实现不同微服务之间的隔离。 对于核心微服务,发布的时候可以独占一个线程/线程池,对于非核心微服务,则可以共享同一个大的线程池,在实现微服务隔离的同时,避免线程过于膨胀: 微服务之间故障隔离 假如非核心服务3发生故障,长时间阻塞线程池1的工作线程,其它与其共用线程池消息队列的非核心服务1和服务2只能在队列中排队等待,当服务3释放线程之后,排队的服务1和服务2可能已经超时,只能被丢弃掉,导致业务处理失败。 采用线程池隔离的核心服务1和服务2,由于各自独占线程池,拥有独立的消息队列,它的执行不受发生故障的非核心服务1影响,因此可以继续正常工作。通过独立线程池部署核心服务,可以防止故障扩散,保障核心服务的正常运行。 在微服务中通常会调用第三方中间件服务,例如分布式缓存服务、分布式消息队列、NoSQL服务等。只要调用第三方服务,就会涉及跨网络操作,由于客户端SDK API的封装,很多故障都是隐性的,因此,它的可靠性需要额外关注。 整体而言,第三方依赖隔离可以采用线程池 响应式编程(例如RxJava)的方式实现,它的原理如下所示:
利用Netflix开源的hystrix RxJava,可以快速实现第三方依赖的隔离,后续章节我们会详细介绍下如何使用。 对于核心的微服务,例如商品购买、用户注册、计费等,可以采用独立部署的方式,实现高可用性。 微服务鼓励软件开发者将整个软件解耦为功能单一的服务,并且这些服务能够独立部署、升级和扩容。如果微服务抽象的足够好,那么微服务的这一优点将能够提升应用的敏捷性和自治理能力。利用Docker容器部署微服务,可以带来如下几个优点:
基于Docker容器部署微服务,实现物理资源层隔离示意图如下所示: 基于Docker容器的微服务隔离 除了Docker容器隔离,也可以使用VM对微服务进行故障隔离,相比于Docker容器,使用VM进行微服务隔离存在如下优势:
当微服务不可用时,需要根据预置的策略做容错处理,大部分的容错能力和策略是公共的,因此可以下沉到服务框架中实现。 当集群环境中微服务调用失败之后,利用路由容错机制,可以在底层实现微服务的自动容错处理,提升系统的可靠性。 常用的容错策略包括:
大促或者业务高峰时,为了保证核心服务的SLA,往往需要停掉一些不太重要的业务,例如商品评论、论坛或者粉丝积分等。 另外一种场景就是某些服务因为某种原因不可用,但是流程不能直接失败,需要本地Mock服务端实现,做流程放通。以图书阅读为例,如果用户登录余额鉴权服务不能正常工作,需要做业务放通,记录消费话单,允许用户继续阅读,而不是返回失败。 通过服务治理的服务降级功能,即可以满足上述两种场景的需求。 当外界的触发条件达到某个临界值时,由运维人员/开发人员决策,对某类或者某个服务进行强制降级。 强制降级的常用策略:
当非核心服务不可用时,可以对故障服务做业务逻辑放通,以保障核心服务的运行。 容错降级与屏蔽降级的主要差异是:
容错降级的常用策略如下:
|