得物自研API网关实践之路

一、业务背景

老网关使用 Spring Cloud Gateway (下称SCG)技术框架搭建,SCG基于webflux 编程范式,webflux是一种响应式编程理念,响应式编程对于提升系统吞吐率和性能有很大帮助; webflux 的底层构建在netty之上性能表现优秀;SCG属于spring生态的产物,具备开箱即用的特点,以较低的使用成本助力得物早期的业务快速发展;但是随着公司业务的快速发展,流量越来越大,网关迭代的业务逻辑越来越多,以及安全审计需求的不断升级和稳定性需求的提高,SCG在以下几个方面逐步暴露了一系列的问题。

网络安全

从网络安全角度来讲,对公网暴露接口无疑是一件风险极高的事情,网关是对外网络流量的重要桥梁,早期的接口暴露采用泛化路由的模式,即通过正则形式( /api/v1/app/order/** )的路由规则开放接口,单个应用服务往往只配置一个泛化路由,后续上线新接口时外部可以直接访问;这带来了极大的安全风险,很多时候业务开发的接口可能仅仅是内部调用,但是一不小心就被泛化路由开放到了公网,甚至很多时候没人讲得清楚某个服务具体有多少接口属于对外,多少对内;另一方面从监控数据来看,黑产势力也在不断对我们的接口做渗透试探。

协同效率

引入了接口注册机制,所有对外暴露接口逐一注册到网关,未注册接口不可访问,安全的问题得到了解决但同时带来了性能问题,SCG采用遍历方式匹配路由规则,接口注册模式推广后路由接口注册数量迅速提升到3W+,路由匹配性能出现严重问题;泛化路由的时代,一个服务只有一个路由配置,变动频率很低,配置工作由网关关开发人员负责,效率尚可,接口注册模式将路由工作转移到了业务开发同学的身上,这就得引入一套完整的路由审核流程,以提升协同效率;由于路由信息早期都存在配置中心,同时这么大的数据量给配置中心也带来极大的压力和稳定性风险。

性能与维护成本

业务迭代的不断增多,也使得API网关堆积了很多的业务逻辑,这些业务逻辑分散在不同的filter中,为了降低开发成本,网关只有一套主线分支,不同集群部署的代码完全相同,但是不同集群的业务属性不同,所需要的filter 逻辑是不一样的;如内网网关集群几乎没什么业务逻辑,但是App集群可能需要几十个filter的逻辑协同工作;这样的一套代码对内网网关而言,存在着大量的性能浪费;如何平衡维护成本和运行效率是个需要思考的问题。

稳定性风险

API网关作为基础服务,承载全站的流量出入,稳定性无疑是第一优先级,但其定位决定了绝不可能是一个简单的代理层,在稳定运行的同时依然需要承接大量业务需求,例如C端用户登录下线能力,App强升能力,B端场景下的鉴权能力等;很难想象较长一段时间以来,网关都保持着双周一次的发版频率;频繁的发版也带来了一些问题,实例启动初期有很多资源需要初始化,此时承接的流量处理时间较长,存在着明显的接口超时现象;早期的每次发版几乎都会导致下游服务的接口短时间内超时率大幅提高,而且往往涉及多个服务一起出现类似情况;为此甚至拉了一个网关发版公告群,提前置顶发版公告,让业务同学和NOC有一个心里预期;在发布升级期间尽可能让业务服务无感知这是个刚需。

定制能力

流量灰度是网关最常见的功能之一,对于新版本迭代,业务服务的某个节点发布新版本后希望引入少部分流量试跑观察,但很遗憾SCG原生并不支持,需要对负载均衡算法进行手动改写才可以,此外基于流量特征的定向节点路由也需要手动开发,在SCG中整个负载均衡算法属于比较核心的模块,不对外直接暴露,存在较高的改造成本。

B端业务和C端业务存在着很大的不同,例如对接口的响应时间的忍受度是不一样的,B端场景下下载一个报表用户可以接受等待10s或者1分钟,但是C端用户现在没有这个耐心。作为代理层针对以上的场景,我们需要针对不同接口定制不同的超时时间,原生的SCG显然也不支持。

诸如此类的定制需求还有很多,我们并不寄希望于开源产品能够开箱即用满足全部需求,但至少定制性拓展性足够好。上手改造成本低。

二、技术痛点

SCG主要使用了webflux技术,webflux的底层构建在reactor-netty之上,而reactor-netty构建于netty之上;SCG能够和spring cloud 的技术栈的各组件,完美适配,做到开箱即用,以较低的使用成本助力得物早期的业务快速发展;但是使用webflux也是需要付出一定成本,首先它会额外增加编码人员的心智负担,需要理解流的概念和常用的操作函数,诸如map, flatmap, defer 等等;其次异步非阻塞的编码形式,充斥着大量的回调函数,会导致顺序性业务逻辑被割裂开来,增加代码阅读理理解成本;此外经过多方面评估我们发现SCG存在以下缺点:

内存泄露问题

SCG存在较多的内存泄漏问题,排查困难,且官方迟迟未能修复,长期运行会导致服务触发OOM并宕机;以下为github上SCG官方开源仓库的待解决的内存泄漏问题,大约有16个之多。

80.png

SCG内存泄漏BUG

下图可以看到SCG在长期运行的过程中内存使用一直在增长,当增长到机器内存上限时当前节点将不可用,联系到网关单节点所承接的QPS 在几千,可想而知节点宕机带来的危害有多大;一段时间以来我们需要对SCG网关做定期重启。

078.png

SCG生产实例内存增长趋势

响应式编程范式复杂

基于webflux 中的flux 和mono ,在对request和response信息读取修改时,编码复杂度高,代码理解困难,下图是对body信息进行修改时的代码逻辑。

607.png

对requestBody 进行修改的方式

多层抽象的性能损耗

尽管相比于传统的阻塞式网关,SCG的性能已经足够优秀,但相比原生的netty仍然比较低下,SCG依赖于webflux编程范式,webflux构建于reactor-netty之上,reactor-netty 构建于netty 之上,多层抽象存在较大的性能损耗。 

106.jpeg

SCG依赖层级

一般认为程序调用栈越深性能越差;下图为只有一个filter的情况下的调用栈,可以看到存在大量的 webflux 中的 subscribe() 和onNext() 方法调用,这些方法的执行不关联任何业务逻辑,属于纯粹的框架运行层代码,粗略估算下没有引入任何逻辑的情况下SCG的调用栈深度在 90+ ,如果引入多个filter处理不同的业务逻辑,线程栈将进一步加深,当前网关的业务复杂度实际栈深度会达到120左右,也就是差不多有四分之三的非业务栈损耗,这个比例是有点夸张的。

205.png

200.png

SCG filter 调用栈深度

路由能力不完善

原生的的SCG并不支持动态路由管理,路由的配置信息通过大量的KV配置来做,平均一个路由配置需要三到四条KV配置信息来支撑,这些配置数据一般放在诸如Apollo或者ark 这样的配置中心,即使是添加了新的配置SCG并不能动态识别,需要引入动态刷新路由配置的能力。另一方面路由匹配算法通过遍历所有的路由信息逐一匹配的模式,当接口级别的路由数量急剧膨胀时,性能是个严重问题。

017.png

SCG路由匹配算法为On时间复杂度

预热时间长,冷启动RT尖刺大

SCG中LoadBalancerClient 会调用choose方法来选择合适的endpoint 作为本次RPC发起调用的真实地址,由于是懒加载,只有在有真实流量触发时才会加载创建相关资源;在触发底层的NamedContextFactory#getContext 方法时存在一个全局锁导致,woker线程在该锁上大量等待。

769.png

NamedContextFactory#getContext方法存在全局锁

209.png

SCG发布时超时报错增多

定制性差,数据流控制耦合

SCG在开发运维过程中已经出现了较多的针对源码改造的场景,如动态路由,路由匹配性能优化等;其设计理念老旧,控制流和数据流混合使用,架构不清晰,如对路由管理操作仍然耦合在filter中,即使引入spring mvc方式管理,依然绑定使用webflux编程范式,同时也无法做到控制流端口独立,存在一定安全风险。

9007.png

filter中对路由进行管理

三、方案调研

理想中的网关

综合业务需求和技术痛点,我们发现理想型的网关应该是这个样子的:

  • 支持海量接口注册,并能够在运行时支持动态添加修改路由信息,具备出色路由匹配性能
  • 编程范式尽可能简单,降低开发人员心智负担,同时最好是开发人员较为熟悉的语言
  • 性能足够好,至少要等同于目前SCG的性能,RT99线和ART较低
  • 稳定性好,无内存泄漏,能够长时间持续稳定运行,发布升级期间要尽可能下游无感
  • 拓展能力强,支持超时定制,多网络协议支持,http,Dubbo等,生态完善
  • 架构设计清晰,数据流与控制流分离,集成UI控制面

开源网关对比

基于以上需求,我们对市面上的常见网关进行了调研,以下几个开源方案对比。

6078.jpeg

结合当前团队的技术栈,我们倾向于选择Java技术栈的开源产品,唯一可选的只有zuul2 ,但是zuul2路由注册和稳定性方面也不能够满足我们的需求,也没有实现数控分离的架构设计。因此唯有走上自研之路。

四、自研架构

通常而言代理网关分为透明代理与非透明代理,其主要区别在于对于流量是否存在侵入性,这里的侵入性主要是指对请求和响应数据的修改;显然API Gateway的定位决定了必然会对流量进行数据调整,常见的调整主要有 添加或者修改head 信息,加密或者解密 query params head ,以及 requestbody 或者responseBody,可以说http请求的每一个部分数据都存在修改的可能性,这要求代理层必须要完全解析数据包信息,而非简单的做一个路由器转发功能。

传统的服务器架构,以reactor架构为主。boss线程和worker线程的明确分工,boss线程负责连接建立创建;worker线程负责已经建立的连接的读写事件监听处理,同时会将部分复杂业务的处理放到独立的线程池中,进而避免worker线程的执行时间过长影响对网络事件处理的及时性;由于网关是IO密集型服务,相对来说计算内容较少,可以不必引入这样的业务线程池;直接基于netty 原生reactor架构实现。

Reactor多线程架构

1009.jpeg

为了只求极致性能和降低多线程编码的数据竞争,单个请求从接收到转发后端,再到接收后端服务响应,以及最终的回写给client端,这一系列操作被设计为完全闭合在一个workerEventLoop线程中处理;这需要worker线程中执行的IO类型操作全部实现异步非阻塞化,确保worker线程的高速运转;这样的架构和NGINX很类似;我们称之为 thread-per-core模式。

1008.jpeg

API网关组件架构

7008.jpeg

数据流控制流分离

数据面板专注于流量代理,不处理任何admin 类请求,控制流监听独立的端口,接收管理指令。

6009.jpeg

五、核心设计

请求上下文封装

新的API网关底层仍然基于Netty,其自带的http协议解析handler可以直接使用。基于netty框架的编程范式,需要在初始化时逐一注册用到的 Handler。 

10035.jpeg

Client到Proxy链路Handler 执行顺序

HttpServerCodec 负责HTTP请求的解析;对于体积较大的Http请求,客户端可能会拆成多个小的数据包进行发送,因此在服务端需要适当的封装拼接,避免收到不完整的http请求;HttpObjectAggregator 负责整个请求的拼装组合。

拿到HTTP请求的全部信息后在业务handler 中进行处理;如果请求体积过大直接抛弃;使用ServerWebExchange 对象封装请求上下文信息,其中包含了client2Proxy的channel, 以及负责处理该channel 的eventLoop 线程等信息,考虑到整个请求的处理过程中可能在不同阶段传递一些拓展信息,引入了getAttributes 方法 用于存储需要传递的数据;此外ServerWebExchange 接口的基本遵循了SCG的设计规范,保证了在迁移业务逻辑时的最小化改动;具体到实现类,可以参考如下代码:

@Getter
  public class DefaultServerWebExchange implements ServerWebExchange {
    private final Channel client2ProxyChannel;
    private final Channel proxy2ClientChannel;
    private final EventLoop executor;
   
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值