记录些企业级网关架构实现方案

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

一、概述

类似于许多企业的做法,携程 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默认增加超时,并按弱依赖处理,忽略错误

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 的关联等等。

千万级连接,知乎长连接网关架构

1、知乎千万级并发的高性能长连接网关技术实践

几乎每个互联网公司都有一套长连接系统,它们在消息提示、实时通信、推送、直播弹幕、游戏、共享定位、股票行情等场景中得到应用。

随着公司规模的扩大和业务场景的复杂化,多个业务可能都需要同时使用长连接系统。分别为各个业务设计长连接将会导致研发和维护成本大幅上升、资源浪费、增加客户端能耗、无法重复利用现有经验等问题。

共享长连接系统则需要协调不同系统间的认证、授权、数据隔离、协议扩展、消息送达保证等需求,在迭代过程中协议需要保持向前兼容,同时由于不同业务的长连接汇聚到一个系统,容量管理的难度也会相应增大。

经过一年多的开发和演进,我们面对内外部的多个 App、接入十几个需求和形态各异的长连接业务、数百万设备同时在线、突发大规模消息发送等场景,提炼出了一个长连接系统网关的通用解决方案,解决了多业务共用长连接时遇到的各种问题。

知乎长连接网关专注于业务数据解耦、消息高效分发、解决容量问题,同时提供一定程度的消息可靠性保证。

2、我们怎么设计通讯协议?

2.1 业务解耦

支持多业务的长连接网关需要同时与多个客户端和多个业务后端进行对接,形成多对多的关系,他们之间仅依赖一条长连接进行通信。

图片

在设计这种多对多的系统时,需要避免过度耦合。业务逻辑是动态调整的,如果将业务协议和逻辑与网关实现紧密结合,将会导致所有业务相互关联,协议升级和维护变得极其困难。

因此,我们尝试采用经典的发布订阅模型来实现长连接网关与客户端和业务后端的解耦,他们之间只需约定主题,便可自由地发布和订阅消息。传输的消息为纯二进制数据,网关无需关心业务方的具体协议规范和序列化方式。

图片

2.2 如何进行客户端的权限控制?

我们采用发布订阅的方式解耦了网关与业务方的实现,然而,我们还需要控制客户端对主题(Topic)的发布订阅权限,防止数据污染或越权访问,无论是有意还是无意的。

比如,当一个讲师在知乎 Live 的 165218 频道进行演讲,客户端进入房间并尝试订阅 165218 频道的 Topic 时,知乎 Live 的后端就需要判断当前用户是否已经付费。在这种情况下,权限是非常灵活的,用户付费后才能订阅,否则就不能订阅。

关于权限的状态,只有知乎 Live 业务后端知道,网关无法独立作出判断。

因此,我们在 ACL 规则中设计了一个基于回调的鉴权机制,可以配置 Live 相关 Topic 的订阅和发布动作都通过 HTTP 回调给知乎 Live 的后端服务进行判断。

图片

同时,根据我们对内部业务的观察,大部分场景下,业务只需要一个当前用户的私有主题来接收服务端下发的通知或消息。在这种情况下,如果让业务都设计回调接口来判断权限,将会非常繁琐。

此,我们在 ACL 规则中设计了 Topic 模板变量,以降低业务方的接入成本。我们为业务方配置允许订阅的 Topic 中包含连接的用户名变量标识,表示只允许用户订阅或发送消息到自己的 Topic。

图片

在这种情况下,网关可以在不与业务方通信的情况下,独立快速判断客户端是否有权限订阅或向 Topic 发送消息。

2.3 消息如何实现高可靠传输?

作为信息传输的关键节点,网关连接业务后端和客户端,转发信息时,必须确保传输过程中的可靠性。

尽管 TCP 可以确保传输的顺序和稳定性,但在 TCP 状态异常、客户端接收逻辑异常或发生了 Crash 等情况下,传输的信息可能会丢失。

为了确保下发或上传的信息能被对方正确处理,我们实现了回执和重传功能。在客户端收到并正确处理重要业务的信息后,需要发送回执,而网关会暂时保存客户端未接收的信息,并根据客户端的接收状况尝试重新发送,直至收到客户端的正确回执。

图片

在面对服务端业务的高流量场景时,如果服务端给网关的每条信息都采用发送回执的方式,效率会较低。因此,我们也提供了基于消息队列的接收和发送方式,将在介绍发布订阅实现时作详细说明。

在设计通讯协议时,我们参照了 MQTT 规范,加强了认证和授权设计,实现了业务信息的隔离和解耦,确保了传输的可靠性。同时,保持了与 MQTT 协议一定程度上的兼容性,以便我们直接使用 MQTT 的各种客户端实现,降低业务方的接入成本。

3、系统架构要考虑的几个维度?

在设计项目整体架构时,我们优先考虑的是:

  • 1)可靠性;

  • 2)水平扩展能力;

  • 3)依赖组件成熟度;

  • 4)简单才值得信赖。

为了保证可靠性,我们没有考虑像传统长连接系统那样将内部数据存储、计算、消息路由等等组件全部集中到一个大的分布式系统中维护,这样增大系统实现和维护的复杂度。我们尝试将这几部分的组件独立出来,将存储、消息路由交给专业的系统完成,让每个组件的功能尽量单一且清晰。

同时我们也需要快速地水平扩展能力。互联网场景下各种营销活动都可能导致连接数陡增,同时发布订阅模型系统中下发消息数会随着 Topic 的订阅者的个数线性增长,此时网关暂存的客户端未接收消息的存储压力也倍增。

将各个组件拆开后减少了进程内部状态,我们就可以将服务部署到容器中,利用容器来完成快速而且几乎无限制的水平扩展。

最终设计的系统架构如下图:

图片

系统主要由四个主要组件组成:

  • 1)接入层使用 OpenResty 实现,负责连接负载均衡和会话保持;

  • 2)长连接 Broker,部署在容器中,负责协议解析、认证与鉴权、会话、发布订阅等逻辑;

  • 3)Redis 存储,持久化会话数据;

  • 4)Kafka 消息队列,分发消息给 Broker 或业务方。

其中 Kafka 和 Redis 都是业界广泛使用的基础组件,它们在知乎都已平台化和容器化,它们也都能完成分钟级快速扩容。

4、如何构建长连接网关?

4.1 接入层

OpenResty(http://openresty.org/en/) 是业界使用非常广泛的支持 Lua 的 Nginx 拓展方案,灵活性、稳定性和性能都非常优异,我们在接入层的方案选型上也考虑使用 OpenResty。

接入层是最靠近用户的一侧,在这一层需要完成两件事:

  • 1)负载均衡,保证各长连接 Broker 实例上连接数相对均衡;

  • 2)会话保持,单个客户端每次连接到同一个 Broker,用来提供消息传输可靠性保证。

负载均衡其实有很多算法都能完成,不管是随机还是各种 Hash 算法都能比较好地实现,麻烦一些的是会话保持。

常见的四层负载均衡策略是根据连接来源 IP 进行一致性 Hash,在节点数不变的情况下这样能保证每次都 Hash 到同一个 Broker 中,甚至在节点数稍微改变时也能大概率找到之前连接的节点。

之前我们也使用过来源 IP Hash 的策略,主要有两个缺点:

  • 1)分布不够均匀,部分来源 IP 是大型局域网 NAT 出口,上面的连接数多,导致 Broker 上连接数不均衡;

  • 2)不能准确标识客户端,当移动客户端掉线切换网络就可能无法连接回刚才的 Broker 了。

所以我们考虑七层的负载均衡,根据客户端的唯一标识来进行一致性 Hash,这样随机性更好,同时也能保证在网络切换后也能正确路由。常规的方法是需要完整解析通讯协议,然后按协议的包进行转发,这样实现的成本很高,而且增加了协议解析出错的风险。

最后我们选择利用 Nginx 的 preread 机制实现七层负载均衡,对后面长连接 Broker 的实现的侵入性小,而且接入层的资源开销也小。

Nginx 在接受连接时可以指定预读取连接的数据到 preread buffer 中,我们通过解析 preread buffer 中的客户端发送的第一个报文提取客户端标识,再使用这个客户端标识进行一致性 Hash 就拿到了固定的 Broker。

4.2 内部消息传输的枢纽如何架构?

我们引入了业界广泛使用的消息队列 Kafka 来作为内部消息传输的枢纽。

前面提到了一些这么使用的原因:

  • 1)减少长连接 Broker 内部状态,让 Broker 可以无压力扩容;

  • 2)知乎内部已平台化,支持水平扩展。

还有一些原因是:

  • 1)使用消息队列削峰,避免突发性的上行或下行消息压垮系统;

  • 2)业务系统中大量使用 Kafka 传输数据,降低与业务方对接成本。

其中利用消息队列削峰好理解,下面我们看一下怎么利用 Kafka 与业务方更好地完成对接。

4.3 海量数据如何发布?

连接 Broker 会根据路由配置将消息发布到 Kafka Topic,同时也会根据订阅配置去消费 Kafka 将消息下发给订阅客户端。

路由规则和订阅规则是分别配置的,那么可能会出现四种情况。

情况一:消息路由到 Kafka Topic,但不消费,适合数据上报的场景,如下图所示。

图片

情况二:消息路由到 Kafka Topic,也被消费,普通的即时通讯场景,如下图所示。

图片

情况三:直接从 Kafka Topic 消费并下发,用于纯下发消息的场景,如下图所示。

图片

情况四:消息路由到一个 Topic,然后从另一个 Topic 消费,用于消息需要过滤或者预处理的场景,如下图所示。

图片

这套路由策略的设计灵活性非常高,可以解决几乎所有的场景的消息路由需求。同时因为发布订阅基于 Kafka,可以保证在处理大规模数据时的消息可靠性。

4.4 订阅

当长连接 Broker 从 Kafka Topic 中消费出消息后会查找本地的订阅关系,然后将消息分发到客户端会话。

我们最开始直接使用 HashMap 存储客户端的订阅关系。当客户端订阅一个 Topic 时我们就将客户端的会话对象放入以 Topic 为 Key 的订阅 Map 中,当反查消息的订阅关系时直接用 Topic 从 Map 上取值就行。

因为这个订阅关系是共享对象,当订阅和取消订阅发生时就会有连接尝试操作这个共享对象。为了避免并发写我们给 HashMap 加了锁,但这个全局锁的冲突非常严重,严重影响性能。

最终我们通过分片细化了锁的粒度,分散了锁的冲突。

本地同时创建数百个 HashMap,当需要在某个 Key 上存取数据前通过 Hash 和取模找到其中一个 HashMap 然后进行操作,这样将全局锁分散到了数百个 HashMap 中,大大降低了操作冲突,也提升了整体的性能。

4.5 如何进行会话持久化?

当消息被分发给会话 Session 对象后,由 Session 来控制消息的下发。

Session 会判断消息是否是重要 Topic 消息, 需要的话,将消息标记 QoS 等级为 1,同时将消息存储到 Redis 的未接收消息队列,并将消息下发给客户端。等到客户端对消息的 ACK 后,再将未确认队列中的消息删除。

有一些业界方案是在内存中维护了一个列表,在扩容或缩容时这部分数据没法跟着迁移。也有部分业界方案是在长连接集群中维护了一个分布式内存存储,这样实现起来复杂度也会变高。

我们将未确认消息队列放到了外部持久化存储中,保证了单个 Broker 宕机后,客户端重新上线连接到其他 Broker 也能恢复 Session 数据,减少了扩容和缩容的负担。

4.6 如何使用滑动窗口进行QoS保障?

在发送消息时,每条 QoS 1 的消息需要被经过传输、客户端处理、回传 ACK 才能确认下发完成,路径耗时较长。如果消息量较大,每条消息都等待这么长的确认才能下发下一条,下发通道带宽不能被充分利用。

为了保证发送的效率,我们参考 TCP 的滑动窗口设计了并行发送的机制。我们设置一定的阈值为发送的滑动窗口,表示通道上可以同时有这么多条消息正在传输和被等待确认。

图片

我们应用层设计的滑动窗口跟 TCP 的滑动窗口实际上还有些差异。

TCP 的滑动窗口内的 IP 报文无法保证顺序到达,而我们的通讯是基于 TCP 的所以我们的滑动窗口内的业务消息是顺序的,只有在连接状态异常、客户端逻辑异常等情况下才可能导致部分窗口内的消息乱序。

因为 TCP 协议保证了消息的接收顺序,所以正常的发送过程中不需要针对单条消息进行重试,只有在客户端重新连接后才对窗口内的未确认消息重新发送。消息的接收端同时会保留窗口大小的缓冲区用来消息去重,保证业务方接收到的消息不会重复。

我们基于 TCP 构建的滑动窗口保证了消息的顺序性同时也极大提升传输的吞吐量。

5、总结

知乎长连接网关由基础架构组 (Infra) 开发和维护 。

基础架构组负责知乎的流量入口和内部基础设施建设,对外我们奋斗在直面海量流量的的第一战线,对内我们为所有的业务提供坚如磐石的基础设施,用户的每一次访问、每一个请求、内网的每一次调用都与我们的系统息息相关。

日200亿次调用,喜马拉雅网关的架构设计

网关作为一种发展较为完善的产品,各大互联网公司普遍采用它作为中间件,以应对公共业务需求的不断浮现,并能迅速迭代更新。

如果没有网关,要更新一个公共特性,就得推动所有业务方都进行更新和发布,这无疑是效率极低的。然而,有了网关之后,这一切都不再是问题。

喜马拉雅也如此,用户数量已增长到 6 亿级别,Web 服务数量超过 500 个,目前我们的网关每天处理超过 200 亿次的调用,单机 QPS 峰值可达 4w+。

除了实现基本的反向代理功能,网关还具备许多公共特性,如黑白名单、流量控制、身份验证、熔断、API 发布、监控和报警等。根据业务方的需求,我们还实现了流量调度、流量复制、预发布、智能升级、流量预热等相关功能。

从技术上来说,喜马拉雅API网关的技术演进路线图大致如下

图片

本文将介绍在喜马拉雅 API 网关面临亿级流量的情况下,我们如何进行技术演进,以及我们的实践经验总结。

1、第1版:Tomcat NIO+Async Servlet

在架构设计中,网关的关键之处在于接收到请求并调用后端服务时,不能发生阻塞(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 作为接入层和服务调用层,也就是我们的第二版,成功地解决了上述问题,实现了理想的性能。

2、第2版:Netty+全异步

基于 Netty 的优势,我们构建了全异步、无锁、分层的架构。

先看下我们基于 Netty 做接入端的架构图

图片

2.1 接入层

Netty 的 IO 线程主要负责 HTTP 协议的编解码工作,同时也监控并报警协议层面的异常情况。

我们对 HTTP 协议的编解码进行了优化,并对异常和攻击性请求进行了监控和可视化处理。

例如,我们对 HTTP 请求行和请求头的大小都有限制,而 Tomcat 是将请求行和请求头一起计算,总大小不超过 8K,而 Netty 是分别对两者设置大小限制。

如果客户端发送的请求超过了设定的阀值,带有 cookie 的请求很容易超过这个限制,一般情况下,Netty 会直接响应 400 给客户端。

在优化后,我们只取正常大小的部分,并标记协议解析失败,这样在业务层就可以判断出是哪个服务出现了这类问题。

对于其他攻击性的请求,例如只发送请求头而不发送 body 或者只发送部分内容,都需要进行监控和报警。

2.2 业务逻辑层

这一层负责实现一系列支持业务的公共逻辑,包括 API 路由、流量调度等,采用责任链模式,这一层不会进行 IO 操作。

在业界和大型企业的网关设计中,业务逻辑层通常都被设计成责任链模式,公共的业务逻辑也在这一层实现。

在这一层,我们也执行了相似的操作,并支持以下功能

  • 1)用户认证和登录验证,支持接口级别的配置;

  • 2)黑白名单:包括全局和应用的黑白名单,以及 IP 和参数级别的限制;

  • 3)流量控制:提供自动和手动控制,自动控制可拦截过大流量,通过令牌桶算法实现;

  • 4)智能熔断:在 Histrix 的基础上进行改进,支持自动升降级,我们采用全自动方式,也支持手动配置立即熔断,即当服务异常比例达到设定值时,自动触发熔断;

  • 5)灰度发布:对于新启动的机器的流量,我们支持类似于 TCP 的慢启动机制,为机器提供一段预热时间;

  • 6)统一降级:我们对所有转发失败的请求都会执行统一降级操作,只要业务方配置了降级规则,都会进行降级,我们支持将降级规则细化到参数级别,包括请求头中的值,非常细粒度,此外,我们还会与 varnish 集成,支持 varnish 的优雅降级;

  • 7)流量调度:支持业务根据筛选规则,将流量分配到对应的机器,也支持仅让筛选的流量访问该机器,这在排查问题/新功能发布验证时非常有用,可以先通过小部分流量验证,再大面积发布上线;

  • 8)流量 copy:我们支持根据规则对线上原始请求 copy 一份,将其写入 MQ 或其他 upstream,用于线上跨机房验证和压力测试;

  • 9)请求日志采样:我们对所有失败的请求都会进行采样并保存到磁盘,以供业务方排查问题,同时也支持业务方根据规则进行个性化采样,我们采样了整个生命周期的数据,包括请求和响应相关的所有数据。

上述提到的所有功能都是对流量进行管理,我们每个功能都作为一个 filter,处理失败都不会影响转发流程,而且所有这些规则的元数据在网关启动时就会全部初始化好。

在执行过程中,不会进行 IO 操作,目前有些设计会对多个 filter 进行并发执行,由于我们的操作都是在内存中进行,开销并不大,所以我们目前并未支持并发执行。另外,规则可能会发生变化,所有需要进行规则的动态刷新。

我们在修改规则时,会通知网关服务,进行实时刷新,我们对内部自己的这种元数据更新请求,通过独立的线程处理,防止 IO 操作时影响业务线程。

2.3 服务调用层

服务调用对于代理网关服务非常关键,这个环节,性能必须很高:必须采用异步方式,我们利用 Netty 实现了这一目标,同时也充分利用了 Netty 提供的连接池,实现了获取和释放的无锁操作。

2.3.1 异步 Push

在发起服务调用后,网关允许工作线程继续处理其他请求,而无需等待服务端返回。

在这个设计中,我们为每个请求创建一个上下文,发送请求后,将该请求的 context 绑定到相应的连接上,当 Netty 收到服务端响应时,会在连接上执行 read 操作。

解码完成后,再从连接上获取相应的 context,通过 context 可以获取到接入端的 session。

这样,push 通过 session 将响应写回客户端,这个设计基于 HTTP 连接的独占性,即连接和请求上下文绑定。

2.3.2 连接池

连接池的原理如下图

图片

服务调用层除了异步发起远程调用外,还需要管理后端服务的连接。

HTTP 与 RPC 不同,HTTP 连接是独占的,所以在释放连接时需要特别小心,必须等待服务端响应完成后才能释放,此外,连接关闭的处理也需要谨慎。

总结如下几点

  • 1)Connection:close;

  • 2)空闲超时,关闭连接;

  • 3)读超时关闭连接;

  • 4)写超时,关闭连接;

  • 5)Fin、Reset。

上面几种需要关闭连接的场景,下面主要说下 Connection:close 和空闲写超时两种,其他情况如读超时、连接空闲超时、收到 fin、reset 码等都比较常见。

2.3.3 Connection:close

后端服务采用的是 Tomcat,它对连接的重用次数有规定,默认为 100 次。

当达到 100 次限制时,Tomcat 会在响应头中添加 Connection:close,要求客户端关闭该连接,否则再次使用该连接发送请求会出现 400 错误。

还有就是如果前端的请求带了 connection:close,那 Tomcat 就不会等待该连接重用满 100 次,即一次就关闭连接。

在响应头中添加 Connection:close 后,连接变为短连接。

