- 说在前面
- 喜马拉雅自研亿级API网关技术实践
- 1、第1版:Tomcat NIO+Async Servlet
- 2、第2版.NETty+全异步
- 2.1 接入层
- 2.2 业务逻辑层
- 2.3 服务调用层
- 2.3.1 异步 Push
- 2.3.2 连接池
- 2.3.3 Connection:close
- 2.3.4 写超时
- 3、全链路超时机制
- 4、监控报警
- 5、性能优化实践
- 5.1 对象池技术
- 5.2 上下文切换
- 5.3 GC优化
- 5.4 日志
- 6、未来规划
- 说在最后:有问题可以找老架构取经
- 部分历史案例
网关作为一种发展较为完善的产品,各大互联网公司普遍采用它作为中间件,以应对公共业务需求的不断浮现,并能迅速迭代更新。
如果没有网关,要更新一个公共特性,就得推动所有业务方都进行更新和发布,这无疑是效率极低的。然而,有了网关之后,这一切都不再是问题。
喜马拉雅也如此,用户数量已增长到 6 亿级别,Web 服务数量超过 500 个,目前我们的网关每天处理超过 200 亿次的调用,单机 QPS 峰值可达 4w+。
除了实现基本的反向代理功能,网关还具备许多公共特性,如黑白名单、流量控制、身份验证、熔断、API 发布、监控和报警等。根据业务方的需求,我们还实现了流量调度、流量复制、预发布、智能升级、流量预热等相关功能。
从技术上来说,喜马拉雅API网关的技术演进路线图大致如下:
注意:请点击图像以查看清晰的视图!
本文将介绍在喜马拉雅 API 网关面临亿级流量的情况下,我们如何进行技术演进,以及我们的实践经验总结。
在架构设计中,网关的关键之处在于接收到请求并调用后端服务时,不能发生阻塞(Block),否则网关的处理能力将受到限制。
这是因为最耗时的操作就是远程调用后端服务这个过程。
如果此处发生阻塞,Tomcat 的工作线程会被全部 block 住了,等待后端服务响应的过程中无法处理其他请求,因此这里必须采用异步处理。
架构图如下:
注意:请点击图像以查看清晰的视图!
在这个版本中,我们实现了一个单独的 Push 层,用于在网关接收到响应后,响应客户端,并通过此层实现与后端服务的通信。
该层使用的是 HttpNioClient,支持业务功能包括黑白名单、流量控制、身份验证、API 发布等。
然而,这个版本仅在功能上满足了网关的要求,处理能力很快成为瓶颈。当单机 QPS 达到 5K 时,会频繁发生 Full GC。
通过分析线上堆,我们发现问题在于 Tomcat 缓存了大量 HTTP 请求。因为 Tomcat 默认会缓存 200 个 requestProcessor,每个处理器都关联一个 request。
另外,Servlet 3.0 的 Tomcat 异步实现可能会导致内存泄漏。后来我们通过减少这个配置,效果明显。
然而,这种调整会导致性能下降。总结一下,基于 Tomcat 作为接入端存在以下问题:
Tomcat 自身的问题:
1)缓存过多,Tomcat 使用了许多对象池技术,在有限内存的情况下,流量增大时很容易触发 GC;
2)内存 Copy,Tomcat 的默认内存使用堆内存,因此数据需要从堆内读取,而后端服务是 Netty,使用堆外内存,需要经过多次 Copy;
3)Tomcat 还有个问题是读 body 是阻塞的, Tomcat 的 NIO 模型和 reactor 模型不同,读 body 是 block 的。
这里再分享一张 Tomcat buffer 的关系图:
注意:请点击图像以查看清晰的视图!
从上图中,我们能够明显观察到,Tomcat 的封装功能相当完善,但在内部默认设置下,会有三次 copy。
HttpNioClient 的问题:在获取和释放连接的过程中都需要进行加锁,针对类似网关这样的代理服务场景,会导致频繁地建立和关闭连接,这无疑会对性能产生负面影响。
鉴于 Tomcat 存在的这些难题,我们在后续对接入端进行了优化,采用 Netty 作为接入层和服务调用层,也就是我们的第二版,成功地解决了上述问题,实现了理想的性能。
基于 Netty 的优势,我们构建了全异步、无锁、分层的架构。
先看下我们基于 Netty 做接入端的架构图:
注意:请点击图像以查看清晰的视图!
Netty 的 IO 线程主要负责 HTTP 协议的编解码工作,同时也监控并报警协议层面的异常情况。
我们对 HTTP 协议的编解码进行了优化,并对异常和攻击性请求进行了监控和可视化处理。
例如,我们对 HTTP 请求行和请求头的大小都有限制,而 Tomcat 是将请求行和请求头一起计算,总大小不超过 8K,而 Netty 是分别对两者设置大小限制。
如果客户端发送的请求超过了设定的阀值,带有 cookie 的请求很容易超过这个限制,一般情况下,Netty 会直接响应 400 给客户端。
在优化后,我们只取正常大小的部分,并标记协议解析失败,这样在业务层就可以判断出是哪个服务出现了这类问题。
对于其他攻击性的请求,例如只发送请求头而不发送 body 或者只发送部分内容,都需要进行监控和报警。
这一层负责实现一系列支持业务的公共逻辑,包括 API 路由、流量调度等,采用责任链模式,这一层不会进行 IO 操作。
在业界和大型企业的网关设计中,业务逻辑层通常都被设计成责任链模式,公共的业务逻辑也在这一层实现。
在这一层,我们也执行了相似的操作,并支持以下功能:
1)用户认证和登录验证,支持接口级别的配置;
2)黑白名单:包括全局和应用的黑白名单,以及 IP 和参数级别的限制;
3)流量控制:提供自动和手动控制,自动控制可拦截过大流量,通过令牌桶算法实现;
4)智能熔断:在 Histrix 的基础上进行改进,支持自动升降级,我们采用全自动方式,也支持手动配置立即熔断,即当服务异常比例达到设定值时,自动触发熔断;
5)灰度发布:对于新启动的机器的流量,我们支持类似于 TCP 的慢启动机制,为机器提供一段预热时间;
6)统一降级:我们对所有转发失败的请求都会执行统一降级操作,只要业务方配置了降级规则,都会进行降级,我们支持将降级规则细化到参数级别,包括请求头中的值,非常细粒度,此外,我们还会与 varnish 集成,支持 varnish 的优雅降级;
7)流量调度:支持业务根据筛选规则,将流量分配到对应的机器,也支持仅让筛选的流量访问该机器,这在排查问题/新功能发布验证时非常有用,可以先通过小部分流量验证,再大面积发布上线;
8)流量 copy:我们支持根据规则对线上原始请求 copy 一份,将其写入 MQ 或其他 upstream,用于线上跨机房验证和压力测试;
9)请求日志采样:我们对所有失败的请求都会进行采样并保存到磁盘,以供业务方排查问题,同时也支持业务方根据规则进行个性化采样,我们采样了整个生命周期的数据,包括请求和响应相关的所有数据。
上述提到的所有功能都是对流量进行管理,我们每个功能都作为一个 filter,处理失败都不会影响转发流程,而且所有这些规则的元数据在网关启动时就会全部初始化好。
在执行过程中,不会进行 IO 操作,目前有些设计会对多个 filter 进行并发执行,由于我们的操作都是在内存中进行,开销并不大,所以我们目前并未支持并发执行。
另外,规则可能会发生变化,所有需要进行规则的动态刷新。
我们在修改规则时,会通知网关服务,进行实时刷新,我们对内部自己的这种元数据更新请求,通过独立的线程处理,防止 IO 操作时影响业务线程。
服务调用对于代理网关服务非常关键,这个环节,性能必须很高:必须采用异步方式,
我们利用 Netty 实现了这一目标,同时也充分利用了 Netty 提供的连接池,实现了获取和释放的无锁操作。
在发起服务调用后,网关允许工作线程继续处理其他请求,而无需等待服务端返回。
在这个设计中,我们为每个请求创建一个上下文,发送请求后,将该请求的 context 绑定到相应的连接上,当 Netty 收到服务端响应时,会在连接上执行 read 操作。
解码完成后,再从连接上获取相应的 context,通过 context 可以获取到接入端的 session。
这样,push 通过 session 将响应写回客户端,这个设计基于 HTTP 连接的独占性,即连接和请求上下文绑定。
连接池的原理如下图:
注意:请点击图像以查看清晰的视图!
服务调用层除了异步发起远程调用外,还需要管理后端服务的连接。
HTTP 与 RPC 不同,HTTP 连接是独占的,所以在释放连接时需要特别小心,必须等待服务端响应完成后才能释放,此外,连接关闭的处理也需要谨慎。
总结如下几点:
上面几种需要关闭连接的场景,下面主要说下 Connection:close 和空闲写超时两种,其他情况如读超时、连接空闲超时、收到 fin、reset 码等都比较常见。
后端服务采用的是 Tomcat,它对连接的重用次数有规定,默认为 100 次。
当达到 100 次限制时,Tomcat 会在响应头中添加 Connection:close,要求客户端关闭该连接,否则再次使用该连接发送请求会出现 400 错误。
还有就是如果前端的请求带了 connection:close,那 Tomcat 就不会等待该连接重用满 100 次,即一次就关闭连接。
在响应头中添加 Connection:close 后,连接变为短连接。
在与 Tomcat 保持长连接时,需要注意这一点,如果要利用该连接,需要主动移除 close 头。
首先,网关在何时开始计算服务的超时时间?
如果从调用 writeAndFlush 开始计算,实际上包含了 Netty 对 HTTP 的编码时间和从队列中发送请求即 flush 的时间,这样对后端服务不公平。
因此,需要在真正 flush 成功后开始计时,这样最接近服务端,当然还包含了网络往返时间和内核协议栈处理时间,这是无法避免的,但基本稳定。
因此,我们在 flush 成功回调后启动超时任务。
需要注意的是:如果 flush 不能快速回调,例如遇到一个大的 POST 请求,body 部分较大,而 Netty 发送时默认第一次只发送 1k 大小。
如果尚未发送完毕,会增大发送大小继续发送,如果在 Netty 发送 16 次后仍未发送完成,将不再继续发送,而是提交一个 flushTask 到任务队列,待下次执行后再发送。
此时,flush 回调时间较长,导致此类请求无法及时关闭,后端服务 Tomcat 会一直阻塞在读取 body 部分,基于上述分析,我们需要设置写超时,对于大的 body 请求,通过写超时及时关闭连接。
注意:请点击图像以查看清晰的视图!
上图是我们在整个链路超时处理的机制:
对于网关的业务方来说,他们能看到的是监控和警报功能,我们能够实现秒级的报警和监控,将监控数据定时上传到我们的管理系统,由管理系统负责汇总统计并存储到 InfluxDB 中。
我们对 HTTP 协议进行了全面的监控和警报,涵盖了协议层和服务层的问题。
协议层:
应用层:
1)监控耗时:包括慢请求,超时请求,以及 tp99,tp999 等;
2)监控 OPS:并及时发出警报;
3)带宽监控和报警:支持对请求和响应的行,头,body 单独监控;
4)响应码监控:特别是 400,和 404;
5)连接监控:我们对接入端的连接,以及与后端服务的连接,以及后端服务连接上待发送字节大小都进行了监控;
6)失败请求监控;
7)流量抖动报警:这是非常必要的,流量抖动可能是出现问题,或者是问题即将出现的预兆。
总体架构:
注意:请点击图像以查看清晰的视图!
针对高并发系统,不断地创建对象不仅会占用内存资源,还会对垃圾回收过程产生压力。
为了解决这个问题,我们在实现过程中会对诸如线程池的任务、StringBuffer 等频繁使用的对象进行重用,从而降低内存分配的开销。
在高并发系统中,通常会采用异步设计。异步化后,线程上下文切换的问题必须得到关注。
我们的线程模型如下:
注意:请点击图像以查看清晰的视图!
我们的网关没有涉及 I/O 操作,但在业务逻辑处理方面仍然采用了 Netty 的 I/O 编解码线程异步方式。
这主要有两个原因:
1)防止开发人员编写的代码出现阻塞现象;
2)在突发情况下,业务逻辑可能会产生大量的日志记录,我们允许在推送线程时使用 Netty 的 I/O 线程作为替代。这种做法可以减少 CPU 上下文切换的次数,从而提高整体吞吐量。我们不能仅仅为了异步而异步,Zuul2 的设计理念与我们的做法相似。
在高并发系统中,垃圾回收GC的优化是必不可少的。
我们采用了对象池技术和堆外内存,使得对象很少进入老年代,同时年轻代的设置较大,SurvivorRatio 设置为 2,晋升年龄设置最大为 15,以尽量让对象在年轻代就被回收。
但监控发现老年代的内存仍在缓慢增长。通过dump分析,我们每个后端服务创建一个链接,都时有一个socket,socket的AbstractPlAInSocketImpl,而AbstractPlainSocketImpl就重写了Object类的finalize方法。
实现如下:
/**
* Cleans up if the user forgets to close it.
*/
protected void finalize() throws IOException {
close();
}
是为了我们没有主动关闭链接,做的一个兜底,在gc回收的时候,先把对应的链接资源给释放了。
由于finalize 的机制是通过 JVM 的 Finalizer 线程处理的,其优先级不高,默认为 8。它需要等待 Finalizer 线程把 ReferenceQueue 的对象对应的 finalize 方法执行完,并等到下次垃圾回收时,才能回收该对象。这导致创建链接的这些对象在年轻代不能立即回收,从而进入了老年代,这也是老年代持续缓慢增长的原因。
在高并发系统中,尤其是 Netty 的 I/O 线程,除了执行 I/O 读写操作外,还需执行异步任务和定时任务。如果 I/O 线程处理不过队列中的任务,可能会导致新进来的异步任务被拒绝。
在什么情况下可能会出现这种情况呢?异步读写问题不大,主要是多耗点 CPU。最有可能阻塞 I/O 线程的是日志记录。目前 Log4j 的 ConsoleAppender 日志 immediateFlush 属性默认为 true,即每次记录日志都是同步写入磁盘,这对于内存操作来说,速度较慢。
同时,AsyncAppender 的日志队列满了也会阻塞线程。Log4j 默认的 buffer 大小是 128,而且是阻塞的。即当 buffer 大小达到 128 时,会阻塞写日志的线程。在并发写日志量较大且堆栈较深的情况下,Log4j 的 Dispatcher 线程可能会变慢,需要刷盘。这样 buffer 就不能快速消费,很容易写满日志事件,导致 Netty I/O 线程被阻塞。因此,在记录日志时,我们需要注意精简。
目前,我们都在使用基于 HTTP/1 的协议。
相对于 HTTP/1,HTTP/2 在连接层面实现了服务,即在一个连接上可以发送多个 HTTP 请求。
这就意味着 HTTP 连接可以像 RPC 连接一样,建立几个连接即可,完全解决了 HTTP/1 连接无法复用导致的重复建连和慢启动的开销。
我们正在基于 Netty 升级到 HTTP/2,除了技术升级外,我们还在不断优化监控报警,以便为业务方提供准确无误的报警。此外,我们还在作为统一接入网关与业务方实施全面的降级措施,以确保全站任何故障都能通过网关第一时间降级,这也是我们的重点工作。