干货 | 日均流量200亿,携程高性能全异步网关实践

作者简介

 

Butters,携程软件技术专家,专注于网络架构、API网关、负载均衡、Service Mesh等领域。

一、概述

与许多公司一样,携程API网关也是同微服务架构一起引入的基础设施,最早版本发布于2014年。随着服务化在公司的快速推进,网关逐渐成为应用暴露到外网的标准方案。后来的“ALL IN无线”、国际化、异地多活等,网关跟随着公司公共业务与基础架构共同演进。截止2021年7月,整体接入服务数3000以上,日均处理流量200亿。

技术方案上,公司微服务早期发展受NetflixOSS影响较深,网关方面最早也是参考了Zuul 1.0进行的二次开发,核心可概括为四点:

  • server端:Tomcat NIO + AsyncServlet

  • 业务流程:独立线程池,分阶段的责任链模式

  • client端:Apache HttpClient,同步调用

  • 核心组件:Archaius(动态配置客户端),Hystrix(熔断限流),Groovy(热更新支持)

18a35310579d7f9954cd235c32cefdb2.png

众所周知,同步调用阻塞线程,系统吞吐受IO影响大。作为行业先驱,Zuul在设计上也考虑到了这点:通过引入Hystrix,资源隔离配合限流,将故障(慢IO)框在一定范围内;配合熔断策略,可提前释放部分线程资源;最终达到局部异常不影响全局的目的。

但随着公司业务的发展,上述策略效果逐渐减弱,主要原因在于两方面的变动:

  • 业务出海:网关作为海外接入层,部分流量需转回国内,慢IO成为常态

  • 服务规模增长:局部异常常态化,加上微服务异常扩散的特性,线程池可能长期处于亚健康状态

3930009844444676ee7c69b81f89d142.png

全异步改造是携程API网关近年的一项核心工作点,本文也将由此展开,聊一聊我们在网关方面的工作与实践。重点包括:性能优化、业务形态、技术架构、治理经验等。

二、高性能网关核心设计

2.1. 异步流程设计

全异步 = server端异步 + 业务流程异步 + client端异步 

对于server与client端,我们选择了Netty框架,NIO/Epoll + Eventloop本身就是事件驱动的设计。改造核心在于业务流程的异步化,常见异步场景包括:

  • 业务IO事件:如请求校验、身份认证,涉及远程调用

  • 自身IO事件:如读取到了报文的前xx字节

  • 请求转发:包括TCP连接,HTTP请求

经验上,异步编程相比同步在设计、读写上都会困难一些,一般包括:

  • 流程设计&状态转换

  • 异常处理,包括常规异常与超时

  • 上下文传递,包括业务上下文与trace log

  • 线程调度

  • 流量控制

尤其在Netty上下文内,对ByteBuf生命周期设计的不完善,很容易造成内存泄漏。围绕这些问题,我们设计了对应外围框架,最大努力对业务代码抹平同步/异步差异,方便开发;同时默认兜底与容错,保证程序整体安全。工具上借助了RxJava,主要流程如下图所示。

464d8762440b5e230bf0d6f138742680.png

  • Maybe

  • RxJava内置容器类,标识正常结束、有且仅有一个对象返回、异常三种状态

  • 响应式,方便整体状态机设计,自带异常处理、超时、线程调度等封装

  • Maybe.empty()/Maybe.just(T),适用同步场景

  • 工具类RxJavaPlugins,方便切面逻辑封装

  • Filter

  • 代表一块独立的业务逻辑,同步&异步业务统一接口,返回Maybe

  • 异步场景(如远程调用)统一封装,如涉及线程切换,通过maybe.obesrveOn(eventloop)切回

  • 异步filter默认增加超时,并按弱依赖处理,忽略错误

public interface Processor<T> {    
    ProcessorType getType();
    
    int getOrder();
    
    boolean shouldProcess(RequestContext context);
    
    //对外统一封装为Maybe    
    Maybe<T> 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<T> processAsync(RequestContext context) throws Exception 
{
        T response = processSyncAndGetReponse(context);
        if (response == null) {
            return Maybe.empty();
        } else {
            return Maybe.just(response);
        }
    };


    @Override
    public Maybe<T> 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();
    }
  • 整体流程

  • 沿用责任链的设计,分为inbound、outbound、error、log四阶段

  • 各阶段由一或多个filter组成

  • filter顺序执行,遇到异常则中断,inbound期间任意filter返回response也触发中断

