gitbook/OpenResty从入门到实战/docs/112229.md
2022-09-03 22:05:03 +08:00

163 lines
9.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 的官方网站上,专门有一个[页面](http://wiki.luajit.org/FFI-Bindings),里面分门别类地介绍了各种 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 的兼容。这个内容也值得进一步研究,如果你学有余力,这里我给你准备了继续学习的[资料](https://github.com/thibaultcha/lua-resty-socket/)。
## 写在最后
总的来说在OpenResty 中,认识到阻塞操作的类型和解决方法,是做好性能优化的基础。那么,在实际的开发中,你遇到过类似的阻塞操作吗?你又是如何来发现和解决的呢?欢迎留言和我分享你的经验,也欢迎你把这篇文章分享出去。