说在前面

在40岁老架构师 尼恩的读者交流群(50+)中,很多小伙伴拿到一线互联网企业如阿里、网易、有赞、希音、百度、滴滴的面试资格。

最近,尼恩指导一个小伙伴简历,写了一个《高并发网关项目》,此项目帮这个小伙拿到 字节/阿里/微博/汽车之家 面邀, 所以说,这是一个牛逼的项目。

为了帮助大家拿到更多面试机会,拿到更多大厂offer。尼恩给大家出一章视频介绍这个项目的架构和实操,《33章:10Wqps 高并发 Netty网关架构与实操》。然后,提供一对一的简历指导,让你简历金光闪闪、脱胎换骨。

《第33章:10Wqps 高并发 Netty网关架构与实操》 的视频介绍在此:

一个顶奢、塔尖的简历黄金项目:18Wqps单体Netty API网关的架构与实操

同时,配合《33章:10Wqps 高并发 Netty网关架构与实操》, 尼恩会梳理几个工业级、生产级网关案例,作为架构素材、设计的素材。前面梳理了:

  • 《企业级API网关,金蝶是如何架构的?》

  • 《日均数十亿访问,个推API网关如何架构?》

  • 《工业级Netty网关,京东是如何架构的?》

  • 《2亿用户,B站API网关如何架构?》

  • 《100万级连接,石墨文档WebSocket网关如何架构?》

  • 《单体120万连接,小爱网关如何架构?》

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

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

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

  • 《千万级连接,知乎如何架构长连接网关?》

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

除了以上的10个案例,这里,尼恩给大家介绍:《企业级API网关从入门到精通》。

注意:这些生产案例来自互联网,并不是尼恩的原创。

这些案例,仅仅是尼恩在《33章:10Wqps 高并发 Netty网关架构与实操》备课的过程中,在互联网查找资料的时候,收集起来的,供大家学习和交流使用。

最新《尼恩 架构笔记》《尼恩高并发三部曲》《尼恩Java面试宝典》的PDF,请到文末公号【技术自由圈】获取

文章目录

    • 说在前面
    • 什么是API网关?
    • 得物自研API网关(DAG)实践之路
    • 一、业务背景
      • SCG的网络安全问题
      • SCG的协同效率问题
      • SCG的性能与维护成本问题
      • SCG的稳定性风险
      • SCG的定制能力问题
    • 二、SCG的技术痛点
      • 内存泄露问题
      • 响应式编程范式复杂
      • 多层抽象的性能损耗
      • 路由能力不完善
      • 预热时间长,冷启动RT尖刺大
      • 定制性差,数据流控制耦合
    • 三、方案调研
      • 理想中的网关
      • 开源网关对比
    • 四、自研架构
      • Reactor多线程架构
      • API网关组件架构
      • 数据流控制流分离
    • 五、核心设计
      • 请求上下文封装
      • FilterChain设计
      • 路由管理与匹配
      • 单线程闭环
      • 连接管理优化
      • AsyncClient设计
      • 请求超时管理
      • 堆外内存管理优化
      • 集群限流改造优化
    • 六、压测性能
      • DAG高压表现
      • SCG高压表现
      • DAG低压表现
      • 数据对比
      • DAG和SCG的对比结论
    • 七、投产收益
      • 安全性提升
      • 稳定性增强
      • 降本增效
    • 八、思考总结
      • 架构演进
      • 稳定性把控
    • 说在最后:有问题可以找老架构取经
    • 尼恩技术圣经系列PDF

什么是API网关?

API 网关是一种服务器,作为应用程序编程接口 (API) 的入口点,它接收来自外部应用程序的请求,进行处理,并给出恰当的回应。你可以将它看作一个中间件,管理API的访问,并在请求与回应之间进行转换、路由、安全检查等操作。

得物自研API网关(DAG)实践之路

作者:簌语,原文来自 得物技术公众号

一、业务背景

我们之前的网关是基于Spring Cloud Gateway(简称SCG)技术框架搭建的。SCG基于webflux编程范式,

webflux是一种响应式编程理念,对于提高系统的吞吐量和性能有很大的帮助;

webflux 的底层构建在netty之上, 性能表现优秀;

SCG是spring生态的产物,具有开箱即用的特点,较低的使用成本助力得物早期的业务快速发展;

然而,随着公司业务的快速发展,流量越来越大,网关的业务逻辑越来越多,以及安全审计需求的不断升级和稳定性需求的提高,SCG在以下几个方面逐步暴露了一系列的问题。

SCG的网络安全问题

从网络安全角度来讲,对公网暴露接口无疑是一件风险极高的事情,