在与 Tomcat 保持长连接时,需要注意这一点,如果要利用该连接,需要主动移除 close 头。

2.3.4 写超时

首先,网关在何时开始计算服务的超时时间?

如果从调用 writeAndFlush 开始计算,实际上包含了 Netty 对 HTTP 的编码时间和从队列中发送请求即 flush 的时间,这样对后端服务不公平。

因此,需要在真正 flush 成功后开始计时,这样最接近服务端,当然还包含了网络往返时间和内核协议栈处理时间,这是无法避免的,但基本稳定。

因此,我们在 flush 成功回调后启动超时任务。

需要注意的是:如果 flush 不能快速回调,例如遇到一个大的 POST 请求,body 部分较大,而 Netty 发送时默认第一次只发送 1k 大小。

如果尚未发送完毕,会增大发送大小继续发送,如果在 Netty 发送 16 次后仍未发送完成,将不再继续发送,而是提交一个 flushTask 到任务队列,待下次执行后再发送。

此时,flush 回调时间较长,导致此类请求无法及时关闭,后端服务 Tomcat 会一直阻塞在读取 body 部分,基于上述分析,我们需要设置写超时,对于大的 body 请求,通过写超时及时关闭连接。

3、全链路超时机制

图片

上图是我们在整个链路超时处理的机制

  • 1)协议解析超时;

  • 2)等待队列超时;

  • 3)建连超时;

  • 4)等待连接超时;

  • 5)写前检查是否超时;

  • 6)写超时;

  • 7)响应超时。

4、监控报警

对于网关的业务方来说,他们能看到的是监控和警报功能,我们能够实现秒级的报警和监控,将监控数据定时上传到我们的管理系统,由管理系统负责汇总统计并存储到 InfluxDB 中。

我们对 HTTP 协议进行了全面的监控和警报,涵盖了协议层和服务层的问题。

协议层

  • 1)针对攻击性请求,只发送头部,不发送或只发送部分 body,我们会进行采样并记录,还原现场,并触发警报;

  • 2)对于 Line 或 Head 或 Body 过大的请求,我们会进行采样记录,还原现场,并及时发出警报。

应用层

  • 1)监控耗时:包括慢请求,超时请求,以及 tp99,tp999 等;

  • 2)监控 OPS:并及时发出警报;

  • 3)带宽监控和报警:支持对请求和响应的行,头,body 单独监控;

  • 4)响应码监控:特别是 400,和 404;

  • 5)连接监控:我们对接入端的连接,以及与后端服务的连接,以及后端服务连接上待发送字节大小都进行了监控;

  • 6)失败请求监控

  • 7)流量抖动报警:这是非常必要的,流量抖动可能是出现问题,或者是问题即将出现的预兆。

总体架构

图片

5、性能优化实践

5.1 对象池技术

针对高并发系统,不断地创建对象不仅会占用内存资源,还会对垃圾回收过程产生压力。

为了解决这个问题,我们在实现过程中会对诸如线程池的任务、StringBuffer 等频繁使用的对象进行重用,从而降低内存分配的开销。

5.2 上下文切换

在高并发系统中,通常会采用异步设计。异步化后,线程上下文切换的问题必须得到关注。

我们的线程模型如下

图片

我们的网关没有涉及 I/O 操作,但在业务逻辑处理方面仍然采用了 Netty 的 I/O 编解码线程异步方式。

这主要有两个原因

  • 1)防止开发人员编写的代码出现阻塞现象;

  • 2)在突发情况下,业务逻辑可能会产生大量的日志记录,我们允许在推送线程时使用 Netty 的 I/O 线程作为替代。这种做法可以减少 CPU 上下文切换的次数,从而提高整体吞吐量。我们不能仅仅为了异步而异步,Zuul2 的设计理念与我们的做法相似。

5.3 GC优化

在高并发系统中,垃圾回收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 方法执行完,并等到下次垃圾回收时,才能回收该对象。这导致创建链接的这些对象在年轻代不能立即回收,从而进入了老年代,这也是老年代持续缓慢增长的原因。

5.4 日志

在高并发系统中,尤其是 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 线程被阻塞。因此,在记录日志时,我们需要注意精简。

6、未来规划

目前,我们都在使用基于 HTTP/1 的协议。

相对于 HTTP/1,HTTP/2 在连接层面实现了服务,即在一个连接上可以发送多个 HTTP 请求。

这就意味着 HTTP 连接可以像 RPC 连接一样,建立几个连接即可,完全解决了 HTTP/1 连接无法复用导致的重复建连和慢启动的开销。

我们正在基于 Netty 升级到 HTTP/2,除了技术升级外,我们还在不断优化监控报警,以便为业务方提供准确无误的报警。此外,我们还在作为统一接入网关与业务方实施全面的降级措施,以确保全站任何故障都能通过网关第一时间降级,这也是我们的重点工作。

100万级连接,爱奇艺WebSocket网关如何架构

HTTP 协议属于一种无状态、基于 TCP 的请求/响应模式的协议,HTTP 协议中,只有客户端能发起请求,由服务端进行回应。

虽然,在许多情况下,这种请求/响应的拉取模式能够满足需求。

然而,在特定情况下,例如实时通知(如 IM 中的离线消息推送最为典型)和消息推送等应用场景,需要将数据实时推到客户端,这就要求服务端具备主动推送数据的能力。

如何推呢?

传统的 Web 服务端推送技术,包括短轮询、长轮询等,虽然能在一定程度上解决问题,但也存在如时效性、资源浪费等问题。

HTML5 标准推出的 WebSocket 规范基本改变了这种状况,已经成为当前服务端消息推送技术的主流。

本文将分享爱奇艺在基于 Netty 实现 WebSocket 长连接实时推送网关过程中的实践经验和总结。

1、旧方案存在的技术痛点

爱奇艺号作为我们内容生态的关键部分,作为前端系统,对用户体验有着较高的要求,这直接影响着创作者的创作热情。

当前,爱奇艺号在多个业务场景中应用了 WebSocket 实时推送技术,包括

1)用户评论:实时地将评论消息推送至浏览器;

2)实名认证:在合同签署前,需要对用户进行实名认证,用户扫描二维码后进入第三方的认证页面,认证完成后异步通知浏览器认证状态;

3)活体识别:类似于实名认证,当活体识别完成后,异步将结果通知浏览器。

在实际业务开发中,我们发现 WebSocket 实时推送技术在使用过程中存在一些问题。

这些问题是

1)首先:WebSocket 技术栈不统一,既有基于 Netty 实现的,也有基于 Web 容器实现的,给开发和维护带来困难;

2)其次:WebSocket 实现分散在各个工程中,与业务系统紧密耦合,如果有其他业务需要集成 WebSocket,将面临重复开发的困境,浪费成本、效率低下;

3)第三:WebSocket 是有状态协议,客户端连接服务器时只与集群中一个节点连接,数据传输过程中也只与这一节点通信。WebSocket 集群需要解决会话共享的问题。如果只采用单节点部署,虽然可以避免这一问题,但无法水平扩展以支持更高的负载,存在单点故障风险;

4)最后:最后:缺乏监控与报警,虽然可以通过 Linux 的 Socket 连接数大致评估 WebSocket 长连接数,但数字并不准确,也无法得知用户数等具有业务含义的指标数据;无法与现有的微服务监控整合,实现统一监控和报警。

2、新方案的技术目标

如上所述,为了解决旧方案中存在的问题,我们需要实现统一的 WebSocket 长连接实时推送网关。

这套新的网关需要具备以下特点

1)集中实现长连接管理和推送能力:采用统一的技术栈,将长连接作为基础功能进行沉淀,以便于功能的迭代和维护;

2)与业务解耦:将业务逻辑与长连接通信分离,使得业务系统无需关心通信细节,避免了重复开发,节约了研发成本;

3)使用简单:提供 HTTP 推送通道,便于各种开发语言的接入。业务系统只需进行简单的调用,便可实现数据推送,从而提高研发效率;

4)分布式架构:构建多节点的集群,支持水平扩展以应对业务增长带来的挑战;节点故障不会影响服务的整体可用性,确保高可靠性;

5)多端消息同步:允许用户使用多个浏览器或标签页同时登录在线,确保消息同步发送;

6)多维度监控与报警:将自定义监控指标与现有的微服务监控系统连接,当出现问题时可以及时报警,保证服务的稳定性。

3、新方案的技术选型

在众多的 WebSocket 实现中,经过对性能、扩展性、社区支持等各方面的权衡,我们最终确定了 Netty。Netty 是一个高性能、事件驱动、异步非阻塞的网络通信框架,已在许多知名的开源项目中得到广泛应用。

WebSocket 具有状态特性,这与 HTTP 的无状态特性不同,因此无法像 HTTP 一样通过集群方式实现负载均衡。在长连接建立后,它会与服务端的某个节点保持会话,所以在集群环境下,要确定会话属于哪个节点会有些困难。

解决以上问题一般有两种技术方案

1)一种是使用类似于微服务注册中心的技术来维护全局的会话映射关系;

2)另一种是使用事件广播,由各节点自行判断是否持有会话。这两种方案的对比如下表所示。

WebSocket集群方案

方案优点缺点
注册中心会话映射关系清晰,集群规模较大时更合适实现复杂,强依赖注册中心,有额外运维成本
事件广播实现简单更加轻量节点较多时,所有节点均被广播,资源浪费

考虑到实现成本和集群规模,我们选择了轻量级的事件广播方案。

实现广播的方法有多种,如基于 RocketMQ 的消息广播、基于 Redis 的 Publish/Subscribe、基于 ZooKeeper 的通知等。

这些方案的优缺点对比如下表所示。在考虑到吞吐量、实时性、持久化和实现难易程度等因素后,我们最终选择了 RocketMQ。

广播的实现方案对比

方案优点缺点
基于RocketMQ吞吐量高、高可用、保证可靠实时性不如Redis
基于Redis实时性高、实现简单不保证可靠
基于ZooKeeper实现简单写入性能较差,不适合频繁写入场景

4、新方案的实现思路

4.1 系统架构

网关的整体架构如下图所示

图片

网关的整体流程如下

1)客户端与网关的任何一个节点建立长连接,节点会将其加入到内存中的长连接队列。客户端会定期向服务端发送心跳消息,若超过设定时间还未收到心跳,则认为客户端与服务端的长连接已断开,服务端会关闭连接,清理内存中的会话。

2)当业务系统需要向客户端推送数据时,通过网关提供的HTTP接口将数据发送至网关。

3)在收到推送请求后,网关会将消息写入RocketMQ

4)网关作为消费者,以广播模式消费消息,所有节点都能收到消息。

5)节点在收到消息后会判断推送的消息目标是否在其内存中维护的长连接队列里,如果存在则通过长连接推送数据,否则直接忽略。

网关通过多节点构成集群,每个节点负责一部分长连接,实现负载均衡。当面临大量连接时,也可以通过增加节点来分散压力,实现水平扩展。

同时,当节点出现故障时,客户端会尝试与其他节点重新建立长连接,确保服务的整体可用性。

4.2 会话管理

在 WebSocket 长连接建立后,会话信息会保存在各个节点的内存中。

SessionManager 组件负责管理会话,它内部使用哈希表来维护 UID 与 UserSession 的关联。

UserSession 表示用户层面的会话,一个用户可能同时拥有多个长连接,因此 UserSession 内部同样使用哈希表来维护 Channel 与 ChannelSession 的关联。

为了防止用户无休止地创建长连接,当 UserSession 内部的 ChannelSession 超过一定数量时,它会关闭最早建立的 ChannelSession,以减少服务器资源的占用。

SessionManager、UserSession、ChannelSession 的关系如下图所示。

SessionManager组件

4.3 监控与报警

为了掌握集群中建立的长连接数量和包含的用户数量,网关提供了基本的监控和报警功能。

网关接入了 Micrometer (https://www.oschina.net/p/micrometer?hmsr=aladdin1e1),将连接数和用户数作为自定义指标暴露,供 Prometheus (https://prometheus.io/)进行采集,从而实现了与现有的微服务监控系统打通。

Grafana (https://grafana.com/)中,可以方便地查看连接数、用户数、JVM、CPU、内存等指标数据,了解网关当前的服务能力和压力。报警规则也可以在 Grafana 中配置,当数据异常时触发奇信(内部报警平台)报警。

5、新方案的性能压测

压测准备

  • 1)选择两台配置为 4 核 16G 的虚拟机,分别作为服务器和客户端;

  • 2)在压力测试时,为网关开放 20 个端口,同时启动 20 个客户端

  • 3)每个客户端使用一个服务器端口建立 5 万个连接,从而可以同时创建百万个连接。

连接数(百万级)与内存使用情况如下图所示

[root@sy-dev-1de4f0c2a target]# ss -s ; free -h
Total: 1002168 (kernel 1002250)
TCP: 1002047 (estab 1002015, closed 4, orphaned 0, synrecv 0, timewait 4/0), ports 0
Transport Total   IP      IPv6
*         1002250 -       -
RAW       0       0       0
UDP       4       2       2
TCP       1002043 1002041 2
INET      1002047 1002043 4
FRAG      0       0       0

          total   used    free  shared  buff/cache  available
Mem:      15G     4.5G    4.5G  232K    6.5G        8.2G
Swap:     4.0G    14M     4.0G

给百万个长连接同时发送一条消息,采用单线程发送,服务器发送完成的平均耗时在10s左右,如下图所示。

服务器推送耗时

2021-01-25 20:51:02.614 INFO [mp-tcp-gateway,54d52e7e4240b65a,54d52e7e4240b65a,false]
[600ebeb62@2559f4507adee3b316c571/507adee3b316c571] 89558 --- [nio-8080-exec-6]
c.i.m.t.g.controller.NotifyController: [] [UID:] send message ...
2021-01-25 20:51:11.973 INF0 [mp-tcp-gateway,54d52e7e4240b65a,54d52e7e4240b65a,false]
[1600ebeb62@2559f4507adee3b316c571/507adee3b316c571] 89558 --- [nio-8080-exec-6]
c.i.m.t.g.controller.NotifyController: [] [UID:] send message to 1001174 channels

一般同一用户同时建立的长连接都在个位数。

以10个长连接为例,在并发数600、持续时间120s条件下压测,推送接口的TPS大约在1600+,如下图所示。

长连接10、并发600、持续时间120s的压测数据

当前的性能指标已满足我们的实际业务场景,可支持未来的业务增长。

6、新方案的实际应用案例

为了更形象地展示优化效果,文章最后,我们以封面图添加滤镜效果为例,介绍了一个爱奇艺号采用新 WebSocket 网关方案的实例。

爱奇艺号自媒体在发布视频时,可以选择为封面图添加滤镜效果,引导用户提供更高质量的封面。

当用户选择封面图后,会提交一个异步的后台处理任务。

一旦异步任务完成,通过 WebSocket 将不同滤镜效果处理后的图片返回给浏览器,业务场景如下图所示。

图片

从研发效率的角度来看,如果在业务系统中集成 WebSocket,至少需要 1-2 天的开发时间。

而直接使用新的 WebSocket 网关的推送功能,只需简单的接口调用就能实现数据推送,将开发时间降低到分钟级别,大幅提高研发效率。

从运维成本的角度来看,业务系统不再包含与业务逻辑无关的通信细节,代码的可维护性更强,系统架构变得更简单,运维成本大幅降低。

7、总结

WebSocket 是实现服务端推送的主流技术,适当使用可以有效提升系统响应能力,增强用户体验。

通过 WebSocket 长连接网关,可以迅速为系统增加数据推送能力,有效降低运维成本,提高开发效率。

长连接网关的价值在于

  • 1)它封装了 WebSocket 通信细节,与业务系统解耦,使得长连接网关与业务系统可独立优化迭代,避免重复开发,便于开发与维护;

  • 2)网关提供了简单易用的 HTTP 推送通道,支持多种开发语言接入,便于系统集成和使用;

  • 3)网关采用了分布式架构,可以实现服务的水平扩容、负载均衡与高可用;

  • 4)网关集成了监控与报警,当系统异常时能及时预警,确保服务的健康和稳定。

目前,新的 WebSocket 长连接实时网关已在爱奇艺号图片滤镜结果通知、MCN 电子签章等多个业务场景中得到应用。

未来还有许多方面需要探索,例如消息的重发与 ACK、WebSocket 二进制数据的支持、多租户的支持等。

亿级长连接,淘宝接入层网关的架构设计

以手机淘宝为例,从早期的 HTTP API 网关,到后来在双十一活动中承担主要流量的自研高性能、全双工、安全的 阿里云通道服务ACCS,在基础架构进化、网络优化、协议改进、异地多活、网络调度等方面,都积累了丰富的经验,本文借此机会总结了整个技术演进过程。

1、技术背景

回顾移动电商在双十一业务启动之初,当时双十一当天的移动成交额达到 243 亿,占总成交额 571 亿的 42.6%。

业务的快速发展,需要更多的主动推送以触达用户,一些新的互动形式和玩法需要连接买家与买家、买家与卖家、买家与达人。

和其他的著名系统一样,早期的推送,是轮询模式的。

由于缺乏有效的通道能力,早期业务采取的是不断轮询服务器。

轮询方式,不仅给服务器带来不必要的压力,也对用户手机的电量和流量造成了巨大的浪费。

在双十一等大型促销活动期间,过多的不必要请求,可能会导致后端集群限流,从而影响用户体验。

2、移动网络环境的挑战性一直都存在

随着 3G、4G、5G 移动网络的广泛应用,网速得到了显著提升。

然而,网络环境的多样性和差异性使得移动网络环境变得更加复杂,双十一等高峰期常常出现移动网络劫持等问题。

解决这类问题的效率很低,需要追踪用户、再现现场,甚至联系网络工程师和运营商进行排查,耗时较长。

在我们的舆情反馈中,用户经常反映“某个页面加载缓慢、页面无法打开、请求速度慢、某个功能打开速度慢”等问题。

过去我们应对这些问题的办法不多,只能逐一排查,非常被动。很多网络问题偶发性较强,一旦错过就难以追踪。

诸如此类的问题,背后的原因很多

  • 1)运营商问题;

  • 2)机房部署原因;

  • 3)客户端SDK Bug;

  • 4)弱网和网络抖动;

  • 5)DNS劫持和数据篡改。

在 PC 时代,我们访问网站的网络条件相对稳定,因此在开发过程中很少考虑网络对用户体验的影响。

然而,移动 APP 的情况就不同了,尤其在我国,基础移动网络环境尚不完善,很多用户在地铁、公交车等移动环境下访问,移动基站的频繁切换进一步加剧了网络不稳定性。

从手机淘宝的数据来看,我们每天活跃用户中有很大一部分来自网络环境较差的地区。如果端到云的连接不稳定、延迟高,那么用户体验就无从谈起。

基础网络效率就像一辆火车,时延是火车的速度(启动时间),带宽是火车的车厢容量,整个传输物理链路就像是火车的铁轨。

在当前复杂的移动网络环境下,我们的目标是让所有用户都能在手机淘宝享受到流畅的体验。

下面这张图能帮助大家更直观地了解我国移动网络环境。

它描述了从用户到 IDC 的端到端路由情况,数据传输耗时长、丢包率高,同时安全性也较差,DNS 劫持、内容劫持等问题在我国相当普遍。

图片

因此,在网络通道优化方面,我们有很多工作可以去做,去探索如何突破运营商基础网络的局限,为用户打造完美的购物体验。

3、整体技术架构

为了满足移动电商业务迅速发展的需求,我们决定构建一个世界一流的网络接入服务,打造一个无线网络下的“水、电、煤”基础设施。

这样一个基础设施需要做到的四个目标

  • 1)全双工;

  • 2)低延时;

  • 3)高安全;

  • 4)开放。

