当前位置: 首页 > 科技观察

碰撞!老大让我设计一个亿级API网关

时间:2023-03-22 14:53:01 科技观察

图片来自Pexels如果没有网关,更新一个publicfeature,需要推动所有业务方更新发布,效率极低。有了网关之后,这一切都变得没有问题了。喜马拉雅也是如此。用户数量增长到6亿多,Web服务数量达到500+。目前我们的网关每天处理200亿+次调用,单机QPS峰值达到40000+。网关除了最基本的反向代理功能外,还具有公共特性,如黑白名单、流量控制、鉴权、断路器、API发布、监控告警等,我们也实现了相关功能如根据业务方需求进行流量调度、流量复制、预发布、智能升级升级、流量预热。以下是我们网关在这些便利方面的一些实践经验和发展历程。以下是喜马拉雅门户的演变过程。第一版:Tomcatnio+AsyncServlet网关架构设计中最关键的一点,就是网关收到请求调用后端服务时,不能阻塞block,否则网关的吞吐量会很难增加,因为最耗时的是调用后端服务这个远程调用过程。如果在这里阻塞,Tomcat的工作线程都被阻塞了,在等待后端服务响应的过程中无法处理其他请求。这个地方一定是异步的。架构图如下:在这个版本中,我们实现了一个单独的Push层。作为网关接收到响应后,响应客户端时,通过这一层,与后端服务通信的是HttpNioClient,支持黑白名单、流控、鉴权。权限、API发布等功能。不过这个版本只是在功能上满足了网关的要求,处理能力很快就会成为瓶颈。当单机QPS达到5K时,会继续进行FullGC。后来通过对Dump行的heap分析,发现Tomcat缓存了很多HTTP请求,因为Tomcat默认缓存了200个req??uestProcessor,每个prcessor关联一个request。另外,Servlet3.0Tomcat的异步实现会造成内存泄漏。后面通过减少这个配置,效果很明显。但是性能肯定会下降。综上所述,基于Tomcat作为接入端,存在以下问题。Tomcat自身的问题:缓存太多,Tomcat使用了大量的对象池技术,在内存有限的情况下,高流量很容易触发GC。内存复制,Tomcat默认使用堆内存,所以需要将数据读入堆中,而我们的后端服务是Netty,有堆外内存,需要多次复制。Tomcat的另一个问题是读取主体被阻塞。Tomcat的NIO模型不同于reactor模型,读body是block。再来一张Tomcatbuffer的关系图:从上图可以看出,Tomcat对外包装的很好,内部默认会有三份。HttpNioClient的问题:获取和释放连接都需要加锁。对应网关等代理服务场景,会频繁建立和关闭连接,势必会影响性能。基于Tomcat中的这些问题,我们后面会修改接入端,使用Netty作为接入层和服务调用层,这是我们的第二个版本,可以彻底解决以上问题,达到理想的性能。版本二:Netty+全异步基于Netty的优点,我们实现了全异步、无锁、分层的架构。我们先来看一下我们基于Netty的接入端架构图:①接入层Netty的IO线程负责HTTP协议的编解码,同时在协议层面进行异常监控和告警.HTTP协议编解码优化,异常、攻击性请求监控可视化。例如,我们对HTTP请求行和请求标头的大小有限制。Tomcat的请求行和请求的总大小不超过8K。Netty分别有大小限制。如果客户端发送超过阈值的请求,带有cookie的请求很容易超过。一般情况下,Netty会直接响应400给客户端。改造后我们只取正常大小的部分,同时标记协议解析失败。到了业务层之后,我们就可以判断是哪个服务出现了这种问题。其他的攻击性请求,比如只发送请求头,不发送body或者其中的一部分需要监控告警。②业务逻辑层负责API路由,流量调度,以及一系列支撑业务的公共逻辑,都在这一层实现,采样责任链模型,这一层不会有IO操作.在业界和一些大厂的网关设计中,业务逻辑层基本设计成责任链模型,公共业务逻辑也在这一层实现。这一层我们也有相同的例程,支持:用户认证和登录验证,支持接口级别的配置。黑白名单,分为全局和应用,还有IP维度和参数级别。流量控制,支持自动和手动,自动是超大流量的自动拦截,通过令牌桶算法实现。智能断路器,在Histrix的基础上改进,支持自动降级,我们都是自动的,也支持手动配置即时断路器,即当异常业务比例达到阈值时,自动触发断路器。灰度发布,我对刚启动的机器的流量支持类似TCP的慢启动机制,给机器一个预热时间窗口。统一降级。对于所有转发失败的请求,我们都会找到一个统一的降级逻辑。只要业务方配置了降级规则,就会被降级。我们支持参数级别的降级规则,包括请求头中的值,这是非常细粒度的,另外,我们会和Varnish打通,支持Varnish的优雅降级。流量调度,支持业务根据过滤规则将流量过滤到对应的机器,也支持只过滤过滤后的流量访问本机,在排查问题/新功能发布验证时非常有用,可以通过一个小先进行部分流量验证,再大规模发布。流量复制,我们支持将原始在线请求按照规则复制一份,写入MQ或者其他上游,用于在线跨机房验证和压力测试。请求日志采样,我们会对所有失败的请求进行采样,提供业务方排查支持,也支持业务方按照规则进行个性化采样,我们对整个生命周期的数据进行采样,包括所有的请求和响应数据。上面说了那么多都是流量的管理。我们的每一个函数都是一个过滤器,处理失败不会影响转发过程,而且这些规则的所有元数据都会在网关启动的时候初始化。执行过程中,不会有IO操作。目前,一些设计会同时执行多个过滤器。由于我们的操作都是内存操作,开销不大,所以目前不支持并发执行。另一件事是规则将被修改。当我们修改规则时,我们会通知网关服务并做实时刷新。我们内部的元数据更新请求是通过独立的线程来处理的,以防止IO在运行过程中影响业务线程。③服务调用层服务调用是代理网关服务的关键,必须是异步的。我们通过Netty来实现,同时也利用好Netty提供的连接池,使得获取和释放都是无锁操作。④异步Push网关发起服务调用后,让工作线程继续处理其他请求,无需等待服务器返回。这里的设计是我们为每个请求创建一个上下文。发送请求后,我们将请求的上下文绑定到相应的连接上。当Netty收到服务器的响应时,它会在连接上执行。读操作。解码后从connection中获取对应的context,通过context获取接入端的session。这样push通过session将response写回给client。这种设计也是基于HTTP的独占连接,即连接与请求上下文绑定。⑤连接池连接池的原理如下图所示:服务调用层除了异步发起远程调用外,还需要管理后端服务的连接。HTTP不同于RPC。HTTP连接是独占的,释放时一定要小心。您必须等待服务器响应才能释放它。关闭连接时也应该小心。总结了以下几点:Connection:closeidletimeout,closeconnectionreadtimeoutcloseconnectionwritetimeout,closeconnectionFin,Reset以上几种需要关闭连接的场景,下面主要讨论一下Connection:close和idlewritetimeout,其他的应该是比较常见的,比如读超时,连接空闲超时,接收fin和复位码。⑥Connection:close后端服务为Tomcat,Tomcat对连接重用次数有限制,默认100次。当达到100次时,Tomcat会在响应头中加入Connection:close要求客户端关闭连接,否则如果再次使用该连接发送,会出现400。还有,如果端上的请求携带connection:close,那么Tomcat不会等待连接重用100次,也就是关闭一次。通过在响应头中添加Connection:close,就变成了一个短连接。与Tomcat保持长连接时,需要注意。如果要使用它,您必须主动删除关闭标头。⑦写入超时首先,网关什么时候开始计算服务的超时时间。如果从调用writeAndFlush算起,这其实包括了Netty编码HTTP的时间和从队列中发送请求的时间,也就是flush时间。最终服务是不公平的。所以需要在实际flush成功后,距离server最近的时候开始计时。当然,还包括网络往返时间和内核协议栈的处理时间。这是不可避免的,但基本没有改变。所以我们在flush回调成功后启动超时任务。这里有一点需要注意。如果flush不能快速回调,比如一个大的post请求来了,body部分比较大,默认是Netty第一次发送的时候。发布1k大小。如果还没有发送完,增加发送大小,继续发送。如果16次之后Netty还没有发送完,则不会继续发送,而是向任务队列提交一个flushTask,等待下一次执行。发送。此时flush回调时间比较长,导致这样的请求不能及时关闭,后端服务Tomcat会一直阻塞在读取body的地方。根据以上分析,我们需要一个写超时时间。对于大体请求,通过Writetimeout及时关闭。全链路超时机制上图是我们对全链路超时处理的机制:协议分析超时等待队列超时连接建立超时等待连接超时写前检查写超时响应超时监控告警网关业务只能看到监控和告警,我们是实现秒级报警,秒级监控,监控数据定期上报给我们的管理系统,由管理系统负责汇总统计并传输到influxdb。我们对HTTP协议进行了全面的监控和告警,包括协议层和服务层。协议层:对于激进的请求,只发送header,不发送/发送body的一部分,放盘,还原场景,对于Line或Head或Body过大的请求报警,放盘,还原场景,报警应用层:消费时间监控,有慢请求,超时请求,tp99,tp999等OPS监控报警。带宽监控告警,支持对请求和响应行、header、body进行单独监控。响应码监控,尤其是400,404。连接监控,我们也监控了接入端的连接,与后端服务的连接,后端服务连接上要发送的字节大小。失败的请求监控。流量抖动报警是非常必要的。流量抖动要么是问题,要么是问题的先兆。总体架构:性能优化实践①对象池技术对于高并发系统,频繁创建对象不仅有内存分配的开销,还会给GC带来压力。在实现过程中,我们会复用线程池任务、StringBuffer等常用任务,减少频繁申请内存的开销。②上下文切换高并发系统通常采用异步设计。异步化之后,还得考虑线程上下文切换。我们的线程模型是这样的:我们整个网关不涉及IO操作,但是我们的业务逻辑还是和Netty的IO编解码线程异步的。原因有二:防止开发写的代码阻塞。可能有很多业务逻辑日志记录。在紧急情况下,支持Netty的IO线程在使用push线程时代替。这里要做的工作更少。这里,异步修改为同步后(通过修改配置调整),CPU的上下文切换减少了20%,从而提高了整体的吞吐量,但是不能为了异步而异步。zull2的设计和我们的差不多。③GC优化在高并发系统中,GC优化是不可避免的。当我们使用对象池技术和堆外内存时,对象很少进入老年代。另外,我们年轻代会设置一个比较大的size,SurvivorRatio=2,promotionage会设置为最大15,尽可能在年轻代回收对象,但是监控显示老一代的内存仍然会缓慢增长。通过dump分析,我们的每一个后端服务都会创建一个连接,总有一个socket,即socket的AbstractPlainSocketImpl。而AbstractPlainSocketImpl重写了Object类的finalize方法,实现如下:,首先释放相应的连接资源。由于finalize机制是由JVM的Finalizer线程处理的,而Finalizer线程的优先级不高,默认为8,需要等到Finalizer线程执行完ReferenceQueue对象的finalize方法。直到下一次GC才能回收该对象。这样一来,这些创建连接的对象就不能在年轻代中立即被回收,从而进入老年代。这就是老年代一直增长缓慢的原因。④在日志高并发下,尤其是Netty的IO线程,不仅在线程上进行IO读写操作,还可以进行异步任务和定时任务。如果IO线程无法处理队列中的任务,很可能会导致新传入的异步任务被拒绝。什么情况下可以?IO是异步读写。这不是一个大问题,但它会消耗更多的CPU。最有可能阻塞IO线程的就是我们敲的log。目前Log4j的ConsoleAppender日志的immediateFlush属性默认为true,即每次记录日志时,同步flush写入磁盘,对于内存操作来说慢很多。同时,当AsyncAppender的日志队列满了,线程就会被阻塞。log4j默认的buffersize是128,而且是block。即如果缓冲区的大小达到128,日志写入线程就会被阻塞。在大量并发日志写入的情况下,尤其是栈比较多的时候,log4jDispatcher线程会变慢,需要flush。这样buffer不能很快被消耗掉,很容易把logevents填满,导致NettyIO线程阻塞,所以我们在logging的时候也要注意精简。未来规划现在我们都是基于HTTP/1。与HTTP/1相比,HTTP/2现在在连接级别实现服务,即可以在一个连接上发送多个HTTP请求。即HTTP连接可以和RPC连接一样,只建立少数几个连接,彻底解决了因为HTTP/1连接不能复用而每次都建立连接和启动慢的开销。我们也在升级到基于Netty的HTTP/2。除了技术升级,我们也在不断优化监控告警。如何给业务方提供准确的告警,我们也一直在努力。另一个是降级。作为统一接入网关,我们和业务方采取了全方位的降级措施,这也是一直在改进的一点。确保全站出现故障,第一时间通过网关降级,也是我们的重点。综上所述,网关已经是互联网公司的标配。这里总结一下实践过程中的一些心得体会。希望能给大家一些参考和解决一些问题。欢迎交流。作者:彭荣鑫编辑:陶佳龙来源:转载自公众号喜马拉雅科技博客(ID:xmly_tech)