本文由携程技术Butters分享,原题“干货 | 日均流量200亿,携程高性能全异步网关实践”,下文有修订和重新排版。
本文分享的是携程API网关全异步改造的实践分享,包括从Zuul 1.0同步架构升级为基于Netty的全异步架构,通过RxJava实现业务流程异步化,结合流式转发、ZGC等技术显著提升性能,并构建控制面实现多协议统一治理与模块化编排。
Butters:携程软件技术专家,专注于网络架构、API网关、负载均衡、Service Mesh等领域。
本文是专题系列文章的第 13 篇,总目录如下:
《长连接网关技术专题(一):京东京麦的生产级TCP网关技术实践总结》
《长连接网关技术专题(二):知乎千万级并发的高性能长连接网关技术实践》
《长连接网关技术专题(三):手淘亿级移动端接入层网关的技术演进之路》
《长连接网关技术专题(四):爱奇艺WebSocket实时推送网关技术实践》
《长连接网关技术专题(五):喜马拉雅自研亿级API网关技术实践》
《长连接网关技术专题(六):石墨文档单机50万WebSocket长连接架构实践》
《长连接网关技术专题(七):小米小爱单机120万长连接接入层的架构演进》
《长连接网关技术专题(八):B站基于微服务的API网关从0到1的演进之路》
《长连接网关技术专题(九):去哪儿网酒店高性能业务网关技术实践》
《长连接网关技术专题(十):百度基于Go的千万级统一长连接服务架构实践》
《长连接网关技术专题(十一):揭秘腾讯公网TGW网关系统的技术架构演进》
《长连接网关技术专题(十二):大模型时代多模型AI网关的架构设计与实现》
《长连接网关技术专题(十三):基于Netty的携程高性能网关异步改造实践》(* 本文)
与许多公司一样,携程API网关也是同微服务架构一起引入的基础设施,最早版本发布于2014年。随着服务化在公司的快速推进,网关逐渐成为应用暴露到外网的标准方案。后来的“ALL IN无线”、国际化、异地多活等,网关跟随着公司公共业务与基础架构共同演进。
技术方案上,公司微服务早期发展受NetflixOSS影响较深,网关方面最早也是参考了Zuul 1.0进行的二次开发。
核心可概括为四点:
众所周知,同步调用阻塞线程,系统吞吐受IO影响大。作为行业先驱,Zuul在设计上也考虑到了这点——通过引入Hystrix,资源隔离配合限流,将故障(慢IO)框在一定范围内;配合熔断策略,可提前释放部分线程资源;最终达到局部异常不影响全局的目的。
但随着公司业务的发展,上述策略效果逐渐减弱。
主要原因在于两方面的变动:
全异步改造是携程API网关近年的一项核心工作点,本文也将由此展开,聊一聊我们在网关方面的工作与实践。重点包括:性能优化、业务形态、技术架构、治理经验等。
全异步 = server端异步 + 业务流程异步 + client端异步
对于server与client端,我们选择了Netty框架,NIO/Epoll + Eventloop本身就是事件驱动的设计。
改造核心在于业务流程的异步化,常见异步场景包括:
经验上,异步编程相比同步在设计、读写上都会困难一些。
所谓的困难,一般包括:
尤其在Netty上下文内,对ByteBuf生命周期设计的不完善,很容易造成内存泄漏。围绕这些问题,我们设计了对应外围框架,最大努力对业务代码抹平同步/异步差异,方便开发;同时默认兜底与容错,保证程序整体安全。工具上借助了RxJava,主要流程如下图所示。
Maybe:
Filter:
public interface Processor<T> { ProcessorType getType(); int getOrder(); boolean shouldProcess(RequestContext context); //对外统一封装为Maybe Maybe process(RequestContext context) throws Exception; }
public abstract class AbstractProcessor implements Processor { //同步&无响应,继承此方法 //场景:常规业务处理 protected void processSync(RequestContext context) throws Exception {} //同步&有响应,继承此方法,健康检测 //场景:健康检测、未通过校验时的静态响应 protected T processSyncAndGetReponse(RequestContext context) throws Exception { process(context); return null; }; //异步,继承此方法 //场景:认证、鉴权等涉及远程调用的模块 protected Maybe processAsync(RequestContext context) throws Exception { T response = processSyncAndGetReponse(context); if (response == null) { return Maybe.empty(); } else { return Maybe.just(response); } }; @Override public Maybe process(RequestContext context) throws Exception { Maybe<T> maybe = processAsync(context); if (maybe instanceof ScalarCallable) { //标识同步方法,无需额外封装 return maybe; } else { //统一加超时,默认忽略错误 return maybe.timeout(getAsyncTimeout(context), TimeUnit.MILLISECONDS, Schedulers.from(context.getEventloop()), timeoutFallback(context)); } } protected long getAsyncTimeout(RequestContext context) { return 2000; } protected Maybe<T> timeoutFallback(RequestContext context) { return Maybe.empty(); }
整体流程:
public class RxUtil{ //组合某阶段(如Inbound)内的多个filter(即Callable<Maybe<T>>) public static Maybe concat(Iterable Iterator while (sources.hasNext()) { Maybe<T> maybe; try { maybe = sources.next().call(); } catch (Exception e) { return Maybe.error(e); } if (maybe != null) { if (maybe instanceof ScalarCallable) { //同步方法 T response = ((ScalarCallable<T>)maybe).call(); if (response != null) { //有response,中断 return maybe; } } else { //异步方法 if (sources.hasNext()) { //将sources传入回调,后续filter重复此逻辑 return new ConcattedMaybe(maybe, sources); } else { return maybe; } } } } return Maybe.empty(); } }
public class ProcessEngine{ //各个阶段,增加默认超时与错误处理 private void process(RequestContext context) { List<Callable<Maybe<Response>>> inboundTask = get(ProcessorType.INBOUND, context); List<Callable<Maybe<Void>>> outboundTask = get(ProcessorType.OUTBOUND, context); List<Callable<Maybe<Response>>> errorTask = get(ProcessorType.ERROR, context); List<Callable<Maybe<Void>>> logTask = get(ProcessorType.LOG, context); RxUtil.concat(inboundTask) //inbound阶段 .toSingle() //获取response .flatMapMaybe(response -> { context.setOriginResponse(response); return RxUtil.concat(outboundTask); }) //进入outbound .onErrorResumeNext(e -> { context.setThrowable(e); return RxUtil.concat(errorTask).flatMap(response -> { context.resetResponse(response); return RxUtil.concat(outboundTask); }); }) //异常则进入error,并重新进入outbound .flatMap(response -> RxUtil.concat(logTask)) //日志阶段 .timeout(asyncTimeout.get(), TimeUnit.MILLISECONDS, Schedulers.from(context.getEventloop()), Maybe.error(new ServerException(500, "Async-Timeout-Processing")) ) //全局兜底超时 .subscribe( //释放资源 unused -> { logger.error("this should not happen, " + context); context.release(); }, e -> { logger.error("this should not happen, " + context, e); context.release(); }, () -> context.release() ); } }
以HTTP为例,报文可划分为initial line/header/body三个组成部分:
在携程,网关层业务不涉及body。因为无需全量存,所以解析完header后可直接进入业务流程。
于此同时,如果接收到body部分:
对比完整解析HTTP报文的方式,这样处理:
虽说提升了性能,但流式的方式也极大提升了整个流程的复杂度
非流式场景下,Netty Server端编解码、入向业务逻辑、Netty Cerver端编解码、出向业务逻辑,各子流程相互独立,各自处理完整的HTTP对象。
采取流式后,请求则可能同时处于多流程内,引入的困难可归纳为以下三点:
针对这些场景,我们采用了单线程的方式,核心设计:
采用单线程的好处:
与之相对的,因为worker线程数较少(一般等于CPU核数),eventloop内必须完全杜绝IO操作,否则将对系统吞吐造成毁灭性打击。
内部变量懒加载:针对请求的cookie/query等字段,如无必要,不提前进行字符串解析
堆外内存&零拷贝:结合前文流式转发的设计,进一步降低系统内存开销
ZGC:项目因TLSv1.3而引入了JDK11(JDK8支持相对较晚,8u261版本,2020.7.14),自然也对新一代的GC算法进行了尝试,实际表现也确实不负盛名。除CPU占用有少量提升,整体GC耗时下降非常明显。
定制的HTTP编解码:HTTP的悠久历史,加之协议自身的开放性,催生了许多“坏实践”,轻则影响成功率,重则威胁网站安全,举两个例子:
流量治理:诸如请求体过大(413)、uri过长(414)、非ASCII字符(400)等问题,一般WebServer会选择直接拒绝并返回对应状态码。由于直接跳过了业务流程,这类问题在统计、服务定为、排障上都会比较麻烦。扩展编解码,让问题请求也能够走完路由流程,可以帮助解决非标流量的治理问题。
请求过滤:如request smuggling(Netty 4.1.61.Final修复,2021.3.30发布)。扩展编解码,增加自定义的校验逻辑,让安全补丁能够更快落地。
作为独立、统一的入向流量收口点,网关对公司的价值主要体现在三方面:
这里展开讲几个细分场景。
私有协议:
链路优化:核心是引入接入层,让远距离用户就近访问,缓解握手开销过大的问题。同时,因为接入层与IDC是可控的两端,网络链路选择、协议交互模式上都有更大的优化空间。
异地多活:区别于按比例分配、就近访问策略等,异地多活模式下,网关(接入层)需按照业务维度的shardingKey进行分流(如userId),防止底层数据冲突。
下图总结了线上网关的工作状态:
横向对应我们的业务流程:不同渠道(APP、H5、小程序、供应商)、不同协议(HTTP、SOTP)的流量经由负载均衡打到网关,经过系列业务逻辑的处理,最终转发至后端服务。经历了第二章的改造后,横向业务在性能、稳定性上都得到了较好的提升。
另一方面:由于多渠道/协议的存在,线上网关按业务划分,进行了独立集群的部署。业务差异(路由数据、功能模块)早期通过独立代码分支管理,随着分支数的增加,整体的运维复杂度越来越高。系统设计中,复杂度往往也意味着风险。如何对多协议、多角色的网关实施统一治理,如何以较低的成本,快速为新业务搭建定制化网关,成为了我们后一阶段的工作重心。
解决方案也比较直观地在图中画了出来:
协议兼容的做法并不新鲜,整体可以参考Tomcat对HTTP/1.0、HTTP/1.1、HTTP/2.0的抽象。HTTP自身虽然在各个版本内新增了大量feature,但我们在做业务开发时通常感知不到这些,核心在于HttpServletRequest接口的抽象。
在携程,网关面对的都是请求—响应模式的无状态协议,报文组成上也可以划分为元数据、扩展头、业务报文三部分,因此可以比较方便地进行类似的尝试。
对应工作可以用以下两点概括:
路由模块是控制面的两个主要组成部分之一。
除了管理网关—服务间的映射关系,服务本身可以用以下模型概括:
{ //匹配方式 "type": "uri", //HTTP默认采用uri前缀匹配,内部通过树结构寻址;私有协议(SOTP)通过服务唯一标识定位。 "value": "/hotel/order", "matcherType": "prefix", //标签与属性 //用于portal端权限管理、切面逻辑运行(如按核心/非核心)等 "tags": [ "owner_admin", "org_framework", "appId_123456" ], "properties": { "core": "true" }, //endpoint信息 "routes": [{ //condition用于二级路由,如按app版本划分、按query重分配等 "condition": "true", "conditionParam": {}, "zone": "PRO", //具体服务地址,权重用于灰度场景 "targets": [{ "url": "http://test.ctrip.com/hotel", "weight": 100 } ] }] }
模块编排是控制面的另一项核心部分。
我们在网关处理流程内预留了多个阶段(上图中用粉色标记)。除开熔断、限流、日志等通用功能,运行时不同网关所需执行的业务功能由控制面统一下发。功能本身在网关内部有独立的代码模块,控制面额外定义了功能对应的执行条件、参数、灰度比例、错误处理方式等。这种编排方式也在侧面保证了模块间的解耦。
{ //模块名称,对应网关内部某个具体模块 "name": "addResponseHeader", //执行阶段 "stage": "PRE_RESPONSE", //执行顺序 "ruleOrder": 0, //灰度比例 "grayRatio": 100, //执行条件 "condition": "true", "conditionParam": {}, //执行参数 //大量${}形式的内置模板,用于获取运行时数据 "actionParam": { "connection": "keep-alive", "x-service-call": "${request.func.remoteCost}", "Access-Control-Expose-Headers": "x-service-call", "x-gate-root-id": "${func.catRootMessageId}" }, //异常处理方式,可以抛出或忽略 "exceptionHandle": "return" }
网关长期以来都是各类技术交流平台上的热点,方案也非常丰富:发展早、易上手的Zuul1.0、高性能的Nginx、集成度高的SpringCloud Gateway、如日中天的Istio等等。最终决定选型的还是各公司自身的业务背景与技术生态。也正因此,在携程我们选择了自研的道路。
技术不断发展,我们也在持续探索,公共网关同业务网关的关系、新协议的落地(HTTP3)、与ServiceMesh的关系等等,真诚欢迎有兴趣的同学一起参与讨论。(本文已同步发布于:http://www.52im.net/thread-4854-1-1.html)
[1] 京东京麦的生产级TCP网关技术实践总结
[2] 手淘亿级移动端接入层网关的技术演进之路
[3] 喜马拉雅自研亿级API网关技术实践
[4] 小米小爱单机120万长连接接入层的架构演进
[5] B站基于微服务的API网关从0到1的演进之路
[6] 去哪儿网酒店高性能业务网关技术实践
[7] 少啰嗦!一分钟带你读懂Java的NIO和经典IO的区别
[8] 史上最强Java NIO入门:担心从入门到放弃的,请读这篇!
[9] Java的BIO和NIO很难懂?用代码实践给你看,再不懂我转行!
[10] 史上最通俗Netty框架入门长文:基本介绍、环境搭建、动手实战
[11] 新手入门:目前为止最透彻的的Netty高性能原理和框架架构解析
本文系转载,前往查看
如有侵权,请联系 cloudcommunity@tencent.com 删除。
本文系转载,前往查看
如有侵权,请联系 cloudcommunity@tencent.com 删除。