9.9 KiB
31 | 性能下降10倍的真凶:阻塞函数
你好,我是温铭。
通过前面几个章节的学习,相信你已经对 LuaJIT、OpenResty 的架构,以及Lua API 和测试等方面有了全面的了解。下面,我们就要进入本专栏内容最多,也是最容易被忽视的性能优化章节了。
在性能优化章节中,我会带你熟悉 OpenResty 中性能优化的方方面面,并把前面章节中提到的零散内容,总结为全面的 OpenResty 的编码指南,以便你编写出更高质量的 OpenResty 代码。
要知道,提升性能并不容易,你需要考虑到系统架构优化、数据库优化、代码优化、性能测试、火焰图分析等不少步骤。但相反,降低性能却很容易,就像今天这节课的标题一样,你只需要加几行代码,就可以让性能下降 10 倍甚至更多。如果你使用了 OpenResty 来编写代码,但性能却一直提不上去,那么很可能就是因为使用了阻塞函数。
所以,在介绍性能优化的具体方法之前,让我们先来了解下 OpenResty 编程中的一个重要原则:避免使用阻塞函数。
我们从小就被家长和老师教育,不要玩火,不要触碰插头,这些都是危险的行为。同样的,在 OpenResty 中也存有这种危险的行为。如果你的代码中存在阻塞的操作,就会导致性能的急剧下降,那么我们使用 OpenResty 来搭建高性能服务端的初衷,也将会落空。
为什么不要用阻塞操作?
了解哪些行为是危险的,并避免使用它们,是性能优化的第一步。让我们先来回顾下,为什么阻塞操作会影响 OpenResty 的性能。
OpenResty 之所以可以保持很高的性能,简单来说,是因为它借用了 Nginx 的事件处理和 Lua 的协程机制,所以:
- 在遇到网络 I/O 等需要等待返回才能继续的操作时,就会先调用 Lua 协程的 yield 把自己挂起,然后在 Nginx 中注册回调;
- 在 I/O 操作完成(也可能是超时或者出错)后,由 Nginx 回调 resume,来唤醒 Lua 协程。
这样的流程,保证了 OpenResty 可以一直高效地使用 CPU 资源,来处理所有的请求。
在这个处理流程中,如果没有使用 cosocket 这种非阻塞的方式,而是用阻塞的函数来处理 I/O,那么 LuaJIT 就不会把控制权交给 Nginx 的事件循环。这就会导致,其他的请求要一直排队等待阻塞的事件处理完,才会得到响应。
综上所述,在 OpenResty 的编程中,对于可能出现阻塞的函数调用,我们要特别谨慎;否则,一行阻塞的代码,就会把整个服务的性能拖垮。
下面,我再来介绍几个常见的坑,也就是一些经常会被误用的阻塞函数;我们也一起来体会下,如何用最简单的方式“搞破坏“,快速让你的服务性能下降 10 倍。
执行外部命令
在很多的场景下,开发者并不只是把 OpenResty 当作 web 服务器,而是会赋予更多业务的逻辑在其中。这种情况下,就有可能需要调用外部的命令和工具,来辅助完成一些操作了。
比如杀掉某个进程:
os.execute("kill -HUP " .. pid)
或者是拷贝文件、使用 OpenSSL 生成密钥等耗时更久的一些操作:
os.execute(" cp test.exe /tmp ")
os.execute(" openssl genrsa -des3 -out private.pem 2048 ")
表面上看, os.execute
是 Lua 的内置函数,而在 Lua 世界中,也确实是用这种方式来调用外部命令的。但是,我们要记住,Lua 是一种嵌入式语言,它在不同的上下文环境中,会有完全不同的推荐用法。
在 OpenResty 的环境中,os.execute
会阻塞当前请求。所以,如果这个命令的执行时间特别短,那么影响还不是很大;可如果这个命令,需要执行几百毫秒甚至几秒钟的时间,那么性能就会有急剧的下降。
问题我们明白了,那么应该如何解决呢?一般来讲,有两个解决方案。
方案一:如果有 FFI 库可以使用,那么我们就优先使用 FFI 的方式来调用。
比如,上面我们是用 OpenSSL 的命令行来生成密钥,就可以改为,用 FFI 调用 OpenSSL 的 C 函数的方式来绕过。
而对于杀掉某个进程的示例,你可以使用 lua-resty-signal
这个 OpenResty 自带的库,来非阻塞地解决。代码实现如下,当然,这里的lua-resty-signal
,其实也是用 FFI 去调用系统函数来解决的。
local resty_signal = require "resty.signal"
local pid = 12345
local ok, err = resty_signal.kill(pid, "KILL")
另外,在 LuaJIT 的官方网站上,专门有一个页面,里面分门别类地介绍了各种 FFI 的绑定库。当你在处理图片、加解密等 CPU 密集运算的时候,可以先去里面看看,是否有已经封装好的库,可以拿来直接使用。
方案二:使用基于 ngx.pipe
的 lua-resty-shell
库。
正如之前介绍过的一样,你可以在 shell.run
中运行你自己的命令,它就是一个非阻塞的操作:
$ resty -e 'local shell = require "resty.shell"
local ok, stdout, stderr, reason, status =
shell.run([[echo "hello, world"]])
ngx.say(stdout) '
磁盘 I/O
我们再来看下,处理磁盘 I/O 的场景。在一个服务端程序中,读取本地的配置文件是一个很常见的操作,比如下面这段代码:
local path = "/conf/apisix.conf"
local file = io.open(path, "rb")
local content = file:read("*a")
file:close()
这段代码使用 io.open
,来获取某个文件中的所有内容。不过,虽然它是一个阻塞的操作,但别忘了,事情都要在实际场景下来考虑。如果你在 init 和 init worker 中调用,那么它其实是个一次性的动作,并没有影响任何终端用户的请求,是完全可以被接受的。
当然,如果每一个用户的请求,都会触发磁盘的读写,那就变得不可接受了。这时,你就需要认真地考虑解决方案了。
第一种方式,我们可以使用 lua-io-nginx-module
这个第三方的 C 模块。它为 OpenResty 提供了“非阻塞”的 Lua API,不过,这里的非阻塞是加了引号的,你不能像 cosocket 一样,随心所欲地去使用它。因为磁盘的 I/O 消耗并不会平白无故地消失,只不过是换了一种方式而已。
这种方式的原理是,lua-io-nginx-module
利用了 Nginx 的线程池,把磁盘 I/O 操作从主线程转移到另外一个线程中处理,这样,主线程就不会因为磁盘 I/O 操作而被阻塞。
不过,使用这个库时,你需要重新编译 Nginx,因为它是一个 C 模块。它的使用方法如下,和 Lua 的 I/O 库基本是一致的:
local ngx_io = require "ngx.io"
local path = "/conf/apisix.conf"
local file, err = ngx_io.open(path, "rb")
local data, err = file: read("*a")
file:close()
第二种方式,则是尝试架构上的调整。对于这类磁盘 I/O,我们是否可以换种方式,不再读写本地磁盘呢?
这里我举一个例子,你可以举一反三去思考。在多年之前,我经手的一个项目中,需要在本地磁盘中记录日志,以便统计和排除问题。
当时的开发者,是用 ngx.log
来写这些日志的,就像下面这样:
ngx.log(ngx.WARN, "info")
这行代码调用的是 OpenResty 提供的 Lua API,看上去没有任何问题。但是,缺点在于,你不能频繁地去调用它。首先, ngx.log
本身就是一个代价不小的函数调用;其次,即使有缓冲区,大量而频繁的磁盘写入,也会严重地影响性能。
那该如何解决呢?让我们回到原始的需求——统计和排错,而写入本地磁盘,本就只是达成目的的手段之一。
所以,你还可以把日志发送到远端的日志服务器上,这样就可以用 cosocket 来完成非阻塞的网络通信了,也就是把阻塞的磁盘 I/O 丢给日志服务,不要阻塞对外的服务。你可以使用 lua-resty-logger-socket
,来完成这样的工作:
local logger = require "resty.logger.socket"
if not logger.initted() then
local ok, err = logger.init{
host = 'xxx',
port = 1234,
flush_limit = 1234,
drop_limit = 5678,
}
local msg = "foo"
local bytes, err = logger.log(msg)
其实,你应该也发现了,上面两个方法的本质都是一样的:如果阻塞不可避免,那就不要阻塞主要的工作线程,丢给外部的其他线程或者服务就可以了。
luasocket
最后,我们来说说 luasocket ,它也是容易被开发者用到的一个 Lua 内置库,经常有人分不清 luasocket 和 OpenResty 提供的 cosocket。luasocket 也可以完成网络通信的功能,但它并没有非阻塞的优势。如果你使用了 luasocket,那么性能也会急剧下降。
但是,luasocket 同样有它独特的使用场景。不知道你还记得吗?前面我们讲过,cosocket 在不少阶段是无法使用的,我们一般可以用 ngx.timer
的方式来绕过。同时,你也可以在 init_by_lua*
和 init_worker_by_lua*
这种一次性的阶段中,使用 luasocket 来完成 cosocket 的功能。越熟悉 OpenResty 和 Lua 的异同,你就越能找到类似这样的有趣的解决方案。
另外,lua-resty-socket
其实就是一个二次封装的开源库,它做到了 luasocket 和 cosocket 的兼容。这个内容也值得进一步研究,如果你学有余力,这里我给你准备了继续学习的资料。
写在最后
总的来说,在OpenResty 中,认识到阻塞操作的类型和解决方法,是做好性能优化的基础。那么,在实际的开发中,你遇到过类似的阻塞操作吗?你又是如何来发现和解决的呢?欢迎留言和我分享你的经验,也欢迎你把这篇文章分享出去。