网关是对外网络流量的重要桥梁,早期的接口暴露采用泛化路由的模式,即通过正则形式( /api/v1/app/order/** )的路由规则开放接口,单个应用服务往往只配置一个泛化路由,后续上线新接口时外部可以直接访问;

这种做法带来了极大的安全风险,很多时候业务开发的接口可能仅仅是内部调用,但是一不小心就被泛化路由开放到了公网,甚至很多时候没人说得清楚某个服务具体有多少接口属于对外,多少对内;

另一方面从监控数据来看,黑产势力也在不断对我们的接口做渗透试探。

网络黑产,也称为网络黑色产业链,主要是指通过互联网技术进行网络攻击、窃取信息、勒索诈骗、盗窃财产、推广色情和赌博等非法网络活动,以及为这些活动提供工具、资源、平台等支持和非法获利变现的渠道和环节。

“黑产势力”产业具备两个特点,一是,他们的骗局设计非常周密,模仿了互联网平台的客户服务场景,从宣传引导到电话和微信等客服交流,精心设计陷阱让首次接触的消费者难以区分真伪。二是,他们利用移动支付手段,利用了互联网信息管理不完善的漏洞。犯罪分子利用他人信息申请虚拟运营商电话卡,隐藏自己的行踪,使公安机关难以找到犯罪分子的真实信息。此外,二维码等移动支付方式非常便捷,消费者支付后的资金流向往往难以追踪。

在中央网信办、工业和信息化部、公安部、人民银行的指导下,国家互联网应急中心支持相关部门开展网络黑产治理工作。从2019年6月至10月,他们累计治理了561万个活跃手机黑卡,3万余个赌博网站,60个非法网络金融平台,1000余个黑产交流传播群组,11个违规广告联盟,124个浏览器主页劫持恶意软件,60个大型赌博洗钱团伙线索,以及28个DDoS攻击控制端。

SCG的协同效率问题

通过引入接口注册机制,所有对外开放的接口都必须注册到网关,未经注册的接口无法被访问,从而确保了安全性,

然而,这种方式也带来了性能问题。SCG采用遍历方式匹配路由规则,接口注册模式推广后路由接口注册数量迅速提升到3W+,路由匹配性能出现严重问题;

在泛化路由时代,每个服务仅需一个路由配置,且变更频率较低,主要由网关开发人员负责配置,这样的效率尚可,

但接口注册模式将路由工作移交给了业务开发人员,这就需要建立一个完整的路由审核流程,以提高协同效率;

由于早期所有路由信息都存储在配置中心,这不仅给配置中心带来了巨大压力,也增加了稳定性风险。

SCG的性能与维护成本问题

随着业务迭代的不断增加,API网关积累了大量业务逻辑,

这些业务逻辑分散在不同的filter中,为了降低开发成本,网关采用了一套主线分支,不同集群的代码完全相同,

然而,不同集群的业务属性不同,所需的filter逻辑也不尽相同;

如内网网关集群几乎没什么业务逻辑,但是App集群可能需要几十个filter的逻辑协同工作;

这样的一套代码对内网网关而言,存在着大量的性能浪费;因此,如何平衡维护成本和运行效率是一个需要深入思考的问题。

SCG的稳定性风险

API网关作为基础服务,承载全站的流量出入,稳定性无疑是第一优先级,

然而,其定位决定了绝不可能是一个简单的代理层,在稳定运行的同时依然需要承接大量业务需求,

例如,C端用户登录下线能力,App强升能力,B端场景下的鉴权能力等;

很难想象较长一段时间以来,网关都保持着双周一次的发版频率;

频繁的发版也带来了一些问题,实例启动初期有很多资源需要初始化,此时承接的流量处理时间较长,存在着明显的接口超时现象;早期的每次发版几乎都会导致下游服务的接口短时间内超时率大幅提高,而且往往涉及多个服务一起出现类似情况;

为此甚至拉了一个网关发版公告群,提前置顶发版公告,让业务同学和NOC有一个心里预期;

在发布升级期间尽可能让业务服务无感知这是个刚需。

SCG的定制能力问题

流量灰度是网关最常见的功能之一,

对于新版本迭代,业务服务的某个节点发布新版本后希望引入少部分流量试跑观察,但很遗憾SCG原生并不支持,

需要对负载均衡算法进行手动改写才可以,此外基于流量特征的定向节点路由也需要手动开发,

由于负载均衡算法是SCG的核心模块,不对外暴露,因此改造成本较高。。

此外,B端和C端业务对接口响应时间的需求不同,SCG也无法满足这种定制化需求,

B端场景下下载一个报表用户可以接受等待10s或者1分钟,但是C端用户现在没有这个耐心。

作为代理层针对以上的场景,我们需要针对不同接口定制不同的超时时间,原生的SCG显然也不支持。

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

SCG在协同效率、性能、维护成本、稳定性风险和定制能力方面存在一系列问题。这些问题源于接口注册机制带来的性能影响、业务逻辑分散导致的维护成本问题、稳定性风险的挑战、缺乏灵活的定制能力,以及对不同业务需求的不适应。解决这些问题需要重新审视和优化接口注册机制、路由策略、负载均衡算法,以及提高网关的定制性和扩展性,以确保API网关能够高效、稳定地支撑业务发展。

二、SCG的技术痛点

SCG主要基于webflux技术构建,其底层依赖于reactor-netty,而reactor-netty又是基于netty的;

这种架构使得SCG能够与spring cloud的技术组件无缝集成,开箱即用,为得物早期的业务快速发展提供了便利;

然而,使用webflux技术也带来了一些成本,

首先它会额外增加编码人员的心智负担,他们需要理解流的概念以及常用的操作函数,诸如map, flatmap, defer 等等;

其次,异步非阻塞的编程方式导致代码中充满了回调函数,这可能会割裂业务逻辑的顺序性,增加代码的阅读和理解难度;

进一步评估发现,SCG存在一些缺点:

内存泄露问题

SCG存在多个内存泄漏问题,这些问题的排查困难,且官方迟迟未能修复。长期运行可能导致服务触发OOM并宕机;

以下为github上SCG官方开源仓库的待解决的内存泄漏问题,大约有16个之多。

SCG内存泄漏BUG

下图可以看到SCG在长期运行的过程中内存使用一直在增长,当增长到机器内存上限时,当前节点将不可用,

联系到网关单节点所承接的QPS 在几千,可想而知节点宕机带来的危害有多大;

一段时间以来我们需要对SCG网关做定期重启。

SCG生产实例内存增长趋势

响应式编程范式复杂

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

对requestBody 进行修改的方式

多层抽象的性能损耗

尽管相比于传统的阻塞式网关,SCG的性能已经足够优秀,

但与原生的netty相比,SCG的性能仍然较低。这是因为SCG依赖于webflux编程范式,而webflux又是构建在reactor-netty之上的,这导致多层抽象存在较大的性能损耗。

SCG依赖层级

一般认为, 程序调用栈越深性能越差;

下图为只有一个filter的情况下的调用栈,可以看到存在大量的 webflux 中的 subscribe() 和onNext() 方法调用,

这些方法的执行不关联任何业务逻辑,属于纯粹的框架运行层代码,

粗略估算下没有引入任何逻辑的情况下,SCG的调用栈深度在 90+ ,

如果引入多个filter处理不同的业务逻辑,线程栈将进一步加深,

当前网关的业务复杂度实际栈深度会达到120左右,也就是差不多有四分之三的非业务栈损耗,这个比例是有点夸张的。

SCG filter 调用栈深度

路由能力不完善

原生的的SCG并不支持动态路由管理,经过改造后,这些配置数据一般放在诸如Apollo或者ark 这样的配置中心,SCG路由的配置信息通过大量的KV配置来做,平均一个路由配置需要三到四条KV配置信息来支撑,

即使是添加了新的配置SCG,并不能动态识别,需要引入动态刷新路由配置的能力。

另一方面路由匹配算法通过遍历所有的路由信息逐一匹配的模式,当接口级别的路由数量急剧膨胀时,性能是个严重问题。

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

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

SCG中LoadBalancerClient 会调用choose方法来选择合适的endpoint 作为本次RPC发起调用的真实地址,由于是懒加载,只有在有真实流量触发时才会加载创建相关资源;

在触发底层的NamedContextFactory#getContext 方法时存在一个全局锁导致,woker线程在该锁上大量等待。

NamedContextFactory#getContext方法存在全局锁

SCG发布时超时报错增多

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

在开发运维过程中,SCG已经出现了较多的针对源码改造场景,如动态路由、路由匹配性能优化等;

其设计理念老旧,控制流和数据流混合使用,架构不清晰。例如,路由管理操作仍然耦合在filter中,

即使引入了spring mvc方式管理,依然绑定使用webflux编程范式,同时也无法做到控制流端口独立,存在一定安全风险。

filter中对路由进行管理

SCG在技术上面临诸多挑战,包括内存泄漏、响应式编程的复杂性、多层抽象导致的性能损耗、不完善的路由能力、长的预热时间和冷启动延迟,以及差的可定制性和数据流控制耦合问题。这些问题影响了SCG的稳定性和性能,亟待解决。

三、方案调研

理想中的网关

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

  • 支持海量接口注册,并能够在运行时支持动态添加修改路由信息,具备出色路由匹配性能

  • 编程范式尽可能简单,降低开发人员心智负担,同时最好是开发人员较为熟悉的语言

  • 性能足够好,至少要等同于目前SCG的性能,RT99线和ART较低

  • 稳定性好,无内存泄漏,能够长时间持续稳定运行,发布升级期间要尽可能下游无感

  • 拓展能力强,自定义超时机制、多种网络协议如HTTP、Dubbo等,并拥有成熟的生态支持

  • 架构设计清晰,数据流与控制流分离,集成UI控制面

开源网关对比

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

结合当前团队的技术栈,我们倾向于选择Java技术栈的开源产品,

尽管zuul2是我们唯一的选择,但它在路由注册和稳定性方面仍无法满足我们的要求,且没有实现数据流与控制流的分离架构设计。

因此唯有走上自研之路。

理想的网关应当是业务和技术双赢的解决方案。它不仅需要具备强大的功能和优秀的性能,还要在稳定性和扩展性上表现出色。在现有开源方案无法完全满足需求的情况下,自研成为了我们的选择。这不仅能够让我们根据自身业务和技术需求进行定制化开发,还能够更好地掌握产品的未来发展,确保长期的适应性和领先性。自研网关的开发将是团队技术实力和创新精神的体现,也是我们对未来业务支撑能力和技术领先地位的投资。

四、自研架构

在代理网关的开发中,我们通常区分透明代理与非透明代理,两者的核心差异体现在对流量的干预程度。具体来说,透明代理不对流量内容做任何修改,而非透明代理则会对请求和响应数据进行必要的调整。

鉴于API Gateway的本质功能,它必须对流转的数据进行适度的处理,

常见的调整主要有

  • 请求修改: 添加或者修改head 信息,加密或者解密 query params head ,

  • 以及 requestbody 或者responseBody 修改,

  • 可以说http请求的每一个部分数据都存在修改的可能性,

这就要求代理层必须深入解读数据包的细节,而不仅仅是执行简单的路由转发操作。

Reactor多线程架构

为了达到更高的性能,我们需要减少多线程环境下代码编写时可能出现的竞争问题,

在处理请求的过程中,从接收请求开始,经过转发到后端服务,接收后端的响应,再到最终发送给客户端,这一连串的操作被设计成在单个的workerEventLoop线程中完整执行;

这需要worker线程中执行的IO类型操作全部实现异步非阻塞化,确保worker线程的高速运转;

这样的架构和NGINX很类似;我们称之为 request-per-thread模式 (单请求闭环)。

API网关组件架构

数据流控制流分离

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

我们通过深入研究代理网关的架构,对透明与非透明代理的差异有了清晰的认识,并明确了API Gateway在数据处理上的需求。通过采用Reactor多线程模型,我们旨在减少线程竞争,提高处理性能。同时,我们将数据流与控制流分离,确保系统的高效稳定运行。这样的设计不仅满足了我们对高性能、高可靠性的需求,而且也为未来的扩展和维护奠定了坚实的基础。通过这些技术策略,我们坚信能够打造出既灵活又强大的API网关,以适应不断变化的业务需求和技术挑战。

五、核心设计

请求上下文封装

在重构API网关的底层架构时,我们保留了基于Netty的坚实基础,并利用其内置的HTTP协议解析handler,省去了繁琐的注册过程。
遵循Netty的编程范式,在初始化阶段,我们无需手动注册每个Handler,因为Netty的 pipeline 机制会自动处理这一过程。

Client到Proxy链路Handler 执行顺序

HttpServerCodec 负责HTTP请求的解析;

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

  • HttpObjectAggregator 负责整个请求的拼装组合。

  • 拿到HTTP请求的全部信息后在业务handler 中进行处理;

  • 如果请求体积过大直接抛弃;

使用ServerWebExchange 对象封装请求上下文信息,其中包含了

  • client2Proxy的channel,

  • 以及负责处理该channel 的eventLoop 线程等信息,

  • 引入了getAttributes 方法 用于存储需要传递的数据;考虑到整个请求的处理过程中可能在不同阶段传递一些拓展信息,

为了最小化从SCG迁移到自研网关的改动,ServerWebExchange 接口在设计上尽量保持了与SCG的一致性,具体实现类也是在此基础上进行调整的。可以参考如下代码:

@Getterpublic class DefaultServerWebExchange implements ServerWebExchange {private final Channel client2ProxyChannel;private final Channel proxy2ClientChannel;private final EventLoop executor;private ServerHttpRequest request;private ServerHttpResponse response;private final Map<String, Object> attributes; }

DefaultServerWebExchange

Client2ProxyHttpHandler作为核心的入口handler ,负责将接收到的FullHttpRequest 进行封装和构建ServerWebExchange 对象,其核心逻辑如下。

@Overrideprotected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest fullHttpRequest) {try {Channel client2ProxyChannel = ctx.channel();DefaultServerHttpRequest serverHttpRequest = new DefaultServerHttpRequest(fullHttpRequest, client2ProxyChannel);ServerWebExchange serverWebExchange = new DefaultServerWebExchange(client2ProxyChannel,(EventLoop) ctx.executor(), serverHttpRequest, null);// request filter chainthis.requestFilterChain.filter(serverWebExchange);}catch (Throwable t){log.error("Exception caused before filters!\n {}",ExceptionUtils.getStackTrace(t));ByteBufHelper.safeRelease(fullHttpRequest);throw t;}}

可以看到: 数据读取封装的逻辑较为简单,并没有植入常见的业务逻辑,封装完对象后随即调用 Request filter chain。

这种设计确保了请求处理的灵活性和高效性,同时也为未来的扩展奠定了基础。通过这种封装方式,我们能够确保在迁移现有业务逻辑时,所需的工作量降至最低,大大提高了开发效率和系统的稳定性。这样的设计不仅简化了开发流程,也为API网关的性能优化和功能增强提供了便利,使得整个系统能够更好地适应不断变化的技术环境和业务需求。

FilterChain设计

filter的执行需要定义先后顺序,这里参考了SCG的方案,每个filter返回一个order值。

不同的地方在于DAG的设计不允许 order值重复,因为在order重复的情况下,很难界定到底哪个Filter 先执行,存在模糊地带,这不是我们期望看到的;

DAG中的Filter 执行顺序为order值从小到大,且不允许order值重复。

为了易于理解,这里将Filter拆分为两类:requestFilter和responseFilter,分别对应于请求处理阶段和响应处理阶段;responseFilter也遵循相同的顺序执行规则和唯一性原则。

public interface GatewayFilter extends Ordered {void filter(ServerWebExchange exchange, GatewayFilterChain chain);}public interface ResponseFilter extends GatewayFilter { }public interface RequestFilter extends GatewayFilter { }

filter接口设计

在API网关的设计中,过滤链的构建是一个关键环节。通过引入顺序值来确定过滤器的执行顺序,我们确保了请求和响应的处理能够按照预定的逻辑顺序进行。DAG的设计避免了执行顺序的模糊性,保证了过滤链的清晰和可预测性。requestFilter和responseFilter的划分,不仅使得过滤链的逻辑更加清晰,而且也便于管理和维护。这种设计思路体现了我们对系统性能和稳定性的追求,同时也为API网关未来的扩展留下了空间,使其能够更好地适应不同的业务场景和技术需求。

路由管理与匹配

以SCG网关注册的路由数量为基准,网关节点的需要支撑的路由规则数量是上万级别的,

按照得物目前的业务量,上限不超过5W,为了确保高效的匹配性能,将路由规则存储在分布式缓存中并不可行,它们需要被保留在节点的内存中。

类似于在nginx中配置上万条location规则,这种手动维护的方式不仅难度巨大,即便在配置中心管理起来也是一项繁琐的任务,因此,引入一个独立的路由管理模块变得至关重要。

在匹配的效率上也需要进一步优化,

SCG的路由匹配策略为轮询,时间效率为On,

在路由规则膨胀到万级别后,SCG性能急剧拉胯,

结合得物的接口规范,新网关采用Hash匹配模式,将匹配效率提升到O1;

hash的key为接口的path,需要强调的是在同一个网关集群中,path是唯一的,

这里的path并不等价于业务服务的接口path, 绝大多数时候存在一些剪裁,例如在业务服务的编写的/order/detail接口,在网关实际注册的接口可能为/api/v1/app/order/detail;

由于使用了path作为key进行hash匹配。

常见的基于path传参数模式的接口均不支持;

因此,网关保留了类似nginx的前缀匹配支持,但这一功能不对外部开放。这种设计决策旨在确保网关能够高效地处理大量路由规则,同时简化维护工作,并保持良好的性能。通过引入独立的路由管理模块和采用Hash匹配模式,新的网关不仅提高了匹配效率,也增强了系统的可维护性和稳定性,为得物未来的业务扩展打下了坚实的基础。

public class Route implements Ordered {private final String id;private final int skipCount;private final URI uri; }

单线程闭环

为了更好地利用CPU,以及减少不必要的数据竞争,将单个请求的处理全部闭合在一个线程当中

这意味着这个请求的业务逻辑处理,RPC调用,权限验证,限流token获取都将始终由某个固定线程处理。

netty中 网络连接被抽象为channel,channel 与eventloop线程的对应关系为 N对1,

一个channel 仅能被一个eventloop 线程所处理,这在处理用户请求时没有问题,

但是在接收请求完毕向下游转发请求时,我们碰到了一些挑战:

因为下游连接通常由连接池管理,而连接池的管理则由另一组eventLoop线程负责。为了维持闭环处理,我们需要将连接池的线程设置为与当前请求处理线程相同,这意味着只能有一个线程来处理这个请求;

因此,默认情况下启动的N个线程(N与机器核心数相同)将分别负责管理一个连接池;

thread-per-core 模式的性能已经在nginx开源组件上得到验证。

这种模型的核心优势在于它可以减少线程间切换带来的开销,并避免了复杂的数据竞争问题。通过将请求的处理完全局限于一个线程,我们能够确保请求的处理流程更加直接和高效。

连接管理优化

为了满足单线程闭环,需要将连接池的管理线程设置为当前的 eventloop 线程,最终我们通过threadlocal 进行线程与连接池的绑定;

在大多数场景下,netty自带的FixedChannelPool连接池能够满足我们的需求,并且它也适用于多线程环境;

由于新网关使用thread-per-core模式并将请求处理的全生命周期闭合在单个线程中,所有为了线程安全的额外操作不再必要且存在性能浪费;为此需要对原生连接池做一些优化, 连接的获取和释放简化为对链表结构的简单getFirst , addLast。

对于RPC 而言,无论是HTTP,还是Dubbo,Redis等最终底层都需要用到TCP连接,将构建在TCP连接上的数据解析协议与连接剥离后,纯粹的连接管理是可以复用的,

对于连接池来说,它不需要了解具体连接的用途,只需要保持到特定endpoint的连接稳定即可。因此,即使是RPC服务的连接,也可以放入连接池中进行托管;

最终的连接池设计架构图。

通过这些优化,API网关能够更加高效地管理连接,同时减少了资源消耗,为得物平台提供了更加稳定和高效的网络服务。这些改进不仅提高了性能,也为未来的可扩展性和维护性打下了基础。

AsyncClient设计

鉴于七层流量几乎全部基于Http请求,RPC请求中Http协议也占据了绝大多数,同时还会涉及到少量的dubbo, Redis 等协议通信的场景。

因此,有必要构建一个异步调用框架来提供支持。这个框架需要具备超时处理、回调执行、错误报告等功能,并且必须与协议无关,还需要支持链式调用以便于使用。

发起一次RPC调用通常可以分为以下几步:

  1. 获取目标地址和使用的协议, 目标服务为集群部署时,需要使用loadbalance模块

  2. 封装发送的请求,这样的请求在应用层可以具体化为某个Request类,网络层序列化为二进制数据流

  3. 出于性能考虑选择非阻塞式发送,发送动作完成后开始计算超时

  4. 接收数据响应,由于采用非阻塞模式,这里的发送线程并不会以block的方式等待数据

  5. 在超时时间内完成数据处理,或者触发超时导致连接取消或者关闭

AsyncClient 模块内容并不复杂,AsyncClient为抽象类不区分使用的网络协议;

ConnectionPool 作为连接的管理者被client所引用,获取连接的key 使用 protocol+ip+port 再适合不过;

通常在某个具体的连接初始化阶段就已经确定了该channel 所使用的协议,因此初始化时会直接绑定协议Handler;当协议为HTTP请求时,HttpClientCodec 为HTTP请求的编解码handler;也可以是构建在TCP协议上的 Dubbo, Mysql ,Redis 等协议的handler。

首先对于一个请求的不同执行阶段需要引入状态定位,这里引入了 STATE 枚举:

enum STATE{INIT,SENDING,SEND,SEND_SUCCESS,FAILED,TIMEOUT,RECEIVED}

其次在执行过程中设计了 AsyncContext作为信息存储的载体,内部包含request和response信息,作用类似于上文提到的ServerWebExchange;channel资源从连接池中获取,使用完成后需要自动放回。

public class AsyncContext<Req, Resp> implements Cloneable{STATE state = STATE.INIT;final Channel usedChannel;final ChannelPool usedChannelPool;final EventExecutor executor;final AsyncClient<Req, Resp> agent;Req request;Resp response;ResponseCallback<Resp> responseCallback;ExceptionCallback exceptionCallback;int timeout;long deadline;long sendTimestamp;Promise<Resp> responsePromise;}

AsyncContext

AsyncClient 封装了基本的网络通信能力,不拘泥于某个固定的协议,可以是Redis, http,Dubbo 等。

当将数据写出去之后,该channel的非阻塞调用立即结束,在没有收到响应之前无法对AsyncContext 封装的数据做进一步处理,如何在收到数据时将接收到的响应和之前的请求管理起来这是需要面对的问题,channel 对象 的attr 方法可以用于临时绑定一些信息,以便于上下文切换时传递数据,可以在发送数据时将AsyncContext对象绑定到该channel的某个固定key上。

当channel收到响应信息时,在相关的 AsyncClientHandler 里面取出AsyncContext。

public abstract class AsyncClient<Req, Resp> implements Client {private static final int defaultTimeout = 5000;private final boolean doTryAgain = false;private final ChannelPoolManager channelPoolManager = ChannelPoolManager.getChannelPoolManager();protected static AttributeKey<AsyncRequest> ASYNC_REQUEST_KEY = AttributeKey.valueOf("ASYNC_REQUEST");public abstract ApplicationProtocol getProtocol();public AsyncContext<Req, Resp> newRequest(EventExecutor executor, String endpoint, Req request) {final ChannelPoolKey poolKey = genPoolKey(endpoint);ChannelPool usedChannelPool = channelPoolManager.acquireChannelPool(executor, poolKey);return new AsyncContext<>(this,executor,usedChannelPool,request, defaultTimeout, executor.newPromise());}public void submitSend(AsyncContext<Req, Resp> asyncContext){asyncContext.state = AsyncContext.STATE.SENDING;asyncContext.deadline = asyncContext.timeout + System.currentTimeMillis(); ReferenceCountUtil.retain(asyncContext.request);Future<Resp> responseFuture = trySend(asyncContext);responseFuture.addListener((GenericFutureListener<Future<Resp>>) future -> {if(future.isSuccess()){ReferenceCountUtil.release(asyncContext.request);Resp response = future.getNow();asyncContext.responseCallback.callback(response);}});}/** * 尝试从连接池中获取连接并发送请求,若失败返回错误 */private Promise<Resp> trySend(AsyncContext<Req, Resp> asyncContext){Future<Channel> acquireFuture = asyncContext.usedChannelPool.acquire();asyncContext.responsePromise = asyncContext.executor.newPromise();acquireFuture.addListener(new GenericFutureListener<Future<Channel>>() {@Overridepublic void operationComplete(Future<Channel> channelFuture) throws Exception {sendNow(asyncContext,channelFuture);}});return asyncContext.responsePromise;}private void sendNow(AsyncContext<Req, Resp> asyncContext, Future<Channel> acquireFuture){boolean released = false;try {if (acquireFuture.isSuccess()) {NioSocketChannel channel = (NioSocketChannel) acquireFuture.getNow();released = true;assert channel.attr(ASYNC_REQUEST_KEY).get() == null;asyncContext.usedChannel = channel;asyncContext.state = AsyncContext.STATE.SEND;asyncContext.sendTimestamp = System.currentTimeMillis();channel.attr(ASYNC_REQUEST_KEY).set(asyncContext);ChannelFuture writeFuture = channel.writeAndFlush(asyncContext.request);channel.eventLoop().schedule(()-> doTimeout(asyncContext), asyncContext.timeout, TimeUnit.MILLISECONDS);} else {asyncContext.responsePromise.setFailure(acquireFuture.cause());}} catch (Exception e){throw new Error("Unexpected Exception.............!");}finally {if(!released) {ReferenceCountUtil.safeRelease(asyncContext.request);}}}}

