日流量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 异步实现可能会导致内存泄漏。后来我们通过减少这个配置,效果明显。
然而,这种调整会导

最低0.47元/天 解锁文章
170万+

被折叠的 条评论
为什么被折叠?