public class RxUtil{
      //组合某阶段(如Inbound)内的多个filter(即Callable<Maybe<T>>)
      public static <T> Maybe<T> concat(Iterable<? extends Callable<Maybe<T>>> iterable) {
          Iterator<? extends Callable<Maybe<T>>> sources = 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()
              );
      }   
  }

2.2. 流式转发&单线程

以HTTP为例,报文可划分为initial line/header/body三个组成部分。

18a3963a7c83148fff54079c6530761e.png

在携程,网关层业务不涉及body。因为无需全量存,所以解析完header后可直接进入业务流程。于此同时,如果接收到body部分:①若已向upstream转发请求,则直接转发;②否则需要将其暂存,待业务流程处理完毕,同initial line/header一并发送;③对upstream端响应的处理方式亦然。

对比完整解析HTTP报文的方式,这样处理:

  • 更早进入业务流程,意味着upstream更早接收到请求,能有效降低网关这层引入的延迟

  • body生命周期被压缩,可降低网关自身的内存开销

虽说提升了性能,但流式的方式也极大提升了整个流程的复杂度。

809853a7055a263c7d34513cbddc696d.png

非流式场景下,Netty Server端编解码、入向业务逻辑、Netty Cerver端编解码、出向业务逻辑,各子流程相互独立,各自处理完整的HTTP对象。采取流式后,请求则可能同时处于多流程内,引入的困难可归纳为以下三点:

  • 线程安全问题:不同流程若采用不同线程,会涉及上下文的并发修改;

  • 多阶段联动:比如Netty Server请求接收一半遇到了连接中断,此时已经连上了upstream,那么upstream侧的协议栈是走不完的,也必须随之关闭连接;

  • 边缘场景处理:比如upstream在请求未完整发送情况下返回了404/413,是选择继续发送、走完协议栈、让连接能够复用,还是选择提前终止流程,节约资源,但同时放弃连接?再比如,upstream已收到请求但未响应,此时Netty Server突然断开,Netty Client是否也要随之断开?等等。

针对这些场景,我们采用了单线程的方式,核心设计:

  • 上线文绑定Eventloop,Netty Server/业务流程/Netty Client在同个eventloop执行;

  • 异步filter如因IO库的关系,必须使用独立线程池,那在后置处理上必须切回;

  • 流程内资源做必要的线程隔离(如连接池);

单线程方式杜绝了并发问题,在多阶段联动、边缘场景问题处理时,整个系统也处于确定的状态下,有效降低了开发难度与风险;此外减少线程切换,一定程度上也能够提升性能。与之相对的,因为worker线程数较少(一般等于CPU核数),eventloop内必须完全杜绝IO操作,否则将对系统吞吐造成毁灭性打击。

2.3 其他优化

  • 内部变量懒加载

针对请求的cookie/query等字段,如无必要,不提前进行字符串解析

  • 堆外内存&零拷贝

结合前文流式转发的设计,进一步降低系统内存开销

  • ZGC

项目因TLSv1.3而引入了JDK11(JDK8支持相对较晚,8u261版本,2020.7.14),自然也对新一代的GC算法进行了尝试,实际表现也确实不负盛名。除CPU占用有少量提升,整体GC耗时下降非常明显。

afca7a18a485b39fa719a0f1fe0971d2.png

221d9e7bf2d2324ed5fc3cb89bda2c87.png

  • 定制的HTTP编解码

HTTP的悠久历史,加之协议自身的开放性,催生了许多“坏实践”,轻则影响成功率,重则威胁网站安全,举两个例子:

  • 流量治理

诸如请求体过大(413)、uri过长(414)、非ASCII字符(400)等问题,一般WebServer会选择直接拒绝并返回对应状态码。由于直接跳过了业务流程,这类问题在统计、服务定为、排障上都会比较麻烦。扩展编解码,让问题请求也能够走完路由流程,可以帮助解决非标流量的治理问题。

  • 请求过滤

如request smuggling(Netty 4.1.61.Final修复,2021.3.30发布)。扩展编解码,增加自定义的校验逻辑,让安全补丁能够更快落地。


三、网关业务形态

作为独立、统一的入向流量收口点,网关对公司的价值主要体现在三方面:

  • 解耦不同网络环境:典型场景包括内网&外网、生产环境&办公区、IDC内部不同安全域、专线等;

  • 天然的公共业务切面:包括安全&认证&反爬、路由&灰度、限流&熔断&降级、监控&告警&排障等;

b625e481718d1532bd1f76fdc4dbe3f1.png

d095f13f5f962170e5fb0a3e47e5ec15.png

  • 高效、灵活的流量控制