AsyncClient核心源码

public class AsyncClientHandler extends SimpleChannelInboundHandler {@Overrideprotected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {AsyncContext asyncContext = ctx.attr(AsyncClient.ASYNC_REQUEST_KEY).get();try {asyncContext.state = AsyncContext.STATE.RECEIVED;asyncContext.releaseChannel();asyncContext.responsePromise.setSuccess(msg);}catch (Throwable t){log.error("Exception raised when set Success callback. Exception \n: {}", ExceptionUtils.getFullStackTrace(t));ByteBufHelper.safeRelease(msg);throw t;}}}

AsyncClientHandler

通过上面几个类的封装得到了一个易用使用的 AsyncClient,下面的代码为调用权限系统的案例:

final FullHttpRequest httpRequest = HttpRequestUtil.getDefaultFullHttpRequest(newAuthReq, serviceInstance, "/auth/newCheckSls");asyncClient.newRequest(exchange.getExecutor(), endPoint,httpRequest).timeout(timeout).onComplete(response -> {String checkResultJson = response.content().toString(CharsetUtil.UTF_8);response.release();NewAuthResult result = Jsons.parse(checkResultJson,NewAuthResult.class);TokenResult tokenResult = this.buildTokenResult(result);String body = exchange.getAttribute(DAGApplicationConfig.REQUEST_BODY);if (tokenResult.getUserInfoResp() != null) {UserInfoResp userInfo = tokenResult.getUserInfoResp();headers.set("userid", userInfo.getUserid() == null " />"" : String.valueOf(userInfo.getUserid()));headers.set("username", StringUtils.isEmpty(userInfo.getUsername()) ? "" : userInfo.getUsername());headers.set("name", StringUtils.isEmpty(userInfo.getName()) ? "" : userInfo.getName());chain.filter(exchange);} else {log.error("{},heads: {},response: {}", path, headers, tokenResult);int code = tokenResult.getCode() != null ? tokenResult.getCode().intValue() : ResultCode.UNAUTHO.code;ResponseDecorator.failResponse(exchange, code, tokenResult.getMsg());}}).onError(throwable -> {log.error("Request service {},occur an exception {}",endPoint, throwable);ResponseDecorator.failResponseWithStatus(exchange,HttpResponseStatus.INTERNAL_SERVER_ERROR,"AuthFilter 验证失败");}).sendRequest();

asyncClient的使用

通过AsyncContext的管理,异步客户端能够在接收到响应时有效地处理请求和响应之间的关系。这种设计不仅提高了网络通信的效率,也增强了系统的可扩展性和灵活性,为得物平台的网络通信提供了强大的支持。

请求超时管理

一个请求的处理时间不能无限期拉长, 超过某个阈值的情况下App的页面会被取消 ,长时间的加载卡顿不如快速报错带来的体验良好;

因此,网关必须对接口调用实施超时管理,特别是在向后端服务发起调用的过程中。通常,我们会设定一个默认的超时阈值,比如3秒。若超过此阈值,网关将向客户端返回超时失败的信息。考虑到网关下游服务多样性,包括对响应时间敏感的C端业务、逻辑复杂的B端服务接口,以及涉及大量计算的监控接口,不同接口对超时时间的需求各不相同。因此,应为每个接口单独设置超时时间,而非采用单一的配置值。

asyncClient.newRequest(exchange.getExecutor(), endPoint,httpRequest).timeout(timeout).onComplete(response -> {String checkResultJson = response.content().toString(CharsetUtil.UTF_8);//..........}).onError(throwable -> {log.error("Request service {},occur an exception {}",endPoint, throwable);ResponseDecorator.failResponseWithStatus(exchange,HttpResponseStatus.INTERNAL_SERVER_ERROR,"AuthFilter 验证失败");}).sendRequest();

asyncClient 的链式调用设计了 timeout方法,用于传递超时时间,我们可以通过一个全局Map来配置这样的信息。

Map 其key为全路径的path 信息,V为设定的超时时间,单位为ms, 至于Map的信息在实际配置过程中如何承载,使用ARK配置或者Mysql 都很容易实现。

处于并发安全和性能的极致追求,超时事件的设定和调度最好能够在与当前channel绑定的线程中执行,庆幸的是 EventLoop线程自带schedule 方法。具体来看上文的 AsyncClient 的56行。

schedule 方法内部以堆结构的方式实现了对超时时间进行管理,整体性能尚可。

通过使用异步客户端的链式调用和全局映射表,可以方便地管理接口超时时间。同时,为了保证并发安全和性能,超时事件的处理应当在EventLoop线程中进行。这种设计不仅提高了系统的响应速度,也增强了用户体验,确保了得物平台在处理网络请求时的稳定性和效率。

堆外内存管理优化

常见的堆外内存手动管理方式,是引用计数,堆外内存在回收的时候条件只有一个,就是RC值为0 ,释放的时候对 RC (引用计数) 的值做调整,

然而,随着业务复杂性的增加,在处理业务的某个环节后,已经不记得当前的引用计数值是多少了,甚至是前面的RC增加了,后面的RC忘记减少了;

我们引入一个safeRelase的思路 , 在数据回写给客户端后,把这个请求整个生命周期所申请的堆外内存全部释放掉,也就是在最终的release的时候,如果当前的RC>0 就不停的 release ,直至为0;

public static void safeRelease(Object msg){if(msg instanceof ReferenceCounted){ReferenceCounted ref = (ReferenceCounted) msg;int refCount = ref.refCnt();for(int i=0; i<refCount; i++){ref.release();}}}

因此只要把这样的逻辑放在netty的最后一个Handler中即可保证内存得到有效释放。

传统的引用计数法在业务复杂时可能会出现问题,因此我们提出了在数据回写后一次性释放整个生命周期堆外内存的策略。通过在Netty的最终处理器中实现这一逻辑,可以确保堆外内存被有效管理,避免了内存泄漏的风险,提高了系统的稳定性和可靠性。

集群限流改造优化

首先来看DAG 启动后sentinel相关线程,类似的问题,线程数量非常多,需要针对性优化。

Sentinel 线程数

sentinel线程分析优化:

最终优化后的线程数量为4个

sentinel原生限流源码分析如下,进一步分析SphU#entry方法发现其底调用 FlowRuleCheck#passClusterCheck;

在passClusterCheck方法中发现底层网络IO调用为阻塞式, 由于该方法的执行线程为workerEventLoop,因此需要使用上文提到的AsyncClient 进行优化。

private void doSentinelFlowControl(ServerWebExchange exchange, GatewayFilterChain chain, String resource){Entry urlEntry = null;try {if (!StringUtil.isEmpty(resource)) {//1. 检测是否限流urlEntry = SphU.entry(resource, ResourceTypeConstants.COMMON_WEB, EntryType.IN);} //2. 通过,走业务逻辑chain.filter(exchange);} catch (BlockException e) {//3. 拦截,直接返回503ResponseDecorator.failResponseWithStatus(exchange, HttpResponseStatus.SERVICE_UNAVAILABLE, ResultCode.SERVICE_UNAVAILABLE.message);} catch (RuntimeException e2) {Tracer.traceEntry(e2, urlEntry);log.error(ExceptionUtils.getFullStackTrace(e2));ResponseDecorator.failResponseWithStatus(exchange, HttpResponseStatus.INTERNAL_SERVER_ERROR,HttpResponseStatus.INTERNAL_SERVER_ERROR.reasonPhrase());} finally {if (urlEntry != null) {urlEntry.exit();}ContextUtil.exit();}}

SentinelGatewayFilter(sentinel 适配SCG的逻辑)

public class RedisTokenService implements InitializingBean {private final RedisAsyncClient client = new RedisAsyncClient();private final RedisChannelPoolKey connectionKey;public RedisTokenService(String host, int port, String password, int database, boolean ssl){connectionKey = new RedisChannelPoolKey(String host, int port, String password, int database, boolean ssl);}//请求tokenpublic Future<TokenResult> asyncRequestToken(ClusterFlowRule rule){....sendMessage(redisReqMsg,this.connectionKey)}private Future<TokenResult> sendMessage(RedisMessage requestMessage, EventExecutor executor, RedisChannelPoolKey poolKey){AsyncRequest<RedisMessage,RedisMessage> request = client.newRequest(executor, poolKey,requestMessage);DefaultPromise<TokenResult> tokenResultFuture = new DefaultPromise<>(request.getExecutor());request.timeout(timeout).onComplete(response -> {...tokenResultFuture.setSuccess(response);}).onError(throwable -> {...tokenResultFuture.setFailure(throwable);}).sendRequest();return tokenResultFuture;}}

RedisTokenService

最终的限流Filter代码如下:

public class SentinelGatewayFilter implements RequestFilter {@ResourceRedisTokenService tokenService;@Overridepublic void filter(ServerWebExchange exchange, GatewayFilterChain chain) {//当前为 netty NioEventloop 线程ServerHttpRequest request = exchange.getRequest();String resource = request.getPath() != null " />.getPath() : "";//判断是否有集群限流规则ClusterFlowRule rule = ClusterFlowManager.getClusterFlowRule(resource);if (rule != null) { //异步非阻塞请求tokentokenService.asyncRequestToken(rule,exchange.getExecutor()).addListener(future -> {TokenResult tokenResult;if (future.isSuccess()) {tokenResult = (TokenResult) future.getNow();} else {tokenResult = RedisTokenService.FAIL;}if(tokenResult == RedisTokenService.FAIL || tokenResult == RedisTokenService.ERROR){log.error("Request cluster token failed, will back to local flowRule check");}ClusterFlowManager.setTokenResult(rule.getRuleId(), tokenResult);doSentinelFlowControl(exchange, chain, resource);});} else {doSentinelFlowControl(exchange, chain, resource);}}}

改造后适配DAG的SentinelGatewayFilter

六、压测性能

DAG高压表现

wrk -t32 -c1000 -d60s -s param-delay1ms.lua –latency http://a.b.c.d:xxxxx

DAG网关的QPS、实时RT、错误率、CPU、内存监控图;

在CPU占用80% 情况下,能够支撑的QPS在4.5W。

DAG网关的QPS、RT 折线图

DAG在CPU占用80% 情况下,能够支撑的QPS在4.5W,ART 19ms

SCG高压表现

wrk -t32 -c1000 -d60s -s param-delay1ms.lua –latency http://a.b.c.d:xxxxx

SCG网关的QPS、实时RT、错误率、CPU、内存监控图:

SCG网关的QPS、RT 折线图:

SCG在CPU占用95% 情况下,能够支撑的QPS在1.1W,ART 54.1ms

DAG低压表现

wrk -t5 -c20 -d120s -s param-delay1ms.lua –latency http://a.b.c.d:xxxxx

DAG网关的QPS、实时RT、错误率、CPU、内存:

DAG网关的QPS、RT 折线图:

DAG在QPS 1.1W情况下,CPU占用30%,ART 1.56ms

数据对比

DAG和SCG的对比结论

满负载情况下,DAG要比SCG的吞吐量高很多,QPS几乎是4倍,RT反而消耗更低,SCG在CPU被打满后,RT表现出现严重性能劣化。

DAG的吞吐控制和SCG一样情况下,CPU和RT损耗下降了更多。

DAG在最大压力下,内存消耗比较高,达到了75%左右,不过到峰值后,就不再会有大幅变动了。对比压测结果,结论令人欣喜,++SCG作为Java生态当前使用最广泛的网关,其性能属于一线水准,DAG的性能达到其4倍以上也是远超意料,这样的结果给与研发同学极大的鼓舞

七、投产收益

安全性提升

完善的接口级路由管理

基于接口注册模式的全新路由上线,包含了接口注册的申请人,申请时间,接口场景备注信息等,接口管理更加严谨规范;

结合路由组功能可以方便的查询当前服务的所有对外接口信息,某种程度上具备一定的API查询管理能力;同时为了缓解用户需要检索的接口太多的尴尬,引入了一键收藏功能,大部分时候用户只需要切换到已关注列表即可。

注册接口列表

接口收藏

防渗透能力极大增强

早期的泛化路由,给黑产的渗透带来了极大的想象空间和安全隐患,甚至可以在外网直接访问某些业务的配置信息。

黑产接口渗透

接口注册模式启用后,所有未注册的接口均无法访问,防渗透能力提升一个台阶,同时自动推送异常接口访问信息。

404接口访问异常推送

稳定性增强

内存泄漏问题解决

通过一系列手段改进优化和严格的测试,新网关的内存使用更加稳健,内存增长曲线直接拉平,彻底解决了泄漏问题。

老网关内存增长趋势

新网关内存增长趋势

降本增效

资源占用下降50% +

SCG平均CPU占用

DAG资源占用

得益于ZGC的优秀算法,JVM17 在GC暂停时间上取得了出色的成果,网关作为延迟敏感型应用对GC的暂停时间尤为看重,为此我们组织升级了JDK17 版本;下面为同等流量压力情况下的配置不同GC的效果对比,++可以看到GC的暂停时间从平均70ms 降低到1ms 内,RT99线得到大幅度提升;吞吐量不再受流量波动而大幅度变化,性能表现更加稳定;同时网关的平均响应时间损耗降低5%。

JDK8-G1 暂停时间表现

JDK17-ZGC暂停时间表现

吞吐量方面,G1伴随流量的变化呈现出一定的波动趋势,均线在99.3%左右。ZGC的吞吐量则比较稳定,维持在无限接近100%的水平。

JDK8-G1 吞吐量

JDK17-ZGC吞吐量

对于实际业务接口的影响,从下图中可以看到平均响应时间有所下降,这里的RT差值表示接口经过网关层的损耗时间;不同接口的RT差值损耗是不同的,这可能和请求响应体的大小,是否经过登录验证,风控验证等业务逻辑有关。

JDK17与JDK8 ART对比

需要指出的是ZGC对于一般的RT敏感型应用有很大提升, 服务的RT 99线得到显著改善。但是如果当前应用大量使用了堆外内存的方式,则提升相对较弱,如大量使用netty框架的应用, 因为这些应用的大部分数据都是通过手动释放的方式进行管理。

八、思考总结

架构演进

API网关的自研并非一蹴而就,而是经历了多次业务迭代循序渐进的过程;从早期的泛化路由引发的安全问题处理,到后面的大量路由注册,带来的匹配性能下降 ,以及最终压垮老网关最后一根稻草的内存泄漏问题;在不同阶段需要使用不同的应对策略,早期业务快速迭代,大量的需求堆积,最快的时候一个功能点的改动需要三四天内上线 ,我们很难有足够的精力去做一些深层次的改造,这个时候需求导向为优先,功能性建设完善优先,是一个快速奔跑的建设期;伴随体量的增长安全和稳定性的重视程度逐步拔高,继而推进了这些方面的大量建设;从拓展SCG的原有功能到改进框架源码,以及最终的自研重写,可以说新的API网关是一个业务推进而演化出来的产物,也只有这样 ”生长“ 出来的架构产品才能更好的契合业务发展的需要。

稳定性把控

自研基础组件是一项浩大的工程,可以预见代码量会极为庞大,如何有效管理新项目的代码质量是个棘手的问题; 原有业务逻辑的改造也需要回归测试;

现实的情况是中间件团队没有专职的测试,质量保证完全依赖开发人员;这就对开发人员的代码质量提出了极高的要求,一方面我们通过与老网关适配相同的代理引擎接口,降低迁移成本和业务逻辑出现bug的概率;另一方面还对编码质量提出了高标准,平均每周两到三次的CodeReview;80%的单元测试行覆盖率要求。

网关作为流量入口,承接全司最高流量,对稳定性的要求极为苛刻。最理想的状态是在业务服务没有任何感知的情况下,我们将新网关逐步替换上去;为此我们对新网关上线的过程做了充分的准备,严格控制上线过程;具体来看整个上线流程分为以下几个阶段:

第一阶段

我们在压测环境长时间高负载压测,持续运行时间24小时以上,以检测内存泄漏等稳定性问题。同时利用性能检测工具抓取热点火焰图,做针对性优化。

第二阶段

发布测试环境试跑,采用并行试跑的方式,新老网关同时对外提供服务(流量比例1 :1,初期新网关承接流量可能只有十分之一),一旦用户反馈的问题可能跟新网关有关,或者发现异常case,立即关停新网关的流量。待查明原因并确认修复后,重新引流。

第三阶段

上线预发,小得物环境试跑,由于这些环境流量不大,依然可以并行长时间试跑,发现问题解决问题。

第四阶段

生产引流,单节点从万分之一比例开始灰度,逐步引流放大,每个阶段停留24小时以上,观察修正后再放大,循环此过程;基于单节点承担正常比例流量后,再次抓取火焰图,基于真实流量场景下的性能热点做针对性优化。

自研过程是一个逐步完善的过程,从解决安全问题到提高性能,再到解决内存泄漏,每个阶段都有其特定的问题和要求。在稳定性控制方面,我们依靠开发人员的代码质量和对新网关上线流程的严格控制,确保了网关的高可用性和稳定性。通过逐步替换和灰度测试,我们确保了新网关能够在不影响现有业务的情况下平稳上线。这个过程中,我们学到了很多,也取得了显著的成果,为得物平台的发展奠定了坚实的基础。

说在最后:有问题可以找老架构取经

架构之路,充满了坎坷

架构和高级开发不一样 , 架构问题是open/开放式的,架构问题是没有标准答案的

正由于这样,很多小伙伴,尽管耗费很多精力,耗费很多金钱,但是,遗憾的是,一生都没有完成架构升级

所以,在架构升级/转型过程中,确实找不到有效的方案,可以来找40岁老架构尼恩求助.

前段时间一个小伙伴,他是跨专业来做Java,现在面临转架构的难题,但是经过尼恩几轮指导,顺利拿到了Java架构师+大数据架构师offer 。所以,如果遇到职业不顺,找老架构师帮忙一下,就顺利多了。

尼恩技术圣经系列PDF

  • 《NIO圣经:一次穿透NIO、Selector、Epoll底层原理》
  • 《Docker圣经:大白话说Docker底层原理,6W字实现Docker自由》
  • 《K8S学习圣经:大白话说K8S底层原理,14W字实现K8S自由》
  • 《SpringCloud Alibaba 学习圣经,10万字实现SpringCloud 自由》
  • 《大数据HBase学习圣经:一本书实现HBase学习自由》
  • 《大数据Flink学习圣经:一本书实现大数据Flink自由》
  • 《响应式圣经:10W字,实现Spring响应式编程自由》
  • 《Go学习圣经:Go语言实现高并发CRUD业务开发》

……完整版尼恩技术圣经PDF集群,请找尼恩领取

《尼恩 架构笔记》《尼恩高并发三部曲》《尼恩Java面试宝典》PDF,请到下面公号【技术自由圈】取↓↓↓