关于java:日流量200亿聊聊-携程网关的架构设计

37次阅读

共计 8661 个字符,预计需要花费 22 分钟才能阅读完成。

大家好,我是不才陈某~

本文目录

– 说在后面

– 日流量 200 亿,携程网关的架构设计

– 一、概述

– 二、高性能网关外围设计

– 2.1. 异步流程设计

– 2.2. 流式转发 & 单线程

– 2.3 其余优化

– 三、网关业务状态

– 四、网关治理

– 4.1 多协定兼容

– 4.2 路由模块

– 4.3 模块编排

– 五、总结

– 说在最初:有问题能够找老架构取经

– 局部历史案例

日流量 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(热更新反对)

家喻户晓,同步调用会阻塞线程,零碎的吞吐能力受 IO 影响较大。

作为行业的领先者,Zuul 在设计时曾经思考到了这个问题:通过引入 Hystrix,实现资源隔离和限流,将故障(慢 IO)限度在肯定范畴内;联合熔断策略,能够提前开释局部线程资源;最终达到部分异样不会影响整体的指标。

然而,随着公司业务的一直倒退,上述策略的成果逐步削弱,次要起因有两方面:

  • 业务出海:网关作为海内接入层,局部流量须要转回国内,慢 IO 成为常态
  • 服务规模增长:部分异样成为常态,加上微服务异样扩散的个性,线程池可能长期处于亚健康状态

全异步革新是携程 API 网关近年来的一项外围工作,本文也将围绕此开展,探讨咱们在网关方面的工作与实践经验。

重点包含:性能优化、业务状态、技术架构、治理教训等。

二、高性能网关外围设计

2.1. 异步流程设计

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

对于 server 与 client 端,咱们采纳了 Netty 框架,其 NIO/Epoll + Eventloop 的实质就是事件驱动的设计。

咱们革新的外围局部是将业务流程进行异步化,常见的异步场景有:

  • 业务 IO 事件:例如申请校验、身份验证,波及近程调用
  • 本身 IO 事件:例如读取到了报文的前 xx 字节
  • 申请转发:包含 TCP 连贯,HTTP 申请

从教训上看,异步编程在设计和读写方面相比同步会略微艰难一些,次要包含:

  • 流程设计 & 状态转换
  • 异样解决,包含惯例异样与超时
  • 上下文传递,包含业务上下文与 trace log
  • 线程调度
  • 流量管制

特地是在 Netty 上下文内,如果对 ByteBuf 的生命周期设计不欠缺,很容易导致内存透露。

围绕这些问题,咱们设计了对应外围框架,最大致力对业务代码抹平同步 / 异步差别,不便开发;同时默认兜底与容错,保障程序整体平安。

在工具方面,咱们应用了 RxJava,其次要流程如下图所示。

  • Maybe
  • RxJava 的内置容器类,示意失常完结、有且仅有一个对象返回、异样三种状态
  • 响应式,便于整体状态机设计,自带异样解决、超时、线程调度等封装
  • Maybe.empty()/Maybe.just(T),实用同步场景
  • 工具类 RxJavaPlugins,不便切面逻辑封装
  • Filter
  • 代表一块独立的业务逻辑,同步 & 异步业务对立接口,返回 Maybe
  • 异步场景(如近程调用)对立封装,如波及线程切换,通过 maybe.obesrveOn(eventloop) 切回
  • 异步 filter 默认减少超时,并按弱依赖解决,疏忽谬误

Java 技术指南:http://www.java-family.cn

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 三个组成部分。

在携程,网关层业务不波及申请体 body。

因为无需全量存,所以解析完申请头 header 后可间接进入业务流程。

同时,如果收到申请体 body 局部:

①若已向 upstream 转发申请,则间接转发;

②否则,须要将其临时存储,期待业务流程处理完毕后,再将其与 initial line/header 一并发送;

③对 upstream 端响应的解决形式亦然。

比照残缺解析 HTTP 报文的形式,这样解决:

  • 更早进入业务流程,意味着 upstream 更早接管到申请,能够无效地升高网关层引入的提早
  • body 生命周期被压缩,可升高网关本身的内存开销

只管性能有所晋升,但流式解决也大大增加了整个流程的复杂性。

在非流式场景下,Netty Server 端编解码、入向业务逻辑、Netty Client 端的编解码、出向业务逻辑,各个子流程互相独立,各自解决残缺的 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),同时也尝试了新一代的垃圾回收算法,其理论体现的确如人们所期待的那样杰出。只管 CPU 占用有所增加,但整体 GC 耗时降落十分显著。

  • 定制的 HTTP 编解码

因为 HTTP 协定的历史悠久及其开放性,产生了很多“不良实际”,轻则影响申请成功率,重则对网站平安构成威胁。

  • 流量治理