在这四个目标之上,是围绕这个接入服务配套的运维体系,旨在帮助最终用户获得良好的终端体验,同时协助开发者快速构建自己的业务。

图片

如上图所示,在整个接入服务上我们划分为两层

  • 1)接入网关:负责保持连接、解析和分发消息;

  • 2)应用网关:实现各种应用层协议,如 API、SYNC、RPC、PUSH 等,应用网关背后是具体的业务系统。

同时,我们采用了统一调度服务而非传统的 DNS,调度服务作为我们的控制中心,可以有效地指挥客户端,并避免受到 DNS 污染的影响。

与服务端的分层架构相对应的是客户端的 SDK,最底层的统一网络库 SDK 汇集了我们的网络优化策略,并为各个应用网关技术的 SDK 提供 API。

基于这种开放架构,业务方可以选择直接开放具体的后端服务,对接不同的应用网关,无需了解网络背后的细节,并通过应用网关(如 API 网关)提供的开发工具快速生成客户端代码。

业务方也可以基于这个接入层设计自己的协议。

统一接入层集中管理了用户的设备和在线状态,并提供信息双向传递能力。

如下图所示

图片

网关将致力于解决中间网络的通讯问题,为上层服务提供高品质的双向通信能力。

4、稳定性与容灾

稳定性与容灾是服务端中间件始终关注的问题,统一接入层汇聚了网关的利益与风险,一旦入口出现问题,受影响的用户范围将无法想象,如何实现更高稳定性,是一项巨大的挑战。

4.1 网关架构的优化

对于一个统一网关而言,不同业务网关的信息传递特性各异。

大部分业务全天较为平稳,但某些营销类业务会在短时间内发布大量信息,这种信息发布会占用网关大量资源,对用户正常访问产生影响。

举个例子:push 服务需要通过网关推送 2 亿条消息,这些消息需在短时间内全部发送完毕。同时,网关还在为正常用户交互提供服务,大量信息推送与正常用户交互争夺资源,最终可能导致正常用户交互失败,对业务而言,这是不能接受的。

基于上面的情况,整个网关在布署上分为两个集群

  • 1)一个集群处理常态的在线用户访问;

  • 2)一个集群处理海量信息的推送。

如下图所示,通过这种部署方式,避免了不同业务形态对统一网关的冲击,实现了不同业务形态的隔离。

图片

4.2 异地多活

在异地多活的整体方案中,统一网关承担了快速引导流量的职责,这是确保该方案成功执行的重要环节。

异地多活是一个多机房的整体方案,异地多活架构,主要是在多个地区同时存在对等的多个机房,以用户维度划分,多机房共同承担全量用户的流量;

在单个机房发生故障时,故障机房的流量可以快速的被迁引到可用机房,从而缩短故障恢复的时间。

4.2.1 无线接入层单元化的协商机制:

先看一下web端在这异地多活中的实现方式:

图片

从上图中我们可以看出,浏览器的业务请求会被发送至 CDN,然后根据 CDN 上保存的分发规则,将流量分发至后续的站点。

无线端也这样做吗?

  • 1)客户端具有强大的能力,能够更加灵活地处理;

  • 2)CDN 的分发节点会增加更多的硬件成本;

  • 3)对于需要双向通信能力的客户端,信息传递会更加复杂。

这些都是我们在考虑与 web 不同的地方,我们是否能做出一些不同的选择呢?

图片

如上图所示,我们借助了客户端的强大能力,利用协商的机制来完成用户的请求正确被分配到不同的单元。

含以下几点

  • 1)客户端的请求需包含当前用户所属单元的信息;

  • 2)当请求抵达服务端时,服务端会判断用户所属单元是否正确,若不正确则将用户重新定向至正确单元;

  • 3)当前请求在服务端上通过网关进行跨单元调用,以确保业务正确性;

  • 4)当客户端所属单元发生更新后,后续请求将发送至正确单元。

4.2.2 无线接入层单元化的旁路调度:

协商机制看起来很不错,这里一个重磅炸弹丢过来了,机房的入口网络断了!

图片

如上图,当外网不可用时,协商的机会都没有,故障单元的用户无法恢复,此时,旁路调度服务登场。

图片

如上图,我们设计的调度中心这时又承担了单元化的旁路调度职责,当app访问的单元无法访问的时候,app会访问不同单元的调度中心,询问用户的归属单元。

通过这种方式取得可用的单元节点,将用户切到正确的单元。

此方案同样适用于单机房接入层网关无法使用的情况。

4.2.3 应用层网关不可用:

某个单元机房的应用层网关不可用,这时等待应用网关排查问题需要的时间比较久,为了达到最快的故障恢复,我们通过开关把修改接入层的转发规则,将流量切到可用的单元。

如下图所示:

图片

5、端到端网络优化

5.1 统一网络库

在网络优化的初期,我们的目标是创建一个通用的网络库,该库包含策略、httpDNS、SPDY 协议等所有系统网络优化所需的各个方面。

上层api网关请求逻辑、推送逻辑、上传下载逻辑对于这样一个通用网络库来说都是业务。

在分层上将通用网络库和上层应用逻辑区分开、完全解耦,对于长期持续优化网络是非常必要的。

如下图所示架构

图片

这样架构上分离,可以让我们更专注更系统化去做无线网络优化。

统一网络接入库的几个重要特性

  • 1)灵活控制客户端网络行为策略(建连、超时处理、请求协议、是否加密);

  • 2)包含HTTPDNS;

  • 3)支持异地多活;

  • 4)更细粒度控制和调度(域名级和域名下参数级)。

1、2、3、4均由网络调度中心的集群控制,我们希望这个可以做到与业务无关,去掉一些阿里的业务属性后,这个模块大家可以理解为HTTPDNS,可以理解我们在HTTPDNS之外做了大量网络优化的端到端的工作。

5.2 就近就快接入

基于网络库,我们实现了一套智能学习的网络策略。

这个策略可以根据客户端在不同网络环境下的连接策略进行智能学习,当用户重新回到这个网络环境时,会给出最优的策略进行快速连接,并定期更新或淘汰本地缓存的历史最优网络策略。

为了实现更快速的穿透各自网络并提供更好的接入性能,接入服务器支持了多种协议和端口,客户端在建立连接时可以实现高速接入网络。

我们关注的一个重要指标是在客户端打开 30 秒内的网络请求成功率,这是为了提供更快的连接速度,以提高用户体验。

基于调度中心,我们构建了一个智能大数据分析平台,智能大数据分析平台收集客户端在网络请求过程中的重要数据,如:

  • 连接时间

  • 首包接收时间

  • 整包接收时间

  • SSL 握手时间等

通过分析这些数据,我们可以确定网络异常区域,调整我们的近距离高速接入规则,甚至推动 IDC 建设和 CDN 布局的优化。

5.3 弱网优化和抗抖动

在弱网优化上,我们尝试了 QUIC 协议,发现在网络延迟较高和丢包严重的情况下,其表现优于 TCP。

经过线上手机淘宝灰度版本的实测,切换到 QUIC 后,平均 RT 收益提高了近 20%。

但考虑到 QUIC 在移动网络可能存在穿透性问题,未来我们计划采取 SPDY 为主,QUIC 为辅的方式来完善我们的网络连接策略。

“SPDY”(发音同 “speedy”)是谷歌正在开发一种新的网络协议,以最小化网络延迟,提升网络速度,优化用户的网络使用体验。

SPDY 并不是一种用于替代 HTTP 的协议,而是对 HTTP 协议的增强。新协议的功能包括数据流的多路复用、请求优先级,以及 HTTP 包头压缩。谷歌已经开发一个网络服务器原型机,以及支持 SPDY 协议的 Chrome 浏览器版本。

谷歌表示,引入 SPDY 协议后,在实验室测试中页面加载速度比原先快 64%。这一数据基于对全球 25 大网站的下载测试。目前 SPDY 团队已经开发了一个可使用的原型产品,谷歌决定开放这一项目,希望 “网络社区能积极参与、提供反馈及帮助”。

在网络环境较差的情况下,我们采用了长短链接结合的策略。

当长链接遇到请求超时或穿透性较差的情况时,我们利用短链接 HTTP 去请求数据(在移动网络环境下,HTTP 协议尤其是 HTTP1.0 的穿透性较好),从而在极端情况下最大限度地保证用户体验。

数据如下图

图片

网络切换和网络抖动情况下的技术优化也是一个很重要的方面,我们经常遇到移动设备网络切换和信号不稳定的情况,在这种情况我们怎么保证用户的体验?

针对这种情况我们的思路是有策略合理增加重试。

我们对一个网络请求以是否发送到socket缓冲区作为分割,将网络请求生命周期划分为“请求开始到发送到 socket缓冲区”和“已经发送到socket缓冲区到请求结束”两个阶段。

在阶段一内请求失败了,会根据业务需求帮助业务请求去做重试。阶段二请求失败只针对读操作提供重试能力。

设想一个场景

用户在进入电梯时发起一个刷新数据请求,由于网络抖动,电梯内的网络连接断开。

在这种情况下,我们可以采取合理的策略进行重试。

这样,当用户离开电梯时,网络请求很可能已经重试成功,帮助用户获取所需的数据,从而提升用户体验和客户端的网络抗抖动能力。

5.4 加密传输1秒钟法则

众所周知,传统的 HTTPS 握手流程较为繁琐,在网络质量较差的情况下,可能导致连接速度缓慢,用户体验较差,甚至无法完成安全握手。

然而,从安全的角度考虑,我们需要一个安全的传输通道来保护用户的隐私数据。

面对安全与网络体验的冲突,我们需要在技术上取得突破。

因此,我们开发了一套 slight-ssl 技术,参考了 TLS1.3 协议,通过合并请求、优化加密算法、使用 session-ticket 等策略,最终在安全和体验之间找到了平衡点。

在基本不牺牲用户体验的前提下,实现了安全传输的目标,同时还显著提升了服务端的性能。

通过技术创新,我们实现了无线网络加密传输下的 1 秒钟法则。

单体120万连接,小爱网关架构设计

1、小爱接入网关的巨大的演进成果

小爱(又名“小爱同学”)是小米公司旗下的一款人工智能语音交互引擎,“小爱同学”是小米集团统一的智能语音服务基础设施,“小爱同学”客户端被集成在小米手机、小米 AI 音箱、小米电视等设备中,广泛应用于个人移动、智能家居、智能穿戴、智能办公、儿童娱乐、智能出行、智慧酒店和智慧学习等八大场景。

小爱接入层是小爱云端设备接入的关键服务,也是核心服务之一。

小米技术团队在 2020 年至 2021 年期间对该服务进行的一系列优化和尝试,最终,成功地将单机可承载的长连接数量从 30 万提高到 120 w+,节省了 30 +台机器。

2、什么是小爱接入层

小爱整体架构的分层如下所示

图片

接入服务主要负责鉴权授权层和传输层的工作,它是所有小爱设备与小爱大脑互动的第一个服务。

从上图我们可以看出,小爱接入服务的重要功能包括以下几点

  • 1)安全传输与鉴权:保护设备与大脑之间的安全通道,确保身份认证的有效性和数据传输的安全;

  • 2)维护长连接:保持设备与大脑之间的长连接(如 Websocket 等),妥善保存连接状态,进行心跳维护等工作;

  • 3)请求转发:针对小爱设备的每次请求进行转发,确保每次请求的稳定性。

3、早期接入层的技术实现

小爱接入层最早的实现是基于 Akka (https://akka.io/) 和 playframework (https://www.playframework.com/),我们使用它们搭建了第一个版本,其特点如下:

  • 1)基于Akka,我们实现了初步的异步化,确保核心线程不会被阻塞,性能表现良好。

  • 2)playframework框架天然支持Websocket,因此我们在有限的人力下能够快速搭建和实现,且能够保证协议实现的标准性。

注意, playframework 简称为 play , 是一款 和springmvc 类似的web 开发框架。

4、早期接入层的技术问题

随着小爱长连接数量达到千万级别,我们发现早期接入层方案存在一些问题。

主要的问题如下

  • 1)随着长连接数量增加,需要维护的内存数据日益增多,JVM 的 GC 成为性能瓶颈,且存在 GC 风险。经过事故分析,我们发现 Akka+Play 版的接入层单实例长连接数量上限约为 28w左右。

  • 2)老版本的接入层实现较为随意,Akka Actor 之间存在大量状态依赖,而非基于不可变消息传递,导致 Actor 间通信如同函数调用,代码可读性较差,维护困难,未能发挥 Akka Actor 在构建并发程序的优势。

  • 3)作为接入层服务,老版本对协议解析有较强依赖,需随版本更新频繁上线,可能引发长连接重连,存在雪崩风险。

  • 4)由于依赖 Play 框架,我们发现其长连接打点不准确(因无法获取底层 TCP 连接数据),这会影响我们每日巡检对服务容量的评估,且在长连接数量增加后,我们无法进行更细致的优化。

5、新版接入层的设计目标

鉴于早期接入层技术方案存在诸多问题,我们决定重构接入层。

新版接入层的设计目标如下

  • 1)高稳定性:上线过程中尽可能避免断连接,保证服务稳定;

  • 2)高性能:目标单机可承载至少 100 万长连接,尽量避免 GC 影响;

  • 3)高可控性:除底层网络 I/O 的系统调用外,其他所有代码需自行实现或采用内部组件,以提高自主权。

因此,我们开始了实现单机百万长连接的探索之旅。

6、新版接入层的优化思路

6.1 接入层的依赖关系

接入层与外部服务的关系理清如下

6.2 接入层的功能划分

接入层的主要职责可以概括为以下几点

  • 1)WebSocket 解码:接收到的客户端数据流需根据 WebSocket 协议进行解析;

  • 2)保持 Socket 状态:存储连接的基本信息;

  • 3)加密与解密:与客户端通讯的数据均需加密,而后端模块间传输为明文 JSON;

  • 4)顺序化:同一物理连接上的先后两个请求 A、B 到达服务器,后端服务中 B 可能先于 A 得到响应,但我们需等待 A 完成后,再按 A、B 顺序发送给客户端;

  • 5)后端消息分发:接入层不仅对接单个服务,还可能根据不同消息将请求转发至不同服务;

  • 6)鉴权:安全相关验证,身份验证等。

6.3 接入层的拆分思路

把之前的单一模块按照是否有状态,拆分为两个子模块。

具体如下

  • 1)前端:有状态,功能最小化,尽量减少上线;

  • 2)后端:无状态,功能最大化,上线时用户无感知。

因此,根据上述原则,理论上我们将实现这样的功能划分,即前端较小,后端较大。示意图如下所示。

图片

7、新版接入层的技术实现

7.1 总览

图片

模块拆分为前端和后端

  • 1)前端有状态,后端无状态;

  • 2)前端和后端是独立的进程,但在同一台机器上部署。

补充:前端负责维护设备长连接的状态,因此它是有状态的服务;而后端负责处理具体的业务请求,所以它是无状态的服务。后端服务的上线不会导致设备连接中断并重新连接,也不会引发鉴权调用,这样就避免了因为版本升级或逻辑调整而导致的长连接状态的不必要波动。

前端使用C++实现

  • 1)自主解析 WebSocket 协议:可以从 Socket 层面获取所有信息,能够处理任何Bug;

  • 2)更高的 CPU 利用率:没有任何额外的 JVM 开销,无GC拖累性能;

  • 3)更高的内存利用率:当连接数量增加时,与连接相关的内存消耗也会增加,自行管理可以实现极端优化。

后端暂时使用Scala实现

  • 1)已实现的功能可以直接迁移,比重新编写的成本要低得多;

  • 2)部分外部服务(如鉴权)有可以直接使用的 Scala(Java)SDK 库,而没有 C++ 版本,如果用 C++ 重写,成本会非常大;

  • 3)全部功能无状态化改造,可以做到随时重启而用户无感知。

通讯使用ZeroMQ

  • 进程间通信最高效的方式是共享内存,ZeroMQ(https://www.zeromq.org/)基于共享内存实现,速度没有问题。

7.2 前端实现

整体架构

图片

如上图所示,由四个子模块组成

  • 1)传输层:Websocket协议解析,XMD协议解析;

  • 2)分发层:屏蔽传输层的差异,无论传输层使用何种接口,在分发层转化为统一的事件并投递给状态机;

  • 3)状态机层:为了实现纯异步服务,采用自主研发的基于 Actor 模型的类 Akka 状态机框架 XMFSM,该框架内实现了单线程的 Actor 抽象;

  • 4)ZeroMQ通讯层:由于 ZeroMQ 接口是阻塞实现,这一层通过两个线程分别负责发送和接收。

7.2.1 传输层:

WebSocket 部分采用 C++和 ASIO 实现 websocket-lib。小爱长连接基于 WebSocket 协议,因此我们自主实现了一个 WebSocket 长连接库。

这个长连接库的特点是

  • a. 无锁化设计,保证性能优秀;

  • b. 基于 BOOST ASIO 开发,保证底层网络性能。

压测显示该库的性能十分优异的

长链接数qpsP99延时
100w5w5ms

这一层同时也负责除原始 WebSocket 外,其他两种通道的收发任务。

目前传输层一共支持以下 3 种不同的客户端接口

  • a. websocket(tcp):简称ws;

  • b. 基于ssl的加密websocket(tcp):简称wss;

  • c. xmd(udp):简称xmd。

7.2.2 分发层:

将不同的传输层事件转化为统一事件并投递给状态机,这一层起到适配器的作用,确保无论前面的传输层使用哪种类型,到达分发层都会变成一致的事件投递给状态机。

7.2.3 状态机处理层:

主要的处理逻辑都在这一层,这里非常重要的一个部分是对发送通道的封装。

对于小爱应用层协议,不同的通道处理逻辑是完全一致的,但在处理和安全相关逻辑上每个通道又有细节差异。

比如

  • a. wss 收发无需加密解密,加密解密由更前端的 Nginx 完成,而 ws 需要使用 AES 加密发送;

  • b. wss 在鉴权成功后无需向客户端发送 challenge 文本,因为 wss 不需要做加密解密;

  • c. xmd 发送的内容与其他两个不同,是基于 protobuf 封装的私有协议,且 xmd 需要处理发送失败后的逻辑,而 ws/wss 不需要考虑发送失败的问题,由底层 Tcp 协议保证。

针对这种情况:我们使用 C++的多态特性来处理,专门抽象了一个 Channel 接口,这个接口中提供的方法包含了一个请求处理的一些关键差异步骤,如何发送消息到客户端,如何停止连接,如何处理发送失败等等。对于 3 种 (ws/wss/xmd) 不同的发送通道,每个通道有自己的 Channel 实现。

客户端连接对象一创建,对应类型的具体 Channel 对象就立刻被实例化。这样状态机主逻辑中只需实现业务层的公共逻辑即可,当有差异逻辑调用时,直接调用 Channel 接口完成,这样一个简单的多态特性帮助我们分割了差异,确保代码整洁。

7.2.4 ZeroMQ 通讯层:

通过两个线程将 ZeroMQ 的读写操作异步化,同时负责若干私有指令的封装和解析。

7.3 后端实现

7.3.1 无状态化改造:

后端做的最重要改造之一就是将所有与连接状态相关的信息进行剔除

整个服务以 Request(一次连接上可以传输 N 个 Request)为核心进行各种转发和处理,每次请求与上一次请求没有任何关联。一个连接上的多次请求在后端模块被当作独立请求处理。

7.3.2 架构:

Scala 服务采用 Akka-Actor 架构实现了业务逻辑。

服务从 ZeroMQ 收到消息后,直接投递到 Dispatcher 中进行数据解析与请求处理,在 Dispatcher 中不同的请求会发送给对应的 RequestActor 进行 Event 协议解析并分发给该 event 对应的业务 Actor 进行处理。最后将处理后的请求数据通过 XmqActor 发送给后端 AIMS&XMQ 服务。

一个请求在后端多个 Actor 中的处理流程

