OpenResty 社区王院生:APISIX 的高性能实践

2019 年 7 月 6 日,OpenResty 社区联合又拍云,举办 OpenResty × Open Talk 全国巡回沙龙·上海站,OpenResty 软件基金会联合创始人王院生在活动上作了《APISIX 的高性能实践》的分享。
OpenResty x Open Talk 全国巡回沙龙是由 OpenResty 社区、又拍云发起,邀请业内资深的 OpenResty 技术专家,分享 OpenResty 实战经验,增进 OpenResty 使用者的交流与学习,推进 OpenResty 开源项目的发展。活动将陆续在深圳、北京、武汉、上海、成都、广州、杭州等城市巡回举办。html

王院生,OpenResty 社区、OpenResty 软件基金会联合创始人,《OpenResty 最佳实践》主要做者,APISIX 项目发起人和主要做者。前端

如下是分享全文:nginx

你们好,我是王院生,很高兴来到上海。首先作下自我介绍,我于 2014 年加入奇虎 360,在那时认识了 OpenResty,此前我是一个纯粹的 C/C++ 语言开发者。在 360 工做期间,利用工做闲暇时间写了《OpenResty 最佳实践》,但愿能影响更多的人正确掌握 OpenResty 入门。2017 年我做为技术合伙人和春哥(章亦春,agentzh)一块儿创业。今年我我的的重心有所调整并在今年三月份离职,准备将更多精力投入到开源上,因而发起了 APISIX 这个项目,企业宗旨是依托开源社区,致力于微服务 API 相关技术的创新和实现。git

什么是 API 网关

API 网关的地位愈来愈重要,它几乎劫持了全部流量,内外之间完成了用户的安全控制、审计,经过自定义插件的方式知足企业自身特定需求,最多见的自由身份认证等。随着服务在数量和复杂度上的不断增加,更多的企业采用了微服务的方式,这时经过 API 网关来完成统一的流量管理和调度就很是有必要。github

微服务网关和传统意义上的 API 网关有一些不一样,主要包括下面几点:编程

  1. 动态更新:在微服务以前,服务不像如今这样常常来回地变化。好比微服务须要作横向扩充,或者故障恢复、热备、切换等,IP 、节点等变更更加频繁。举例如微博上一旦出现了爆点事件,就急速扩充计算点,必需要很是快地扩充新机器来扛压。波峰波谷变化明显,分钟级别的机器动态管理,已经愈加是常态。
  2. 更低延迟:一般动态就意味着可能会作一些延迟(复杂度增长),在微服务里面,对于延迟要求比较高,尤为对于如今的用户体验,超过 1 秒以上的延迟是彻底不可接受的。
  3. 用户自定义插件:API 网关是给企业用户使用的,它必定存在私有逻辑(好比特殊的认证受权等),因此微服务网关必须可以支持企业用户自定义插件。
  4. 更集中的管理 API:如前面所说 API 网关劫持了用户的全部流量,因此用网关来作统一的 API 管理是很是必要的。在网关角度能够看到 API 是如何设计,是否存在延迟、安全问题,以及响应速度和健康信息等。

咱们要作的微服务 API 网关产品,除了上面的基本要求,还有一些是咱们区别于其余人的:json

  1. 经过社区聚焦:经过开源方式聚焦有共同需求的人群,让更多不一样公司的人能够一块儿协做,共同打磨更好的产品,减小冗余开发。
  2. 简洁的 core:产品的内核必须是很是简洁的,若是内核复杂,会使得你们的上手成本高不少,望而却步确定不是咱们指望的。
  3. 可扩展性、顶级性能、低延迟:这几项都是要同时严格保障的,也是咱们会花主要精力保证的。目前 APISIX 项目的性能比空跑 OpenResty 只低 15%,这点仍是很是值得傲娇的。

APISIX 高性能微服务网关

APISIX 架构与功能

上图是 APISIX 的基本架构,罗列用到的几个基本组件。其中包括 ETCD 能够完成配置存储,因为 ETCD 能够走集群,因此咱们能够借用它完成动态伸缩、高可用集群等。ETCD 数据支持经过 watch 的方式增量获取,使得 APISIX 节点规则更新能够作到毫秒级,甚至更低。APISIX 自身是无服务状态的,因此方便横向扩充。api

