为什么 Apache APISIX 选择 Nginx + Lua 这个技术栈?

spacewander

更新时间 8/25/2021

笔者在今年的 COSCUP 大会做分享时,曾有观众问这样的问题,为什么 Apache APISIX、Kong 和 3scale 这些网关都采用 Lua 来编写逻辑?

是啊,Lua 并不是一门广为人知的语言,离“主流编程语言”的圈子大概还差个十万八千里吧。甚至有一次,我在跟别人交流的时候,对方在说到 Lua 之前,先停顿了片刻,随后终于打定主意,以"L U A"逐个字母发音的方式表达了对这一罕见之物的称呼。

所以,为什么 Apache APISIX 和其他知名的网关会选择用 Lua 呢?

事实上,Apache APISIX 采用的技术栈并不是纯粹的 Lua,准确来说,应该是 Nginx + Lua。Apache APISIX 以底下的 Nginx 为根基,以上层的 Lua 代码为枝叶。

LuaJIT VS Go

严谨认真的读者必然会指出,Apache APISIX 并非基于 Nginx + Lua 的技术栈,而是 Nginx + LuaJIT (又称 OpenResty,以下为了避免混乱,会仅仅采用 Nginx + Lua 这样的称呼)。

LuaJIT 是 Lua 的一个 JIT 实现,性能比 Lua 好很多,而且额外添加了 FFI 的功能,能方便高效地调用 C 代码。 由于现行的主流 API 网关,如果不是基于 OpenResty 实现,就是使用 Go 编写,所以时不时会看到各种 Go 和 Lua 谁的性能更好的比较。

就我个人观点看,脱离场景比较语言的性能,是没有意义的。

首先明确一点,Apache APISIX 是基于 Nginx + Lua 的技术栈,只是外层代码用的是 Lua。所以如果要论证哪种网关性能更好,正确的比较对象是 C + LuaJIT 跟 Go 的比较。网关的性能的大头,在于代理 HTTP 请求和响应,这一块的工作主要是 Nginx 在做。

所以倘若要比试比试性能,不妨比较 Nginx 和 Go 标准库的 HTTP 实现。

众所周知,Nginx 是一个 bytes matter 的高性能服务器实现,对内存使用非常抠门。举两个例子:

  1. Nginx 里面的 request header 在大多数时候都只是指向原始的 HTTP 请求数据的一个指针,只有在修改的时候才会创建副本。
  2. Nginx 代理上游响应时对 buffer 的复用逻辑非常复杂,是我读过的最为烧脑的代码之一。

凭借这种抠门,Nginx 得以屹立在高性能服务器之巅。

相反的,Go 标准库的 HTTP 实现,是一个滥用内存的典型反例。