7.3.3 Dispatcher 请求分发:

通过使用 Protobuf,前端和后端可以进行交互,这样可以节省 Json 解析的性能,同时让协议更加规范化。

在接收到 ZeroMQ 发送的消息后,后端服务会在 DispatcherActor 中对 PB 协议进行解析,并根据不同的分类(简称 CMD)进行数据处理,分类如下:

BIND 命令

这个功能是用来进行设备鉴权的,因为鉴权逻辑复杂,用 C++实现起来比较困难,所以目前仍然在 scala 业务层进行鉴权。这部分主要是解析设备端请求的 HTTP Headers,提取其中的 token 进行鉴权,然后将结果返回给前端。

LOGIN 命令

这个命令用于设备登录。设备在通过鉴权后,连接已经成功建立,就会执行 LOGIN 命令,将这个长连接信息发送到 AIMS 并记录在 Varys 服务中,以便后续的主动推送等功能。在 LOGIN 过程中,服务首先会请求 Account 服务获取长连接的 uuid(用于连接过程中的路由寻址),然后将设备信息+uuid 发送到 AIMS 进行设备登录操作。

LOGOUT 命令

这个命令用于设备登出。设备在与服务端断开连接时,需要执行 Logout 操作,用于从 Varys 服务中删除这个长连接记录。

UPDATE 与 PING 命令

  • a. Update 命令,设备状态信息更新,用于更新该设备在数据库中保存的相关信息;

  • b. Ping 命令,连接保活,用于确认该设备处于在线连接状态。

TEXT_MESSAGE 与 BINARY_MESSAGE

文本消息与二进制消息,在收到文本消息或二进制消息时将根据 requestid 发送给该请求对应的RequestActor进行处理。

7.3.4 Request 请求解析:

收到的文本和二进制消息会根据 requestId 被 DispatcherActor 发送给相应的 RequestActor 进行处理。

其中:文本消息会被解析为 Event 请求,然后根据其中的 namespace 和 name 分发给指定的业务 Actor。而二进制消息则会根据当前请求的业务场景被分发给对应的业务 Actor。

7.4其他优化

在完成新架构 1.0 调整过程中,我们也在不断压测长连接容量,总结几点对容量影响较大的点。

7.4.1 协议优化:
  • a. JSON替换为Protobuf:早期的前后端通信采用 JSON 文本协议,但发现 JSON 序列化、反序列化占用 CPU 较多,改用 Protobuf 协议后,CPU 占用率明显下降。

  • b. JSON支持部分解析:由于业务层协议基于 JSON,无法直接替换,我们采用“部分解析 JSON”的方式,仅解析较小的 header 部分获取 namespace 和 name,然后将大部分直接转发的消息转发出去,只对少量 JSON 消息进行完整反序列化成对象。此种优化后 CPU 占用下降 10%。

7.4.2 延长心跳时间:

在首次测试 20w 连接时,我们发现在前后端收发的消息中,用于保持用户在线状态的心跳 PING 消息占总消息量的 75%,收发这个消息消耗了大量 CPU。因此,我们延长心跳时间,也达到了降低 CPU 消耗的目的。

7.4.3 自研内网通讯库:

为提高与后端服务通信性能,我们使用自研的 TCP 通讯库,该库基于 Boost ASIO 开发,是一个纯异步的多线程 TCP 网络库,其优异性能帮助我们将连接数提升到 120w+。

8、未来规划

经过新版架构1.0版的优化,验证了我们的拆分方向是正确的,因为预设的目标已经达到

  • 1)单机承载的连接数 28w => 120w+(普通服务端机器 16G内存 40核 峰值请求QPS过万),接入层下线节省了50%+的机器成本;

  • 2)后端可以做到无损上线。

再重新审视下我们的理想目标,以这个为方向,我们就有了2.0版的雏形

图片

具体就是

  • 1)后端模块使用C++重写,进一步提高性能和稳定性。同时将后端模块中无法使用C++重写的部分,作为独立服务模块运维,后端模块通过网络库调用;

  • 2)前端模块中非必要功能尝试迁移到后端,让前端功能更少,更稳定;

  • 3)如果改造后,前端与后端处理能力差异较大,考虑到ZeroMQ实际是性能过剩的,可以考虑使用网络库替换掉ZeroMQ,这样前后端可以从1:1单机部署变为1:N多机部署,更好的利用机器资源。

2.0版目标是:经过以上改造后,期望单前端模块可以达到200w+的连接处理能力。

100万级连接,石墨文档WebSocket网关架构设计

在石墨文档的部分业务中,例如文档分享、评论、幻灯片演示和文档表格跟随等场景,涉及到多客户端数据实时同步和服务端批量数据在线推送的需求,一般的 HTTP 协议无法满足服务端主动 Push 数据的场景,因此选择采用 WebSocket 方案进行业务开发。

随着石墨文档业务发展,目前日连接峰值已达百万量级,日益增长的用户连接数和不符合目前量级的架构设计导致了内存和 CPU 使用量急剧增长,因此我们考虑对长连接网关进行重构。

本文分享了石墨文档长连接网关从1.0架构演进到2.0的过程,并总结了整个性能优化的实践过程。

1、v1.0架构面临的问题

这套长连接网关系统的v1.0版是使用 Node.js 基于 Socket.IO 进行修改开发的版本,很好的满足了当时用户量级下的业务场景需求。

1.1 架构介绍

1.0版架构设计图:

图片

1.0版客户端连接流程:

  • 1)用户通过 NGINX 连接网关,该操作被业务服务感知;

  • 2)业务服务感知到用户连接后,会进行相关用户数据查询,再将消息 Pub 到 Redis;

  • 3)网关服务通过 Redis Sub 收到消息;

  • 4)查询网关集群中的用户会话数据,向客户端进行消息推送。

1.2 面临的问题

虽然 1.0 版本的长连接网关在线上运行良好,但是不能很好的支持后续业务的扩展。

并且有以下几个问题需要解决:

  • 1)资源消耗:Nginx 仅使用 TLS 解密,请求透传,产生了大量的资源浪费,同时之前的 Node 网关性能不好,消耗大量的 CPU、内存;

  • 2)维护与观测:未接入石墨的监控体系,无法和现有监控告警联通,维护上存在一定的困难;

  • 3)业务耦合问题:业务服务与网关功能被集成到了同一个服务中,无法针对业务部分性能损耗进行针对性水平扩容,为了解决性能问题,以及后续的模块扩展能力,都需要进行服务解耦。

2、v2.0架构演进实践

2.1 概述

长连接网关系统的v2.0版需要解决很多问题。

比如,石墨文档内部有很多组件(文档、表格、幻灯片和表单等等),在 1.0 版本中组件对网关的业务调用可以通过Redis、Kafka 和 HTTP 接口,来源不可查,管控困难。

此外,从性能优化的角度考虑也需要对原有服务进行解耦合,将 1.0 版本网关拆分为网关功能部分和业务处理部分。

具体是:

  • 1)网关功能部分为 WS-Gateway:集成用户鉴权、TLS 证书验证和 WebSocket 连接管理等;

  • 2)业务处理部分为 WS-API:组件服务直接与该服务进行 gRPC 通信。

另外还有:

  • 1)可针对具体的模块进行针对性扩容;

  • 2)服务重构加上 Nginx 移除,整体硬件消耗显著降低;

  • 3)服务整合到石墨监控体系。

2.2 整体架构

2.0版本架构设计图:

图片

2.0版本客户端连接流程:

  • 1)客户端与 WS-Gateway 服务通过握手流程建立 WebSocket 连接;

  • 2)连接建立成功后,WS-Gateway 服务将会话进行节点存储,将连接信息映射关系缓存到 Redis 中,并通过 Kafka 向 WS-API 推送客户端上线消息;

  • 3)WS-API 通过 Kafka 接收客户端上线消息及客户端上行消息;

  • 4)WS-API 服务预处理及组装消息,包括从 Redis 获取消息推送的必要数据,并进行完成消息推送的过滤逻辑,然后 Pub 消息到 Kafka;

  • 5)WS-Gateway 通过 Sub Kafka 来获取服务端需要返回的消息,逐个推送消息至客户端。

2.3 握手流程

网络状态良好的情况下,完成如下图所示步骤 1 到步骤 6 之后,直接进入 WebSocket 流程;

网络环境较差的情况下,WebSocket 的通信模式会退化成 HTTP 方式,客户端通过 POST 方式推送消息到服务端,再通过 GET 长轮询的方式从读取服务端返回数据。

客户端初次请求服务端连接建立的握手流程:

图片

流程说明如下:

  • 1)Client 发送 GET 请求尝试建立连接;

  • 2)Server 返回相关连接数据,sid 为本次连接产生的唯一 Socket ID,后续交互作为凭证:{"sid":"xxx","upgrades":["websocket"],"pingInterval":xxx,"pingTimeout":xxx}

  • 3)Client 携带步骤 2 中的 sid 参数再次请求;

  • 4)Server 返回 40,表示请求接收成功;

  • 5)Client 发送 POST 请求确认后期降级通路情况;

  • 6)Server 返回 ok,此时第一阶段握手流程完成;

  • 7)尝试发起 WebSocket 连接,首先进行 2probe 和 3probe 的请求响应,确认通信通道畅通后,即可进行正常的 WebSocket 通信。

2.4 TLS 内存消耗优化

客户端与服务端连接建立采用的 wss 协议,在 1.0 版本中 TLS 证书挂载在 Nginx 上,HTTPS 握手过程由 Nginx 完成。为了降低 Nginx 的机器成本,在 2.0 版本中我们将证书挂载到服务上。

通过分析服务内存,如下图所示,TLS 握手过程中消耗的内存占了总内存消耗的大概 30% 左右。

图片

这个部分的内存消耗无法避免,我们有两个选择:

  • 1)采用七层负载均衡,在七层负载上进行 TLS 证书挂载,将 TLS 握手过程移交给性能更好的工具完成;

  • 2)优化 Go 对 TLS 握手过程性能,在与业内大佬曹春晖(曹大)的交流中了解到,他最近在 Go 官方库提交的 PR (https://github.com/golang/go/issues/43563),以及相关的性能测试数据 (https://github.com/golang/go/pull/48229)。

2.5 Socket ID 设计

对每次连接必须产生一个唯一码,如果出现重复会导致串号,消息混乱推送的问题。

这里,选择SnowFlake算法作为唯一码生成算法。

物理机场景中,对副本所在物理机进行固定编号,即可保证每个副本上的服务产生的 Socket ID 是唯一值。

K8S 场景中,这种方案不可行,于是采用注册下发的方式返回编号,WS-Gateway 所有副本启动后,向数据库写入服务的启动信息,获取副本编号,以此作为参数作为 SnowFlake 算法的副本编号进行 Socket ID 生产,服务重启会继承之前已有的副本编号,有新版本下发时会根据自增 ID 下发新的副本编号。

于此同时,Ws-Gateway 副本会向数据库写入心跳信息,以此作为网关服务本身的健康检查依据。

2.6 集群会话管理方案:事件广播

客户端完成握手流程后,会话数据在当前网关节点内存存储,部分可序列化数据存储到 Redis,redis 会话存储结构说明如下图所示。

说明
ws:user:clients:${uid}存储用户和 WebSocket 连接的关系,采用有序集合方式存储
ws:guid:clients:${guid}存储文件和 WebSocket 连接的关系,采用有序结合方式存储
ws:client:${socket.id}存储当前 WebSocket连接下的全部用户和文件关系数据,采用Redis Hash 方式进行存储,对应 key 为 user和guid

由客户端触发或组件服务触发的消息推送,通过 Redis 存储的数据结构,在 WS-API 服务查询到返回消息体的目标客户端的 Socket ID,再由 WS-Gateway 服务进行集群消费。

如果 Socket ID 不在当前节点,则需要进行节点与会话关系的查询,找到客端户 Socket ID 实际对应的 WS-Gateway 节点,通常有以下两种方案(如下图所示)。

优点缺点
事件广播实现简单消息广播数量会随着节点数量上升
注册中心会话与节点映射关系清晰注册中心强依赖,额外运维成本

在确定使用事件广播方式进行网关节点间的消息传递后,进一步选择使用哪种具体的消息中间件,列举了三种待选的方案(如下图所示)。

特性RedisKafkaRocketMQ
开发语言CScalaJava
单机吞吐量10w+10w+10w+
可用性主从架构分布式架构分布式架构
特点功能简单吞吐量、可用性极高功能丰富、定制化强,吞吐量可用性高
功能特性数据10K 以内性能优异,功能简单,适用于简单业务场景支持核心的 MQ 功能,不支持消息查询或消息回溯等功能支持核心的 MQ 功能,扩展性强

于是对 Redis 和其他 MQ 中间件进行 100w 次的入队和出队操作,在测试过程中发现在数据小于 10K 时 Redis 性能表现十分优秀。

进一步结合实际情况:广播内容的数据量大小在 1K 左右,业务场景简单固定,并且要兼容历史业务逻辑,最后选择了 Redis 进行消息广播。

后续还可以将 WS-API 与 WS-Gateway 两两互联,使用 gRPC stream 双向流通信节省内网流量。

2.7 心跳机制

会话在节点内存与 Redis 中存储后,客户端需要通过心跳上报持续更新会话时间戳,客户端按照服务端下发的周期进行心跳上报,上报时间戳首先在内存进行更新,然后再通过另外的周期进行 Redis 同步,避免大量客户端同时进行心跳上报对 Redis 产生压力。

具体流程:

  • 1)客户端建立 WebSocket 连接成功后,服务端下发心跳上报参数;

  • 2)客户端依据以上参数进行心跳包传输,服务端收到心跳后会更新会话时间戳;

  • 3)客户端其他上行数据都会触发对应会话时间戳更新;

  • 4)服务端定时清理超时会话,执行主动关闭流程;

  • 5)通过 Redis 更新的时间戳数据进行 WebSocket 连接、用户和文件之间的关系进行清理。

会话数据内存以及 Redis 缓存清理逻辑:

for {
   select {
   case <-t.C:
      var now = time.Now().Unix()
      var clients = make([]*Connection, 0)
      dispatcher.clients.Range(func(_, v interface{}) bool {
         client := v.(*Connection)
         lastTs := atomic.LoadInt64(&client.LastMessageTS)
         if now-lastTs > int64(expireTime) {
            clients = append(clients, client)
         } else {
            dispatcher.clearRedisMapping(client.Id, client.Uid, lastTs, clearTimeout)
         }
         return true
      })
      for _, cli := range clients {
         cli.WsClose()
      }
   }
}

在已有的两级缓存刷新机制上,进一步通过动态心跳上报频率的方式降低心跳上报产生的服务端性能压力,默认场景中客户端对服务端进行间隔 1s 的心跳上报,假设目前单机承载了 50w 的连接数,当前的 QPS 为:QPS1 = 500000/1

从服务端性能优化的角度考虑,实现心跳正常情况下的动态间隔,每 x 次正常心跳上报,心跳间隔增加 a,增加上限为 y,动态 QPS 最小值为:QPS2=500000/y

极限情况下,心跳产生的 QPS 降低 y 倍。在单次心跳超时后服务端立刻将 a 值变为 1s 进行重试。采用以上策略,在保证连接质量的同时,降低心跳对服务端产生的性能损耗。

2.8 自定义Headers

使用 Kafka 自定义 Headers 的目的是避免网关层出现对消息体解码而带来的性能损耗。

客户端 WebSocket 连接建立成功后,会进行一系列的业务操作,我们选择将 WS-Gateway 和 WS-API 之间的操作指令和必要的参数放到 Kafka 的 Headers 中,例如通过 X-XX-Operator 为广播,再读取 X-XX-Guid 文件编号,对该文件内的所有用户进行消息推送。

字段说明描述
X-IDWebSocket ID连接 ID
X-Uid用户 ID用户 ID
X-Guid文件 ID文件 ID
X-Inner网关内部操作指令用户加入、用户退出
X-Event网关事件Connect/Message/Disconnect
X-Locale语言类型设置语言类型设置
X-Operatorapi层操作指令单播、广播、网关内部操作
X-Auth-Type用户鉴权类型SDKV2、主站、微信、移动端、桌面
X-Client-Version客户端版本客户端版本
X-Server-Version网关版本服务端版本
X-Push-Client-ID客户端 ID客户端 ID
X-Trace-ID链路 ID链路 ID

在 Kafka Headers 中写入了 trace id 和 时间戳,可以追中某条消息的完整消费链路以及各阶段的时间消耗。

图片

2.9 消息接收与发送

type Packet struct {
  ...
}
 
type Connect struct {
  *websocket.Con
  send chan Packet
}
 
func NewConnect(conn net.Conn) *Connect {
  c := &Connect{
    send: make(chan Packet, N),
  }
  go c.reader()
  go c.writer()
  return c
}

客户端与服务端的消息交互第一版的写法类似以上写法。

对 Demo 进行压测,发现每个 WebSocket 连接都会占用 3 个 goroutine,每个 goroutine 都需要内存栈,单机承载连十分有限。

主要受制于大量的内存占用,而且大部分时间 c.writer() 是闲置状态,于是考虑,是否只启用 2 个 goroutine 来完成交互。

type Packet struct {
  ...
}
 
type Connect struct {
  *websocket.Conn
  mux sync.RWMutex
}
 
func NewConnect(conn net.Conn) *Connect {
  c := &Connect{
    send: make(chan Packet, N),
  }
  go c.reader()
  return c
}
 
func (c *Connect) Write(data []byte) (err error) {
   c.mux.Lock()
   defer c.mux.Unlock()
   ...
   return nil
}

保留 c.reader() 的 goroutine,如果使用轮询方式从缓冲区读取数据,可能会产生读取延迟或者锁的问题,c.writer() 操作调整为主动调用,不采用启动 goroutine 持续监听,降低内存消耗。

调研了 gev (https://github.com/Allenxuxu/gev) 和 gnet (https://github.com/panjf2000/gnet) 等基于事件驱动的轻量级高性能网络库,实测发现在大量连接场景下可能产生的消息延迟的问题,所以没有在生产环境下使用。

2.10 核心对象缓存

确定数据接收与发送逻辑后,网关部分的核心对象为 Connection 对象,围绕 Connection 进行了 run、read、write、close 等函数的开发。

使用 sync.pool 来缓存该对象,减轻 GC 压力,创建连接时,通过对象资源池获取 Connection 对象。

生命周期结束之后,重置 Connection 对象后 Put 回资源池。

在实际编码中,建议封装 GetConn()、PutConn() 函数,收敛数据初始化、对象重置等操作。

var ConnectionPool = sync.Pool{
   New: func() interface{} {
      return &Connection{}
   },
}
 
func GetConn() *Connection {
   cli := ConnectionPool.Get().(*Connection)
   return cli
}
 
func PutConn(cli *Connection) {
   cli.Reset()
   ConnectionPool.Put(cli) // 放回连接池
}

2.11数据传输过程优化

消息流转过程中,需要考虑消息体的传输效率优化,采用 MessagePack 对消息体进行序列化,压缩消息体大小。调整 MTU 值避免出现分包情况,定义 a 为探测包大小,通过如下指令,对目标服务 ip 进行 MTU 极限值探测。

ping -s {a} {ip}

a = 1400 时,实际传输包大小为:1428。

其中 28 由 8(ICMP 回显请求和回显应答报文格式)和 20(IP 首部)构成。

图片

如果 a 设置过大会导致应答超时,在实际环境包大小超过该值时会出现分包的情况。

图片

在调试合适的 MTU 值的同时通过 MessagePack 对消息体进行序列号,进一步压缩数据包的大小,并减小 CPU 的消耗。

2.12 基础设施支持

使用 EGO框架 (https://github.com/gotomicro/ego) 进行服务开发:业务日志打印,异步日志输出,动态日志级别调整等功能,方便线上问题排查提升日志打印效率;微服务监控体系,CPU、P99、内存、goroutine 等监控。

图片

客户端 Redis 监控:

图片

客户端 Kafka 监控:

图片

自定义监控大盘:

图片

3、检查成果的时刻:性能压测

3.1 压测准备

准备的测试平台有:

  • 1)选择一台配置为 4 核 8G 的虚拟机,作为服务机,目标承载 48w 连接;

  • 2)选择八台配置为 4 核 8G 的虚拟机,作为客户机,每台客户机开放 6w 个端口。