对于申请体过大(413)、URI 过长(414)、非 ASCII 字符(400)等问题,个别的 Web 服务器会抉择间接回绝并返回相应的状态码。因为这类问题跳过了业务流程,因而在统计、服务定位和故障排查方面会带来一些麻烦。通过扩大编解码,让问题申请也能实现路由流程,有助于解决非标准流量的治理问题。

  • 申请过滤

例如 request smuggling(Netty 4.1.61.Final 修复,2021.3.30 公布)。通过扩大编解码,减少自定义校验逻辑,能够让安全补丁更快地得以利用。

三、网关业务状态

作为独立的、对立的入向流量收口点,网关对企业的价值次要展示在三个方面:

  • 解耦不同网络环境:典型场景包含内网 & 外网、生产环境 & 办公区、IDC 外部不同平安域、专线等;
  • 人造的公共业务切面:包含平安 & 认证 & 反爬、路由 & 灰度、限流 & 熔断 & 降级、监控 & 告警 & 排障等;

  • 高效、灵便的流量管制

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

  • 公有协定

在收口的客户端(APP)中,框架层会拦挡用户发动的 HTTP 申请,通过公有协定(SOTP)的形式传送到服务端。

选址方面:①通过服务端调配 IP,避免 DNS 劫持;②进行连贯预热;③采纳自定义的选址策略,能够依据网络情况、环境等因素自行切换。

交互方式上:①采纳更轻量的协定体;②对立进行加密与压缩与多路复用;③在入口处由网关对立转换协定,对业务无影响。

  • 链路优化

关键在于引入接入层,让近程用户就近拜访,解决握手开销过大的问题。同时,因为接入层与 IDC 两端都是可控的,因而在网络链路抉择、协定交互模式等方面都有更大的优化空间。

  • 异地多活

与按比例调配、就近拜访策略等不同,在异地多活模式下,网关(接入层)须要依据业务维度的 shardingKey 进行分流(如 userId),避免底层数据抵触。

四、网关治理

下所示的图表概括了网上网关的工作状态。纵向对应咱们的业务流程:各种渠道(如 APP、H5、小程序、供应商)和各种协定(如 HTTP、SOTP)的流量通过负载平衡调配到网关,通过一系列业务逻辑解决后,最终被转发到后端服务。通过第二章的改良后,横向业务在性能和稳定性方面都失去了显著晋升。

另一方面,因为多渠道 / 协定的存在,网上网关依据业务进行了独立集群的部署。晚期,业务差别(如路由数据、功能模块)通过独立的代码分支进行治理,然而随着分支数量的减少,整体运维的复杂性也在一直进步。在零碎设计中,复杂性通常也意味着危险。因而,如何对多协定、多角色的网关进行对立治理,如何以较低的老本疾速为新业务构建定制化的网关,成为了咱们下一阶段的工作重点。

解决方案曾经在图中直观地出现进去,一是在协定上进行兼容解决,使网上代码在一个框架下运行;二是引入管制面,对网上网关的差别个性进行对立治理。

4.1 多协定兼容

多协定兼容的办法并不新鲜,能够参考 Tomcat 对 HTTP/1.0、HTTP/1.1、HTTP/2.0 的形象解决。只管 HTTP 在各个版本中减少了许多新个性,但在进行业务开发时,咱们通常无奈感知到这些变动,关键在于 HttpServletRequest 接口的形象。

在携程,网上网关解决的都是申请 – 响应模式的无状态协定,报文构造也能够划分为元数据、扩大头、业务报文三局部,因而能够不便地进行相似的尝试。相干工作能够用以下两点来概括:

  • 协定适配层:用于屏蔽不同协定的编解码、交互模式、对 TCP 连贯的解决等
  • 定义通用两头模型与接口:业务面向两头模型与接口进行编程,更好地关注到协定对应的业务属性上

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 模块编排

模块调度是管制面的另一个要害组成部分。咱们在网关解决流程中设置了多个阶段(图中用粉色示意)。除了熔断、限流、日志等通用性能外,运行时,不同网关须要执行的业务性能由管制面统一分配。这些性能在网关外部有独立的代码模块,而管制面则额定定义了这些性能对应的执行条件、参数、灰度比例和错误处理形式等。这种调度形式也在肯定水平上保障了模块之间的解耦。

{
    // 模块名称,对应网关外部某个具体模块
    "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"
}

五、总结

网关在各种技术交流平台上始终是备受关注的话题,有很多成熟的解决方案:易于上手且倒退较早的 Zuul 1.0、高性能的 Nginx、集成度高的 Spring Cloud Gateway、日益风行的 Istio 等等。

最终的选型还是取决于各公司的业务背景和技术生态。

因而,在携程,咱们抉择了自主研发的路线。

技术在一直倒退,咱们也在继续摸索,包含公共网关与业务网关的关系、新协定(如 HTTP3)的利用、与 ServiceMesh 的关联等等。

正文完
 0