这可不是我的一面之辞,Fast HTTP,一个重新实现 Go 标准库里面的 HTTP 包的项目,就举了两个例子:

  1. 标准库的 HTTP Request 结构体没法复用
  2. headers 总是被提前解析好,存储成 map[string][]string,即使没有用到(原文见:Fast HTTP 常见文件

Fast HTTP 文档里面还提到一些 bytes matter 的优化技巧,建议大家可以阅读下。

事实上,即使不去比较作为网关核心的代理功能,用 LuaJIT 写的代码不一定比 Go 差多少。原因有二。

其一,拜 Lua 跟 C 良好的亲和力所赐,许多 Lua 的库核心其实是用 C 写的。

比如 lua-cjson 的 json 编解码,lua-resty-core 的 base64 编解码,实际上大头是用 C 实现的。 而 Go 的库,当然是大部分用 Go 实现的。虽然有 CGO 这种东西,但是受限于 Go 的协程调度和工具链的限制,它在 Go 的生态圈里面只能处于从属的地位。

关于 LuaJIT 和 Go 对于 C 的亲和力的比较,推荐 Hacker News 上的这篇文章:《Comparing the C FFI overhead in various programming languages》(链接 https://news.ycombinator.com/item?id=17161168

于是我们比较 Lua 的某些功能,其实还是会回到 C 和 Go 的比较中。

其二,LuaJIT 的 JIT 优化无出其右。

讨论动态语言的性能,可以把动态语言分成两类,带 JIT 和不带 JIT 的。JIT 优化能够把动态语言的代码在运行时编译成机器码,进而把原来的代码的性能提升一个数量级。

带 JIT 的语言还可以分成两类,能充分 JIT 的和只支持部分 JIT 的。而 LuaJIT 属于前者。

人所皆知,Lua 是一门非常简单的语言。相对鲜为人知的是,LuaJIT 的作者 Mike Pall 是一个非常厉害的程序员。这两者的结合,诞生了 LuaJIT 这种能跟 V8 比肩的作品。

关于 LuaJIT 和 V8 到底谁更快,一直是长盛不衰的争论话题。展开讲 LuaJIT 的 JIT 已经超过了本文想要讨论的范畴。简单来说,JIT 加持的 LuaJIT 跟预先编译好的 Go 性能差别并不大。

至于谁比谁慢,慢多少,那就是个见仁见智的问题了。这里我举个例子:

1local text = {"The", "quick", "brown", "fox", "jumped", "over", "the", "lazy", "dog", "at", "a", "restaurant", "near", "the", "lake", "of", "a", "new", "era"}
2local map = {}
3local times = 1e8
4local n = #text
5for i = 1, n do
6    map[text[i]] = 0
7    for _ = 1, times do
8        map[text[i]] = map[text[i]] + 1
9    end
10end
11
12for i = 1, n do
13    io.write(text[i], " ", map[text[i]], "\n")
14end
1package main
2import "fmt"
3func main() {
4    text := []string{"The", "quick", "brown", "fox", "jumped", "over", "the", "lazy", "dog", "at", "a", "restaurant", "near", "the", "lake", "of", "a", "new", "era"}
5    m := map[string]int{}
6    times := int(1e8)
7    for _, t := range text {
8        m[t] = 0
9        for i := 0; i < times; i++ {
10            m[t]++
11        }
12    }
13    for _, t := range text {
14        fmt.Println(t, " ", m[t])
15    }
16}

上面两段代码是等价的。你猜是第一个 Lua 版本的快,还是第二个 Go 版本的快?

在我的机器上,第一个用时不到 1 秒,第二个花了 23 秒多。

举这个例子并不是想证明 LuaJIT 比 Go 快 20 倍。我只想说明用 micro benchmark 证明某个语言比另一个语言快的意义不大,因为影响性能的因素很多。一个简单的 micro benchmark 很有可能过分强调某一个因素,导致出乎意料的结果。

Nginx + Lua :高性能 + 灵活

让我们转回 Apache APISIX 的 Nginx + Lua 的技术栈。Nginx + Lua 的技术栈给我们带来的,不仅仅是高性能。

经常有人问我们,既然你们是基于 Nginx 开源版本,而 Nginx 并不支持动态配置,为什么 Apache APISIX 声称自己可以实现动态配置?你们是不是改了点东西?

是的,我们确实有在维护自己的 Nginx 发行版,不过 Apache APISIX 的大部分功能在官方的 Nginx 上就能使用。我们之所以能做到动态配置,全靠把配置放到 Lua 代码里面来实现。

举路由系统作为一个例子,Nginx 的路由需要在配置文件里面进行配置,每次更改路由,都需要 reload 之后才能生效。这是因为 Nginx 的路由分发只支持静态配置,不能动态增减路由。

为了实现路由动态配置,Apache APISIX 做了两件事:

  1. 在 Nginx 配置文件里面配置单个 server,这个 server 里面只有一个 location。我们把这个 location 作为主入口,这样所有的请求都会走到这个地方上来。
  2. 我们用 Lua 完成路由分发的工作。Apache APISIX 的路由分发模块,支持在运行时增减路由,这样就能动态配置路由了。

你可能会问,在 Lua 里面做路由分发,会比 Nginx 的实现慢吗?

就像前面提到过的一样,凡是对性能要求比较高的,我们会把核心代码用 C 改写。我们的路由分发模块就是这么干的。路由分发模块在匹配路由时,会采用一个前缀树来匹配。而这个前缀树是用 C 实现的。感兴趣的读者可以看下代码:lua-resty-radixtree

完成了 C 层面上的前缀树匹配,接下来就该 Lua 发挥灵活性的时刻了。对于匹配同一前缀的各个路由,我们支持通过许多别的方式来进行下一级的匹配,其中就包含通过一个特定的表达式来匹配。尽管硬着头皮,也能在 C 层面上接入一个表达式引擎,但是纯 C 实现做不了非常灵活地自定义表达式里面的变量。

举个例子,下面是 Apache APISIX 用来匹配 GraphQL 请求的 route 配置:

1{
2    "methods": [
3        "POST"
4    ], 
5    "upstream": {
6        "nodes": {
7            "127.0.0.1:1980": 1
8        }, 
9        "type": "roundrobin"
10    }, 
11    "uri": "/hello", 
12    "vars": [
13        [
14            "graphql_name", 
15            "==", 
16            "repo"
17        ]
18    ]
19}

它会匹配这样的 GraphQL 请求:

1query repo
2{
3  owner
4  {
5    name
6  }
7}

这里的 graphql_name 并非 Nginx 内置变量,而是通过 Lua 代码定义的。Apache APISIX 一共定义了三个 GraphQL 相关的变量,连同解析 GraphQL body 在内不过 62 行 Lua 代码。如果要通过 Nginx C 模块来定义变量,62 行可能只不过是把相关方法的样板代码搭建起来,都还没有到真正的解析 GraphQL 的逻辑呢。

采用 Lua 代码来做路由还有一个好处:它减低了二次开发的门槛。

如果在路由过程中需要有特殊的逻辑,用户可以实现成自定义的变量和运算符,比如通过 IP 库匹配到的地理位置来决定采用哪条路由。用户只需要写一些 Lua 代码,这要比修改 Nginx C module 的难度小多了。

在 Apache APISIX 里面,不仅仅路由是动态的,我们的 TLS 服务端证书和上游节点配置都是动态的,而且无需修改 Nginx —— 上述功能可以跑在官方的 Nginx + Lua 技术栈上。当然通过修改 Nginx,我们还实现了更多的高级功能,比如动态的 gzip 配置和动态的客户端请求大小限制。后续我们将推行自己的 Nginx 发行版,这样开源用户也能轻松用上这些高级功能。