3.2 模拟场景一

用户上线,50w 在线用户。

服务CPUMemory数量CPU%Mem%
WS-Gateway16核32G1台22.38%70.59%

单个 WS-Gateway 每秒建立连接数峰值为:1.6w 个/s,每个用户占用内存:47K。

3.3 模拟场景二

测试时间 15 分钟,在线用户 50w,每 5s 推送一条所有用户,用户有回执。

推送内容为:

42["message",{"type":"xx","data":{"type":"xx","clients":[{"id":xx,"name":"xx","email":"xx@xx.xx","avatar":"ZgG5kEjCkT6mZla6.png","created_at":1623811084000,"name_pinyin":"","team_id":13,"team_role":"member","merged_into":0,"team_time":1623811084000,"mobile":"+xxxx","mobile_account":"","status":1,"has_password":true,"team":null,"membership":null,"is_seat":true,"team_role_enum":3,"register_time":1623811084000,"alias":"","type":"anoymous"}],"userCount":1,"from":"ws"}}]

测试经过 5 分钟后,服务异常重启,重启原因是内存使用量到超过限制。

图片

图片

图片

图片

分析内存超过限制的原因:

图片

新增的广播代码用掉了 9.32% 的内存:

图片

接收用户回执消息的部分消耗了 10.38% 的内存:

图片

进行测试规则调整,测试时间 15 分钟,在线用户 48w,每 5s 推送一条所有用户,用户有回执。

推送内容为:

42["message",{"type":"xx","data":{"type":"xx","clients":[{"id":xx,"name":"xx","email":"xx@xx.xx","avatar":"ZgG5kEjCkT6mZla6.png","created_at":1623811084000,"name_pinyin":"","team_id":13,"team_role":"member","merged_into":0,"team_time":1623811084000,"mobile":"+xxxx","mobile_account":"","status":1,"has_password":true,"team":null,"membership":null,"is_seat":true,"team_role_enum":3,"register_time":1623811084000,"alias":"","type":"anoymous"}],"userCount":1,"from":"ws"}}]
服务CPUMemory数量CPU%Mem%
WS-Gateway16核32G1台44%91.75%

连接数建立峰值:1w 个/s,接收数据峰值:9.6w 条/s,发送数据峰值 9.6w 条/s。

3.4 模拟场景三

测试时间 15 分钟,在线用户 50w,每 5s 推送一条所有用户,用户无需回执。

推送内容为:

42["message",{"type":"xx","data":{"type":"xx","clients":[{"id":xx,"name":"xx","email":"xx@xx.xx","avatar":"ZgG5kEjCkT6mZla6.png","created_at":1623811084000,"name_pinyin":"","team_id":13,"team_role":"member","merged_into":0,"team_time":1623811084000,"mobile":"+xxxx","mobile_account":"","status":1,"has_password":true,"team":null,"membership":null,"is_seat":true,"team_role_enum":3,"register_time":1623811084000,"alias":"","type":"anoymous"}],"userCount":1,"from":"ws"}}]
服务CPUMemory数量CPU%Mem%
WS-Gateway16核32G1台30%93%

连接数建立峰值:1.1w 个/s,发送数据峰值 10w 条/s,出内存占用过高之外,其他没有异常情况。

图片

图片

图片

图片

内存消耗极高,分析火焰图,大部分消耗在定时 5s 进行广播的操作上。

图片

3.5 模拟场景四

测试时间 15 分钟,在线用户 50w,每 5s 推送一条所有用户,用户有回执。每秒 4w 用户上下线。

推送内容为:

42["message",{"type":"xx","data":{"type":"xx","clients":[{"id":xx,"name":"xx","email":"xx@xx.xx","avatar":"ZgG5kEjCkT6mZla6.png","created_at":1623811084000,"name_pinyin":"","team_id":13,"team_role":"member","merged_into":0,"team_time":1623811084000,"mobile":"+xxxx","mobile_account":"","status":1,"has_password":true,"team":null,"membership":null,"is_seat":true,"team_role_enum":3,"register_time":1623811084000,"alias":"","type":"anoymous"}],"userCount":1,"from":"ws"}}]
服务CPUMemory数量CPU%Mem%
WS-Gateway16核32G1台46.96%65.6%

连接数建立峰值:18570 个/s,接收数据峰值:329949 条/s,发送数据峰值:393542 条/s,未出现异常情况。

3.6 压测总结

在16核32G内存的硬件条件下:单机 50w 连接数,进行以上包括用户上下线、消息回执等四个场景的压测,内存和 CPU 消耗都符合预期,并且在较长时间的压测下,服务也很稳定。

测试的结果基本上是能满足目前量级下的资源节约要求的,我们认为完全可以在此基础上继续完善功能开发。

4、总结

面临日益增加的用户量,网关服务的重构是势在必行。

本次重构主要是:

  • 1)对网关服务与业务服务的解耦,移除对 Nginx 的依赖,让整体架构更加清晰;

  • 2)从用户建立连接到底层业务推送消息的整体流程分析,对其中这些流程进行了具体的优化。

2.0 版本的长连接网关有了更少的资源消耗,更低的单位用户内存损耗、更加完善的监控报警体系,让网关服务本身更加可靠。

以上优化内容主要是以下各个方面:

  • 1)可降级的握手流程;

  • 2)Socket ID 生产;

  • 3)客户端心跳处理过程的优化;

  • 4)自定义 Headers 避免了消息解码,强化了链路追踪与监控;

  • 5)消息的接收与发送代码结构设计上的优化;

  • 6)对象资源池的使用,使用缓存降低 GC 频率;

  • 7)消息体的序列化压缩;

  • 8)接入服务观测基础设施,保证服务稳定性。

在保证网关服务性能过关的同时,更进一步的是收敛底层组件服务对网关业务调用的方式,从以前的 HTTP、Redis、Kafka 等方式,统一为 gRPC 调用,保证了来源可查可控,为后续业务接入打下了更好的基础。

2亿用户,B站API网关架构设计

如果你在 2015 年就已经成为 B 站的用户,那么你肯定还记得那一年 B 站工作日选择性崩溃,周末必然性崩溃的情况。

同样在那一年,B 站的投稿数量大幅增长,访问量也急剧上升,而过去的 PHP 全家桶技术开始逐渐显示出疲态,运维困难、监控困难、故障排查困难、调用路径深不可测。

因此,在 2015 年,B 站开始正式采用 Go 语言重构,从此 B 站的 API 网关技术开始了从零到一的持续演进。

1、正式用Go重构B站

面对引言中列出的各种技术问题,2015 年,B 站开始正式采用 Go 语言进行重构。B 站的第一个 Go 项目是由冠冠老师(郝冠伟)在一个周末完成的,名为 bilizone。

实际上,bilizone 是一个功能全面的应用,其重构的主要目标是将混乱的 PHP 逻辑梳理成一个标准的 Go 应用。

bilizone 在当时的最大意义在于,它为用户终端提供了基本稳定的数据结构、相对可靠的接口和有效的监控

然而,由于 bilizone 仍然是一个单体应用,因此它仍然具有单体应用的缺点:

  • 1)代码复杂度高:方法被滥用、超时设置混乱、牵一发而动全身;

  • 2)一挂全挂:最常见的,比如超时设置不合理、goroutine 大量堆积、雪崩;

  • 3)测试及维护成本高:即使是小改动,也需要测试所有 case,运维发布时总是提心吊胆。

因此,尽管此时 B 站的崩溃频率已经有所下降,但一炸全炸的问题仍然是一个心头大患。

2、基于微服务的B站架构初具雏形

鉴于 bilizone 面临的单体应用技术问题,接下来的一次重构使 B 站基于微服务的全局架构初具雏形。

为了实现微服务模式下的 bilibili,我们将一个 bilizone 应用拆分成多个独立的业务应用,如账号、稿件、广告等,这些业务通过 SLB 直接对外提供 API。

当时的调用模式如下图所示:

图片

然而,在功能拆分后,我们向外部公开了一批微服务,但由于缺乏统一的出口而遇到了诸多困难。

这些困难主要是:

  • 1)客户端与微服务直接交互,导致强耦合;

  • 2)需要发起多次请求,客户端负责数据整合,工作量大且延迟较高;

  • 3)协议不统一,各部门之间存在差异,反而需要客户端来实现兼容;

  • 4)面向“端”的 API 适配,导致内部服务耦合;

  • 5)多终端兼容逻辑复杂,每个服务都需要处理;

  • 6)统一逻辑无法收敛,比如安全认证、限流。

3、基于BFF模式的微服务架构

鉴于前面提到的初级微服务架构所引发的技术问题,以及我们希望将对端处理进行内聚的考虑,我们自然而然地想在客户端与后端服务之间添加一个 app-interface 组件,app-interface 的工作模式如下图所示:

图片

这个 app-interface 接口组件,对应到架构模式上,就是 BFF(Backend for Frontend)模式。

引入 BFF 后,我们可以在该服务中进行大量数据整合,并根据业务场景设计出粗粒度的 API。

这样,后续服务的演进也带来了很多优势:

  • 1)轻量交互:协议精简、聚合;

  • 2)差异服务:数据裁剪以及聚合、针对终端定制化 API;

  • 3)动态升级:原有系统兼容升级,更新服务而非协议;

  • 4)沟通效率提升:协作模式演进为移动业务和网关小组。

BFF 可以认为是一种适配服务

将后端微服务,根据客户端需求进行适配,主要包括

  • 聚合剪裁

  • 格式适配等

BFF 可以向终端设备展示友好且统一的 API,便于无线设备接入访问后端服务,其中可能还包含埋点、日志、统计等需求。

然而,这个阶段的 BFF 还存在一个致命问题——整个 app-interface 属于 single point of failure,严重的代码缺陷或流量洪峰可能导致集群崩溃,所有接口无法使用。

Single Point of Failure(SPoF)是指一个系统的这样一个部件,如果它失效或停止运转,将会导致整个系统不能工作。我们当然不希望看到,在一个要求高度可用性的系统中存在这样的部分,但这种情况在网络,软件应用以及其它工业系统中都存在。

4、基于BFF集群的微服务架构

针对单体 BFF 模式下Single Point of Failure(SPoF)问题,我们在此基础上进行了进一步迭代,将 app-interface 进行集群化架构,基于BFF集群的微服务架构:

由此模式开始,基本确定了 B 站微服务接口的对接模式,这套模式也随之在全公司内推广开来。

5、垂直BFF模式时代(2016年至2019年)

接上节,当 B 站网关的架构发展为多套垂直 BFF 之后,开发团队围绕此模式稳定迭代了相当长一段时间。

随着 B 站业务扩展、团队人员增加以及组织架构调整,直播、电商等独立业务逐渐崭露头角,这些业务的发展我们将在后续详细介绍。

在这些调整之后,一个团队的职责变得越来越明确:主站网关组

主站网关组的主要职责是维护各类功能的 BFF 网关,此时 bilibili 的主要流量入口为粉板 App。下面我们简要介绍粉板 App 上的所有业务组成。

主站业务:

  • 1)网关组维护的 BFF,如推荐、稿件播放页等;

  • 2)业务层自行维护的 BFF,如评论、弹幕、账号等。

独立业务:

  • 1)电商服务;

  • 2)直播服务;

  • 3)动态服务。

主站业务的 BFF 其实被分为两类:

  • 1)一类是由网关组负责的 BFF;

  • 2)另一类是业务自行维护的 BFF。

这两类 BFF 的技术栈基本相同,基本功能职责也相差无几。

划分的原因是让网关组能更专注于迭代客户端特性功能,无需理解部分独立业务场景的接口,如登录页应由负责安全方面的同事自行维护。

在这里我们也可以简述一下,一个新需求应该如何决定参与的 BFF :

  • 1)如果这个功能能由业务层的业务 BFF 独立完成,则网关组无需参与;

  • 2)如果该功能是一个客户端特性需求,如推荐流等复合型业务,需要对接公司大量部门时,则由网关同学参与开发 BFF。

当时主站技术部的后端同学遵循以上两个规则,基本能满足业务的快速开发和迭代。

我将这段时间称为垂直 BFF 时代,因为基本上主站每个业务都有各种形式的网关存在,大家通过这个网关向外提供接口,该网关与 SLB 直接交互。

6、基于业务的统一API网关架构

接上节,我们再来谈一谈几项重要的业务:电商、直播和动态。

电商和直播其实并不是同一时期衍生的,直播在主站 PHP 时代就已诞生,而电商则稍晚一些。

当时直播的技术栈包括 C++、PHP 和 Go。在早期,大部分业务逻辑由 PHP 和 C++ 实现,稍后也逐步开始尝试在主站使用 Go 实现部分业务逻辑。其中 PHP 负责提供终端接口,C++ 主要实现核心业务功能。因此,我们可以简单地认为直播使用了由 PHP 编写的 BFF 网关。

动态团队其实派生自直播团队,因此技术栈和直播当时基本一致,这里可以简单省略。

众所周知,大部分电商团队的技术栈是 Java 和 Spring 或 Dubbo。

由于这几个业务之间几乎没有相似之处,而且大家对 gRPC 协议逐渐达成共识,因此大家在技术栈上基本上没有统一的想法,只要能互相调通即可。

然而,随着 B 站团队的进一步壮大和流量持续增长,大家逐渐发现了这套架构下的许多问题。

这些问题主要包括:

  • 1)单个复杂模块会导致后续业务集成难度加大,根据康威法则,复杂聚合型 BFF 和多团队之间会出现不匹配问题,团队间沟通协调成本高,交付效率低下;

  • 2)许多跨横切面逻辑,如安全认证、日志监控、限流熔断等。随着时间的推移和功能迭代,代码变得越来越复杂,技术债务逐渐累积。

在这种情况下,我们需要一个能够协调横切面的组件,将路由、认证、限流、安全等组件全部上提,实现统一更新发布,将业务集成度高的 BFF 层和通用功能服务层进行分层。

因此,大家开始引入基于业务的“统一 API 网关”架构。如下图所示:

在新的架构中:统一网关扮演了重要角色,它是解耦拆分和后续升级迁移的利器。

在统一网关的配合下:单块 BFF 实现了解耦拆分,各业务线团队可以独立开发和交付各自的微服务,研发效率大幅提升。

此外:将跨横切面逻辑从 BFF 剥离到网关上后,BFF 的开发人员可以更加专注于业务逻辑交付,实现了架构上的关注分离(Separation of Concerns)。

7、从基于业务的多网关到全局统一网关(2022年至今)

在过去的两三年中,各个业务团队都建立了自己的业务网关,并为网关的功能做出了很多贡献。

然而,随着 B 站业务的发展和公司级中间件功能的不断更新,如果在每个网关上都实现与各个中间件的对接,将会导致人力投入和沟通成本大幅增加,同时实现标准和运营方式的不统一也无法充分发挥 API 网关的优势。

因此,微服务团队开发了一款 B 站内部的标准 API 网关(全局统一 API 网关),该网关总结了过去各型网关在流量治理方面的优秀经验,对相关功能进行了完善的设计和改进。

目前,这款 API 网关的主要功能除了常规的限流、熔断、降级和染色之外,还基于这些基础功能和公司各类中间件,提供各种附加能力。

这些附加的高级 API 质量治理功能主要包括:

  • 1)全链路灰度;

  • 2)流量采样分析、回放;

  • 3)流量安全控制;

  • ...

业务团队在接入 API 网关后,可以同时获得这些功能,为业务的快速迭代提供有力的支持。

8、不仅仅是 API 网关

在开发 API 网关的过程中,我们将更加关注业务团队在开发和对接 API 时的体验。我们将以网关为起点,建立统一的 API 规范,为业务团队提供更高效的 API 开发生态。

这些 API 开发生态可能包括:

  • 1)规划 API 业务域,简化 SRE 运维;

  • 2)标准 API 元信息平台;

  • 3)精确的 API 文档和调试工具;

  • 4)类型安全的 API 集成 SDK;

  • 5)API 兼容性保障服务。

API 网关是我们 API 治理生态的一个重要里程碑。我们希望在 API 网关的开发过程中,能够广泛听取大家的意见和建议,以便更好地理清思路。

10Wqps网关接入层,LVS+Keepalived(DR模式)搭建

背景

在互联网的中型项目中,单服务器往往已经无法满足业务本身的性能要求。

第一个问题是,如何支撑10Wqps?

第二个问题是,如何高可用的支持10Wqps?

《10Wqps Netty API网关架构与实操》 的总体架构图如下:

图片

10Wqps Netty API网关网络拓扑架构:

  • 方案1:基于ECS/IDC机房的手动扩容网络拓扑架构

  • 方案2:基于ECS/IDC机房+K8S云原生自伸缩自扩容网络拓扑架构

方案1基于ECS/IDC机房的手动扩容网络拓扑架构,核心如下:

图片

方案2基于ECS/IDC机房+K8S云原生自伸缩自扩容网络拓扑架构。

不论方案1、方案2, 在接入层,都需要进行10Wqps网关接入层的架构设计。

为什么要用LVS,因为一个Nginx扛不住 10Wqps。

接入层的架构目标

接入层集群架构目标,包括如下三点:高可用、可扩展、负载均衡。

一个完整的集群架构,则需要包含如下三个功能:负载均衡、故障隔离、失败切换

  • 负载均衡:根据设定的算法,通过负载均衡器(Director)把外部请求转发到各个集群中的服务器上(Real server)

  • 故障隔离:当集群中一个或多个服务器(Real server)发生故障或无法提供服务时,集群能够把它们从转发队列中移除出去,以确保用户访问不会被错误转发到无法提供的服务器(Real server)处理。而当故障的服务器(Real server)重新恢复正常时候,又能够重新加入到集群的转发队列中。

  • 失败切换:失败切换主(Master)要就是去除负载均衡器(Director)的单点问题,一旦负载均衡器(Director)发生故障,备机(Slave)能够替代主(Master)负载均衡器(Director)接受用户请求,而当主负载均衡器(Director)恢复正常时,能够重新接管用户请求(根据配置而定)

什么是LVS:

  1. 高伸缩性,高可用的服务;

  2. 已被集成到Linux中;

  3. 整体架构和Nginx相似,也是集群;

图片

lvs基于四层,处理能力是Nginx的几十倍,负载能力更高。lvs可以只接受不响应,nginx接收并相应

思考: LVS 和 Nginx 很像,为什么还要使用LVS?

  1. LVS基于四层,工作效率高

    1. LVS基于四层,请求接收到直接转发

    2. Nginx接收到请求后,还需要对请求做一定处理(会有一定性能损耗)

    3. 假如Nginx能支持的并发能达到几万,那么LVS能支持的负载可以达到Nginx的几十倍

  2. 单个Nginx承受不了压力,需要集群

    1. 当使用Nginx集群的时候,Nginx前置的调度者不能再由Nginx充当了,因为这样还是相当于Nginx承担了所有的压力,所以我们用LVS充当Nginx集群前置调度者;

    2. 备注: LVS+Nginx 相当于 LVS就是Nginx,Nginx就是上游服务器;

  3. Nginx接收请求来回, LVS可以只接受不响应;所以LVS的负载能力肯定比Nginx高;

补充一下名称:

LVS是Linux Virtual Linux的缩写,即Linux虚拟服务器,是一个常用的服务器集群系统,该功能已经广泛集成在Linux内核中。

