性能优化
避免使用 NYI
pairs() vs ipairs()
如果 table 内部为 array,应该优先使用哪个?为什么?
NYI(Not Yet Implemented)的概念
LuaJIT 的运行时环境,除了一个汇编实现的 Lua 解释器外,还有一个可以直接生成机器代码的 JIT 编译器。
LuaJIT 中 JIT 编译器的实现还不完善,有一些原语它还无法编译,因为这些原语实现起来比较困难,再加上 LuaJIT 的作者目前处于半退休状态。
这些原语包括常见的 pairs() 函数、unpack() 函数、基于 Lua CFunction 实现的 Lua C 模块等。
这样一来,当 JIT 编译器在当前代码路径上遇到它不支持的操作时,便会退回到解释器模式。
全称为 Not Yet Implemented NYI 完整列表
string.byte 对应的能否被编译的状态是 yes,表明可以被 JIT。
string.char 对应的编译状态是 2.1,表明从 LuaJIT 2.1 开始支持。
string.dump 对应的编译状态是 never,即不会被 JIT,会退回到解释器模式。
string.find 对应的编译状态是 2.1 partial,意思是从 LuaJIT 2.1 开始部分支持,后面的备注中写的是 只支持搜索固定的字符串,不支持模式匹配。所以对于固定字符串的查找,使用 string.find 是可以被 JIT 的。
NYI 的替代方案
string.gsub() 函数
这个函数是一个 NYI 原语,无法被 JIT 编译。
它是 Lua 内置的字符串操作函数,作用是做全局的字符串替换。
打开 lua-nginx-module 的 GitHub 文档页面,可以用 gsub 作为关键字,在文档页面中搜索,找到 ngx.re.gsub
string.find() 函数
和 string.gsub 不同的是,string.find 在 plain 模式(即固定字符串的查找)下,是可以被 JIT 的;
而带有正则这种的字符串查找,string.find 并不能被 JIT ,这时就要换用 OpenResty 自己的 API,也就是 ngx.re.find 来完成。
所以,当在 OpenResty 中做字符串查找时,首先一定要明确区分,要查找的是固定的字符串,还是正则表达式。
如果是前者,就要用 string.find,并且记得把最后的 plain 设置为 true:
1 | string.find("foo bar", "foo", 1, true) |
如果是后者,应该用 OpenResty 自己的 API,并开启 PCRE 的 JIT 选项:
1 | ngx.re.find("foo bar", "^foo", "jo") |
可以做一层封装,并把优化选项默认打开,不要让最终的使用者知道这么多细节。这样,对外就是统一的字符串查找函数了。
pairs() 函数
遍历哈希表的 pairs() 函数,它也不能被 JIT 编译。
这个并没有等价的替代方案,只能尽量避免使用,或者改用数字下标访问的数组,特别是在热代码路径上不要遍历哈希表。
代码热路径的意思是,这段代码会被反复执行很多次,比如在一个很大的循环里面。
实践总结
优先使用 OpenResty 提供的 API,而不是 Lua 的标准库函数。这里要牢记, Lua 是嵌入式语言,我们实际上是在 OpenResty 中编程,而不是 Lua。
如果万不得已要使用 NYI 原语,请一定确保它没有在代码热路径上。
如何检测 NYI?
LuaJIT 自带的 jit.dump 和 jit.v 模块。它们都可以打印出 JIT 编译器工作的过程。
前者会输出非常详细的信息,可以用来调试 LuaJIT 本身;
后者的输出比较简单,每行对应一个 trace,通常用来检测是否可以被 JIT。
想要简单验证的话, 使用 resty 就足够了
其中,resty 的 -j 就是和 LuaJIT 相关的选项;后面的值为 dump 和 v,就对应着开启 jit.dump 和 jit.v 模式。
在 jit.v 模块的输出中,每一行都是一个成功编译的 trace 对象。上面是一个能够被 JIT 的例子,而如果遇到 NYI 原语,输出里面就会指明 NYI,比如下面这个 pairs 的例子:
系统验证
可以先在 init_by_lua 中,添加以下两行代码:
1 | local v = require "jit.v" |
避免使用阻塞操作
OpenResty 之所以可以保持很高的性能,简单来说,是因为它借用了 Nginx 的事件处理和 Lua 的协程机制。
- 在遇到网络 I/O 等需要等待返回才能继续的操作时,就会先调用 Lua 协程的 yield 把自己挂起,然后在 Nginx 中注册回调;
- 在 I/O 操作完成(也可能是超时或者出错)后,由 Nginx 回调 resume,来唤醒 Lua 协程。
这样的流程,保证了 OpenResty 可以一直高效地使用 CPU 资源,来处理所有的请求。
在这个处理流程中,如果没有使用 cosocket 这种非阻塞的方式,而是用阻塞的函数来处理 I/O,那么 LuaJIT 就不会把控制权交给 Nginx 的事件循环。这就会导致,其他的请求要一直排队等待阻塞的事件处理完,才会得到响应。
另外,Lua 世界的库很可能会带来阻塞,让原本高性能的服务,直接下降几个数量级。在可选择的情况下,一般要选择 OpenResty 或者 LuaJit 实现的库,而不是采用 Lua 世界的库。
综上所述,在 OpenResty 的编程中,对于可能出现阻塞的函数调用,要特别谨慎;否则,一行阻塞的代码,就会把整个服务的性能拖垮,让服务性能下降 10 倍。
os.execute
1 | os.execute(" cp test.exe /tmp ") |
os.execute 是 Lua 的内置函数,属于阻塞操作,而在 Lua 世界中,也确实是用这种方式来调用外部命令的。
但是,我们要记住,Lua 是一种嵌入式语言,它在不同的上下文环境中,会有完全不同的推荐用法。
在 OpenResty 的环境中,os.execute 会阻塞当前请求。所以,如果这个命令的执行时间特别短,那么影响还不是很大;
可如果这个命令,需要执行几百毫秒甚至几秒钟的时间,那么性能就会有急剧的下降。
解决方案:
- 可以优先使用 优先使用 FFI 的方式来调用
- 使用基于 ngx.pipe 的 lua-resty-shell 库
磁盘 I/O
比如 io.open ,属于阻塞操作,来获取某个文件中的所有内容。
如果在 init 和 init worker 中调用,那么它其实是个一次性的动作,并没有影响任何终端用户的请求,是完全可以被接受的。
但是,如果每一个用户的请求,都会触发磁盘的读写,那就变得不可接受了。
解决方案:
- 可以使用 lua-io-nginx-module 这个第三方的 C 模块。这种方式的原理是,lua-io-nginx-module 利用了 Nginx 的线程池,把磁盘 I/O 操作从主线程转移到另外一个线程中处理,这样,主线程就不会因为磁盘 I/O 操作而被阻塞。不过,使用这个库时,你需要重新编译 Nginx,因为它是一个 C 模块。
- 尝试架构上的调整。对于这类磁盘 I/O,是否可以换种方式,不再读写本地磁盘。比如记录日志 ngx.log 会大量而频繁的磁盘写入,也会严重地影响性能。这时可以把日志发送到远端的日志服务器上,这样就可以用 cosocket 来完成非阻塞的网络通信了,也就是把阻塞的磁盘 I/O 丢给日志服务,不要阻塞对外的服务。
luasocket
也是容易被开发者用到的一个 Lua 内置库,属于阻塞操作,经常有人分不清 luasocket 和 OpenResty 提供的 cosocket。luasocket 也可以完成网络通信的功能,但它并没有非阻塞的优势。如果使用了 luasocket,那么性能也会急剧下降。
但是,luasocket 同样有它独特的使用场景。前面讲过,cosocket 在不少阶段是无法使用的,一般可以用 ngx.timer 的方式来绕过。同时,也可以在 init_by_lua* 和 init_worker_by_lua* 这种一次性的阶段中,使用 luasocket 来完成 cosocket 的功能。
字符串拼接
在 Lua 中,字符串是不可变的。涉及到字符串的新增和 GC 时,每当新增一个字符串,LuaJIT 都得调用 lj_str_new,去查询这个字符串是否已经存在;没有的话,便需要再创建新的字符串。如果操作很频繁,自然就会对性能有非常大的影响。
1 | $ resty -e 'local begin = ngx.now() |
改为使用 table.concat
1 | $ resty -e 'local begin = ngx.now() |
ngx.location.capture
在 OpenResty 中如果使用子请求,会使用到 ngx.location.capture 这个函数,但是根据 ngx.location.capture 的官方文档 以及作者的亲自回复,如果在传递一个很大的 body 的时候,应该使用流式的 cosocket 库来代替。
因为这个函数每次都会先把很大的 body 放到内存里,然后再处理转发,这样的效率是非常低的。
table.new(narray, nhash)
这个函数,会预先分配好指定的数组和哈希的空间大小,而不是在插入元素时自增长,这也是它的两个参数 narray 和 nhash 的含义。
如果不使用这个函数,自增长是一个代价比较高的操作,会涉及到空间分配、resize 和 rehash 等,我们应该尽量避免。
table.new 的文档并没有出现在 LuaJIT 的官网,而是深藏在 GitHub 项目的 扩展文档 里,用谷歌也很难找到,所以很多人并不知道这个函数的存在。
超出预设的空间大小,也可以正常使用,只不过性能会退化,也就失去了使用 table.new 的意义。
需要根据实际场景,来预设好 table.new 中数组和哈希空间的大小,这样才能在性能和内存占用上找到一个平衡点。
下图是在 lua-resty-mysql 中的使用方法,同样,在 lua-resty-redis 以及其它项目里也存在类似的例子
table.clear()
清空 table,它用来清空某个 table 里的所有数据,但并不会释放数组和哈希部分占用的内存。
所以,它在循环利用 Lua table 时非常有用,可以避免反复创建和销毁 table 的开销。
对于大的 table,效率反而会降低,因为清空的时间高于重新创建的时间 (>1000)
table.insert
table.insert 虽然是一个很常见的操作,但性能并不乐观。
如果不是根据指定下标来插入元素,那么每次都需要调用 LuaJIT 的 lj_tab_len 来获取数组的长度,以便插入队尾。获取 table 长度的时间复杂度为 O(n) 。
一个综合实际优化的例子:
ingress-nginx 是 k8s 官方的一个项目,主要使用 Go、 Nginx 和 lua-nginx-module 来处理入口流量。
其中一个关于使用 table 相关函数进行性能优化的 PR:
https://github.com/kubernetes/ingress-nginx/pull/3673/commits
OpenResty 的 table 扩展函数
OpenResty 自己维护的 LuaJIT 分支,也对 table 做了扩展,它 新增了几个 API:table.isempty、table.isarray、 table.nkeys 和 table.clone。
关于 OpenResty 的正则
OpenResty 中并行着两套字符串匹配方法:Lua 自带的 sting 库,以及 OpenResty 提供的 ngx.re.* API。
其中, Lua 正则模式匹配是自己独有的格式,和 PCRE 的写法不同。
Lua 自带的正则匹配库,不仅代码维护成本高,而且性能低—,不能被 JIT,而且被编译过一次的模式也不会被缓存。
所以,在使用 Lua 内置的 string 库去做 find、match 等操作时,如果有类似正则这样的需求,直接使用 OpenResty 提供的 ngx.re 来替代。
只有在查找固定字符串的时候,我们才考虑使用 plain 模式来调用 string 库。
这是一个函数示例:ngx.re.match
通过查看参数 options 的文档,会发现,只要我们把它设置为 jo,就开启了 PCRE 的 JIT。 这样,使用 ngx.re.gsub 的代码,既可以被 LuaJIT 进行 JIT 编译,也可以被 PCRE JIT 进行 JIT 编译。
1 | local function fun1(str) |
性能差距在一个数量级,大概 20 倍左右。