另外一个组件是 JSON Schema,它是一个标准协议,主要用来验证数据的有效性。JSON Schema 目前对外公开有四个不一样版本,咱们最终选用 RapidJSON,由于他对这四个版本都有相对完整的支持。缓存

图中的 Admin API 和 APISIX 能够放在一块儿,也能够分开。Admin API 接收用户提交的请求,在请求参数保存到 ETCD 以前,会使用 JSON Schema 作一次完整校验,有了校验能够肯定到 ETCD 里的都是有效数据。安全

上图右侧是接收外部用户的真实流量,APISIX 从 ETCD 中订阅全部配置规则,拿到配置规则后给到下面的路由引擎(libr3),目前默认使用的路由引擎是 libr3,我以前在武汉的分享中进行过详细介绍(https://www.upyun.com/opentalk/428.html)。 libr3 是一个路由引擎实现,基于前缀树,因为他还支持正则,因此效率很是高的,同时功能也很强大。

APISIX 的 v0.5 版本具有如下功能:

APISIX 的性能

一般来讲,引入了前面提到的十几项功能,会伴随着性能的降低,那么究竟降低了多少呢?这里我作了一个性能的测试对比。如上图,右侧是我为了测试写的一个虚假的服务,这个服务里面空空如也,只是把 ngx_lua 里的一些变量拿出来,而后传给了什么都不作的 fake_fetch,后面的 http filter、log 阶段等同样,没有任何计算量。

而后对 APISIX 和右边的虚假服务分别跑压力测定,对比结果发现 APISIX 的性能仅仅降低了 15%,也就是说在接受了 15% 的性能降低的同时,就能够享受前面提到的全部功能。

说一下具体数值,这里使用的是阿里云的计算平台,单 worker 下能够跑到 23-24k QPS,4 worker 能够跑到 68k 的 QPS。

APISIX 目前的状态

目前最新版本是 v0.5,架构是基于 ETCD+libr3+RapidJSON。这个版本加的最多的是代码覆盖率,v0.4 版本代码覆盖率不超过 5%,但最新版本中代码覆盖率达到 70%,这其中 95% 是核心代码,周边的代码覆盖率相对较低,主要是插件的相关测试有所欠缺。

本来计划在 0.5 版本上线管理界面功能,这样能够下降入门门槛,可是遗憾的是目前还没开发完成,这与咱们自身专业有关系,不擅长作前端界面,须要借助前端的专家帮咱们实现,咱们计划会在 0.6 的版本上线(注:目前已经发布了 v0.6 版本:https://github.com/iresty/apisix/blob/master/CHANGELOG_CN.md#060)。

OpenResty 编程哲学与优化技巧

我从 2014 年开始作 OpenResty 开发,至今已经有六年了。在 OpenResty 的领域里,它的哲学是要学会大事化小,小事化了,由于 Nginx 的内存管理方式是把全部的请求内存默认放到一个内存池里,请求退出的时再把内存池销毁。若是不能很快地一进一出,它就会不停申请,最后释放时资源损耗很大,这是 Nginx 不擅长的。因此用 OpenResty 作长链接就须要很是当心,避免把内存池搞大。

此外,要尽量少地建立临时对象。这里所指的临时对象有两类,一类是 table 类,一类是字符串拼接,好比某两个变量拼接产生新的字符串,这个看似在其余不少语言都没有问题,但在 OpenResty 里须要尽可能少作这种操做。Lua 语言虽然简单,但也是门高级语言,携带了优良的 GC ,让咱们无需关心全部变量的生命周期,只负责申请就行了,但若是滥用临时变量等,会让 GC 比较忙碌,付出代价是总体运行效能不高。Lua 擅长动态和流程控制,若是遇到硬核的 CPU 运算任务,仍是推荐交给 C/C++ 实现。

今天和你们分享优化技巧,主要仍是如何写好 Lua,毕竟他的受众群体更多。在 APISIX 的 core 中,咱们使用了一些比较特别的优化技巧,下面逐一给你们介绍。

技巧一:delay_json

先说一下场景:好比上面的这行日志调用,若是当前日志级别是 info ,咱们指望会正常 json encode;而当是 error 级别,咱们就不指望发生 json encode 操做,若是能自动跳过是最完美了。那咱们如何近似的实现这个目的呢?

咱们看一下 delay_encode 的实现源码,首先用元方法重载了 tostring ,下面 delay_encode 只是对 delay_tab 的两个对象 data 和 force 作了赋值,而后没有作其余的事情,这与你们平时看到的 json encode 方法都不同。由于真正在写日志时,若是给定的参数是 table,在 OpenResty 里会把他转成 string 的,过程是检查是否有 tostring 的元方法注册,若是有就调这个方法把它转换成字符串。有了上面的封装,咱们就在高性能和易用性上作了很好的平衡。

技巧二:HASH vs 前缀树 vs 遍历

  • Lua table 的 HASH:性能最好的匹配方式,缺点是只能作全量匹配。
  • 前缀树:借助 libr3 完成前缀等高级匹配(支持正则)。
  • 遍历:永远是最糟糕的。

在 APISIX 的世界里,我把 HASH 和前缀树作了融合,若是你的请求和路由规则不包含高级规则匹配,会默认走 HASH 来保证效率;但若是有模糊匹配逻辑,则使用前缀树。

技巧三:ngx.log 是 NYI

由于 ngx.log 是 NYI,因此咱们要尽可能减小下面这段代码的触发频率:

return ngx_log(log_level,…)

要降到最低,须要判断当前日志级别,若是当前的日志级别和你输入的日志级别存在大小比值关系,发现不须要输入就直接 return。避免出现日志处理完,传到 Nginx 内核后再发现不须要写日志,这样就会浪费很是多的资源。

前面提到的压力测试,都是把日志打到 error 级别,加了很是多的调试代码而且保留不删,这些测试代码的存在彻底不会影响性能结果。

技巧四:gc for cdata and table

场景:当某个 table 对象被系统回收时,但愿触发特定逻辑以释放关联资源。那么咱们如何给 table 注册 gc 呢?请参考下图示例:

当咱们没法控制 Lua table 的整个生命周期,能够用上图的方法去注册一个 GC,当 table 对象没有任何引用时会触发 GC,释放关联资源。

技巧五:如何保护常驻内存的 cdata 对象

咱们在使用 r3 这个 C 库时遇到这么一个问题:咱们给 r3 添加不少路由规则,而后生成 r3 tree,若是规则没有变化 r3 将被反复使用,因为 r3 内部没有申请额外的内存存储,只是引用指针地址。但外面传入的 Lua 变量多是临时变量,引用计数为 0 后会被 Lua GC 自动回收。致使的现象是 r3 内部引用的原有内存地址内容忽然发生变化,最后导致路由匹配失败。

知道了问题缘由,解决方法就比较简单了,只须要避免变量 A 提早释放,让 Lua 里面变量 A 的生命周期和 r3 对象的生命周期保持一致便可。

技巧六:ngx.var.* 是比较慢的

你们知道 C 是不支持动态的,它是编译性语言。ngx.var.* 的内部实现能够查看 Nginx 源代码,或者经过火焰图的方式能够看到他内部的实现方式。为了完成动态获取变量,内部必须经过一次 hash 查找,到后用内部的规则把变量值读出。

解决方案是用上图这个库(http://github.com/iresty/lua-var-nginx-module),很是简单没有技术含量的办法。好比要获取客户端的 IP,在 C 里面直接把代码摘出来,而后经过 Lua FFI 方式读取变量的值,就是这么一段小代码可让 APISIX 性能有 5% 提高。这么作缺点是必需要对 OpenResty 编译时添加这个第三方模块,上手成本略高。

技巧七:减小每请求的垃圾对象

咱们要尽量下降每请求产生的垃圾对象的数量,做为 OpenResty 开发者,若是把这句话理解透彻,基本上能够进阶到前 50% 的行列。

减小没必要要的字符串的拼接,并不是意味着在须要作拼接字符串的时候不要拼接,而是须要在脑子里一直有这个意识,把无效的拼接下降下来,当这些小细节累积下来,性能提高就会很是大。

技巧八:重用 table

首先介绍下初级版的 table.clear。当须要使用一个临时 table,你们习惯性的写法是

local t ={}

咱们来聊聊这么作的缺点,若是在开头建立了一个临时的 table t,当函数退出的时候,t 会被回收;下次再进来这个函数,又会产生一个临时的 table t。在 Lua 世界,table 的产生和销毁是很是耗资源的,由于 table 是一个复杂对象,它不像 number、字符串等简单对象,申请和释放能够用一个结构体搞定,它会让你的 GC 一会儿变得很是忙碌。

若是 worker 里只须要一个惟一实例 table 对象,那么就可使用 table.clear 方式来反复使用这个临时表,好比上图的临时表 local_plugins_hash。

重用 table :进阶版 table.pool

有些 Lua table 的生命周期是每请求的,一般是请求进入申请对象,请求退出释放对象,这时候使用 table.pool 会很是合适。tablepool 中文翻译过来是表池,里面放的是能够重用的 table。官方文档能够到 https://github.com/openresty/lua-tablepool#synopsis 查看,结合 APISIX 的业务使用代码,更容易理解。

在 APISIX 中最集中使用的是两个地方,除了上图这里作回收,还有是申请的地方。在回收以后,这些 table 能够被其余请求所复用,由 tablepool 作统一控制,在 pool 里维持的对象可能就固定的几10、几百个,会反复使用,不存在销毁的状况。这个技巧的正确使用,性能至少能够提高 20%,提高效果很是明显。

技巧九:Irucache 的正确姿式

简单介绍下 Irucache,Irucache 能够完成在 worker 内的数据的缓存和复用,Irucache 有一个很是大的优点是能够存储任何对象。而共享内存则是完成不一样 worker 之间的数据共享,但它只能存储简单对象,有些东西是不能跨 worker 共享,好比 function、cdata 对象等。

对 Irucache 进行二次封装,封装的内容主要包括:

  • key 要尽可能短、简单:咱们在写 key 时最重要的是要简单,key 最糟糕的设计是里面东西很长,可是有用信息很少。key 理论上你们都喜欢用字符串,但他能够是 table 等对象,key 尽可能作到明确,只包含你感兴趣的内容,能省略的尽可能省略,下降拼接成本。
  • version 可下降垃圾缓存:这点算是我在作 APISIX 的突破:提取出了 version, Irucache+ version 这套组合,能够极大地下降垃圾缓存。
  • 重用 stale 状态的缓存数据。

上图是 lrucache 的封装,从下往上看,key 是 /routes,它跟的版本号是 conf_version,global 函数里作的事情是根据 key+version 的方式,去查找有无陈旧数据的缓存,若是有就直接返回,若是没有就调 creat_r3_router 完成建立,creat_r3_router 是负责建立一个新的对象,它只接受一个传参 routes,这个传参是由 routes.values 传进去的。

这层封装,把 Irucache new、数量等都隐藏起来,这样不少东西咱们看不到,当咱们须要自定义的时候可能仍是须要关心这些。APISIX 为了简化插件开发者对各类东西的理解,因此必需要作一层封装,简化使用。

image

△ lrucache 最佳实践示例

△ lrucache 最佳实践⽤例

上图是用 version 下降垃圾缓存、重用 stale 状态的缓存数据,这 Irucache 的二次封装的代码。首先来看第二行,根据 key 去缓存里面取对象,而后把对象的 cache_ver 拿出来和当前传入的 version 作比较,若是相同则断定这个缓存对象必定是可用的。

往下多了 stale_obj,stale_obj 在文档里面说明的比较少,它只有在一种状况会发生:缓存对象在 Irucache 中已经被淘汰了,可是它只是到了淘汰的边缘,尚未彻底被扔掉。上图中经过陈旧数据的 cache_ver 与进来的 version 作比较,若是 version 一致那就是有效的。因此只要源头的数据没有变化,就能够再次使用。这样咱们就能够复用 stale_obj 从而避免再次建立新的对象。

到这里能够解释一下前面提到的:version 可下降垃圾缓存。若是没有 version,咱们须要把 version 写到 key 里面,每次 version 变化都会产生一个新的 key,那些被淘汰的旧数据会一直存在,没办法剔除掉。同时意味着 Irucache 里面的对象数会不停增长。而咱们前面的方式是保证 key 若是是一个对象,只会有一个 table 与它对应,不会根据不一样的 version 产生不一样的对象缓存,进而下降缓存总数。

以上是我今天的所有分享,谢谢你们!

演讲视频及PPT下载传送门:

APISIX 的高性能实践

相关文章
相关标签/搜索