Keepalived则是一个服务器检测状态软件,能够为集群提供故障隔离和失败切换功能

ipvsadm则是LVS的管理工具,能够添加,修改,删除,查看当前集群的配置和转发状态等。在使用Keepalived的方案中,只要系统安装有ipvsadm命令,只需要通过Keepalived的配置文件即可实现集群的所有配置。

LVS 工作模式(三种)

NAT 基于网络地址转换

(这种模式是和Nginx模式类似,不适用超大并发场景)

图片

TUN  ip隧道模式

条件限制: 所有的计算机节点都必须要有网卡, RealServer 向客户端响应不用经过LVS, 而是通过网卡之间建立一种 "隧道",通信就通过这种隧道的方式;

图片

优点: 请求上行(客户端-服务器) 没啥变化,下行(realserver - 客户端)大大优化;大大提高吞吐量和并发能力;

缺点: 需要每一个计算机节点都配置一个网卡, RealServerl暴露在公网(不好)

DR  直接路由模式

(解决  Real Server 暴露在公网问题)

图片

高并发负载均衡—LVS DR

LVS-DR(Linux Virtual Server Director Server)工作模式 ,是生产环境中最常用的一 种工作模式。

  • LVS-DR 模式,Director Server 作为群集的访问入口,不作为网关使用

  • 节点 Director Server 与 Real Server 需要在同一个网络中,返回给客户端的数据不需要经过 Director Server。

为了响应对整个群集的访问,Director Server 与 Real Server 都需要配置 VIP 地址。

图片

客户机发起请求,经过调度服务器(lvs),经过算法调度,去访问真实服务器(RS)

由于不原路返回,客户机不知道,真实主机的ip地址,所以只能通过调度服务器的外网ip(vip)去反回报文信息。

LVS DR(Direct Routing)

名称含义
DS(Director Server)前端负载均衡节点服务器
RS(Real SERVER)后端真实服务器
CIP(Client IP)客户端IP地址
VIP(Virtual IP)负载均衡对外提供访问的IP地址,一般负载均衡IP都会通过Viirtual IP实现高可用
RIP(RealServer IP)负载均衡后端的真实服务器IP地址
  • Director Server 和 Real Server 必须在同一个物理网络中。

  • Real Server 可以使用私有地址,也可以使用公网地址。如果使用公网地址,可以通过 互联网对 RIP 进行直接访问。

  • 所有的请求报文经由 Director Server,但回复响应报文不能经过 Director Server。

  • Real Server 的网关不允许指向 Director Server IP,即不允许数据包经过 Director S erver。

  • Real Server 上的 lo 接口配置 VIP 的 IP 地址。

DR拓补图

图片

DR拓扑图

  • VIP:虚拟服务器地址

  • DIP:转发的网络地址

  • RIP:后端真实主机(后端服务器)

  • CIP:客户端IP地址

DR数据包流向分析

(1) 客户端 发送请求到 Director Server,请求的数据报文(源 IP 是 CIP,目标 IP 是 VIP) 到达内核空间。

(2) Director Server 和 Real Server 在同一个网络中,数据通过二层数据链路层来传输。

(3) 内核空间判断数据包的目标 IP 是本机 VIP,此时 IPVS 比对数据包请求的服务是否是集群服务,是集群服务就重新封装数据包。修改源MAC 地址为 Director Server 的 MAC 地址,修改目标 MAC 地址为 Real Server 的 MAC 地址,源IP 地址与目标 IP 地址没有改变(源 IP 是 CIP,目标 IP 是 VIP),然后将数据包发送给 Real Server。

(4) 到达 Real Server 的请求报文的 MAC 地址是自身的 MAC 地址,就接收此报文。

(5) 响应的时候,数据包重新封装报文(源 IP 地址为VIP,目标 IP 为 CIP),将响应报文通过 lo 接口传送给物理 网卡然后向外发出。Real Server 直接将响应报文传送到客户端。

问题一:IP 地址冲突

在LVS-DR负载均衡集群中,负载均衡器 DS与节点服务器 RS 都要配置相同的VIP地址,在局域网中具有相同的IP地址。势必会造成各服务器ARP通信的紊乱

  • 当ARP广播发送到LVS-DR集群时,因为负载均衡器和节点服务器都是连接到相同的网络上,它们都会接收到ARP广播

  • 只有前端的负载均衡器进行响应,其他节点服务器不应该响应ARP广播

解决思路:

路由器发送ARP请求(广播)

ARP---->广播去找ip地址解析成mac地址, 默认使用DR调度服务器上的外网地址(vip地址)响应,需要在真实服务器上修改内核参数,使真实服务器只对自己服务器上的真实IP地址响应ARP解析。

解决方法:

对RS节点服务器进行处理,使其不响应针对VIP的ARP请求。

策略是要配置环回接口、修改内核参数。

用虚接口lo:0承载VIP地址。设置内核参数arp_ ignore=1: 系统只响应目的IP为本地IP的ARP请求

内核参数 arp_ignore 的作用:定义接收到ARP请求时的相应级别

两个级别:

  1. 只要本地有相应地址就给予响应

  2. 仅在请求的目标(MAC)地址配置请求到达接口才给予响应

问题二:第二次再有访问请求

RealServer返回报文(源IP是VIP)经路由器转发,重新封装报文时,需要先获取路由器的MAC地址,发送ARP请求时,Linux默认使用IP包的源IP地址(即VIP)作为ARP请求包中的源IP地址,而不使用发送接口的IP地址,路由器收到ARP请求后,将更新ARP表项,原有的VIP对应Director的MAC地址会被更新为VIP对应RealServer的MAC地址。路由器根据ARP表项,会将新来的请求报文转发给RealServer,导致Director的VIP失效

解决方法:

对RS节点服务器进行处理,设置内核参数arp_announce=2:系统不使用IP包的源地址来设置ARP请求的源地址,而选择DR发送接口的IP地址

路由器上绑定了 真实服务器1的mac信息,

#请求到达真实服务器

在真实服务器上修改内核参数

只对所有服务器真实网卡上的地址进行反馈,解析

内核参数 arp_announce:定义将自己的地址向外通告的级别

  1. 将本地任何接口上的地址向外通告

  2. 试图仅向目标网络通告与其网络匹配的地址

  3. 仅向与本地接口上地址匹配的网络进行通告

配置重点

将server的VIP配置为对外隐藏,对内可见。

  • 将VIP配置在环回接口:隐藏VIP,外界请求不能到达

  • arp_ignore修改为1

  • arp_announce修改为2

  1. arp_ignore=1

防止网关路由发送ARP广播时调度调节器和节点服务器都进行响应,导致ARP缓存表紊乱,不对非本地物理网卡的ARP请求进行响应,因为vip时,承载lo:0

  1. arp_announce=2

系统不使用响应数据的源IP地址(VIP)来作为本机进行ARP请求报文的源IP地址,而使用发送报文的物理网卡IP地址作为ARP请求报文的源IP地址,这样可以防止网关路由器接收到的源IP地址为VIP的ARP请求报文后又更新ARP缓存表,导致外网再发送请求时,数据包到达不了调度器

lvs实操

图片

主机IP地址
DR服务器192.168.61.44
web服务器192.168.61.22
web服务器192.168.61.33
vip(虚拟回环)192.168.61.45
客户端192.168.61.55

配置负载调度器

192.168.61.44 虚拟vip:192.168.61.45

   1. #关闭防火墙
      systemctl stop firewalld.service
      setenforce 0
   2. #安装ipvsadm工具
      yum install ipvsadm.x86_64 -y
   3. #配置虚拟IP地址(VIP:192.168.61.44)
      cd /etc/sysconfig/network-scripts/
      cp ifcfg-ens33 ifcfg-ens33:0
      vim ifcfg-ens33:0
      #删除UUID,dns与网关,注意子网
      NAME=ens33:0
      DEVICE=ens33:0
      IPADDR=192.168.61.45
      NETMASK=255.255.255.255
   4. #重启网络服务、启动网卡
      systemctl restart network
      ifup ifcfg-ens33:0
   5. #调整/proc响应参数   
      #对于 DR 群集模式来说,由于 LVS 负载调度器和各节点需要共用 VIP 地址,应该关闭 Linux 内核的重定向参数响应服务器不是一台路由器,那么它不会发送重定向,所以可以关闭该功能
      vi /etc/sysctl.conf
      net.ipv4.ip_forward = 0
      net.ipv4.conf.all.send_redirects = 0
      net.ipv4.conf.default.send_redirects = 0
      net.ipv4.conf.ens33.send_redirects = 0
   6. #刷新配置
      sysctl -p
   7. #加载模块
      modprobe ip_vs
      cat /proc/net/ip_vs
   8. #配置负载分配策略,并启动服务
      ipvsadm-save >/etc/sysconfig/ipvsadm
      systemctl start ipvsadm.service
   9. #清空ipvsadm,并做策略
      ##添加真实服务器-a  指定VIP地址及TCP端口-t   指定RIP地址及TCP端口 -r 指定DR模式-g
      ipvsadm -C
      ipvsadm -A -t 192.168.61.45:80 -s rr
      ipvsadm -a -t 192.168.61.45:80 -r 192.168.61.22:80 -g
      ipvsadm -a -t 192.168.61.45:80 -r 192.168.61.33:80 -g
   10. #保存设置
       ipvsadm
       ipvsadm -ln
       ipvsadm-save >/etc/sysconfig/ipvsadm

关闭防火墙

图片

安装ipvsadm工具

图片

配置虚拟IP地址(VIP:192.168.61.45)

图片

重启网络服务、启动网卡

图片

调整/proc响应参数

图片

刷新配置

图片

加载模块

图片

配置负载分配策略,并启动服务

图片

清空ipvsadm,并做策略

图片

保存设置

图片

第一台Web节点服务器

192.168.61.22

1. #关闭防火墙
systemctl stop firewalld.service
setenforce 0
 
2. #安装httpd、开启服务
yum install httpd -y
systemctl start httpd
 
3. #创建一个站点文件
vim /var/www/html/index.html
this is 192.168.61.22
 
3. #添加回环网卡,修改回环网卡名,IP地址,子网掩码
cd /etc/sysconfig/network-scripts/
cp ifcfg-lo ifcfg-lo:0
vim ifcfg-lo:0
DEVICE=lo:0
IPADDR=192.168.61.45
NETMASK=255.255.255.255
NETWORK=127.0.0.0
 
systemctl restart network
 
 
4. #设置路由
route add -host 192.168.61.45 dev lo:0
route -n
 
5. #开机执行命令
vim /etc/rc.d/rc.local 
/usr/sbin/route add -host 192.168.61.45 dev lo:0
 
chmod +x /etc/rc.d/rc.local
 
6. #调整 proc 响应参数
#添加系统只响应目的IP为本地IP的ARP请求
#系统不使用原地址来设置ARP请求的源地址,而是物理mac地址上的IP
vim /etc/sysctl.conf
 
net.ipv4.conf.all.arp_ignore = 1
net.ipv4.conf.all.arp_announce = 2
net.ipv4.conf.default.arp_ignore = 1
net.ipv4.conf.default.arp_announce = 2
net.ipv4.conf.lo.arp_ignore = 1
net.ipv4.conf.lo.arp_announce = 2
 
sysctl -p

关闭防火墙

图片

安装httpd、开启服务

图片

创建一个站点文件

图片

添加回环网卡,修改回环网卡名,IP地址,子网掩码

图片

设置路由

图片

开机执行命令

图片

图片

调整 proc 响应参数

图片

基础知识:LINUX中的lo(回环接口)

上面的配置,添加回环网卡,修改回环网卡名,IP地址,子网掩码

cd /etc/sysconfig/network-scripts/
cp ifcfg-lo ifcfg-lo:0
vim ifcfg-lo:0
DEVICE=lo:0
IPADDR=192.168.61.45
NETMASK=255.255.255.255
NETWORK=127.0.0.0

什么是LO接口?

在LINUX系统中,除了网络接口eth0,还可以有别的接口,比如lo(本地环路接口)。

LO接口的作用是什么?假如包是由一个本地进程为另一个本地进程产生的, 它们将通过外出链的’lo’接口,然后返回进入链的’lo’接口.

网络接口的命名

这里并不存在一定的命名规范,但网络接口名字的定义一般都是要有意义的。例如:

  • eth0: ethernet的简写,一般用于以太网接口。

  • wifi0:wifi是无线局域网,因此wifi0一般指无线网络接口。

  • ath0: Atheros的简写,一般指Atheros芯片所包含的无线网络接口。

  • lo: local的简写,一般指本地环回接口。

网络接口如何工作

网络接口是用来发送和接受数据包的基本设备。

系统中的所有网络接口组成一个链状结构,应用层程序使用时按名称调用。

每个网络接口在linux系统中对应于一个struct net_device结构体,包含name,mac,mask,mtu…信息。

每个硬件网卡(一个MAC)对应一个网络接口,其工作完全由相应的驱动程序控制。

虚拟网络接口

虚拟网络接口的应用范围非常广泛。最着名的当属“lo”了,基本上每个linux系统都有这个接口。

虚拟网络接口并不真实地从外界接收和发送数据包,而是在系统内部接收和发送数据包,因此虚拟网络接口不需要驱动程序。

虚拟网络接口和真实存在的网络接口在使用上是一致的。

网络接口的创建

硬件网卡的网络接口由驱动程序创建。而虚拟的网络接口由系统创建或通过应用层程序创建。

驱动中创建网络接口的函数是:

  • register_netdev(struct net_device * )

  • 或者register_netdevice(struct net_device * )。

这两个函数的区别是:register_netdev(…)会自动生成以”eth”作为打头名称的接口,而register_netdevice(…)需要提前指定接口名称.

事实上,register_netdev(…)也是通过调用register_netdevice(…)实现的。

第二台Web节点服务器

192.168.61.33

1. #关闭防火墙
systemctl stop firewalld.service
setenforce 0
 
2. #安装httpd、开启服务
yum install httpd -y
systemctl start httpd
 
3. #创建一个站点文件
vim /var/www/html/index.html
this is 192.168.61.33
 
3. #添加回环网卡,修改回环网卡名,IP地址,子网掩码
cd /etc/sysconfig/network-scripts/
cp ifcfg-lo ifcfg-lo:0
vim ifcfg-lo:0
DEVICE=lo:0
IPADDR=192.168.61.45
NETMASK=255.255.255.255
NETWORK=127.0.0.0
 
systemctl restart network
 
 
4. #设置路由
route add -host 192.168.59.188 dev lo:0
route -n
 
5. #开机执行命令
vim /etc/rc.d/rc.local 
/usr/sbin/route add -host 192.168.59.188 dev lo:0
 
chmod +x /etc/rc.d/rc.local
 
6. #调整 proc 响应参数
#添加系统只响应目的IP为本地IP的ARP请求
#系统不使用原地址来设置ARP请求的源地址,而是物理mac地址上的IP
vim /etc/sysctl.conf
 
net.ipv4.conf.all.arp_ignore = 1
net.ipv4.conf.all.arp_announce = 2
net.ipv4.conf.default.arp_ignore = 1
net.ipv4.conf.default.arp_announce = 2
net.ipv4.conf.lo.arp_ignore = 1
net.ipv4.conf.lo.arp_announce = 2
 
sysctl -p

关闭防火墙

图片

安装httpd、开启服务

图片

图片

创建一个站点文件

图片

添加回环网卡,修改回环网卡名,IP地址,子网掩码

图片

图片

设置路由

图片

开机执行命令

图片

调整 proc 响应参数

图片

客户机测试(192.168.61.55)

图片

图片

图片

故障隔离:LVS实现健康性检查Ldirectord使用

独立的lvs并不具备对后端服务器执行健康检查的机制,这时通常需要配合第三方的工具来一起使用。

而ldirectord的作用就是用来监测Real Server,当Real Server失效时,把它从虚拟服务器列表中删除,恢复时重新添加到列表。

  1. LVS不可用,整个系统将不可用;SPoF Single Point of Failure

解决方案:高可用

keepalived heartbeat/corosync

  1. RS不可用时,Director依然会调度请求至此RS

解决方案:由Director对各RS健康状态进行检查,失败时禁用,成功时启用

keepalived  heartbeat/corosync   ldirectord

检测方式

(a) 网络层检测,icmp

(b) 传输层检测,端口探测

(c) 应用层检测,请求某关键资源

RS全不用时:backup server, sorry server

ldirectord

ldirectord是一个守护进程,用于监视和管理负载平衡虚拟服务器的LVS集群中的真实服务器。

ldirectord通常用作Linux HA的资源,但也可以从命令行运行。

使用ldirectord程序,这个程序在启动时自动建立IPVS表,然后监视集群节点的健康情况,在发现失效节点时将其自动从IPVS表中移除。

ldirectord守护进程通过向每台真实服务器真实IP(RIP)上的集群资源发送访问请求来实现对真实服务器的监控,这对所有类型的LVS集群都是成立的:LVS-DR,LVS-NAT和LVS-TUN。

正常情况下,为每个Director上的VIP地址运行一个ldirectord守护进程,当真实服务器不响应运行在Director上的ldirectord守护进程时,ldirectord守护进程运行适当的ipvsadm命令将VIP地址从IPVS表中移除。(以后,当真实服务器回到在线状态时,ldirectord使用适当的ipvsadm命令将真实服务器重新添加到IPVS表中)。

另外,Ldirectord也可以通过定期请求已知的URL,并检查响应是否包含预期的响应,来监视实际服务器的运行状况。如果一个真正的服务器出现故障,那么该服务器将被删除,并在重新联机后重新激活。

如果所有真正的服务器都关闭了,那么会在池中插入一个回退服务器,这将使一个静态的真正的Web服务器重新联机。通常,回退服务器是本地主机。如果正在提供HTTP虚拟服务,那么运行一个ApacheHTTP服务器是很有用的,该服务器返回一个页面,指示该服务暂时不可访问。

Idirectord 软件和配置文件说明:

包名:ldirectord-3.9.6-0rc1.1.1.x86_64.rpm
下载:http://download.opensuse.org/repositories/network:/ha-clustering:/Stable/CentOS_CentOS-7/x86_64/
安装:yum install ldirectord-3.9.6-0rc1.1.2.x86_64.rpm -y  (需要epel源中的Perl)
 
文件:
    /etc/ha.d/ldirectord.cf 主配置文件
    /usr/share/doc/ldirectord-3.9.6/ldirectord.cf 配置模版
    /usr/lib/systemd/system/ldirectord.service 服务
    /usr/sbin/ldirectord 主程序,Perl实现
    /var/log/ldirectord.log 日志
    /var/run/ldirectord.ldirectord.pid pid文件
 
 
Ldirectord配置文件说明
checktimeout=20           #判定real server出错的时间间隔。
checkinterval=10          #指定ldirectord在两次检查之间的间隔时间。
fallback=127.0.0.1:80     #当所有的real server节点不能工作时,web服务重定向的地址。
autoreload=yes            #是否自动重载配置文件,选yes时,配置文件发生变化,自动载入配置信息。
logfile="/var/log/ldirectord.log"   #设定ldirectord日志输出文件路径。
logfile="local0"            #rsyslog方式定义日志输出。
 
