在传统的单体架构中,业务服务调用都是本地方法调用,不会涉及到网络通信、协议栈、消息序列化和反序列化等,当使用 RPC 框架将业务由单体架构改造成分布式系统之后,本地方法调用将演变成跨进程的远程调用,会引入一些新的故障点,如下所示:
新引入的潜在故障点包括:
1.消息的序列化和反序列化故障,例如,不支持的数据类型。
2.路由故障:包括服务的订阅、发布故障,服务实例故障之后没有及时刷新路由表,导致 RPC 调用仍然路由到故障节点。
3.网络通信故障,包括网络闪断、网络单通、丢包、客户端浪涌接入等。
RPC 服务通常会依赖第三方服务,包括数据库服务、文件存储服务、缓存服务、消息队列服务等,这种第三方依赖同时也引入了潜在的故障:
1.网络通信类故障, 如果采用 BIO 调用第三方服务,很有可能被阻塞。
2.“雪崩效用”导致的级联故障,例如服务端处理慢导致客户端线程被阻塞。
3.第三方不可用导致 RPC 调用失败。
典型的第三方依赖示例如下:
当网络发生单通、连接被防火墙 Hang 住、长时间 GC 或者通信线程发生非预期异常时,会导致链路不可用且不易被及时发现。特别是异常发生在凌晨业务低谷期间,当早晨业务高峰期到来时,由于链路不可用会导致瞬间的大批量业务失败或者超时,这将对系统的可靠性产生重大的威胁。
从技术层面看,要解决链路的可靠性问题,必须周期性的对链路进行有效性检测。目前最流行和通用的做法就是心跳检测。
心跳检测机制分为三个层面:
1.TCP 层面的心跳检测,即 TCP 的 Keep-Alive 机制,它的作用域是整个 TCP 协议栈。
2. 协议层的心跳检测,主要存在于长连接协议中。例如 MQTT 协议。
3. 应用层的心跳检测,它主要由各业务产品通过约定方式定时给对方发送心跳消息实现。
心跳检测的目的就是确认当前链路可用,对方活着并且能够正常接收和发送消息。做为高可靠的 NIO 框架,Netty 也提供了心跳检测机制,下面我们一起熟悉下心跳的检测原理。
心跳检测的原理示意图如下:
不同的协议,心跳检测机制也存在差异,归纳起来主要分为两类:
1.Ping-Pong 型心跳:由通信一方定时发送 Ping 消息,对方接收到 Ping 消息之后,立即返回 Pong 应答消息给对方,属于请求 - 响应型心跳。
2.Ping-Ping 型心跳:不区分心跳请求和应答,由通信双方按照约定定时向对方发送心跳 Ping 消息,它属于双向心跳。
心跳检测策略如下:
1.连续 N 次心跳检测都没有收到对方的 Pong 应答消息或者 Ping 请求消息,则认为链路已经发生逻辑失效,这被称作心跳超时。
2.读取和发送心跳消息的时候如何直接发生了 IO 异常,说明链路已经失效,这被称为心跳失败。
无论发生心跳超时还是心跳失败,都需要关闭链路,由客户端发起重连操作,保证链路能够恢复正常。
Netty 的心跳检测实际上是利用了链路空闲检测机制实现的,它的空闲检测机制分为三种:
1.读空闲,链路持续时间 t 没有读取到任何消息。
2.写空闲,链路持续时间 t 没有发送任何消息。
3.读写空闲,链路持续时间 t 没有接收或者发送任何消息。
Netty 的默认读写空闲机制是发生超时异常,关闭连接,但是,我们可以定制它的超时实现机制,以便支持不同的用户场景,链路空闲接口定义如下:
复制代码
protected void channelIdle(ChannelHandlerContextctx, IdleStateEventevt)throws Exception { ctx.fireUserEventTriggered(evt); }
链路空闲的时候并没有关闭链路,而是触发 IdleStateEvent 事件,用户订阅 IdleStateEvent 事件,用于自定义逻辑处理,例如关闭链路、客户端发起重新连接、告警和打印日志等。利用 Netty 提供的链路空闲检测机制,可以非常灵活的实现链路空闲时的有效性检测。
当发生如下异常时,客户端需要释放资源,重新发起连接:
1.服务端因为某种原因,主动关闭连接,客户端检测到链路被正常关闭。
2.服务端因为宕机等故障,强制关闭连接,客户端检测到链路被 Rest 掉。
3.心跳检测超时,客户端主动关闭连接。
4.客户端因为其它原因(例如解码失败),强制关闭连接。
5.网络类故障,例如网络丢包、超时、单通等,导致链路中断。
客户端检测到链路中断后,等待 INTERVAL 时间,由客户端发起重连操作,如果重连失败,间隔周期 INTERVAL 后再次发起重连,直到重连成功。
为了保证服务端能够有充足的时间释放句柄资源,在首次断连时客户端需要等待 INTERVAL 时间之后再发起重连,而不是失败后就立即重连。
为了保证句柄资源能够及时释放,无论什么场景下的重连失败,客户端都必须保证自身的资源被及时释放,包括但不限于 SocketChannel、Socket 等。重连失败后,需要打印异常堆栈信息,方便后续的问题定位。
利用 Netty Channel 提供的 CloseFuture,可以非常方便的检测链路状态,一旦链路关闭,相关事件即被触发,可以重新发起连接操作,代码示例如下:
复制代码
future.channel().closeFuture().sync(); }finally{ // 所有资源释放完成之后,清空资源,再次发起重连操作 executor.execute(new Runnable() { publicvoidrun() { try{ TimeUnit.SECONDS.sleep(3);//3 秒之后发起重连,等待句柄释放 try{ // 发起重连操作 connect(NettyConstant.PORT, NettyConstant.REMOTEIP); }catch(Exception e) { ...... 异常处理相关代码省略 } });
当我们调用消息发送接口的时候,消息并没有真正被写入到 Socket 中,而是先放入 NIO 通信框架的消息发送队列中,由 Reactor 线程扫描待发送的消息队列,异步的发送给通信对端。假如很不幸,消息队列中积压了部分消息,此时链路中断,这会导致部分消息并没有真正发送给通信对端,示例如下:
发生此故障时,我们希望 NIO 框架能够自动实现消息缓存和重新发送,遗憾的是作为基础的 NIO 通信框架,无论是 Mina 还是 Netty,都没有提供该功能,需要通信框架自己封装实现,基于 Netty 的实现策略如下:
1.调用 Netty ChannelHandlerContext 的 write 方法时,返回 ChannelFuture 对象,我们在 ChannelFuture 中注册发送结果监听 Listener。
2.在 Listener 的 operationComplete 方法中判断操作结果,如果操作不成功,将之前发送的消息对象添加到重发队列中。
3.链路重连成功之后,根据策略,将缓存队列中的消息重新发送给通信对端。
需要指出的是,并非所有场景都需要通信框架做重发,例如服务框架的客户端,如果某个服务提供者不可用,会自动切换到下一个可用的服务提供者之上。假定是链路中断导致的服务提供者不可用,即便链路重新恢复,也没有必要将之前积压的消息重新发送,因为消息已经通过 FailOver 机制切换到另一个服务提供者处理。所以,消息缓存重发只是一种策略,通信框架应该支持链路级重发策略。
在传统的同步阻塞编程模式下,客户端 Socket 发起网络连接,往往需要指定连接超时时间,这样做的目的主要有两个:
1.在同步阻塞 I/O 模型中,连接操作是同步阻塞的,如果不设置超时时间,客户端 I/O 线程可能会被长时间阻塞,这会导致系统可用 I/O 线程数的减少。
2.业务层需要:大多数系统都会对业务流程执行时间有限制,例如 WEB 交互类的响应时间要小于 3S。客户端设置连接超时时间是为了实现业务层的超时。
对于 NIO 的 SocketChannel,在非阻塞模式下,它会直接返回连接结果,如果没有连接成功,也没有发生 I/O 异常,则需要将 SocketChannel 注册到 Selector 上监听连接结果。所以,异步连接的超时无法在 API 层面直接设置,而是需要通过用户自定义定时器来主动监测。
Netty 在创建 NIO 客户端时,支持设置连接超时参数。Netty 的客户端连接超时参数与其它常用的 TCP 参数一起配置,使用起来非常方便,上层用户不用关心底层的超时实现机制。这既满足了用户的个性化需求,又实现了故障的分层隔离。
以 Netty 的 HTTPS 服务端为例,针对客户端的并发连接数流控原理如下所示:
基于 Netty 的 Pipeline 机制,可以对 SSL 握手成功、SSL 连接关闭做切面拦截(类似于 Spring 的 AOP 机制,但是没采用反射机制,性能更高),通过流控切面接口,对 HTTPS 连接做计数,根据计数器做流控,服务端的流控算法如下:
1.获取流控阈值。
2.从全局上下文中获取当前的并发连接数,与流控阈值对比,如果小于流控阈值,则对当前的计数器做原子自增,允许客户端连接接入。
3.如果等于或者大于流控阈值,则抛出流控异常给客户端。
4.SSL 连接关闭时,获取上下文中的并发连接数,做原子自减。
在实现服务端流控时,需要注意如下几点:
1.流控的 ChannelHandler 声明为 @ChannelHandler.Sharable,这样全局创建一个流控实例,就可以在所有的 SSL 连接中共享。
2.通过 userEventTriggered 方法拦截 SslHandshakeCompletionEvent 和 SslCloseCompletionEvent 事件,在 SSL 握手成功和 SSL 连接关闭时更新流控计数器。
3.流控并不是单针对 ESTABLISHED 状态的 HTTP 连接,而是针对所有状态的连接,因为客户端关闭连接,并不意味着服务端也同时关闭了连接,只有 SslCloseCompletionEvent 事件触发时,服务端才真正的关闭了 NioSocketChannel,GC 才会回收连接关联的内存。
4.流控 ChannelHandler 会被多个 NioEventLoop 线程调用,因此对于相关的计数器更新等操作,要保证并发安全性,避免使用全局锁,可以通过原子类等提升性能。
NIO 通信的内存保护主要集中在如下几点:
1. 链路总数的控制:每条链路都包含接收和发送缓冲区,链路个数太多容易导致内存溢出。
2. 单个缓冲区的上限控制:防止非法长度或者消息过大导致内存溢出。
3. 缓冲区内存释放:防止因为缓冲区使用不当导致的内存泄露。
4.NIO 消息发送队列的长度上限控制。
当我们对消息进行解码的时候,需要创建缓冲区。缓冲区的创建方式通常有两种:
1. 容量预分配,在实际读写过程中如果不够再扩展。
2. 根据协议消息长度创建缓冲区。
在实际的商用环境中,如果遇到畸形码流攻击、协议消息编码异常、消息丢包等问题时,可能会解析到一个超长的长度字段。笔者曾经遇到过类似问题,报文长度字段值竟然是 2G 多,由于代码的一个分支没有对长度上限做有效保护,结果导致内存溢出。系统重启后几秒内再次内存溢出,幸好及时定位出问题根因,险些酿成严重的事故。
Netty 提供了编解码框架,因此对于解码缓冲区的上限保护就显得非常重要。下面,我们看下 Netty 是如何对缓冲区进行上限保护的:
首先,在内存分配的时候指定缓冲区长度上限:
复制代码
/** * Allocatea{@linkByteBuf}withthegiveninitialcapacityandthegiven * maximal capacity. If it is a direct or heap buffer depends on the actual * implementation. */ ByteBuf buffer(int initialCapacity, int maxCapacity);
其次,在对缓冲区进行写入操作的时候,如果缓冲区容量不足需要扩展,首先对最大容量进行判断,如果扩展后的容量超过上限,则拒绝扩展:
复制代码
@Override publicByteBuf capacity(intnewCapacity) { ensureAccessible(); if(newCapacity<0||newCapacity> maxCapacity()) { thrownewIllegalArgumentException("newCapacity: "+newCapacity); }
在消息解码的时候,对消息长度进行判断,如果超过最大容量上限,则抛出解码异常,拒绝分配内存,以 LengthFieldBasedFrameDecoder 的 decode 方法为例进行说明:
复制代码
if(frameLength > maxFrameLength) { longdiscard= frameLength -in.readableBytes(); tooLongFrameLength = frameLength; if(discard<0) { in.skipBytes((int) frameLength); }else{ discardingTooLongFrame =true; bytesToDiscard =discard; in.skipBytes(in.readableBytes()); } failIfNecessary(true); returnnull; }
RPC 调用过程中除了通信层的异常,通常也会遇到如下几种故障:
服务路由失败。
服务端超时。
服务端调用失败。
RPC 框架需要能够针对上述常见的异常做容错处理,以提升业务调用的可靠性。
RPC 客户端通常会基于订阅 / 发布的机制获取服务端的地址列表,并将其缓存到本地,RPC 调用时,根据负载均衡策略从本地缓存的路由表中获取到一个唯一的服务端节点发起调用,原理如下所示:
通过缓存的机制能够提升 RPC 调用的性能,RPC 客户端不需要每次调用都向注册中心查询目标服务的地址信息,但是也可能会发生如下两类潜在故障:
1.某个 RPC 服务端发生故障,或者下线,客户端没有及时刷新本地缓存的服务地址列表,就会导致 RPC 调用失败。
2.RPC 客户端和服务端都工作正常,但是 RPC 客户端和服务端的连接或者网络发生了故障,如果没有链路的可靠性检测机制,就会导致 RPC 调用失败。
当服务端无法在指定的时间内返回应答给客户端,就会发生超时,导致超时的原因主要有:
1.服务端的 I/O 线程没有及时从网络中读取客户端请求消息,导致该问题的原因通常是 I/O 线程被意外阻塞或者执行长周期操作。
2.服务端业务处理缓慢,或者被长时间阻塞,例如查询数据库,由于没有索引导致全表查询,耗时较长。
3.服务端发生长时间 Full GC,导致所有业务线程暂停运行,无法及时返回应答给客户端。
有时会发生服务端调用失败,导致服务端调用失败的原因主要有如下几种:
1.服务端解码失败,会返回消息解码失败异常。
2.服务端发生动态流控,返回流控异常。
3.服务端消息队列积压率超过最大阈值,返回系统拥塞异常。
4.访问权限校验失败,返回权限相关异常。
5.违反 SLA 策略,返回 SLA 控制相关异常。
6.其他系统异常。
需要指出的是,服务调用异常不包括业务层面的处理异常,例如数据库操作异常、用户记录不存在异常等。
因为注册中心有集群内所有 RPC 客户端和服务端的实例信息,因此通过注册中心向每个服务端和客户端发送心跳消息,检测对方是否在线,如果连续 N 次心跳超时,或者心跳发送失败,则判断对方已经发生故障或者下线(下线可以通过优雅停机的方式主动告知注册中心,实时性会更好)。注册中心将故障节点的服务实例信息通过心跳消息发送给客户端,由客户端将故障的服务实例信息从本地缓存的路由表中删除,后续消息调用不再路由到该节点。
在一些特殊场景下,尽管注册中心与服务端、客户端的连接都没有问题,但是服务端和客户端之间的链路发生了异常,由于发生链路异常的服务端仍然在缓存表中,因此消息还会继续调度到故障节点上,所以,利用 RPC 客户端和服务端之间的双向心跳检测,可以及时发现双方之间的链路问题,利用重连等机制可以快速的恢复连接,如果重连 N 次都失败,则服务路由时不再将消息发送到连接故障的服务节点上。
利用注册中心对服务端的心跳检测和通知机制、以及服务端和客户端针对链路层的双向心跳检测机制,可以有效检测出故障节点,提升 RPC 调用的可靠性,它的原理如下所示:
常用的集群容错策略包括:
1.失败自动切换 (Failover)。
2.失败通知(Failback)。
3.失败缓存(Failcache)。
4.快速失败(Failfast)。
失败自动切换策略:服务调用失败自动切换策略指的是当发生 RPC 调用异常时,重新选路,查找下一个可用的服务提供者。
服务发布的时候,可以指定服务的集群容错策略。消费者可以覆盖服务提供者的通用配置,实现个性化的容错策略。
Failover 策略的设计思路如下:消费者路由操作完成之后,获得目标地址,调用通信框架的消息发送接口发送请求,监听服务端应答。如果返回的结果是 RPC 调用异常(超时、流控、解码失败等系统异常),根据消费者集群容错的策略进行容错路由,如果是 Failover,则重新返回到路由 Handler 的入口,从路由节点继续执行。选路完成之后,对目标地址进行比对,防止重新路由到故障服务节点,过滤掉上次的故障服务提供者之后,调用通信框架的消息发送接口发送请求消息。
RPC 框架提供 Failover 容错策略,但是用户在使用时需要自己保证用对地方,下面对 Failover 策略的应用场景进行总结:
1.读操作,因为通常它是幂等的。
2.幂等性服务,保证调用 1 次与 N 次效果相同。
需要特别指出的是,失败重试会增加服务调用时延,因此框架必须对失败重试的最大次数做限制,通常默认为 3,防止无限制重试导致服务调用时延不可控。
失败通知(Failback):在很多业务场景中,客户端需要能够获取到服务调用失败的具体信息,通过对失败错误码等异常信息的判断,决定后续的执行策略,例如非幂等性的服务调用。
Failback 的设计方案如下:RPC 框架获取到服务提供者返回的 RPC 异常响应之后,根据策略进行容错。如果是 Failback 模式,则不再重试其它服务提供者,而是将 RPC 异常通知给客户端,由客户端捕获异常进行后续处理。
失败缓存(Failcache):Failcache 策略是失败自动恢复的一种,在实际项目中它的应用场景如下:
1.服务有状态路由,必须定点发送到指定的服务提供者。当发生链路中断、流控等服务暂时不可用时,RPC 框架将消息临时缓存起来,等待周期 T,重新发送,直到服务提供者能够正常处理该消息。
2.对时延要求不敏感的服务。系统服务调用失败,通常是链路暂时不可用、服务流控、GC 挂住服务提供者进程等,这种失败不是永久性的失败,它的恢复是可预期的。如果客户端对服务调用时延不敏感,可以考虑采用自动恢复模式,即先缓存,再等待,最后重试。
3.通知类服务。例如通知粉丝积分增长、记录接口日志等,对服务调用的实时性要求不高,可以容忍自动恢复带来的时延增加。
为了保证可靠性,Failcache 策略在设计的时候需要考虑如下几个要素:
1.缓存时间、缓存对象上限数等需要做出限制,防止内存溢出。
2.缓存淘汰算法的选择,是否支持用户配置。
3.定时重试的周期 T、重试的最大次数等需要做出限制并支持用户指定。
4.重试达到最大上限仍失败,需要丢弃消息,记录异常日志。
快速失败(Failfast):在业务高峰期,对于一些非核心的服务,希望只调用一次,失败也不再重试,为重要的核心服务节约宝贵的运行资源。此时,快速失败是个不错的选择。快速失败策略的设计比较简单,获取到服务调用异常之后,直接忽略异常,记录异常日志。
尽管很多第三方服务会提供 SLA,但是 RPC 服务本身并不能完全依赖第三方服务自身的可靠性来保障自己的高可靠,第三方服务依赖隔离的总体策略如下:
1.第三方依赖隔离可以采用线程池 + 响应式编程(例如 RxJava)的方式实现。
2.对第三方依赖进行分类,每种依赖对应一个独立的线程 / 线程池。
3.服务不直接调用第三方依赖的 API,而是使用异步封装之后的 API 接口。
4.异步调用第三方依赖 API 之后,获取 Future 对象。利用响应式编程框架,
可以订阅后续的事件,接收响应,针对响应进行编程。
如果第三方服务提供的是标准的 HTTP/Restful 服务,则利用异步 HTTP 客户端,例如 Netty、Vert.x、异步 RestTemplate 等发起异步服务调用,这样无论是服务端自身处理慢还是网络慢,都不会导致调用方被阻塞。
如果对方是私有或者定制化的协议,SDK 没有提供异步接口,则需要采用线程池或者利用一些开源框架实现故障隔离。
异步化示例图如下所示:
异步化的几个关键技术点:
1.异步具有依赖和传递性,如果想在某个业务流程的某个过程中做异步化,则入口处就需要做异步。例如如果想把 Redis 服务调用改造成异步,则调用 Redis 服务之前的流程也需要同时做异步化,否则意义不大(除非调用方不需要返回值)。
2.通常而言,全栈异步对于业务性能和可靠性提升的意义更大,全栈异步会涉及到内部服务调用、第三方服务调用、数据库、缓存等平台中间件服务的调用,异步化改造成本比较高,但是收益也比较明显。
3.不同框架、服务的异步编程模型尽量保持一致,例如统一采取 RxJava 风格的接口、或者 JDK8 的 CompletableFuture。如果不同服务 SDK 的异步 API 接口风格差异过大,会增加业务的开发成本,也不利用线程模型的归并和整合。
集成 Netflix 开源的 Hystrix 框架,可以非常方便的实现第三方服务依赖故障隔离,它提供的主要功能包括:
1.依赖隔离。
2.熔断器。
3.优雅降级。
4.Reactive 编程。
5.信号量隔离。
建议的集成策略如下:
1.第三方依赖隔离:使用 HystrixCommand 做一层异步封装,实现业务的 RPC 服务调用线程和第三方依赖的线程隔离。
2.依赖分类管理:对第三方依赖进行分类、分组管理,根据依赖的特点设置熔断策略、优雅降级策略、超时策略等,以实现差异化的处理。
总体集成视图如下所示:
基于 Hystrix 可以非常方便的实现第三方依赖服务的熔断降级,它的工作原理如下:
1.熔断判断:服务调用时,对熔断开关状态进行判断,当熔断器开关关闭时, 请求被允许通过熔断器。
2.熔断执行:当熔断器开关打开时,服务调用请求被禁止通过,执行失败回调接口。
3.自动恢复:熔断之后,周期 T 之后允许一条消息通过,如果成功,则取消熔断状态,否则继续处于熔断状态。
流程如下所示:
李林锋,10 年 Java NIO、平台中间件设计和开发经验,精通 Netty、Mina、分布式服务框架、API Gateway、PaaS 等,《Netty 进阶之路》、《分布式服务框架原理与实践》作者。目前在华为终端应用市场负责业务微服务化、云化、全球化等相关设计和开发工作。
联系方式:新浪微博 Nettying 微信:Nettying
Email: neu_lilinfeng@sina.com