这里展开讲几个细分场景:

  • 私有协议

在收口的客户端(APP),由框架层拦截用户发起的HTTP请求,通过私有协议(SOTP)的方式发往服务端。

选址方面:①通过服务端下发IP,杜绝DNS劫持;②连接预热;③自定义的选址策略,可依据网络质量、环境等自行切换。

交互方式上:①更加轻量的协议体;②统一加密&压缩&多路复用;③协议在入口处由网关统一转换,对业务透明。

  • 链路优化

核心是引入接入层,让远距离用户就近访问,缓解握手开销过大的问题。同时,因为接入层与IDC是可控的两端,网络链路选择、协议交互模式上都有更大的优化空间。

  • 异地多活

区别于按比例分配、就近访问策略等,异地多活模式下,网关(接入层)需按照业务维度的shardingKey进行分流(如userId),防止底层数据冲突。

2c6c3e805c0d7e1f069be7d456a52767.png

四、网关治理

下图总结了线上网关的工作状态。横向对应我们的业务流程:不同渠道(APP、H5、小程序、供应商)、不同协议(HTTP、SOTP)的流量经由负载均衡打到网关,经过系列业务逻辑的处理,最终转发至后端服务。经历了第二章的改造后,横向业务在性能、稳定性上都得到了较好的提升。

1f1bd2daf25e74ed2b167e51f1b61f0a.png

另一方面,由于多渠道/协议的存在,线上网关按业务划分,进行了独立集群的部署。业务差异(路由数据、功能模块)早期通过独立代码分支管理,随着分支数的增加,整体的运维复杂度越来越高。系统设计中,复杂度往往也意味着风险。如何对多协议、多角色的网关实施统一治理,如何以较低的成本,快速为新业务搭建定制化网关,成为了我们后一阶段的工作重心。

解决方案也比较直观地在图中画了出来,一是协议上兼容处理,让线上代码跑在一套框架下;二是引入控制面,对线上网关的差异特性进行统一管理。

dfb5ab695f26c584e3d8a36f3ccf8a0d.png

4.1 多协议兼容

协议兼容的做法并不新鲜,整体可以参考Tomcat对HTTP/1.0、HTTP/1.1、HTTP/2.0的抽象。HTTP自身虽然在各个版本内新增了大量feature,但我们在做业务开发时通常感知不到这些,核心在于HttpServletRequest接口的抽象。

在携程,网关面对的都是请求—响应模式的无状态协议,报文组成上也可以划分为元数据、扩展头、业务报文三部分,因此可以比较方便地进行类似的尝试。对应工作可以用以下两点概括:

  • 协议适配层:用于屏蔽不同协议的编解码、交互模式、对TCP连接的处理等

  • 定义通用中间模型与接口:业务面向中间模型与接口编程,更好地聚焦到协议对应的业务属性上去

eaf4032b6c4f4ccdfd79b8901867a7d6.png


4.2 路由模块

路由模块是控制面的两个主要组成部分之一,除了管理网关—服务间的映射关系,服务本身可以用以下模型概括:

{
      //匹配方式
      "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
          }
        ]
      }]
  }


4.3 模块编排

模块编排是控制面的另一项核心部分。我们在网关处理流程内预留了多个阶段(图中用粉色标记)。除开熔断、限流、日志等通用功能,运行时不同网关所需执行的业务功能由控制面统一下发。功能本身在网关内部有独立的代码模块,控制面额外定义了功能对应的执行条件、参数、灰度比例、错误处理方式等。这种编排方式也在侧面保证了模块间的解耦。

42882053e80051f4fa9ea5b2be09a44d.png

{
      //模块名称,对应网关内部某个具体模块
      "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的关系等等,真诚欢迎有兴趣的同学一起参与讨论。

团队招聘信息

我们是平台研发中心,一个为携程快速发展提供各类基础产品和服务的平台,我们以技术驱动提升客户体验,提升跨团队协作效率。

我们拥有优秀而强大的团队,引导你学习业内领先的开发技术,与技术高手交流对话,学习切磋。在亿级用户严苛的品质要求中,激发你脑中不断涌现的创新思维,带领你体验飞速成长的惊喜快乐,并在各种机遇与挑战中发展自我,成就自身。

目前我们前端、后台、算法、测试等技术岗位均有职位。简历投递:tech@trip.com,邮件标题:【姓名】-【携程平台研发中心】-【投递职位】

【推荐阅读】

3b04fee94da0a9ecb0b7234046a1602f.png

 “携程技术”公众号

  分享,交流,成长

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值