quiescent=no                        #当RS服务down时状态时, yes权重设为0,no为删除RS服务器。
当选择no时,如果一个节点在checktimeout设置的时间周期内没有响应,ldirectord将会从LVS的路由表中直接移除real server,此时,将中断现有的客户端连接,并使LVS丢掉所有的连接跟踪记录和持续连接模板,
如果选择为yes,当某个real server失效时,ldirectord将失效节点的权值设置为0,新的连接将不能到达,但是并不从LVS路由表中清除此节点,同时,连接跟踪记录和程 序连接模板仍然保留在Director上。
virtual=5               #指定虚拟的IP地址和端口号,FWM(标签)或 IP:PORT
virtual=192.168.60.200:80           #指定虚拟的IP地址和端口号
   real=192.168.60.132:80 gate      #指定Real Server服务器地址和端口,同时设定LVS工作模式,用gate表示DR模式,ipip表示TUNL模式,masq表示NAT模式。
   real=192.168.60.144:80 gate
   fallback=127.0.0.1:80 gate       #sorry server
   service=http                     #指定服务的类型,这里是对http服务做负载均衡。
   request="index.html"             #ldirectord将根据指定的RealServer地址,结合该选项给出的请求路径,发送访问请求,检查RealServer上的服务是否正常运行,确保这里给出的页面地址是可访问的,不然ldirectord会误认为此节点已经失效,发生错误监控现象。
   receive="Test Page"              #指定请求和应答字串。
   scheduler=rr                     #指定调度算法,这里是rr(轮叫)算法。
   protocol=tcp                     #指定协议的类型,LVS支持TCP和UDP协议。
   checktype=negotiate              #指定Ldirectord的检测类型,默认为negotiate。
   checkport=80                     #指定监控的端口号。
   persistence=360                  #持久连接
   virtualhost=www.gaojf.com        #虚拟服务器的名称,随便指定

案例图:

图片

LVS_DR lvs 与 rs 不同网段

前期规则设置(参考: 实践LVS的DR模式,lvs与rs不同网段)
CIP:192.168.10.50      gateway:192.168.10.60
Route: eth0(192.168.10.60)    eth0(192.168.80.60 、10.0.0.200/8 ) 启用IP_forward的功能
LVS:DIP:192.168.80.100  gateway:192.168.80.60   VIP: 10.0.0.100/32    gateway和VIP两个IP绑定在同一个网卡上
RS1: RIP:192.168.80.110  gateway:192.168.80.60   VIP: 10.0.0.100/32  VIP绑定在lo
RS2: RIP:192.168.80.120  gateway:192.168.80.60   VIP: 10.0.0.100/32  VIP绑定在lo
 
 
Route设置
ip a a  10.0.0.200/8 dev eth0   与80.60同一网卡上。
         
 
LVS服务器设置:
设置VIP地址  
    ip addr add  10.0.0.100/8 dev eth0
 
RS的服务器设置:
    echo 1 > /proc/sys/net/ipv4/conf/all/arp_ignore
    echo 1 > /proc/sys/net/ipv4/conf/lo/arp_ignore
    echo 2 > /proc/sys/net/ipv4/conf/all/arp_announce
    echo 2 > /proc/sys/net/ipv4/conf/lo/arp_announce
两台RS设置VIP地址
    ip addr add 10.0.0.100/8 dev lo:1
 
安装sorry Sever服务器,和 ldirectord服务
    yum install ldirectord-3.9.6-0rc1.1.2.x86_64.rpm -y
    yum install httpd -y
    echo Sorry Server > /var/www/html/index.html
    systemctl start httpd
清空规则,复制配置文件模版
    ipvsadm -C  
    cp /usr/share/doc/ldirectord-3.9.6/ldirectord.cf /etc/ha.d/
设置配置文件
Ldirectord配置文件说明
    checktimeout=3                  #检查RS次数
    checkinterval=1                 #检查RS时间
    autoreload=yes
    logfile=“/var/log/ldirectord.log“ #日志文件
    #logfile="local0"                 #日志级别
    quiescent=no                      #当RS服务down时状态时, yes权重设为0,no为删除RS服务器。
virtual=10.0.0.100:80
    real=192.168.80.110:80 gate 2       #gate指DR模型,权重为 2
    real=192.168.80.120:80 gate 1
    fallback=127.0.0.1:80 gate          #sorry server, 当两个RS服务器出现问题,LVS的提示。
    service=http
    scheduler=wrr
    protocol=tcp
    checktype=negotiate
    checkport=80
    request="index.html"
 
 
# systemctl restart ldirectord.service
 
 
 while true ; do curl http://10.0.0.100 ;sleep 1; done    systemectl stop httpd 测试
192.168.80.120
192.168.80.110
192.168.80.120
192.168.80.110
192.168.80.120
192.168.80.120
sorry services
sorry services

失败切换:keepalived+LVS实现高可用性集群

失败切换主(Master)要就是去除负载均衡器(Director)的单点问题,一旦负载均衡器(Director)发生故障,备机(Slave)能够替代主(Master)负载均衡器(Director)接受用户请求,而当主负载均衡器(Director)恢复正常时,能够重新接管用户请求(根据配置而定)

keepalived+LVS-DR实现高可用

当LVS负载均衡的主服务器出现故障时,keepalived会及时切换到备份服务器;

图片

keepalived+LVS实现高可用性集群_LVS

keepalive故障自动切换

① 两台DS同时安装好keepalived并且启动服务

当启动的时候master主机获得所有资源并且对用户提供服务(请求)当角色Backup的主机作为master热备,当master出现故障,Backup主机自动接管master主机所有工作

② 当master主机故障修复后,就会自动接管回它原来的所有工作,同时Backup主机则释放原master主机的所有工作,此时两台主机恢复到初始角色以及工作状态

抢占与非抢占

抢占:master恢复后,将VIP从Backup节点中抢占过来,回复自己master工作

非抢占:master恢复后,不抢占Backup目前的状态,Backup升级为master继续工作

keepalive+LVS

keepalive可以通过读取自身的配置文件,实现通过更底层的接口直接管理,LVS配置以及服务的启动、停止功能,这会使LVS应用更加简便

LVS集中节点的健康检查

Keeplived.conf文件配置LVS的节点IP和相关参数来实现对LVS直接管理,如果几个节点服务器同时发生故障无法提供服务,Keeplived服务会自动把那个失效节点服务器从LVS正常转发列中清除出去,并且将请求调度到别的正常节点服务器上,从而保证最终用户的访问不受影响,当故障的节点服务器修复以后,Keepalived服务又会自动把他们加入到转发列中,对外面客户提供服务

部署LVS + keeplived 高可用集群

1)部署DR模式的负载均衡集群

禁用网卡发送重定向

#0 表示禁用发送重定向,禁用发送重定向可以防止ARP欺骗和IP欺骗
vim /etc/sysctl.conf

net.ipv4.conf.all.send_redirects = 0  #所有网卡的
net.ipv4.conf.default.send_redirects = 0 #默认网卡的
net.ipv4.conf.ens33.send_redirects = 0    #ens33网卡的(根据需要修改为真实网卡即可)
#添加完毕后,刷新内核参数使其生效
sysctl -p   #刷新内核参数
2)配置web服务器

修改内核文件,控制arp行为

#配置内核文件,控制arp行为

arp-ignore
# 0 只要本机配置有相应IP地址就响应;
# 1 仅在请求的目标地址配置在请求到达网络接口上时,才给予响应;
arp-announce
# 0 将本机任何网络接口上的任何地址都向外通告;
# 1 尽可能避免向目标网络通告与其网络不匹配的地址信息表;
# 2 仅向目标网络通告与其网络相匹配的地址信息。
net.ipv4.conf.all.arp_ignore = 1
net.ipv4.conf.all.arp_announce = 2
net.ipv4.conf.default.arp_ignore = 1
net.ipv4.conf.default.arp_announce = 2
net.ipv4.conf.lo.arp_ignore = 1
net.ipv4.conf.lo.arp_announce = 2
3)添加路由
route add -host 10.10.10.100 dev lo:0   #DR模式下,后端的真实服务器必须添加此路由,否则无法与客户端通信
4)配置keepalived

下载keepalived源码包

https://www.keepalived.org/software/keepalived-2.0.18.tar.gz

解压文件:

tar -zxvf [下载的包名]
#安装相关依赖
yum install -y kernel-devel openssl-devel popt-devel gcc*
5)编译安装

编译安装后,在/etc/查看是否存在keepalived目录,如果不存在则在解压的源码目录/root/keepalived-2.0.18/keepalived/etc/keepalived中将keepalived.conf配置文件复制一份过来否则无法启动

#在解压后的目录中执行编译安装
 ./configure --prefix=/
 make && make install  
检查/etc/init.d/目录下是否存在 keepalived 文件,如果不存在则在源码的init.d文件夹中拷贝一份启动脚本到/etc/init.d/目录下
cp /root/keepalived-2.0.18/keepalived/etc/init.d  /etc/init.d/
#拷贝完成后设置为开机自启
chkconfig --add keepalived  #添加为系统服务
chkconfig keepalived on     #设置为开机自启
使用yum的方式直接安装keepalived
#安装依赖
yum install -y kernel-devel openssl-devel popt-devel gcc*
#安装keepalived
yum install keepalived -y
6)修改keepalived配置文件

vim /etc/keepalived/keepalived.conf

global_defs {
   router_id LVS_MASTER   #设置自定义名称
}

vrrp_instance VI_1 {
    state MASTER      #设置状态主或备,根据实际情况修改
    interface eth0    #指定网卡做心跳检测
    virtual_router_id 51    #配置虚拟组,主备lvs服务器必须在同一个组里否则不生效
    priority 100            #优先级配置。主与从之间差距最好在50,最大设置为150
    advert_int 1            #心跳检测间隔为1s
    authentication {        #配置主备之间的认证
        auth_type PASS
        auth_pass 1111
    }
    virtual_ipaddress {
        10.10.10.100
        #此处填写集群IP地址
    }
}

#配置集群的IP端口,算法与部署模式
virtual_server 10.10.10.100 80 {
    delay_loop 6    #检测循环的次数以及时间
    lb_algo rr       #设置当前集群使用的算法
    lb_kind DR      #设置当前集群的模式  
    persistence_timeout 50    # 同一IP的连接50秒内被分配到同一台realserver
    protocol TCP      #设置检测使用的协议为TCP

    #配置真实服务器的相关IP和端口信息,有几个真实服务器就填写几个real_server
    real_server 10.10.10.12 80 {          
        weight 1                              #权重,最大越高,lvs就越优先访问
        TCP_CHECK {                           #keepalived默认的健康检查方式为HTTP_GET,其他健康检测方式分别是:SSL_GET | TCP_CHECK | SMTP_CHECK | MISC
            connect_timeout 3                #健康监测超时时间
            retry 3                           #重连次数3次
            delay_before_retry 3              #重连间隔时间
            connect_port 80                   #健康检查realserver的端口
        }                                     
    }
修改完配置文件后重启keeplived使其生效
service keeplived start
7)配置backup备份

网卡配置部分略,跟主服务器同网段IP即可,虚拟IP主备必须保持一致。在同一局域网配置相同ip后启动网卡时会报错。这是因为网卡启动时会通过发送ARP请求检测目标IP地址是否与其他主机发生了冲突,所以这里需要手动关闭backup服务器网卡的arp检测功能。

vim /etc/sysconfig/network-scripts/ifup-eth    
 #由于不通系统版本文件存放位置不一致,此路径为centos7下的配置文件存放位置。其他系统需重新找到ifup-eth文件的正确位置

#在配置文件中注释掉arp命令检测的相关判断,在配置文件的275行左右注释后即可重启网卡成功
if [ $? = 1 ]; then
   ARPINGMAC=$(echo $ARPING |  sed -ne 's/.*\[\(.*\)\].*/\1/p')
   net_log $"Error, some other host ($ARPINGMAC) already uses address ${ipaddr[$idx]}."
   exit 1
 fi

安装keepalived步骤不在赘述,与master安装步骤一致。

修改配置文件,将状态设置为backup

! Configuration File for keepalived

global_defs {
   router_id LVS2   #修改备份服务器的名称,不能与主服务器名称重复
}

vrrp_instance VI_1 {     
    state BACKUP    #状态设置为backup或者SLAVE
    interface ens33    
    virtual_router_id 51
    priority 50    #修改优先级
    advert_int 1
    authentication {
        auth_type PASS
        auth_pass 1111
    }
    virtual_ipaddress {
        10.10.10.100
    }
}
virtual_server 10.10.10.100 80 {
    delay_loop 3
    lb_algo rr
    lb_kind DR
    #persistence_timeout 50
    protocol TCP

    real_server 10.10.10.12 80 {
        weight 1
        TCP_CHECK{
            connect_port 80
            connect_timeout 3
            retry 3
            delay_before_retry 3
        }
    }

    real_server 10.10.10.13 80 {
        weight 1
        TCP_CHECK{
            connect_timeout 3
            connect_port 80
            retry 3
            delay_before_retry 3
        }
    }
}

工业级Netty网关,京东是如何架构的?

京麦是京东商城为其商家提供的一款后台管理工具,它能够让商家在不登录后台的情况下生成订单,快速完成订单下载和发货流程。这与淘宝的旺旺商家版(现已更名为淘宝千牛)类似。

本文主要阐述了京麦 TCP 网关的技术架构以及 Netty 的应用实践。

京东京麦商家管理平台从 2014 年开始搭建网关,从 HTTP 网关逐步升级为 TCP 网关。到了 2016 年,基于 Netty4.x+Protobuf3.x 技术,京麦构建了一个高可用、高性能和高稳定的 TCP 长连接网关,支持 PC 端和应用程序的上下行通信。

早期的京麦主要依靠 HTTP 和 TCP 长连接来发送消息通知,而没有将其应用于 API 网关。

然而,随着对 NIO 技术的深入了解和对 Netty 框架的熟练掌握,以及对系统通信稳定性要求的提高,京麦开始尝试运用 NIO 技术来实现 API 请求调用。这一设想在 2016 年终于实现,并成功支持业务运营。

得益于采用了 TCP 长连接容器、Protobuf 序列化、服务泛化调用框架等多种优化措施,京麦的 TCP 网关性能比 HTTP 网关提升了 10 倍以上,稳定性也显著超过了 HTTP 网关。

1、TCP网关的网络结构

通过 Netty 构建京麦 TCP 网关的长连接容器,作为网关接入层提供服务 API 请求调用。

客户端通过域名 + 端口访问 TCP 网关,不同域名对应不同运营商的 VIP,VIP 发布在 LVS 上,LVS 将请求转发给后端的 HAProxy,然后由 HAProxy 将请求转发给后端的 Netty 的 IP+Port。

主要,这个是高并发接入层的标准架构

LVS 将请求转发给后端的 HAProxy,经过 LVS 的请求,但响应是由 HAProxy 直接返回给客户端,这就是 LVS 的 DR 模式。

LVS+Keepalived(DR模式)

2、TCP网关长连接容器架构

TCP网关的核心组件是Netty,而Netty的NIO模型是Reactor反应堆模型(Reactor相当于有分发功能的多路复用器Selector)。

每一个连接对应一个Channel(多路指多个Channel,复用指多个连接复用了一个线程或少量线程,在Netty指EventLoop),一个Channel对应唯一的ChannelPipeline,多个Handler串行的加入到Pipeline中,每个Handler关联唯一的ChannelHandlerContext。TCP网关长连接容器的Handler就是放在Pipeline的中。

我们知道TCP属于OSI的传输层,所以建立Session管理机制构建会话层来提供应用层服务,可以极大的降低系统复杂度。所以,每一个Channel对应一个Connection,一个Connection又对应一个Session,Session由Session Manager管理,Session与Connection是一一对应,Connection保存着ChannelHandlerContext (ChannelHanderContext可以找到Channel), Session通过心跳机制来保持Channel的Active状态。

每一次Session的会话请求(ChannelRead)都是通过Proxy代理机制调用Service层,数据请求完毕后通过写入ChannelHandlerConext再传送到Channel中。

数据下行主动推送也是如此,通过Session Manager找到Active的Session,轮询写入Session中的ChannelHandlerContext,就可以实现广播或点对点的数据推送逻辑。如下图所示。

京麦TCP网关使用Netty Channel进行数据通信,使用Protobuf进行序列化和反序列化,每个请求都将被封装成Byte二进制字节流,在整个生命周期中,Channel保持长连接,而不是每次调用都重新创建Channel,达到链接的复用。

我们接下来来看看基于Netty的具体技术实践。

3、TCP网关Netty Server的IO模型

具体的实现过程如下:

  • 1)创建ServerBootstrap,设定BossGroup与WorkerGroup线程池;

  • 2)bind指定的port,开始侦听和接受客户端链接(如果系统只有一个服务端port需要监听,则BossGroup线程组线程数设置为1);

  • 3)在ChannelPipeline注册childHandler,用来处理客户端链接中的请求帧。

4、TCP网关的线程模型

TCP网关使用Netty的线程池,共三组线程池,分别为BossGroup、WorkerGroup和ExecutorGroup。

其中,BossGroup用于接收客户端的TCP连接,WorkerGroup用于处理I/O、执行系统Task和定时任务,ExecutorGroup用于处理网关业务加解密、限流、路由,及将请求转发给后端的抓取服务等业务操作。

NioEventLoop是Netty的Reactor线程,其角色:

  • 1)Boss Group:作为服务端Acceptor线程,用于accept客户端链接,并转发给WorkerGroup中的线程;

  • 2)Worker Group:作为IO线程,负责IO的读写,从SocketChannel中读取报文或向SocketChannel写入报文;

  • 3)Task Queue/Delay Task Queu:作为定时任务线程,执行定时任务,例如链路空闲检测和发送心跳消息等。

5、TCP网关执行时序图

如上图所示,其中步骤一至步骤九是Netty服务端的创建时序,步骤十至步骤十三是TCP网关容器创建的时序。

步骤一:创建ServerBootstrap实例,ServerBootstrap是Netty服务端的启动辅助类。

步骤二:设置并绑定Reactor线程池,EventLoopGroup是Netty的Reactor线程池,EventLoop负责所有注册到本线程的Channel。

步骤三:设置并绑定服务器Channel,Netty Server需要创建NioServerSocketChannel对象。

步骤四:TCP链接建立时创建ChannelPipeline,ChannelPipeline本质上是一个负责和执行ChannelHandler的职责链。

步骤五:添加并设置ChannelHandler,ChannelHandler串行的加入ChannelPipeline中。

步骤六:绑定监听端口并启动服务端,将NioServerSocketChannel注册到Selector上。

步骤七:Selector轮训,由EventLoop负责调度和执行Selector轮询操作。

步骤八:执行网络请求事件通知,轮询准备就绪的Channel,由EventLoop执行ChannelPipeline。

步骤九:执行Netty系统和业务ChannelHandler,依次调度并执行ChannelPipeline的ChannelHandler。

步骤十:通过Proxy代理调用后端服务,ChannelRead事件后,通过发射调度后端Service。

步骤十一:创建Session,Session与Connection是相互依赖关系。

步骤十二:创建Connection,Connection保存ChannelHandlerContext。

步骤十三:添加SessionListener,SessionListener监听SessionCreate和SessionDestory等事件。

6、TCP网关源码分析

6.1 Session管理

Session是客户端与服务端建立的一次会话链接,会话信息中保存着SessionId、连接创建时间、上次访问事件,以及Connection和SessionListener,在Connection中保存了Netty的ChannelHandlerContext上下文信息。Session会话信息会保存在SessionManager内存管理器中。

创建Session的源码:

@Override
public synchronized Session createSession(String sessionId, ChannelHandlerContext ctx){
    Session session = sessions.get(sessionId);
    if (session != null){
        session.close();
    }
    session = new ExchangeSession();
    session.setSessionId(sessionId);
    session.setValid(true);
    session.setMaxInactiveInterval(this.getMaxInactiveInterval());
    session.setCreationTime(System.currentTimeMillis());
    session.setLastAccessedTime(System.currentTimeMillis());
    session.setSessionManager(this);
    session.setConnection(createTcpConnection(session, ctx));
    for (SessionListener listener : essionListeners){
        session.addSessionListener(listener);
    }
    return session;
}

通过源码分析,如果Session已经存在销毁Session,但是这个需要特别注意,创建Session一定不要创建那些断线重连的Channel,否则会出现Channel被误销毁的问题。因为如果在已经建立Connection(1)的Channel上,再建立Connection(2),进入session.close方法会将cxt关闭,Connection(1)和Connection(2)的Channel都将会被关闭。在断线之后再建立连接Connection(3),由于Session是有一定延迟,Connection(3)和Connection(1/2)不是同一个,但Channel可能是同一个。

所以,如何处理是否是断线重练的Channel,具体的方法是在Channel中存入SessionId,每次事件请求判断Channel中是否存在SessionId,如果Channel中存在SessionId则判断为断线重连的Channel,代码如下图所示。

private String getChannelSessionHook(ChannelHandlerContext ctx){
    return ctx.channel().attr (Constants.SERVER_SESSION_HOOK).get();
}

private void setChannelSessionHook(ChannelHandlerContext ctx, String sessionId){
    ctx.channel().attr(Constants.SERVER_SESSION_HOOK).set(sessionId);
}

6.2 心跳

心跳用于检测保持连接的客户端是否仍然活跃,客户端每隔一段时间发送一次心跳包到服务端,服务端收到心跳后更新 Session 的最后访问时间。

在服务端,长连接会话检测通过轮询 Session 集合来判断最后访问时间是否过期,如果过期,则关闭 Session 和 Connection,包括从内存中删除,同时注销 Channel 等。如下面代码所示。

Session session = tcpSessionManager.createSession(wrapper.getSessionId(), ctx);
session.addSessionListener(tcpHeartbeatListener);
session.connect();

tcpSessionManager.addSession(session);

通过源码分析,在每个Session创建成功之后,都会在Session中添加TcpHeartbeatListener这个心跳检测的监听,TcpHeartbeatListener是一个实现了SessionListener接口的守护线程,通过定时休眠轮询Sessions检查是否存在过期的Session,如果轮训出过期的Session,则关闭Session。如下面代码所示。

public void checkHeartBeat(){
    Session[] sessions = tcpSessionManager.getSessions();
    for (Session session : sessions){
        if (session.expire()){
            session.close();
            logger.info("heart is expire, clear sessionId:"+ session.getSessionId());
        }
    }
}

同时,注意到session.connect方法,在connect方法中会对Session添加的Listeners进行添加时间,它会循环调用所有Listner的sessionCreated事件,其中TcpHeartbeatListener也是在这个过程中被唤起。如下面代码所示。

private void addSessionEvent(){
    SessionEvent event = new SessionEvent(this);
    for (SessionListener listener : listeners){
        try{
            listener.sessionCreated(event);
            logger.info("SessionListener" + listener + ".sessionCreated() is invoked successfully!");
        } catch (Exception e){
            logger.error("addSessionEvent error.", e);
        }
    }
}

6.3 数据上行

数据上行特指从客户端发送数据到服务端,数据从ChannelHander的channelRead方法获取数据。数据包括创建会话、发送心跳、数据请求等。这里注意的是,channelRead的数据包括客户端主动请求服务端的数据,以及服务端下行通知客户端的返回数据,所以在处理object数据时,通过数据标识区分是请求-应答,还是通知-回复。如下面代码所示。

public void channelRead(ChannelHandlerContext ctx, Object o) throws Exception{
    try{
        if (o instanceof MessageBuf.JMTransfer) {
            SystemMessage sMsg = generateSystemMessage(ctx);
            MessageBuf.JMTransfer message = (MessageBuf.JMTransfer) o;
            //inbound
            if(message.getFormat() == SEND) {
                MessageWrapper wrapper = proxy.invoke(sMsg, message);
                if (wrapper != null)
                    this.receive(ctx, wrapper);
            }
            // outbound
            if (message.getFormat() == REPLY) {
                notify.reply(message);
            }
        }else{
            logger. warn("TcpServerHandler channelRead message is not proto.");
        }
    }catch (Exception e) {
        logger.error("TcpServerHandler TcpServerHandler handler error.", e);
        throw e;
    }
}

6.4 数据下行

数据下行通过MQ广播机制到所有服务器,所有服务器收到消息后,获取当前服务器所持有的所有Session会话,进行数据广播下行通知。

如果是点对点的数据推送下行,数据也是先广播到所有服务器,每个服务器判断推送的端是否是当前服务器持有的会话,如果判断消息数据中的信息是在当前服务,则进行推送,否则抛弃。如下面代码所示。

private Notifyfuture doSendAsync(long seq, Messagelrapper wrapper, int timeout) throws Exception {
    if (wrapper == null) {
        throw new Exception("wrapper cannot be null.");
    }
    String sessionId = wrapper.getSessionId();
    if (StringUtils.isBlank(sessionId)) {
        throw new Exception("sessionId cannot be null.")
    }
    if (tcpConnector.exist sessionId)) {
        //start.
        final NotifyFuture future = new NotifyFuture(timeout);
        this.futureMap.put(seq, future);
        tcpConnector.send(sessionId, wrapper.getBody());
        future.setSentTime(System.currentTimeMillis()); // 置为已发送return future.
    } else {
        // tcpConnector not exist sessionId
        return null;
    }
}

通过源码分析,数据下行则通过NotifyProxy的方式发送数据,需要注意的是Netty是NIO,如果下行通知需要获取返回值,则要将异步转同步,所以NotifyFuture是实现java.util.concurrent.Future的方法,通过设置超时时间,在channelRead获取到上行数据之后,通过seq来关联NotifyFuture的方法。如下面代码所示。

public void reply (MessageBuf.JMTransfer message) throws Exception {
    try {
        long seg = message.getSeq();
        final NotifyFuture future = this.futureMap.get(seg);
        if (future != null){
            future.setSuccess(true);
            futureMap.remove(seg);
        }
    } catch (Exception e) {
        throw e;
    }
}

下行的数据通过TcpConnector的send方法发送,send方式则是通过ChannelHandlerContext的writeAndFlush方法写入Channel,并实现数据下行,这里需要注意的是,之前有另一种写法就是cf.await,通过阻塞的方式来判断写入是否成功,这种写法偶发出现BlockingOperationException的异常。如下面代码所示。

ChannelFuture cf = cxt.writeAndFlush(message);
cf.addListener(new ChannelFutureListener() {
    @Override
    public void operationComplete(ChannelFuture future) throws PushException{
        if (future.isSuccess()) {
            logger.debug("send success.");
        } else {
            throw new PushException("Failed to send message.");
        }
        Throwable cause = future.cause() ;
        if (cause != null) {
            throw new PushException(cause);
        }
    }
});

使用阻塞获取返回值的写法:

boolean success = true;
boolean sent = true;
int timeout = 60;
try {
    ChannelFuture cf = cxt.write(message);
    cxt.flush();
    if (sent){
        success = cf.await(timeout);
    }
    if (cf.isSuccess()) {
        logger.debug("send success.");
    }
    Throwable cause = cf.cause();
    if (cause != null) {
        this.fireError(new PushException(cause));
    }
} catch (Throwable e) {
    this.fireError(new PushException("Failed to send message, cause:" + e. getMessage(),e));
}

关于BlockingOperationException的问题我在StackOverflow进行提问,非常幸运的得到了Norman Maurer(Netty的核心贡献者之一)的解答:

最终结论大致分析出,在执行write方法时,Netty会判断current thread是否就是分给该Channe的EventLoop,如果是则行线程执行IO操作,否则提交executor等待分配。

当执行await方法时,会从executor里fetch出执行线程,这里就需要checkDeadLock,判断执行线程和current threads是否时同一个线程,如果是就检测为死锁抛出异常BlockingOperationException。

日均数十亿访问,个推API网关如何架构?

近期,个推的服务端技术大师李白受邀参与了 SegmentFault D-Day 在线技术直播活动,与来自知名互联网公司后端技术专家们共同探讨了“后端架构的演变之路”。李白以“API 网关的演变之路”为主题,分享了个推基于golang进行API网关建设的实践经验和深度思考

API网关之源起

API 网关是一种随着“微服务”理念而崛起的架构模式。

在微服务拆分过程中,庞大的单体应用和业务系统被拆分成许多微服务系统进行独立维护和部署,导致 API 数量大幅增加,API 治理的难度也逐渐加大。

同时,子系统通过 API 对外提供服务时,还存在通用能力重复建设的问题。因此,使用API网关统一发布和管理API逐渐成为一种架构趋势。

图片

在个推,消息推送、金融风控等业务核心系统均采用微服务架构,部分系统也拥有自建的网关模块。随着各个系统之间的依赖关系日益增多,高效进行接口治理的需求日益迫切。因此,个推早期就引入了统一的 API 网关,以解决权限控制、流量控制、服务降级、灰度发布、版本管理等一系列问题。

1、个推早期API网关

2015年,SpringCloud 的诞生极大促进了微服务架构的发展和流行。个推也是在这一时期,依赖于SpringCloud gateway构建了自己真正意义上的API网关。

SpringCloud 在生态系统方面相对友好,有许多可以即插即用的监控和容错组件,但由于不支持流量和安全治理,因此无法很好地满足公司后续进行 API 治理的需求;尤其是个推后来建设数据中台,更亟需一个统一的API网关来承担数据中台的入口流量

此外,在开发语言上,SpringCloud 仅支持 Java,适用性有限,且存在性能不佳、运维难度大等问题。

因此,为了实现更强大的 API 治理能力、简化接入和运维方式,同时也为了更好地满足公司数据中台建设需求,个推决定自主研发 API 网关。

2、个推自研API网关

2.1 自研目标

个推建设API网关的几个关键目标包括:

1)要能够对API完整生命周期进行统一治理

例如,在API设计上,要统一规范,确保 API 具有归属服务和标签,实现 API 设计上的隔离;

设计完成后,能够直接进行调试,并自动生成测试代码;

发布时,对 API 流量进行精细化控制,支持服务的灰度发布

在 API 运行过程中,全方位监控 API 调用情况并告警,对出现问题的服务能够及时地熔断隔离;下线后能够及时回收资源。

2)需要有完善的功能组件,来处理再一次请求过程

在整个请求链路中,我们设计实现了链路追踪、日志、鉴权、限流、熔断、插件等一系列核心功能。

3)保证“三高”的同时,用户迁移和接入要简单

这意味着 API 网关易于运维和使用,提供各种指标检测功能,且支持自动容错和弹性扩容

2.2 技术选型

基于以上的目标设计和技术调研之后,我们选择将 golang 作为自研 API 网关的主要开发语言。

选择 golang 的原因有:

1)个推在做多机房容灾建设和数据拆分迁移过程中,设计了gproxy-codis和gproxy-es,并已搭建了一套 proxy 集群来路由不同的用户。这些 proxy 运行良好,一些大的集群单机 QPS 超过 6W。在这些项目的开发过程中,我们积累了许多开发经验和基础组件,其中的许多轮子可以直接复用。

因此,我们使用 golang 并基于现有的这些组件,开发一套 gproxy-http 相对轻松。

2)golang 语言本身就支持高并发,开发速度快,最重要的是节省机器成本

3)作为云原生框架中使用最广泛的编程语言,golang 的技术沉淀也是为个推云原生建设铺路

2.3 设计与实现

确认目标和技术选型之后,接下来就是一些具体的设计与实现。

1)整体架构

个推API网关的整体架构设计,如图所示:

图片

首先,这是一个Web管理平台,这个平台负责所有 API 的创建、发布以及后续管理,用户可以在上面进行相应的配置。

配置完成后,通知配置中心,网关就会定时拉取全量的 API 配置。

接下来是网关的核心组件,例如插件引擎,主要是执行配置的一些插件。

转发引擎也是API网关的核心模块,个推的转发引擎支持 http、grpc,还有个推自研的 gcf 协议,在数据中台的业务场景下,也支持了 kafka 的数据推送能力。

2)插件服务

从图中可以看到,个推 API 网关的整体架构中有一个独立的插件服务。

这个设计的主要原因是 golang 是类 C 语言,打包后是可执行文件,由于 Golang 的原生插件是直接编译好的,它们不支持更新和卸载,因此也无法在界面上直接实现新增和更新插件的功能

为了解决这个问题,我们选择使用 Java 来开发插件服务。借助 Java 的动态语言特性,我们可以灵活地支持插件的新增、更新和卸载。然而,网关通过 gRPC 与插件服务进行通信,这可能会导致一定的性能损耗。

为了尽可能减少这种性能损耗,我们将加密和特定序列化相关的插件都使用 Golang 的一个原生插件来完成。对于一些业务定制较强的组件,我们建议使用 Java 插件服务。

3)资源隔离

资源隔离是实现系统高可用的常用手段,在隔离设计上主要有集群和线程池的隔离。

API网关主要支持服务集群隔离,通过这种集群级别的隔离,在上层可以支持多租户,如果再彻底一点,在 LB 层面可以把网关集群也隔离出去。

另外,服务的灰度发布也是通过网关这种集群隔离来实现的。具体过程是,在升级时,用户可以在界面上配置流量转发规则和集群,通过流量回放,将部分测试流量导入到灰度集群,或把线上真实流量按比例转发给灰度集群,确保没有问题后再全量发布。

线程的隔离主要体现在数据服务上,其主要功能是将数据 API 化

这意味着通过简单的配置,在界面上就可以将 MySQL、ES、Hbase 的数据通过 API 提供出去,无需开发人员编写 CRUD 或客户端代码,非常便捷。

目前,个推数据中台业务场景的大部分流量都是请求数据服务,因此我们设计了普通线程池、慢线程池和自定义配置的线程池等三类线程池

当请求时长超过慢阈值后,接口会被分配到慢线程池处理,防止慢请求拖垮整个服务。

4)服务编排

服务编排是 API 网关需要满足的常见需求之一,主要是将多个 API 进行聚合调用,大幅度减少调用延迟

过去,这部分功能是在网关上实现的,网关支持的服务编排相对基础,能够支持 API 的并发聚合调用,不过,在处理复杂的业务组合,特别是涉及到事务的编排场景时,然而表现并不理想。

所以,将这部分功能独立出来,作为一个独立的服务,后续的链路网关将直接访问服务编排模块,从而保证网关整体保持相对轻量。

图片

5)性能优化

针对 API 网关的性能,我们进行了一系列压力测试和优化,例如,用开源函数替代或重写内部大量使用的序列化、加解密等函数;大量使用 Sync.Pool 复用对象,对其内部逻辑进行纯异步处理;自主研发 gnet,替换原生 net 框架,优化网络模型等。

从线上实际运行结果来看,目前个推数据中台中的 API 平台每天调用量超过 10 亿次,单机 QPS 峰值在 2W 左右,整体性能损耗在 10%+,性能表现超过预期。

6)易用性设计

通过上述插件机制、隔离手段和性能上的极致优化,我们确保建设的 API 网关平台整体是高可用且易扩展的。而平台搭建好后,还需便于用户接入和使用以及运维。

图片

因此,为了提升易用性,我们选用了纯Web的界面设计,并内置了多个API模板。用户只需执行简单的配置,便可生成一个 API,如接口授权有效期、QPS、限额等权限设置,都能通过可视化界面进行操作;创建完成后,用户还可以直接在界面上进行调试。

同时,在对外提供API的场景下,用户可以批量导出某个服务下的API文档,非常方便。个推API平台还实现了监控和统计的功能,例如提供 API 被调用的走势、整个服务中 API 调用量和错误统计等数据,对运营和研发人员十分友好。

个推 API 网关总结

总的来看,个推 API 网关基于 golang 独立开发,实现全 Web 化配置,使得所有 API 接口标准化且可视化;除了满足网关的基本需求外,还支持插件热更新、多协议转换、数据推送、集群级别资源隔离等高级功能。

个推 API 网关不仅能够整合到系统微服务架构中,还可作为公司数据中台的流量入口,日均承担数十亿级的访问量。

企业级API网关,金蝶是如何架构的?

许多开发者可能已经编写了许多 API 接口,但未必了解或接触过API网关的功能与作用。

在大多数情况下,我们的 API 都是“裸奔”在外,在高流量,高并发等场景下,可能会轻易导致服务异常。在这种情况下,网关就应运而生,为我们的系统提供保护!

图片

API网关功能

什么是API网关?

API 网关是一种服务器,作为应用程序编程接口 (API) 的入口点,用于接收和处理来自外部应用程序的请求,并提供适当的响应。可以将其视为一个管理 API 访问的中间件,在请求和响应之间进行转换、路由、安全检查和其他处理。

如果将 API 网关比喻为地铁的进站口,那么它可以更好地理解其重要作用。就像地铁的进站口一样,API 网关是所有流量进入系统的入口点,需要进行安全检查和身份验证,以确保只有授权的用户和应用程序可以访问 API。

当系统中的流量超过 API 的承受能力时,API 网关可以执行限流操作,以确保系统的稳定性和可靠性。这可以通过减少同时连接的数量或限制请求的速率来实现。API 网关还可以执行其他任务,如日志记录、监控和分析,以便更好地了解应用程序的使用情况和性能。

因此,API 网关对于保障应用程序的平稳运行和安全稳定性至关重要。它充当着整个系统的流量大门,管理着所有 API 访问,确保只有经过授权的请求才能进入系统,并提供适当的响应。

金蝶API网关KCGW介绍

网关作为一个如此重要作用的产品,基本上各大互联网公司都会有网关这个中间件,金蝶也不例外。

在苍穹云基础平台部中,有一个名为KCGW(Kingdee-Cloud-API-Gateway)的网关组件,它是一款全动态、高性能的自研企业级 API 网关,提供了基础的反向代理、负载均衡、动态路由、服务限流、服务熔断、身份认证、可观测性等功能。

图片

KCGW功能

KCGW功能

KCGW 是基于 Golang+etcd 实现的云原生网关,采用数据面与控制面分离的架构,控制面下发规则,数据面处理规则。

图片

KCGW架构

在 KCGW 中,一切都是动态的。

配置、路由规则、插件等都是以毫秒级热更新热加载至内存的,无需重启服务就可以持续更新配置和插件。

KCGW 还引入了 RadixTree 压缩前缀树来作为高性能路由的保障,大家看看路由性能压测数据就知道它有多强悍了。

场景QPS时延(微秒)
路径全部匹配31177681.73
前缀匹配30149691.99
动态参数匹配30544713.86

路由性能压测数据1

图片

路由性能压测数据2

KCGW网关的功能

下面我们将简要说明如何使用 KCGW,作为服务的流量保护伞。

假设我们作为 API 发布者,场景是API发布者如何将API发布到网关

新建服务分组

API 分组可以理解为同一业务 API 的集合,API 开发者以 API 分组为单位,管理分组内的所有 API。

图片

新建服务分组

新建API并发布

API(Application Programming Interface,应用程序编程接口)是一些预先定义的函数,应用将自身的服务能力封装成 API,并通过 API 网关开放给用户调用。

创建完对应的 API 分组后,我们需要定义 API 的请求、后端信息,并设置对应的认证、流量控制等策略。

创建API的方式有两种

1)可以通过在界面上定义并创建API;

2)也支持通过swagger来批量导入。

本次我们通过前一种方式来创建API。

图片

图片

图片

图片

新建API流程

新建应用

一个应用(APP)可以定义 API 的调用者身份。一个 API 可以被多个应用授权,同样,多个 API 也可以被同一个应用授权。

我们可以把“应用”理解为API的消费者,创建完成后,就可以进行 API 的授权调用测试了!

图片

图片

图片

新建应用

其他功能

作为一款企业级网关,KCGW 不仅支持上述功能,还提供

  • 环境管理

  • 多租户

  • 多区域

  • API 分组管理

  • API 的授权订阅

  • Swagger 接口的导入导出

  • 配额管理

  • 限流熔断

  • 可观测性

  • 调用链等

API的申请和使用流程

最后,我们再提供一下API的申请和使用流程

  • 租户内调用

  • 跨租户调用

租户内调用流程如下:

图片

租户内调用

跨租户调用流程如下:

图片

跨租户调用

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值