This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View File

@@ -0,0 +1,164 @@
<audio id="audio" title="31 | 性能下降10倍的真凶阻塞函数" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1b/21/1bb3e044955c5966a18c03d422c77f21.mp3"></audio>
你好,我是温铭。
通过前面几个章节的学习,相信你已经对 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(&quot;kill -HUP &quot; .. pid)
```
或者是拷贝文件、使用 OpenSSL 生成密钥等耗时更久的一些操作:
```
os.execute(&quot; cp test.exe /tmp &quot;)
os.execute(&quot; openssl genrsa -des3 -out private.pem 2048 &quot;)
```
表面上看, `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 &quot;resty.signal&quot;
local pid = 12345
local ok, err = resty_signal.kill(pid, &quot;KILL&quot;)
```
另外,在 LuaJIT 的官方网站上,专门有一个[页面](http://wiki.luajit.org/FFI-Bindings),里面分门别类地介绍了各种 FFI 的绑定库。当你在处理图片、加解密等 CPU 密集运算的时候,可以先去里面看看,是否有已经封装好的库,可以拿来直接使用。
### 方案二:使用基于 `ngx.pipe` 的 `lua-resty-shell` 库。
正如之前介绍过的一样,你可以在 `shell.run` 中运行你自己的命令,它就是一个非阻塞的操作:
```
$ resty -e 'local shell = require &quot;resty.shell&quot;
local ok, stdout, stderr, reason, status =
shell.run([[echo &quot;hello, world&quot;]])
ngx.say(stdout) '
```
## 磁盘 I/O
我们再来看下,处理磁盘 I/O 的场景。在一个服务端程序中,读取本地的配置文件是一个很常见的操作,比如下面这段代码:
```
local path = &quot;/conf/apisix.conf&quot;
local file = io.open(path, &quot;rb&quot;)
local content = file:read(&quot;*a&quot;)
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 &quot;ngx.io&quot;
local path = &quot;/conf/apisix.conf&quot;
local file, err = ngx_io.open(path, &quot;rb&quot;)
local data, err = file: read(&quot;*a&quot;)
file:close()
```
第二种方式,则是尝试架构上的调整。对于这类磁盘 I/O我们是否可以换种方式不再读写本地磁盘呢
这里我举一个例子,你可以举一反三去思考。在多年之前,我经手的一个项目中,需要在本地磁盘中记录日志,以便统计和排除问题。
当时的开发者,是用 `ngx.log` 来写这些日志的,就像下面这样:
```
ngx.log(ngx.WARN, &quot;info&quot;)
```
这行代码调用的是 OpenResty 提供的 Lua API看上去没有任何问题。但是缺点在于你不能频繁地去调用它。首先 `ngx.log` 本身就是一个代价不小的函数调用;其次,即使有缓冲区,大量而频繁的磁盘写入,也会严重地影响性能。
那该如何解决呢?让我们回到原始的需求——统计和排错,而写入本地磁盘,本就只是达成目的的手段之一。
所以,你还可以把日志发送到远端的日志服务器上,这样就可以用 cosocket 来完成非阻塞的网络通信了,也就是把阻塞的磁盘 I/O 丢给日志服务,不要阻塞对外的服务。你可以使用 `lua-resty-logger-socket` ,来完成这样的工作:
```
local logger = require &quot;resty.logger.socket&quot;
if not logger.initted() then
local ok, err = logger.init{
host = 'xxx',
port = 1234,
flush_limit = 1234,
drop_limit = 5678,
}
local msg = &quot;foo&quot;
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 中,认识到阻塞操作的类型和解决方法,是做好性能优化的基础。那么,在实际的开发中,你遇到过类似的阻塞操作吗?你又是如何来发现和解决的呢?欢迎留言和我分享你的经验,也欢迎你把这篇文章分享出去。

View File

@@ -0,0 +1,207 @@
<audio id="audio" title="32 | 让人又恨又爱的字符串操作" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/33/28/332cb3b45ddd593a265452bfedb46c28.mp3"></audio>
你好,我是温铭。
上节课里,我带你熟悉了 OpenResty 中常见的阻塞函数,它们都是初学者经常犯错的地方。从今天开始,我们就要进入性能优化的核心部分了,这其中会涉及到很多优化的技巧,可以帮助你快速提升 OpenResty 代码的性能,所以千万不要掉以轻心。
在这个过程中,你需要多写一些测试代码,来体会这些优化技巧如何使用,并验证它们的有效性,做到心中有数,拿来即用。
## 性能优化技巧的背后
优化技巧都是属于“术”的部分,在此之前,我们不妨先来聊一下优化之“道”。
性能优化的技巧,会随着 LuaJIT 和 OpenResty 的版本迭代而发生变化,一些技巧可能直接被底层技术优化,不再需要我们掌握;同时,也另会有一些新的优化技巧产生。所以,掌握这些优化技巧背后的不变的理念,才是最为重要的。
下面,让我们先来看下,在 OpenResty 编程中,有关性能方面的几个重要理念。
### 理念一:处理请求要短、平、快
OpenResty 是一个 Web 服务器,所以经常会同时处理几千、几万甚至几十万的终端请求。想要在整体上达到最高性能,我们就一定要保证单个请求被快速地处理完成,并回收内存等各种资源。
- 这里提到的“短”,是指请求的生命周期要短,不要长时间占用资源而不释放;即使是长连接,也要设定一个时间或者请求次数的阈值,来定期地释放资源。
- 第二个字“平”,则是指在一个 API 中只做一件事情。要把复杂的业务逻辑拆散为多个 API保持代码的简洁。
- 最后的“快”,是指不要阻塞主线程,不要有大量 CPU 运算。即使是不得不有这样的逻辑,也别忘了咱们上节课介绍的方法,要配合其他的服务去完成。
其实,这种架构上的考虑,不仅适合 OpenResty在其他的开发语言和平台上也都是适用的希望你能认真理解和思考。
### 理念二:避免产生中间数据
避免中间的无用数据,可以说是 OpenResty 编程中最为主要的优化理念。这里,我先给你举一个小例子,来讲解下什么是中间的无用数据。我们来看下面这段代码:
```
$ resty -e 'local s= &quot;hello&quot;
s = s .. &quot; world&quot;
s = s .. &quot;!&quot;
print(s)
'
```
这段代码,我们对`s` 这个变量做了多次拼接操作,才得到了`hello world!` 对结果。但很显然,只有 `s` 的最终状态,也就是 `hello world!` 这个状态是有用的。而 `s` 的初始值和中间的赋值,都属于中间数据,应该尽量少生成。
因为这些临时数据,会带来初始化和 GC 的性能损耗。不要小看这些损耗,如果这出现在循环等热代码中,就会带来非常明显的性能下降了。稍后我也会用字符串的示例来讲解这一点。
## 字符串是不可变的!
现在,回到本节课的主题——字符串。这里,我着重强调,**在 Lua 中,字符串是不可变的**。
当然,这并不是说字符串不能做拼接、修改等操作,而是想告诉你,在你修改一个字符串的时候,其实并没有改变原来的字符串,而是产生了一个新的字符串对象,并改变了对字符串的引用。自然,如果原有字符串没有其他的任何引用,就会给 Lua 的 GC 给回收掉。
字符串不可变的好处显而易见,那就是节省内存。这样一来,同样内容的字符串在内存中就只有一份了,不同的变量都会指向同一个内存地址。
至于这样设计的缺点,那就是涉及到字符串的新增和 GC时每当你新增一个字符串LuaJIT 都得调用 `lj_str_new`,去查询这个字符串是否已经存在;没有的话,便需要再创建新的字符串。如果操作很频繁,自然就会对性能有非常大的影响。
我们来看一个具体的例子,类似这个例子中的字符串拼接操作,在很多 OpenResty 的开源项目中都会出现:
```
$ resty -e 'local begin = ngx.now()
local s = &quot;&quot;
-- for 循环,使用 .. 进行字符串拼接
for i = 1, 100000 do
s = s .. &quot;a&quot;
end
ngx.update_time()
print(ngx.now() - begin)
'
```
这段示例代码的作用,是对`s` 变量做十万次字符串拼接,并把运行时间打印出来。虽然例子有些极端,但却能很好地体现出性能优化前后的差异。未经优化时,这段代码在我的笔记本上跑了 0.4 秒钟,还是比较慢的。那么应该如何优化呢?
在前面的课程里,我其实已经给出了答案,那就是使用 table 做一层封装,去掉所有临时的中间字符串,只保留原始数据和最终结果。我们来看下具体的代码实现:
```
$ resty -e 'local begin = ngx.now()
local t = {}
-- for 循环,使用数组来保存字符串,每次都计算数组长度
for i = 1, 100000 do
t[#t + 1] = &quot;a&quot;
end
-- 使用数组的 concat 方法拼接字符串
local s = table.concat(t, &quot;&quot;)
ngx.update_time()
print(ngx.now() - begin)
'
```
你可以看到,我用 table 依次保存了每一个字符串,下标由 `#t + 1` 来决定,也就是用 table 的当前长度加 1最后使用 `table.concat` 函数,把数组的每一个元素进行拼接,直接得到最终结果。这样自然就跳过了所有的临时字符串,避免了 10 万次 `lj_str_new` 和 GC。
刚刚是我们对于代码的分析,那么优化的具体效果如何呢?很明显,优化后的代码耗时只有 0.007 秒,也就是说,性能提升了五十多倍。事实上,在实际的项目中,性能提升可能会更加明显,因为在这个示例中,我们每次只新增了一个字符 `a`
如果新增的字符串,是 10 个 `a` 的长度,性能差异会有多大呢?这是留给你的一个作业题,欢迎在留言中分享你运行的结果。
回到我们的优化工作上,刚刚这段 0.007 秒的代码,是否就已经足够好了呢?其实不然,它还有继续优化的空间。我们不妨再来修改一行代码,然后来看下效果:
```
$ resty -e 'local begin = ngx.now()
local t = {}
-- for 循环,使用数组来保存字符串,自己维护数组的长度
for i = 1, 100000 do
t[i] = &quot;a&quot;
end
local s = table.concat(t, &quot;&quot;)
ngx.update_time()
print(ngx.now() - begin)
'
```
这次,我把 `t[#t + 1] = "a"` ,改为了 `t[i] = "a"`,只修改了这么一行代码,却就可以避免十万次获取数组长度的函数调用。还记得我们之前在 table 章节中,提到的获取数组长度的操作吗?它的时间复杂度是 O(n),显然是一个比较昂贵的操作。所以,这里我们干脆自己维护数组下标,绕过了这个获取数组长度的操作。正所谓,惹不起就躲着走呗。
当然,这是比较简化的写法。我写的下面这段代码,则更加清楚地说明了,如何自己来维护数组下标,你可以参照理解:
```
$ resty -e 'local begin = ngx.now()
local t = {}
local index = 1
for i = 1, 100000 do
t[index] = &quot;a&quot;
index = index + 1
end
local s = table.concat(t, &quot;&quot;)
ngx.update_time()
print(ngx.now() - begin)
'
```
## 减少其他临时字符串
刚刚我们所讲的字符串拼接造成的临时字符串还是显而易见的通过上面几个示例代码的提醒相信你就不会再犯类似的错误了。但是OpenResty 中还存在着一些更隐蔽的临时字符串的产生,它们就更不容易被发现了。比如下面我将讲到的这个字符串处理函数,是经常被用到的,你能想到它也会生成临时的字符串吗?
我们知道,`string.sub` 函数的作用是截取字符串的指定部分。正如我们前面所提到的Lua 中的字符串是不可变的,那么截取出来的新字符串,就会涉及到 `lj_str_new` 和后续的 GC 操作。
```
resty -e 'print(string.sub(&quot;abcd&quot;, 1, 1))'
```
上面这段代码的作用,是获取字符串的第一个字符,并打印出来。自然,它不可避免会生成临时字符串。要完成同样的效果,还有别的更好的办法吗?
```
resty -e 'print(string.char(string.byte(&quot;abcd&quot;)))'
```
自然如此。看第二段代码,我们先用 `string.byte` 获取到第一个字符的数字编码,再用 `string.char` 把数字转为对应的字符。这个过程中并没有生成任何临时的字符串。因此,使用 `string.byte` 来完成字符串相关的扫描和分析,是效率最高的。
## 利用 SDK 对 table 类型的支持
学会了减少临时字符串的方法后,你是不是跃跃欲试了呢?我们可以把上面示例代码的结果,作为响应体的内容输出给客户端。到这里,你可以暂停一下,先自己动手试着写写这段代码。
```
$ resty -e 'local begin = ngx.now()
local t = {}
local index = 1
for i = 1, 100000 do
t[index] = &quot;a&quot;
index = index + 1
end
local response = table.concat(t, &quot;&quot;)
ngx.say(response)
'
```
能写出这段代码,你就已经超越了绝大部分 OpenResty 的开发者了。不过不要骄傲你依然有进步的空间。OpenResty 的 Lua API ,已经考虑到了这种利用 table 来做字符串拼接的情况,所以,在 `ngx.say``ngx.print``ngx.log``cosocket:send` 等这些可能接受大量字符串的 API 中,它不仅接受 string 作为参数,也同时接受 table 作为参数:
```
resty -e 'local begin = ngx.now()
local t = {}
local index = 1
for i = 1, 100000 do
t[index] = &quot;a&quot;
index = index + 1
end
ngx.say(t)
'
```
在最后这段代码中,我们省略掉了 `local response = table.concat(t, "")` 这个字符串拼接的步骤,直接把 table 传给了 `ngx.say`。这样,就把字符串拼接的任务,从 Lua 层面转移到了 C 层面,又避免了一次字符串的查找、生成和 GC。对于比较长的字符串而言这又是一次不小的性能提升。
## 写在最后
学完这节课你应该也发现了OpenResty 的性能优化,很多都是在抠各种细节。所以,你需要对 LuaJIT 和 OpenResty 的 Lua API 了如指掌,才能达到最优的性能。这也提醒你,前面的内容如果有遗忘了,一定要及时复习巩固了。
最后,给你留一个作业题。我要求把 hello、world和感叹号这三个字符串写到错误日志中。你能写出一个不用字符串拼接的示例代码吗
另外,别忘了文中的另一个作业题,在下面的代码中,如果新增的字符串是 10 个 `a` 的长度,性能差异会有多大呢?
```
$ resty -e 'local begin = ngx.now()
local t = {}
for i = 1, 100000 do
t[#t + 1] = &quot;a&quot;
end
local s = table.concat(t, &quot;&quot;)
ngx.update_time()
print(ngx.now() - begin)
'
```
希望你积极思考和操作,并在留言区分享你的答案和感想。也欢迎你把这篇文章分享给你的朋友,一起学习和交流。

View File

@@ -0,0 +1,223 @@
<audio id="audio" title="33 | 性能提升10倍的秘诀必须用好 table" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a1/54/a15e8b77f80f9f0f0ce474e313dac854.mp3"></audio>
你好,我是温铭。
在 OpenResty 中除了字符串经常出现性能问题外table 也是性能的拦路虎。在之前的章节中,我们零零散散地介绍过 table 相关的函数但并没有专门提到它对性能方面的提升。今天我就带你专门来聊聊table 操作对性能的影响。
不同于对字符串的熟悉,开发者对于 table 相关的性能优化知之甚少,这主要有两个方面的原因。
- 其一OpenResty 中使用的是 Lua ,是自己的 LuaJIT 分支,不是标准的 LuaJIT也不是标准的 Lua。而大部分开发者并不知道它们之间的区别倾向于使用标准 Lua 的 table 库来写 OpenResty 代码。
- 其二,在标准 LuaJIT 和 OpenResty 自己的 LuaJIT 分支中table 操作相关的文档都藏得非常深,开发者很难找到;而且文档中也没有示例代码,需要开发者自己去开源项目中寻找示例。
这就形成了比较高的认知壁垒,导致了两极分化的结果——资深的 OpenResty 开发者能够写出很高性能的代码,而刚入门的则会怀疑 OpenResty 的高性能是不是一个泡沫。当然,等你学习完这节课的内容,你就可以轻松地戳破这层窗户纸,让性能提升 10 倍不是梦。
在详细介绍 table 优化之前我想先强调的一点是table 相关的优化,有一个自己的简单原则:
**尽量复用,避免不必要的 table 创建。**
你先记住这一点,下面,我们就从 table 的创建、元素的插入、清空、循环使用等方面,分别来介绍相关的优化。
## 预先生成数组
第一步,自然是创建数组。在 Lua 中,我们创建数组的方式很简单:
```
local t = {}
```
上面这行代码,就创建了一个空数组;当然,你也可以在创建的时候,就加上初始化的数据:
```
local color = {first = &quot;red&quot;, &quot;blue&quot;, third = &quot;green&quot;, &quot;yellow&quot;}
```
不过,第二种写法对于性能的损失比较大,原因在于每次新增和删除数组元素的时候,都会涉及到数组的空间分配、`resize``rehash`
那么应该如何优化呢?空间换时间,是一种常见的优化思路。既然这里的性能瓶颈是动态分配数组空间,那么优化的方向,就可以是预先生成一个指定大小的数组。这样做虽然可能会浪费一部分的内存空间,但多次的空间分配、`resize``rehash` 等动作,就可以合并为一次完成了,效率高了不少。
事实上LuaJIT 中的 `table.new(narray, nhash)` 函数,就是因此而新增的。
这个函数,会预先分配好指定的数组和哈希的空间大小,而不是在插入元素时自增长,这也是它的两个参数 `narray``nhash` 的含义。
下面我们通过一个简单的例子,来看下具体的使用。因为这个函数是 LuaJIT 扩展出来的,所以,在使用它之前,我们需要先 `require` 一下:
```
local new_tab = require &quot;table.new&quot;
local t = new_tab(100, 0)
for i = 1, 100 do
t[i] = i
end
```
另外,因为之前的 OpenResty 并没有完全绑定 LuaJIT还支持标准 Lua所以有些旧的代码会做这方面的兼容。如果没有找到 `table.new` 这个函数,就会模拟出来一个空的函数,来保证调用方的统一。
```
local ok, new_tab = pcall(require, &quot;table.new&quot;)
if not ok then
new_tab = function (narr, nrec) return {} end
end
```
## 自己计算 table 下标
有了 table 对象之后,下一步就是向它里面增加元素了。最直接的方法,就是调用 `table.insert` 这个函数来插入元素:
```
local new_tab = require &quot;table.new&quot;
local t = new_tab(100, 0)
for i = 1, 100 do
table.insert(t, i)
end
```
或者是先获取当前数组的长度,通过下标的方式来插入元素:
```
local new_tab = require &quot;table.new&quot;
local t = new_tab(100, 0)
for i = 1, 100 do
t[#t + 1] = i
end
```
不过,这两种方式都需要先计算数组的长度,然后再新增元素。显然,这个操作是 O(n) 的时间复杂度。就拿上面代码的例子来说for 循环会计算 100 次数组的长度,这样下来性能自然不乐观,并且数组越大时,性能也会越低。
这一点又该如何解决呢?让我们看下 `lua-resty-redis` 这个官方的库是如何做的吧:
```
local function _gen_req(args)
local nargs = #args
local req = new_tab(nargs * 5 + 1, 0)
req[1] = &quot;*&quot; .. nargs .. &quot;\r\n&quot;
local nbits = 2
for i = 1, nargs do
local arg = args[i]
req[nbits] = &quot;$&quot;
req[nbits + 1] = #arg
req[nbits + 2] = &quot;\r\n&quot;
req[nbits + 3] = arg
req[nbits + 4] = &quot;\r\n&quot;
nbits = nbits + 5
end
return req
en
```
这个函数预先生成了数组 `req`,它的大小由函数的入参来决定,这样就可以保证尽量不浪费空间。
然后,它使用 `nbits` 这个变量,来自己维护 `req` 的下标,自然就抛弃了 Lua 内置的 `table.insert` 函数和获取长度的操作符 `#`。你可以看到,在 for 循环中,`nbits + 1` 等一些运算,就是直接用下标的方式插入元素;并在最后用 `nbits = nbits + 5` ,让下标保持一个正确的值。
这种的好处很明显,它省略了获取数组大小这个 O(n) 的操作,而是直接用下标访问,时间复杂度也变成了 O(1) 。当然,缺点也一样明显,那就是降低了代码的可读性,并且出错概率大大提高,可以说,这是一把双刃剑。
## 循环使用单个 table
既然 table 这么来之不易,我们自然要好好珍惜,尽量做到重复使用。不过,循环利用也是有条件的。我们先要把 table 中原有的数据清理干净,以免对下一个使用者造成污染。
这时,`table.clear` 函数就派上用场了。从它的名字你就能看出它的作用,它会把数组中的所有数据清空,但数组的大小不会变。也就是说,你用 `table.new(narray, nhash)` 生了一个长度为 100 的数组clear 后,长度还是 100。
为了让你能够更清楚它的实现,下面我给出了一个代码示例,它兼容了标准 Lua
```
local ok, clear_tab = pcall(require, &quot;table.clear&quot;)
if not ok then
clear_tab = function (tab)
for k, _ in pairs(tab) do
tab[k] = nil
end
end
end
```
可以看到clear 函数实际上就是把每一个元素都置为了nil。
一般来说,我们会把这种循环使用的 table放在一个模块的 top level 中。这样,在你使用模块中的函数的时候,就可以根据自己的实际情况来决定,到底是直接使用,还是 clear 后再使用。
比如我们来看一个实际应用的例子。下面这段 [伪代码](https://github.com/iresty/apisix/blob/master/lua/apisix/plugin.lua) 取自开源的微服务 API 网关 APISIX这是它在加载插件时候的逻辑
```
local local_plugins = {}
function load()
core.table.clear(local_plugins)
local local_conf = core.config.local_conf()
local plugin_names = local_conf.plugins
local processed = {}
for _, name in ipairs(plugin_names) do
if processed[name] == nil then
processed[name] = true
insert_tab(local_plugins, name)
end
end
return local_plugins
```
你可以看到,`local_plugins` 这个数组,是 plugin 这个模块的 top level 变量。在 load 这个加载插件函数的开始位置, table 就会被清空,然后根据当前的情况生成新的插件列表。
## table 池
到现在,你就掌握了对单个 table 循环使用的优化方法了。那么更进一步,你还可以用缓存池的方式来保存多个 table以便随用随取官方提供的 `lua-tablepool` 正是出于这个目的。
下面这段代码,展示了 table 池的基本使用方法。我们可以从指定的池子中获取一个 table使用完以后再释放回去
```
local tablepool = require &quot;tablepool&quot;
local tablepool_fetch = tablepool.fetch
local tablepool_release = tablepool.release
local pool_name = &quot;some_tag&quot;
local function do_sth()
local t = tablepool_fetch(pool_name, 10, 0)
-- -- using t for some purposes
tablepool_release(pool_name, t)
end
```
显然tablepool 中会用到前面我们介绍过的几个方法,而且它的代码只有不到一百行,所以,如果你学有余力,我十分推荐你可以自己搜索并研究一下。这里,我主要介绍下它的两个 API。
第一个是 fetch 方法,它的参数和 table.new 基本一样,只是多了一个 `pool_name`。如果池子中没有空闲的数组fetch 方法就会调用 table.new 来新建一个数组。
```
tablepool.fetch(pool_name, narr, nrec)
```
第二个是 release 这个把 table 放回池子的函数。在它的参数中,最后的 `no_clear` ,用来配置是否要调用 table.clear 把数组清空。
```
tablepool.release(pool_name, tb, [no_clear])
```
你看,我们前面介绍到的方法,到这里是不是就全部串联起来了?
不过注意不要因此滥用tablepool。tablepool 在实际项目中的使用并不多,比如 Kong 中就没有用到APISIX 也只有少数几个调用。大多数情况下,不用 tablepool 的这层封装,也是足够我们使用的。
## 写在最后
性能优化,是 OpenResty 中的硬骨头也是我们大家关注的热点。今天我介绍了table相关的性能优化技巧希望能对你的实际项目有所帮助。
最后给你留一个作业题:你可以自己做个性能测试,对比下使用 table 相关优化技巧前后的性能差异吗?欢迎留言和我交流,你的做法和观点都是我希望听到的声音,也欢迎你把这篇文章分享出去,让更多的人一起参与进来。

View File

@@ -0,0 +1,457 @@
<audio id="audio" title="34 | 特别放送OpenResty编码指南" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c1/8b/c16e45390e340c66277c9d9ae9bb0d8b.mp3"></audio>
你好,我是温铭。
很多开发语言都有自己的编码规范,来告诉开发者这个领域内一些约定俗成的东西,让大家写的代码风格保持一致,并且避免一些常见的陷阱。这对于新手来说是非常友好的,可以让初学者快速准确地上手。比如 Python 的 PEP 80就是其中的典范几乎所有的 Python 开发者都阅读过这份 Python 作者执笔的编码规范。
**让开发者统一思想,按照规范来写代码,是一件非常重要的事情**。OpenResty 还没有自己的编码规范,有些开发者在提交 PR 后,会在代码风格上被反复 review 和要求修改,消耗了大量本可避免的时间和精力。
其实,在 OpenResty 中也有两个可以帮你自动化检测代码风格的工具luacheck 和 lj-releng。前者是 Lua 和 OpenResty 世界通用的检测工具,后者则是 OpenResty 自己用 perl 写的代码检测工具。
对我自己来说,我会在 VS Code 编辑器中安装 luacheck 的插件,这样在我写代码的时候就有工具来自动提示;而在项目的 CI 中,则是会把这两个工具都运行一遍,比如:
```
luacheck -q lua
./utils/lj-releng lua/*.lua lua/apisix/*.lua
```
毕竟,多一个工具的检测总不是坏事。
但是,这两个工具更多的是检测全局变量、每行长度等这些最基础的代码风格,离 Python PEP 80 的详细程度还有遥远的距离,并且也没有文档给你参考。
所以今天我就根据自己在OpenResty 相关开源项目中的经验,总结了一下 OpenResty 的编码风格文档,这个规范也和一些常见的 API 网关比如 Kong、APISIX 的代码风格是一致的。
## 缩进
在 OpenResty 中,我们使用 4 个空格作为缩进的标记,虽然 Lua 并没有这样的语法要求。下面是错误和正确的两段代码示例:
```
--No
if a then
ngx.say(&quot;hello&quot;)
end
```
```
--yes
if a then
ngx.say(&quot;hello&quot;)
end
```
为了方便,你可以在使用的编辑器中,把 tab 改为 4 个空格,来简化操作。
## 空格
在操作符的两边,都需要用一个空格来做分隔。下面是错误和正确的两段代码示例:
```
--No
local i=1
local s = &quot;apisix&quot;
```
```
--Yes
local i = 1
local s = &quot;apisix&quot;
```
## 空行
不少开发者会把其他语言的开发习惯带到 OpenResty 中来,比如在行尾增加一个分号:
```
--No
if a then
ngx.say(&quot;hello&quot;);
end;
```
但事实上,增加分号会让 Lua 代码显得非常丑陋,也是没有必要的。同时,你也不要为了节省代码的行数,追求所谓的“简洁”,而把多行代码变为一行。这样做会让你在定位错误的时候,不知道到底是哪一段代码出了问题:
```
--No
if a then ngx.say(&quot;hello&quot;) end
```
```
--yes
if a then
ngx.say(&quot;hello&quot;)
end
```
另外,函数之间需要用两个空行来做分隔:
```
--No
local function foo()
end
local function bar()
end
```
```
--Yes
local function foo()
end
local function bar()
end
```
如果有多个 if elseif 的分支,它们之间也需要一个空行来做分隔:
```
--No
if a == 1 then
foo()
elseif a== 2 then
bar()
elseif a == 3 then
run()
else
error()
end
```
```
--Yes
if a == 1 then
foo()
elseif a== 2 then
bar()
elseif a == 3 then
run()
else
error()
end
```
## 每行最大长度
每行不能超过 80 个字符,如果超过的话,需要你换行并对齐。并且,在换行对齐的时候,我们要体现出上下两行的对应关系。就下面的示例而言,第二行函数的参数,要在第一行左括号的右边。
```
--No
return limit_conn_new(&quot;plugin-limit-conn&quot;, conf.conn, conf.burst, conf.default_conn_delay)
```
```
--Yes
return limit_conn_new(&quot;plugin-limit-conn&quot;, conf.conn, conf.burst,
conf.default_conn_delay)
```
如果是字符串拼接问题的对齐,则需要把 `..` 放到下一行中:
```
--No
return limit_conn_new(&quot;plugin-limit-conn&quot; .. &quot;plugin-limit-conn&quot; ..
&quot;plugin-limit-conn&quot;)
```
```
--Yes
return limit_conn_new(&quot;plugin-limit-conn&quot; .. &quot;plugin-limit-conn&quot;
.. &quot;plugin-limit-conn&quot;)
```
## 变量
这一点我前面也多次强调过,我们应该永远使用局部变量,不要使用全局变量:
```
--No
i = 1
s = &quot;apisix&quot;
```
```
--Yes
local i = 1
local s = &quot;apisix&quot;
```
至于变量的命名,应该使用 `snake_case` 风格:
```
--No
local IndexArr = 1
local str_Name = &quot;apisix&quot;
```
```
--Yes
local index_arr = 1
local str_name = &quot;apisix&quot;
```
而对于常量,则是要使用全部大写的形式:
```
--No
local max_int = 65535
local server_name = &quot;apisix&quot;
```
```
--Yes
local MAX_INT = 65535
local SERVER_NAME = &quot;apisix&quot;
```
## 数组
在OpenResty中我们使用`table.new` 来预先分配数组:
```
--No
local t = {}
for i = 1, 100 do
t[i] = i
end
```
```
--Yes
local new_tab = require &quot;table.new&quot;
local t = new_tab(100, 0)
for i = 1, 100 do
t[i] = i
end
```
另外注意,一定不要在数组中使用 nil
```
--No
local t = {1, 2, nil, 3}
```
如果一定要使用空值,请用 ngx.null 来表示:
```
--Yes
local t = {1, 2, ngx.null, 3}
```
## 字符串
千万不要在热代码路径上拼接字符串:
```
--No
local s = &quot;&quot;
for i = 1, 100000 do
s = s .. &quot;a&quot;
end
```
```
--Yes
local t = {}
for i = 1, 100000 do
t[i] = &quot;a&quot;
end
local s = table.concat(t, &quot;&quot;)
```
## 函数
函数的命名也同样遵循 `snake_case`
```
--No
local function testNginx()
end
```
```
--Yes
local function test_nginx()
end
```
并且,函数应该尽可能早地返回:
```
--No
local function check(age, name)
local ret = true
if age &lt; 20 then
ret = false
end
if name == &quot;a&quot; then
ret = false
end
-- do something else
return ret
```
```
--Yes
local function check(age, name)
if age &lt; 20 then
return false
end
if name == &quot;a&quot; then
return false
end
-- do something else
return true
```
## 模块
所有 require 的库都要 local 化:
```
--No
local function foo()
local ok, err = ngx.timer.at(delay, handler)
end
```
```
--Yes
local timer_at = ngx.timer.at
local function foo()
local ok, err = timer_at(delay, handler)
end
```
为了风格的统一require 和 ngx 也需要 local 化:
```
--No
local core = require(&quot;apisix.core&quot;)
local timer_at = ngx.timer.at
local function foo()
local ok, err = timer_at(delay, handler)
end
```
```
--Yes
local ngx = ngx
local require = require
local core = require(&quot;apisix.core&quot;)
local timer_at = ngx.timer.at
local function foo()
local ok, err = timer_at(delay, handler)
end
```
## 错误处理
对于有错误信息返回的函数,我们必须对错误信息进行判断和处理:
```
--No
local sock = ngx.socket.tcp()
local ok = sock:connect(&quot;www.google.com&quot;, 80)
ngx.say(&quot;successfully connected to google!&quot;)
```
```
--Yes
local sock = ngx.socket.tcp()
local ok, err = sock:connect(&quot;www.google.com&quot;, 80)
if not ok then
ngx.say(&quot;failed to connect to google: &quot;, err)
return
end
ngx.say(&quot;successfully connected to google!&quot;)
```
而如果是自己编写的函数,错误信息要作为第二个参数,用字符串的格式返回:
```
--No
local function foo()
local ok, err = func()
if not ok then
return false
end
return true
end
```
```
--No
local function foo()
local ok, err = func()
if not ok then
return false, {msg = err}
end
return true
end
```
```
--Yes
local function foo()
local ok, err = func()
if not ok then
return false, &quot;failed to call func(): &quot; .. err
end
return true
end
```
## 写在最后
这个编程规范算是一个最初版本,我会公开到 [GitHub](https://github.com/apache/incubator-apisix/blob/v1.3/CODE_STYLE.md) 中来持续更新和维护。如果文中没有包含到你想知道的规范非常欢迎你留言提问我来给你解答。也欢迎你把这篇规范分享出去让更多的OpenResty使用者参与进来。

View File

@@ -0,0 +1,38 @@
<video poster="https://static001.geekbang.org/resource/image/53/80/536c067253bc7d68cfbb54f762484980.jpg" preload="none" controls=""><source src="https://media001.geekbang.org/customerTrans/fe4a99b62946f2c31c2095c167b26f9c/1a97c39b-16ce823e7a2-0000-0000-01d-dbacd.mp4" type="video/mp4"><source src="https://media001.geekbang.org/402947d480674f578b44c42e194ef714/8e5079a430654fe4a9901fad3e5a9a3a-72367b8ed74bf04d395686d3b65b78d1-sd.m3u8" type="application/x-mpegURL"><source src="https://media001.geekbang.org/402947d480674f578b44c42e194ef714/8e5079a430654fe4a9901fad3e5a9a3a-71ddb15e8eaa40b4179db24e849427d2-hd.m3u8" type="application/x-mpegURL"></video>
你好,我是温铭。
今天的内容,我同样会以视频的形式来讲解。老规矩,在你进行视频学习之前,先问你这么几个问题:
- 如何在开源项目中找到可能存在的性能问题?
- 在 Github 上,如何与其他开发者正确地交流?
这几个问题,也是今天视频课要解决的核心内容,希望你可以先自己思考一下,并带着问题来学习今天的视频内容。
同时,我会给出相应的文字介绍,方便你在听完视频内容后,及时总结与复习。下面是今天这节课的文字介绍部分。
## 今日核心
[ingress-nginx](https://github.com/kubernetes/ingress-nginx) 是 k8s 官方的一个项目主要使用Go、 Nginx 和 lua-nginx-module 来处理入口流量。
在今天的视频中,我会为你清楚介绍,如何运用我们刚刚学习的性能优化方面的知识,来发现开源项目的性能问题。要知道,在我们给开源项目贡献 PR 时,跑通测试案例集以及与项目维护者积极沟通,都是非常重要的。
下面是 ingress-nginx 中,和 OpenResty 性能相关的两个 PR
- [https://github.com/kubernetes/ingress-nginx/pull/3673](https://github.com/kubernetes/ingress-nginx/pull/3673)
- [https://github.com/kubernetes/ingress-nginx/pull/3674](https://github.com/kubernetes/ingress-nginx/pull/3674)
从中你也可以发现,即使是资深的开发者,对 LuaJIT 相关的优化,可能也并不是很熟悉。一方面是因为,这两个 PR 涉及到的代码,并不会对整体系统造成严重的性能下降;另一个方面,这方面的优化知识,没有人系统地总结过,开发者即使想优化也找不到方向。
事实上,很多时候,我们站在代码可读性和可维护性的角度来看,可有可无的优化是不必要的,你只要去优化那些被频繁执行的代码片段就可以了,过度优化是万恶之源。
那么,学完今天这节课后,你是否可以在其他的开源项目中,找到类似的性能优化点呢?
## 课件参考
今天的课件已经上传到了我的GitHub上你可以自己下载学习。
链接如下:[https://github.com/iresty/geektime-slides](https://github.com/iresty/geektime-slides)
如果有不清楚的地方,你可以在留言区提问,另也可以在留言区分享你的学习心得。期待与你的对话,也欢迎你把这篇文章分享给你的同事、朋友,我们一起交流、一起进步。

View File

@@ -0,0 +1,122 @@
<audio id="audio" title="36 | 盘点OpenResty的各种调试手段" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/be/58/bebe93a3eec4ec596dcd3b4cd35bc258.mp3"></audio>
你好,我是温铭。
在 OpenResty 的交流群里面经常会有开发者提出这样的疑问OpenResty 里面怎么调试呢据我所知OpenResty 中有一些支持断点调试的工具,包括 VSCode 中的插件,但至今使用并不广泛。包括作者 agentzh 以及我认识的几个贡献者在内,大家都是使用最简单的 `ngx.log``ngx.say` 来做调试。
显然,这对于大部分的新手来说并不友好。难道说众多 OpenResty 的核心维护者们,在遇到疑难杂症的时候,手里就只有打印日志这个原始的方法了吗?
当然不是,在 OpenResty 的世界中SystemTap 和火焰图,才是处理棘手问题和性能问题的标准利器。如果你在邮件列表或者 issue 里面有这方面的提问,项目的维护者肯定会让你上传火焰图,要求用图说话而不是文字描述。
接下来的两节课,我就和你聊聊调试,以及 OpenResty 专门为调试而创造的工具集。今天我们先来看下,有哪些调试程序的方法。
## 断点和打印日志
在我工作的很长一段时间里面,我都是依赖编辑器的高级调试功能来跟踪程序的,这个看上去也是理所当然的。对于能在测试环境中重现的问题,不管有多复杂,我都有信心可以找到问题的根源,这是因为,这个 bug 可以被不停地重复制造出来。只要通过设置断点和增加日志,问题的根源就会慢慢浮出水面,你所需要的,只是耐心罢了。
从这个角度来看,解决测试环境中稳定复现的 bug实际上是一个体力活。我工作中解决的绝大部分 bug 都属于这一类。
不过要注意,这里有两个前提:测试环境,以及稳定复现。现实总没有那么理想,如果是线上环境才会复现的 bug是否有调试的方法呢
这里我推荐一个工具——Mozilla RR你可以把它当作是一个复读机可以把程序的行为录制下来然后反复地重放。说白了不管线上环境还是测试环境只要你能够把 bug 的“罪证”录制下来,那就可以作为“呈堂证供”慢慢地分析了。
## 二分查找和注释
不过,对于一些大型的项目,或者涉及面比较多的系统,比如 bug 可能来自多个服务中的某一个,也可能是查询数据库的 SQL 语句有问题,在这种情况下,即使 bug 能够稳定重现,你也并不能确定 bug 出现在哪一个环节。所以Mozilla RR 这类录制的工具就失效了。
这时候,你可能会回忆起“二分查找”这个经典的算法。我们先在代码中注释掉一半的逻辑,如果问题依旧,那么就说明 bug 出在没有被注释的代码中,这时再注释掉剩下的一半逻辑,继续上面的循环。用不了几次,问题就被缩小到一个完全可控的范围了。
这个方法虽然听着有些笨,但在很多场景下确实见效很快。当然,随着技术的进步和系统复杂性的增加,现在我们更推荐使用 OpenTracing 这样的标准,来进行分布式追踪。
OpenTracing可以在系统的各处埋点通过 Trace ID 把多个 Span 组成的调用链和埋点数据上报到服务端,进行分析和图形化的展现。这样就可以发现很多隐藏的问题,而且历史数据都会保存下来,方便我们随时对比和查看。
另外,如果你的系统比较复杂,比如是在微服务的环境下,那么 Zipkin、Apache SkyWalking 都是不错的选择。
## 动态调试
上面我讲的这些调试方法,基本上已经可以解决大部分的问题了。但是,如果你遇到的是只在线上才会偶然出现的故障,那么通过增加日志、埋点的方式来追踪的话,就会耗费相当多的时间。
我就曾经遇到过这样的一个 bug。多年前我负责的一个系统在每天凌晨 1 点钟左右时,数据库资源就会被耗尽,并导致整个系统雪崩。当时,我们白天排查代码中的计划任务,到了晚上,团队的同学们就蹲守在公司等 bug 复现,复现的时候再去查看各自子模块的运行状态。这样下来,直到第三个晚上才找到了 bug 的元凶。
我的这个经历,和 Solaris 几个系统工程师创造 Dtrace 的背景很类似。当时 Solaris 的工程师们也是花了几天几夜的时间排查一个诡异的线上问题最后才发现是因为一个配置写错了。但和我不同的是Solaris 的工程师决定彻底避免这种问题,于是发明了 Dtrace专门用于动态调试。
动态调试,也叫做活体调试。和 GDB 这种静态调试工具不同,动态调试可以调试线上的服务,而对调试的程序而言,整个调试过程是无感知、无侵入的,不用你修改代码,更不用重启。打一个比方,动态调试就像 X 光,可以在病人无感知的情况下检查身体,而不需要抽血和胃镜。
Dtrace 便是最早的动态追踪框架受到它的影响其他系统中也逐渐出现了类似的动态调试工具。比如Red Hat 的工程师,就在 Linux 平台上创造了 Systemtap也就是我接下来要讲的主角。
## Systemtap
Systemtap 有自己的 DSL也就是小语言可以用来设置探测点。在介绍更多的内容之前为了不仅仅停留在抽象的概念上让我们先来安装下 Systemtap吧。这里用系统的包管理器来安装就可以了
```
sudo apt install systemtap
```
我们再来看下,用 Systemtap 写的 hello world 程序是什么样子的:
```
# cat hello-world.stp
probe begin
{
print(&quot;hello world!&quot;)
exit()
}
```
是不是很简单?不过,你需要使用 sudo 权限才可以运行:
```
sudo stap hello-world.stp
```
它会打印出我们想要的 `hello world!` 。在大部分场景下,我们都不需要自己写 stap 脚本来进行分析,因为 OpenResty 已经有了很多现成的 stap 脚本来做常规的分析,下节课我就会为你介绍这些脚本。所以,今天我们只用对 stap 脚本有一个简单的认识就行了。
操作了几下后回到我们的概念上来。Systemtap 的工作原理,是将上述 stap 脚本转换为 C运行系统 C 编译器来创建 kernel 模块。当模块被加载的时候,它会通过 hook 内核的方式,来激活所有的探测事件。
比如,刚刚这个示例代码中的 `probe` 就是一个探针。`begin` 会在探测的最开始运行,与之对应的是 `end`,所以上面的 `hello world` 程序也可以写成下面的这种方式:
```
probe begin
{
print(&quot;hello &quot;)
exit()
}
probe end
{
print(&quot;world!&quot;)
```
这里,我只对 Systemtap 进行了非常粗浅的介绍。其实Systemtap 的作者 Frank Ch. Eigler 写了一本电子书《Systemtap tutorial》详细地介绍了Systemtap。如果你想进一步地学习和深入了解 Systemtap那么我建议从这本书开始入手就是最好的学习路径。
## 其他动态追踪框架
当然,对于内核和性能分析工程师来说,只有 Systemtap 还是不够用的。首先, Systemtap 并没有默认进入系统内核;其次,它的工作原理决定了它的启动速度比较慢,而且有可能对系统的正常运行造成影响。
eBPFextended BPF则是最近几年 Linux 内核中新增的特性。相比 SystemtapeBPF有内核直接支持、不会死机、启动速度快等优点同时它并没有使用 DSL而是直接使用了 C 语言的语法,所以也大大降低了它的上手难度。
除了开源的解决方案外Intel 出品的 VTune 也是神兵利器之一。它直观的界面操作和数据展示,可以让你不写代码也能分析出性能的瓶颈。
## 火焰图
最后让我们再来回忆下前面课程中提到过的火焰图。前面我们也提到过perf 和 Systemtap 等工具产生的数据,都可以通过火焰图的方式,来进行更加直观的展示。下面这张图就是火焰图的示例:
<img src="https://static001.geekbang.org/resource/image/6e/32/6e72452ac3b97d46a44234d41993c832.png" alt="">
在火焰图中,色块的颜色和深浅都是没有意义的,只是为了对不同的色块儿做出简单的区分。火焰图其实是把每次采样的数据进行叠加,所以,真正有意义的是色块的宽度和长度。
对于 on CPU 火焰图来说,色块的宽度是函数占用的 CPU 时间百分比,色块越宽,则说明性能消耗越大。如果出现一个平顶的山峰,那它就是性能的瓶颈所在。而色块的长度,代表的是函数调用的深度,最顶端的框显示正在运行的函数,在它之下的都是这个函数的调用者。所以,在下面的函数是上面函数的父函数,山峰越高,则说明调用的函数层级越深。
为了让你更透彻掌握火焰图这个利器,在后面的视频课中,我会用一个真实的代码案例,给你演示,如何使用火焰图来找出性能的瓶颈并解决它。
## 最后
要知道,哪怕是动态跟踪这种无侵入的技术,也并不是完美的。它只能检测某一个单独的进程,而且一般情况下,我们只短暂开启它,以使用这段时间内的采样数据。所以,如果你需要跨越多个服务,或者是进行长时间的检测,还是需要 opentracing 这样的分布式追踪技术。
不知道你在平时的工作中,都使用到了哪些调试工具和技术呢?欢迎留言和我讨论,也欢迎你把这篇文章分享给你的朋友,我们一起学习和进步。

View File

@@ -0,0 +1,202 @@
<audio id="audio" title="37 | systemtap-toolkit和stapxx如何用数据搞定“疑难杂症”" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5e/2e/5e990a9aaf32d686769080c79fa07a2e.mp3"></audio>
你好,我是温铭。
正如上节课介绍过的,作为服务端开发工程师,我们并不会对动态调试的工具集做深入的学习,大都是停留在使用的这个层面上,最多去编写一些简单的 stap 脚本。更底层的,比如 CPU 缓存、体系结构、编译器等,那就是性能工程师的领域了。
在 OpenResty 中有两个开源项目:`openresty-systemtap-toolkit``stapxx` 。它们是基于 Systemtap 封装好的工具集,用于 Nginx 和 OpenResty 的实时分析和诊断。它们可以覆盖 on CPU、off CPU、共享字典、垃圾回收、请求延迟、内存池、连接池、文件访问等常用的功能和调试场景。
在今天这节课中,我会带你浏览下这些工具和对应的使用方法,目的是帮你在遇到 Nginx 和 OpenResty 的疑难杂症时,可以快速找到定位问题的工具。在 OpenResty 的世界中,学会使用这些工具是你进阶的必经之路,也是和其他开发者沟通的非常有效的方式——毕竟,工具产生的数据,会比你用文字描述更加准确和详尽。
不过需要特别注意的是OpenResty 的最新版本 1.15.8 默认开启了 LuaJIT GC64 模式,但是 `openresty-systemtap-toolkit``stapxx` 并没有跟着做对应的修改,这就会导致里面的工具都无法正常使用。所以,你最好在 OpenResty 旧的 1.13 版本中来使用这些工具。
开源项目的贡献者大都是兼职身份,他们并没有义务来保证这些工具可以一直正常使用,这也是你在使用开源项目时候需要意识到的一点。
## 以共享字典为例
按照惯例,我先用一个你最熟悉的、也是上手最简单的工具 `ngx-lua-shdict`,来作为今天开篇的示例。
`ngx-lua-shdict` 这个工具,可以分析 Nginx 的共享内存字典,并且追踪字典的操作。你可以用 `-f` 选项指定 dict 和 key来获取共享内存字典里面的数据。 `--raw` 选项可以导出指定 key 的原始值。
下面是一个从共享内存字典中获取数据的命令行示例:
```
# 假设 nginx worker pid 是 5050
$ ./ngx-lua-shdict -p 5050 -f --dict dogs --key Jim --luajit20
Tracing 5050 (/opt/nginx/sbin/nginx)...
type: LUA_TBOOLEAN
value: true
expires: 1372719243270
flags: 0xa
```
类似的,你可以用 `-w`选项,来追踪指定 key 的字典写操作:
```
$./ngx-lua-shdict -p 5050 -w --key Jim --luajit20
Tracing 5050 (/opt/nginx/sbin/nginx)...
Hit Ctrl-C to end
set Jim exptime=4626322717216342016
replace Jim exptime=4626322717216342016
^C
```
让我们看看这个工具是怎么实现的吧。`ngx-lua-shdict` 是一个 perl 的脚本,但具体的实现和 perl 并没有关系perl 只是被用来生成了 stap 脚本并运行起来:
```
open my $in, &quot;|stap $stap_args -x $pid -&quot; or die &quot;Cannot run stap: $!\n&quot;;
```
你完全可以用 Python、PHP、Go 或者你喜欢的任何语言来编写。stap 脚本中,比较关键的地方是下面这行代码:
```
probe process(&quot;$nginx_path&quot;).function(&quot;ngx_http_lua_shdict_set_helper&quot;)
```
这就是我们在上节课中提到的探针`probe`,探测的是 `ngx_http_lua_shdict_set_helper` 这个函数。而这个函数的调用,都是在 `lua-nginx-module` 模块的 `lua-nginx-module/src/ngx_http_lua_shdict.c` 文件中:
```
static int
ngx_http_lua_shdict_add(lua_State *L)
{
return ngx_http_lua_shdict_set_helper(L, NGX_HTTP_LUA_SHDICT_ADD);
}
static int
ngx_http_lua_shdict_safe_add(lua_State *L)
{
return ngx_http_lua_shdict_set_helper(L, NGX_HTTP_LUA_SHDICT_ADD
|NGX_HTTP_LUA_SHDICT_SAFE_STORE);
}
static int
ngx_http_lua_shdict_replace(lua_State *L)
{
return ngx_http_lua_shdict_set_helper(L, NGX_HTTP_LUA_SHDICT_REPLACE);
}
```
这样,我们只要探测这个函数,就可以追踪到共享字典的所有操作了。
## on CPU 和 off CPU
在使用 OpenResty 的过程中,你最常遇到的应该就是性能问题了把。性能比较差,也就是 QPS 很低的表现主要有两类CPU 占用过高和 CPU 占用过低。前者的瓶颈可能是没有使用我们之前介绍过的性能优化的方法而后者可能是因为使用了阻塞函数。相对应的on CPU 和 off CPU 火焰图,可以帮助我们确认最终的根源所在。
要生成 C 级别的 on CPU 火焰图,你需要使用 systemtap-toolkit 中的`sample-bt`;而 Lua 级别的 on CPU 火焰图,则是由 stapxx 中的 `lj-lua-stacks` 来生成的。
我们以 `sample-bt` 为例来介绍下如何使用。`sample-bt` 这个脚本,可以对你指定的任意用户进程(不仅限于 Nginx 和 OpenResty 进程),来进行调用栈的采样。
例如,我们可以用下列代码,对一个正在运行的 Nginx worker 进程PID 是 8736采样 5 秒钟:
```
$ ./sample-bt -p 8736 -t 5 -u &gt; a.bt
WARNING: Tracing 8736 (/opt/nginx/sbin/nginx) in user-space only...
WARNING: Missing unwind data for module, rerun with 'stap -d stap_df60590ce8827444bfebaf5ea938b5a_11577'
WARNING: Time's up. Quitting now...(it may take a while)
WARNING: Number of errors: 0, skipped probes: 24
```
它输出的结果文件 a.bt 可以使用 FlameGraph 工具集来生成火焰图:
```
stackcollapse-stap.pl a.bt &gt; a.cbt
flamegraph.pl a.cbt &gt; a.svg
```
这里的`a.svg` ,就是生成的火焰图,你可以用浏览器打开查看。不过要注意,在采样期间,我们需要保持一定的请求压力,否则采样数为 0 的话,就没办法生成火焰图了。
接着我们再来看下如何采样 off CPU你需要使用的脚本是 systemtap-toolkit 中的 `sample-bt-off-cpu`。它的使用方法和 `sample-bt` 类似,我也写在了下面的代码中:
```
$ ./sample-bt-off-cpu -p 10901 -t 5 &gt; a.bt
WARNING: Tracing 10901 (/opt/nginx/sbin/nginx)...
WARNING: _stp_read_address failed to access memory location
WARNING: Time's up. Quitting now...(it may take a while)
WARNING: Number of errors: 0, skipped probes: 23
```
在stapxx 中,分析延迟的工具是`epoll-loop-blocking-distr`,它会对指定的用户进程进行采样,并输出连续的 `epoll_wait` 系统调用之间的延迟分布:
```
$ ./samples/epoll-loop-blocking-distr.sxx -x 19647 --arg time=60
Start tracing 19647...
Please wait for 60 seconds.
Distribution of epoll loop blocking latencies (in milliseconds)
max/avg/min: 1097/0/0
value |-------------------------------------------------- count
0 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ 18471
1 |@@@@@@@@ 3273
2 |@ 473
4 | 119
8 | 67
16 | 51
32 | 35
64 | 20
128 | 23
256 | 9
512 | 2
1024 | 2
2048 | 0
4096 | 0
```
你可以看到,这个输出结果显示,绝大部分延迟都小于 1 毫秒,但也有少数是在 200 毫秒以上的,这些就是需要关注的。
## 上游和阶段跟踪
除了 OpenResty 的代码本身可能出现性能问题外,当 OpenResty 通过 `cosocket` 或者 `proxy_pass` 这样的上游模块,与上游服务进行通信时,如果上游服务自身的延时比较大,也会对整体的性能带来很大的影响。
这个时候,你可以使用 `ngx-lua-tcp-recv-time``ngx-lua-udp-recv-time``ngx-single-req-latency` 这几个工具来进行分析,这里我以 `ngx-single-req-latency` 为例解释下。
这个工具和工具集里面的大部分工具并不太一样。其他工具,多是基于大量的采样和统计分析,得出一个数学上的分布结论。而 `ngx-single-req-latency` 分析的却是单个的请求,跟踪出单个请求在 OpenResty 中各个阶段的耗时,比如 rewrite、access、content 阶段以及上游的耗时。
我们可以来看一个具体的示例代码:
```
# making the ./stap++ tool visible in PATH:
$ export PATH=$PWD:$PATH
# assuming an nginx worker process's pid is 27327
$ ./samples/ngx-single-req-latency.sxx -x 27327
Start tracing process 27327 (/opt/nginx/sbin/nginx)...
POST /api_json
total: 143596us, accept() ~ header-read: 43048us, rewrite: 8us, pre-access: 7us, access: 6us, content: 100507us
upstream: connect=29us, time-to-first-byte=99157us, read=103us
$ ./samples/ngx-single-req-latency.sxx -x 27327
Start tracing process 27327 (/opt/nginx/sbin/nginx)...
GET /robots.txt
total: 61198us, accept() ~ header-read: 33410us, rewrite: 7us, pre-access: 7us, access: 5us, content: 27750us
upstream: connect=30us, time-to-first-byte=18955us, read=96us
```
这个工具会跟踪它启动后遇到的第一个请求。输出的内容和 opentracing 非常类似,你甚至可以把 systemtap-toolkit 和 stapxx ,当作是 OpenResty 中 APM应用性能管理的非侵入版本。
## 写在最后
除了今天我讲到的这些常用工具OpenResty 自然还提供了更多的工具,它们就交给你自己去探索和学习了。
其实在追踪技术方面OpenResty 和其他的开发语言、平台相比,还有一个比较大的不同之处,希望你可以慢慢体会:
>
保持代码基的简洁和稳定,不要在其中增加探针,而是通过外部动态跟踪的技术来进行采样。
最后给你留一个问题,你在使用 OpenResty 的时候,使用过哪些工具来进行跟踪和分析问题呢?欢迎留言和我探讨这个问题,也欢迎你把这篇文章分享出去,我们一起交流和进步。

View File

@@ -0,0 +1,33 @@
<video poster="https://static001.geekbang.org/resource/image/0b/02/0bf3ba61bafc3a97514996c701c99e02.jpg" preload="none" controls=""><source src="https://media001.geekbang.org/customerTrans/fe4a99b62946f2c31c2095c167b26f9c/1d56d7e0-16ce8221564-0000-0000-01d-dbacd.mp4" type="video/mp4"><source src="https://media001.geekbang.org/98c47d3154564eb08b624deb1aa4e260/d32353463ce34201b74a0b3807160f6b-cf761b25fd08f06e963332465b9b0f4c-sd.m3u8" type="application/x-mpegURL"><source src="https://media001.geekbang.org/98c47d3154564eb08b624deb1aa4e260/d32353463ce34201b74a0b3807160f6b-335f34e72825be78cd1db071594c6c08-hd.m3u8" type="application/x-mpegURL"></video>
你好,我是温铭。
今天是我们专栏中的最后一节视频课了,后面内容仍然以图文形式呈现。老规矩,为了更有针对性地学习,在你进行视频学习之前,我想先问你这么几个问题:
- 你测试过 OpenResty 程序的性能吗?如何才能科学地找到性能瓶颈?
- 如何看懂火焰图的信息,并与 Lua 代码相对应呢?
这几个问题,也是今天视频课要解决的核心内容,希望你可以先自己思考一下,并带着问题来学习今天的视频内容。
同时,我会给出相应的文字介绍,方便你在听完视频内容后,及时总结与复习。下面是今天这节课的文字介绍部分。
## 今日核心
今天的视频课,我会用一个开源的小项目来演示一下,如何通过 wrk 和火焰图来优化代码,这个项目地址为:[https://github.com/iresty/lua-performance-demo](https://github.com/iresty/lua-performance-demo)。
视频中的环境是 Ubuntu 16.04,其中的 systemtap 和 wrk 工具,都是使用 apt-get 来安装的,不推荐你用源码来安装。
这里的demo 有几个不同的版本,我会用 wrk 来压测每一个版本的 qps。同时在压测过程中我都会使用 stapxx 来生成火焰图,并用火焰图来指导我们去优化哪一个函数和代码块。
最后的结果是,我们会看到一个性能提升 10 倍以上的版本,当然,这其中的优化方式,都是在专栏前面课程中提到过的。建议你可以 clone 这个 demo 项目,来复现我在视频中的操作,加深对 wrk、火焰图和性能优化的理解。
要知道,性能优化并不是感性和直觉的判断,而是需要科学的数据来做指导的。这里的数据,不仅仅是指 qps 等最终的性能指标,也包括了用数据来定位具体的瓶颈。
## 课件参考
今天的课件已经上传到了我的GitHub上你可以自己下载学习。
链接如下:[https://github.com/iresty/geektime-slides](https://github.com/iresty/geektime-slides)
如果有不清楚的地方,你可以在留言区提问,另也可以在留言区分享你的学习心得。期待与你的对话,也欢迎你把这篇文章分享给你的同事、朋友,我们一起交流、一起进步。

View File

@@ -0,0 +1,154 @@
<audio id="audio" title="39 | 高性能的关键shared dict 缓存和 lru 缓存" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d0/f6/d03110e6462211c716e17d52f14b61f6.mp3"></audio>
你好,我是温铭。
在前面几节课中,我已经把 OpenResty 自身的优化技巧和性能调优的工具都介绍过了分别涉及到字符串、table、Lua API、LuaJIT、SystemTap、火焰图等。
这些都是系统优化的基石,需要你好好掌握。但是,只懂得它们,还是不足以面对真实的业务场景。在一个稍微复杂一些的业务中,保持高性能是一个系统性的工作,并不仅仅是代码和网关层面的优化。它会涉及到数据库、网络、协议、缓存、磁盘等各个方面,这也正是架构师存在的意义。
今天这节课,就让我们一起来看下,性能优化中扮演非常关键角色的组件——缓存,看看它在 OpenResty 中是如何使用和进行优化的。
## 缓存
在硬件层面,大部分的计算机硬件都会用缓存来提高速度,比如 CPU 会有多级缓存RAID 卡也有读写缓存。而在软件层面,我们用的数据库就是一个缓存设计非常好的例子。在 SQL 语句的优化、索引设计以及磁盘读写的各个地方,都有缓存。
这里,我也建议你在设计自己的缓存之前,先去了解下 MySQL 里面的各种缓存机制。我给你推荐的资料是《High Performance MySQL》 这本非常棒的书。我在多年前负责数据库的时候,从这本书中获益良多,而且后来不少其他的优化场景,也借鉴了 MySQL 的设计。
回到缓存上来说,我们知道,一个生产环境的缓存系统,需要根据自己的业务场景和系统瓶颈,来找出最好的方案。这是一门平衡的艺术。
一般来说,缓存有两个原则。
- 一是越靠近用户的请求越好。比如,能用本地缓存的就不要发送 HTTP 请求,能用 CDN 缓存的就不要打到源站,能用 OpenResty 缓存的就不要打到数据库。
- 二是尽量使用本进程和本机的缓存解决。因为跨了进程和机器甚至机房,缓存的网络开销就会非常大,这一点在高并发的时候会非常明显。
自然在OpenResty 中缓存的设计和使用也遵循这两个原则。OpenResty 中有两个缓存的组件shared dict 缓存和 lru 缓存。前者只能缓存字符串对象,缓存的数据有且只有一份,每一个 worker 都可以进行访问,所以常用于 worker 之间的数据通信。后者则可以缓存所有的 Lua 对象,但只能在单个 worker 进程内访问,有多少个 worker就会有多少份缓存数据。
下面这两个简单的表格,可以说明 shared dict 和 lru 缓存的区别:
<img src="https://static001.geekbang.org/resource/image/ba/cd/baa08571c1ca48585d14a6141e2f00cd.png" alt=""><br>
<img src="https://static001.geekbang.org/resource/image/c3/a9/c3f7415b4cb793a556f27b08da370ca9.png" alt=""><br>
shared dict 和 lru 缓存,并没有哪一个更好的说法,而是应该根据你的场景来配合使用。
- 如果你没有 worker 之间共享数据的需求那么lru 可以缓存数组、函数等复杂的数据类型,并且性能最高,自然是首选。
- 但如果你需要在 worker 之间共享数据,那就可以在 lru 缓存的基础上,加上 shared dict 的缓存,构成两级缓存的架构。
接下来,我们具体来看看这两种缓存方式。
## 共享字典缓存
在 Lua 章节中,我们已经对 shared dict 做了具体的介绍,这里先简单回顾下它的使用方法:
```
$ resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs
dict:set(&quot;Tom&quot;, 56)
print(dict:get(&quot;Tom&quot;))'
```
你需要事先在 Nginx 的配置文件中,声明一个内存区 dogs然后在 Lua 代码中才可以使用。如果你在使用的过程中,发现给 dogs 分配的空间不够用,那么是需要先修改 Nginx 配置文件,然后重新加载 Nginx 才能生效的。因为我们并不能在运行时进行扩容和缩容。
下面,我们重点聊下,在共享字典缓存中,和性能相关的几个问题。
### 缓存数据的序列化
第一个问题,缓存数据的序列化。由于共享字典中只能缓存字符串对象,所以,如果你想要缓存数组,就少不了要在 set 的时候要做一次序列化,在 get 的时候做一次反序列化:
```
resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs
dict:set(&quot;Tom&quot;, require(&quot;cjson&quot;).encode({a=111}))
print(require(&quot;cjson&quot;).decode(dict:get(&quot;Tom&quot;)).a)'
```
不过,这类序列化和反序列化操作是非常消耗 CPU 资源的。如果每个请求都有那么几次这种操作,那么,在火焰图上你就能很明显地看到它们的消耗。
所以如何在共享字典里避免这种消耗呢其实这里并没有什么好的办法要么在业务层面避免把数组放到共享字典里面要么自己去手工拼接字符串为JSON 格式,当然,这也会带来字符串拼接的性能消耗,以及可能会隐藏更多的 bug 在其中。
大部分的序列化都是可以在业务层面进行拆解的。你可以把数组的内容打散,分别用字符串的形式存储在共享字典中。如果还不行的话,那么也可以把数组缓存在 lru 中,用内存空间来换取程序的便捷性和性能。
此外,缓存中的 key 也应该尽量选择短和有意义的,这样不仅可以节省空间,也方便后续的调试。
### stale 数据
共享字典中还有一个 `get_stale` 的读取数据的方法,相比 `get` 方法,多了一个过期数据的返回值:
```
resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs
dict:set(&quot;Tom&quot;, 56, 0.01)
ngx.sleep(0.02)
local val, flags, stale = dict:get_stale(&quot;Tom&quot;)
print(val)'
```
在上面的这个示例中,数据只在共享字典中缓存了 0.01 秒,在 set 后的 0.02 秒后,数据就已经超时了。这时候,通过 get 接口就不会获取到数据了,但通过 `get_stale` 还可能获取到过期的数据。这里我之所以用“可能”两个字,是因为过期数据所占用的空间,是有一定几率被回收,再给其他数据使用的,这也就是 LRU 算法。
看到这里,你可能会有疑惑吗:获取已经过期的数据有什么用呢?不要忘记了,我们在 shared dict 中存放的是缓存数据,即使缓存数据过期了,也并不意味着源数据就一定有更新。
举个例子,数据源存储在 MySQL 中,我们从 MySQL 中获取到数据后,在 shared dict 中设置了 5 秒超时,那么,当这个数据过期后,我们就会有两个选择:
- 当这个数据不存在时,重新去 MySQL 中再查询一次,把结果放到缓存中;
- 判断 MySQL 的数据是否发生了变化,如果没有变化,就把缓存中过期的数据读取出来,修改它的过期时间,让它继续生效。
很明显,后者是更优化的方案,这样可以尽可能少地去和 MySQL 交互,让终端的请求都从最快的缓存中获取数据。
这时候,如何判断数据源中的数据是否发生了变化,就成为了我们需要考虑和解决的问题。接下来,让我们以 lru 缓存为例,看看一个实际的项目是如何来解决这个问题的。
## lru 缓存
lru 缓存的接口只有 5 个:`new``set``get``delete``flush_all`。和上面问题相关的就只有 `get` 接口,让我们先来了解下这个接口是如何使用的:
```
resty -e 'local lrucache = require &quot;resty.lrucache&quot;
local cache, err = lrucache.new(200)
cache:set(&quot;dog&quot;, 32, 0.01)
ngx.sleep(0.02)
local data, stale_data = cache:get(&quot;dog&quot;)
print(stale_data)'
```
你可以看到在lru 缓存中, get 接口的第二个返回值直接就是 `stale_data`,而不是像 shared dict 那样分为了 `get``get_stale` 两个不同的 API。这样的接口封装对于使用过期数据来说显然更加友好。
在实际的项目中,我们一般推荐使用版本号来区分不同的数据,这样,在数据发声变化后,它的版本号也就跟着变了。比如,在 etcd 中的 `modifiedIndex` ,就可以拿来当作版本号,来标记数据是否发生了变化。有了版本号的概念后,我们就可以对 lru 缓存做一个简单的二次封装,比如来看下面的伪码,摘自[https://github.com/iresty/apisix/blob/master/lua/apisix/core/lrucache.lua](https://github.com/iresty/apisix/blob/master/lua/apisix/core/lrucache.lua)
```
local function (key, version, create_obj_fun, ...)
local obj, stale_obj = lru_obj:get(key)
-- 如果数据没有过期,并且版本没有变化,就直接返回缓存数据
if obj and obj._cache_ver == version then
return obj
end
-- 如果数据已经过期,但还能获取到,并且版本没有变化,就直接返回缓存中的过期数据
if stale_obj and stale_obj._cache_ver == version then
lru_obj:set(key, obj, item_ttl)
return stale_obj
end
-- 如果找不到过期数据,或者版本号有变化,就从数据源获取数据
local obj, err = create_obj_fun(...)
obj._cache_ver = version
lru_obj:set(key, obj, item_ttl)
return obj, err
end
```
从这段代码中你可以看到,我们通过引入版本号的概念,在版本号没有变化的情况下,充分利用了过期数据来减少对数据源的压力,达到了性能的最优。
除此之外,在上面的方案中,其实还有一个潜在的很大优化点,那就是我们把 key 和版本号做了分离,把版本号作为 value 的一个属性。
我们知道,更常规的做法是把版本号写入 key 中。比如 key 的值是 `key_1234`,这种做法非常普遍,但在 OpenResty 的环境下,这样其实是存在浪费的。为什么这么说呢?
举个例子你就明白了。假如版本号每分钟变化一次,那么`key_1234` 过一分钟就变为了 `key_1235`,一个小时就会重新生成 60 个不同的 key以及 60 个 value。这也就意味着 Lua GC 需要回收 59 个键值对背后的 Lua 对象。如果你的更新更加频繁,那么对象的新建和 GC 显然会消耗更多的资源。
当然,这些消耗也可以很简单地避免,那就是把版本号从 key 挪到 value 中。这样,一个 key 不管更新地多么频繁,也只有固定的两个 Lua 对象。可以看出,这样的优化技巧非常巧妙,不过,简单巧妙的技巧背后,其实需要你对 OpenResty 的 API 以及缓存的机制都有很深入的了解才可以。
## 写在最后
诚然OpenResty 的文档比较详细,但如何和业务组合以产生最大的优化效果,就需要你自己来来体会和领悟了。很多时候,文档中只有一两句的地方,比如 stale data这样的却会产生巨大的性能差异。
那么,你在使用 OpenResty 的过程中,是否有过类似的经历呢?欢迎留言和我分享,也欢迎你把这篇文章分享出去,我们一起学习和进步。

View File

@@ -0,0 +1,145 @@
<audio id="audio" title="40 | 缓存与风暴并存,谁说缓存风暴不可避免?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b8/90/b8eec903993ce72ea8cb8b9423871790.mp3"></audio>
你好,我是温铭。
在前面缓存的那节课中,我为你介绍了,共享字典和 lru 缓存在高性能方面的一些优化技巧。其实,我们还遗留了一个非常重要的问题,也值得我们今天用单独的一节课来介绍,那就是“缓存风暴”。
## 什么是缓存风暴?
什么是缓存风暴呢?让我们先来设想下面这么一个场景。
数据源在 MySQL 数据库中,缓存的数据放在共享字典中,超时时间为 60 秒。在这 60 秒内的时间里所有的请求都从缓存中获取数据MySQL 没有任何的压力。但是,一旦到达 60 秒,也就是缓存数据失效的那一刻,如果正好有大量的并发请求进来,在缓存中没有查询到结果,就要触发查询数据源的函数,那么这些请求全部都将去查询 MySQL 数据库,直接造成数据库服务器卡顿,甚至卡死。
这种现象就叫做“缓存风暴”,它也有一个对应的英文名字`Dog-Pile`。很明显,我们之前出现的缓存相关的代码,都没有做过对应的处理。比如下面这段代码,就是有缓存风暴隐患的伪代码:
```
local value = get_from_cache(key)
if not value then
value = query_db(sql)
set_to_cache(value, timeout 60)
end
return value
```
这段伪代码看上去逻辑都是正常的,你使用单元测试或者端对端测试,都不会触发缓存风暴。只有长时间的压力测试才会发现这个问题,每隔 60 秒的时间,数据库就会出现一次查询的峰值,非常有规律。不过,如果你这里的缓存失效时间设置得比较长,那么缓存风暴问题被发现的几率就会降低。
## 如何避免缓存风暴?
现在明白了什么是缓存风暴,我们的下一步就是要搞清楚如何避免它了。下面,让我们分为几个不同的情况来讨论一下。
### 主动更新缓存
在上面的伪代码中,缓存是被动更新的。只有在终端请求发现缓存失效时,它才会去数据库查询新的数据。那么,如果我们把缓存的更新,从被动改为主动,也就可以直接绕开缓存风暴的问题了。
在 OpenResty 中,我们可以这样来实现。首先,使用 `ngx.timer.every` 来创建一个定时任务,每分钟运行一次,去 MySQL 数据库中获取最新的数据,并放入共享字典中:
```
local function query_db(premature, sql)
local value = query_db(sql)
set_to_cache(value, timeout 60)
end
local ok, err = ngx.timer.every(60, query_db, sql)
```
然后,在终端请求的代码逻辑中,去掉查询 MySQL 的部分,只保留获取共享字典缓存的代码:
```
local value = get_from_cache(key)
return value
```
通过这样两段伪码的操作缓存风暴的问题就被绕过去了。但这种方式也并非完美因为这样的每一个缓存都要对应一个周期性的任务OpenResty 中 timer 是有上限的,不能太多);而且缓存过期时间和计划任务的周期时间还要对应好,如果这中间出现了什么纰漏,终端就可能一直获取到的都是空数据。
所以,在实际的项目中,我们一般都是使用加锁的方式来解决缓存风暴问题。接下来,我将为你讲解几种不同的加锁方式,你可以根据需要自行选择。
### lua-resty-lock
我知道,一提到加锁,很多人可能会眉头一皱,觉得这是一个比较重的操作。而且,如果发生死锁了该怎么办呢?这显然还要处理不少异常情况。
但是,使用 OpenResty 中的 `lua-resty-lock` 这个库来加锁,这样的担心就大可不必了。`lua-resty-lock` 是 OpenResty 自带的 resty 库,它底层是基于共享字典,提供非阻塞的 lock API。我们先来看一个简单的示例
```
resty --shdict='locks 1m' -e 'local resty_lock = require &quot;resty.lock&quot;
local lock, err = resty_lock:new(&quot;locks&quot;)
local elapsed, err = lock:lock(&quot;my_key&quot;)
-- query db and update cache
local ok, err = lock:unlock()
ngx.say(&quot;unlock: &quot;, ok)'
```
因为 `lua-resty-lock` 是基于共享字典来实现的,所以我们需要事先声明 shdict 的名字和大小;然后,再使用 `new` 方法来新建 lock 对象。你可以看到,这段代码中,我们只传了第一个参数 `shdict` 的名字。其实, `new` 方法还有第二个参数,可以用来指定锁的过期时间、等待锁的超时时间等多个参数。不过这里,我们使用的是默认值,它们就是用来避免死锁等各种异常问题的。
接着,我们就可以调用 `lock` 方法尝试获取锁。如果成功获取到锁的话,那就可以保证只有一个请求去数据源更新数据;而如果因为锁已经被抢占、超时等导致加锁失败,那就需要从陈旧的缓存中获取数据,返回给终端。这个过程是不是听起来很熟悉?没错,这里就正好用到了我们上节课介绍过的的 `get_stale` API
```
local elapsed, err = lock:lock(&quot;my_key&quot;)
# elapsed 为 nil 表示加锁失败err的返回值是 timeout、 locked 中的一个
if not elapsed and err then
dict:get_stale(&quot;my_key&quot;)
end
```
如果 `lock` 成功,那么就可以安全地去查询数据库,并把结果更新到缓存中。最后,我们再调用 `unlock` 接口,把锁释放掉就可以了。
结合 `lua-resty-lock``get_stale`,我们就完美地解决了缓存风暴的问题。在 `lua-resty-lock` 的文档中,给出了非常完整的处理代码,推荐你可以点击[链接](https://github.com/openresty/lua-resty-lock#for-cache-locks)查看。
不过,每当遇到一些有趣的实现,我们总是希望能够看看它的源码是如何实现的,这也是开源的好处之一。这里,我们再深入一步,看看 `lock` 这个接口是如何加锁的,下面便是它的源码:
```
local ok, err = dict:add(key, true, exptime)
if ok then
cdata.key_id = ref_obj(key)
self.key = key
return 0
end
```
在共享字典章节中我曾经提到过shared dict 的所有 API 都是原子操作,不用担心出现竞争,所以用 shared dict 来标记锁的状态是个不错的主意。
这里 `lock` 接口的实现,便使用了 `dict:add` 接口来尝试设置 key。如果 key 在共享内存中不存在,`add` 接口就会返回成功,表示加锁成功;其他并发的请求走到 `dict:add` 这一行的代码逻辑时,就会返回失败,然后根据返回的 err 信息,选择是直接返回,还是多次重试。
### lua-resty-shcache
不过,在上面 `lua-resty-lock` 的实现中,你需要自己来处理加锁、解锁、获取过期数据、重试、异常处理等各种问题,还是相当繁琐的。所以,这里我再给你介绍一个简单的封装:`lua-resty-shcache`
`lua-resty-shcache`是 CloudFlare 开源的一个 lua-resty 库,它在共享字典和外部存储之上,做了一层封装;并且额外提供了序列化和反序列化的接口,让你不用去关心上述的各种细节:
```
local shcache = require(&quot;shcache&quot;)
local my_cache_table = shcache:new(
ngx.shared.cache_dict
{ external_lookup = lookup,
encode = cmsgpack.pack,
decode = cmsgpack.decode,
},
{ positive_ttl = 10, -- cache good data for 10s
negative_ttl = 3, -- cache failed lookup for 3s
name = 'my_cache', -- &quot;named&quot; cache, useful for debug / report
}
)
local my_table, from_cache = my_cache_table:load(key)
```
这段示例代码摘自官方的示例,不过,我已经把细节都隐藏了,方便你更好地把握重点。事实上,这个缓存封装的库并非是我们的最佳选择,但比较适合初学者去学习,所以我首先介绍的是它。在下一节课中,我会给你介绍其他的几个更好、更常用的封装,方便我们选择最合适的来使用。
### Nginx 配置指令
另外,即使你没有使用 OpenResty 的 lua-resty 库,你也可以用 Nginx 的配置指令,来实现加锁和获取过期数据——即`proxy_cache_lock``proxy_cache_use_stale`。不过,这里我并不推荐使用 Nginx 指令这种方式,它显然不够灵活,性能也比不上 Lua 代码。
## 写在最后
这节课,我主要为你介绍了缓存风暴和相应的几种应对方式。不得不说,缓存风暴,和之前我们反复提到的 race 问题一样,通过 code review 和测试都很难被发现,最好的方法还是提升我们本身的编码水平,或者使用封装好的类库来解决这类问题。
最后,给你留一个作业题。在你熟悉的语言和平台中,都是如何处理缓存风暴之类问题的呢?是否有比 OpenResty 更好的解决思想和方法呢?欢迎留言和我讨论这个问题,也欢迎你把这篇文章分享给你的同事朋友,一起学习和进步。

View File

@@ -0,0 +1,160 @@
<audio id="audio" title="41 | lua-resty-* 封装,让你远离多级缓存之痛" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/18/10/18fb26d6243c091597db8cf4af421510.mp3"></audio>
你好,我是温铭。
前面两节课中,我们已经学习了 OpenResty 中的缓存,以及容易出错的缓存风暴问题,这些都是属于偏基础的一些知识。在实际的项目开发中,开发者自然更希望有一个已经把各种细节处理好并隐藏起来的开箱即用的库,可以拿来直接开发业务代码。
这其实就是分工的一个好处,基础组件的开发者,重心在于架构灵活、性能极致、代码稳定,并不需要关心上层业务逻辑;而应用层的工程师,更关心的是业务实现和快速迭代,并不希望被底层的各种技术细节分心。这中间的鸿沟,就需要有一些封装库来填平了。
OpenResty 中的缓存,也面临一样的问题。共享字典和 lru 缓存足够稳定和高效,但需要处理太多的细节。如果没有一些好用的封装,那么到达应用开发工程师的“最后一公里”,就会变得比较痛苦。这个时候,就要体现社区的重要性了。一个活跃的社区,会主动去发现鸿沟,并迅速地填平。
## lua-resty-memcached-shdict
让我们回到缓存的封装上来。`lua-resty-memcached-shdict` 是 OpenResty 官方的一个项目,它使用 shared dict 为 memcached 做了一层封装,处理了缓存风暴和过期数据等细节。如果你的缓存数据正好存储在后端的 memcached 中,那么你可以尝试使用这个库。
它虽然是 OpenResty 官方开发的库,但默认并没有打进 OpenResty 的包中。如果你想在本地测试,需要先把它的[源码](https://github.com/openresty/lua-resty-memcached-shdict)下载到本地 OpenResty 的查找路径下。
这个封装库,其实和我们上节课中提到的解决方案是一样的。它使用 `lua-resty-lock` 来做到互斥,在缓存失效的情况下,只有一个请求去 memcached 中获取数据,避免缓存风暴。如果没有获取到最新数据,则使用 stale 数据返回给终端。
不过,这个 lua-resty 库虽说是 OpenResty 官方的项目,但也并不完美。首先,它没有测试案例覆盖,这就意味着代码质量无法得到持续的保证;其次,它暴露的接口参数过多,有 11 个必填参数和 7 个选填参数:
```
local memc_fetch, memc_store =
shdict_memc.gen_memc_methods{
tag = &quot;my memcached server tag&quot;,
debug_logger = dlog,
warn_logger = warn,
error_logger = error_log,
locks_shdict_name = &quot;some_lua_shared_dict_name&quot;,
shdict_set = meta_shdict_set,
shdict_get = meta_shdict_get,
disable_shdict = false, -- optional, default false
memc_host = &quot;127.0.0.1&quot;,
memc_port = 11211,
memc_timeout = 200, -- in ms
memc_conn_pool_size = 5,
memc_fetch_retries = 2, -- optional, default 1
memc_fetch_retry_delay = 100, -- in ms, optional, default to 100 (ms)
memc_conn_max_idle_time = 10 * 1000, -- in ms, for in-pool connections,optional, default to nil
memc_store_retries = 2, -- optional, default to 1
memc_store_retry_delay = 100, -- in ms, optional, default to 100 (ms)
store_ttl = 1, -- in seconds, optional, default to 0 (i.e., never expires)
}
```
这其中暴露的绝大部分参数,其实可以通过“新建一个 memcached 的处理函数”的方式来简化。当前这种把所有参数一股脑儿地丢给用户来填写的封装方式并不友好,所以,我也很欢迎有兴趣的开发者,贡献 PR 来做这方面的优化。
另外,在这个封装库的文档中,其实也提到了进一步的优化方向:
- 一是使用 `lua-resty-lrucache` ,来增加 worker 层的缓存,而不仅仅是 server 级别的 shared dict 缓存;
- 二是使用 `ngx.timer` ,来做异步的缓存更新操作。
第一个方向其实是很不错的建议,因为 worker 内的缓存性能自然会更好;而第二个建议,就需要你根据自己的实际场景来考量了。不过,一般我并不推荐使用,这不仅是因为 timer 的数量是有限制的,而且如果这里的更新逻辑出错,就再也不会去更新缓存了,影响面比较大。
## lua-resty-mlcache
接下来,我们再来介绍下,在 OpenResty 中被普遍使用的缓存封装: `lua-resty-mlcache`。它使用 shared dict 和 lua-resty-lrucache ,实现了多层缓存机制。我们下面就通过两段代码示例,来看看这个库如何使用:
```
local mlcache = require &quot;resty.mlcache&quot;
local cache, err = mlcache.new(&quot;cache_name&quot;, &quot;cache_dict&quot;, {
lru_size = 500, -- size of the L1 (Lua VM) cache
ttl = 3600, -- 1h ttl for hits
neg_ttl = 30, -- 30s ttl for misses
})
if not cache then
error(&quot;failed to create mlcache: &quot; .. err)
end
```
先来看第一段代码。这段代码的开头引入了 mlcache 库,并设置了初始化的参数。我们一般会把这段代码放到 init 阶段,只需要做一次就可以了。
除了缓冲名和字典名这两个必填的参数外,第三个参数是一个字典,里面 12 个选项都是选填的,不填的话就使用默认值。这种方式显然就比 `lua-resty-memcached-shdict` 要优雅很多。其实,我们自己来设计接口的话,也最好采用 mlcache 这样的做法——让接口尽可能地简单,同时还保留足够的灵活性。
下面再来看第二段代码,这是请求处理时的逻辑代码:
```
local function fetch_user(id)
return db:query_user(id)
end
local id = 123
local user , err = cache:get(id , nil , fetch_user , id)
if err then
ngx.log(ngx.ERR , &quot;failed to fetch user: &quot;, err)
return
end
if user then
print(user.id) -- 123
end
```
你可以看到,这里已经把多层缓存都给隐藏了,你只需要使用 mlcache 的对象去获取缓存,并同时设置好缓存失效后的回调函数就可以了。这背后复杂的逻辑,就可以被完全地隐藏了。
说到这里,你可能好奇,这个库内部究竟是怎么实现的呢?接下来,再让我们来看下这个库的架构和实现。下面这张图,来自 mlcache 的作者 thibault 在 2018 年 OpenResty 大会上演讲的幻灯片:
<img src="https://static001.geekbang.org/resource/image/19/97/19a701636a95e931e6a9a8d0127e4f97.png" alt="">
从图中你可以看到mlcache 把数据分为了三层即L1、L2和L3。
L1 缓存就是 lua-resty-lrucache。每一个 worker 中都有自己独立的一份,有 N 个 worker就会有 N 份数据,自然也就存在数据冗余。由于在单 worker 内操作 lrucache 不会触发锁,所以它的性能更高,适合作为第一级缓存。
L2 缓存是 shared dict。所有的 worker 共用一份缓存数据,在 L1 缓存没有命中的情况下,就会来查询 L2 缓存。ngx.shared.DICT 提供的 API使用了自旋锁来保证操作的原子性所以这里我们并不用担心竞争的问题
L3 则是在 L2 缓存也没有命中的情况下,需要执行回调函数去外部数据库等数据源查询后,再缓存到 L2 中。在这里,为了避免缓存风暴,它会使用 lua-resty-lock ,来保证只有一个 worker 去数据源获取数据。
整体而言,从请求的角度来看,
- 首先会去查询 worker 内的 L1 缓存如果L1命中就直接返回。
- 如果L1没有命中或者缓存失效就会去查询 worker 间的 L2 缓存。如果L2命中就返回并把结果缓存到 L1 中。
- 如果L2 也没有命中或者缓存失效,就会调用回调函数,从数据源中查到数据,并写入到 L2 缓存中这也就是L3数据层的功能。
从这个过程你也可以看出,缓存的更新是由终端请求来被动触发的。即使某个请求获取缓存失败了,后续的请求依然可以触发更新的逻辑,以便最大程度地保证缓存的安全性。
不过,虽然 mlcache 已经实现得比较完美了,但在现实使用中,其实还有一个痛点——数据的序列化和反序列化。这个其实并不是 mlcache 的问题,而是我们之前反复提到的 lrucache 和 shared dict 之间的差异造成的。在 lrucache 中,我们可以存储 Lua 的各种数据类型,包括 table但 shared dict 中,我们只能存储字符串。
L1 也就是 lrucache 缓存是用户真正接触到的那一层数据我们自然希望在其中可以缓存各种数据包括字符串、table、cdata 等。可是,问题在于, L2 中只能存储字符串。那么,当数据从 L2 提升到 L1 的时候,我们就需要做一层转换,也就是从字符串转成我们可以直接给用户的数据类型。
还好mlcache 已经考虑到了这种情况,并在 `new``get` 接口中,提供了可选的函数 `l1_serializer`,专门用于处理 L2 提升到 L1 时对数据的处理。我们可以来看下面的示例代码,它是我从测试案例集中摘选出来的:
```
local mlcache = require &quot;resty.mlcache&quot;
local cache, err = mlcache.new(&quot;my_mlcache&quot;, &quot;cache_shm&quot;, {
l1_serializer = function(i)
return i + 2
end,
})
local function callback()
return 123456
end
local data = assert(cache:get(&quot;number&quot;, nil, callback))
assert(data == 123458)
```
简单解释一下。在这个案例中,回调函数返回数字 123456而在 `new` 中,我们设置的 `l1_serializer` 函数会在设置 L1 缓存前,把传入的数字加 2也就是变成 123458。通过这样的序列化函数数据在 L1 和 L2 之间转换的时候,就可以更加灵活了。
可以说mlcache 是一个功能很强大的缓存库,而且[文档](https://github.com/thibaultcha/lua-resty-mlcache)也写得非常详尽。今天这节课我只提到了核心的一些原理,更多的使用方法,建议你一定要自己花时间去探索和实践。
## 写在最后
有了多层缓存,服务端的性能才能得到最大限度的保证,而这中间又隐藏了很多的细节。这时候,有一个稳定、高效的封装库,我们就能省心很多。我也希望通过今天介绍的这两个封装库,帮助你更好地理解缓存。
最后给你留一个思考题,共享字典这一层缓存是必须的吗?如果只用 lrucache 是否可以呢?欢迎留言和我分享你的观点,也欢迎你把这篇文章分享出去,和更多的人一起交流和进步。

View File

@@ -0,0 +1,134 @@
<audio id="audio" title="42 | 如何应对突发流量:漏桶和令牌桶的概念" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f7/1e/f75dea29705a1b6124928709c2e9371e.mp3"></audio>
你好,我是温铭。
在前面几节课中,我们学习了代码的优化和缓存的设计,这两者都和应用的整体性能息息相关,自然值得我们重视。不过,在真实的业务场景下,我们还需要考虑到突发流量对性能的影响。这里的突发流量,可能是正常的,比如突发新闻、促销活动等带来的流量;也可能是不正常的流量,比如 DDoS 攻击等。
OpenResty 现在主要被用于作为接入层的 Web 应用,比如 WAF 和 API 网关,这些都要应对刚刚提到的正常和不正常的突发流量。毕竟,如果不能处理好突发流量,后端的服务就很容易被打垮,业务也就无法正常响应了。所以今天,我们就专门来看下,应对突发流量的方法。
## 流量控制
流量控制是 WAF 和 API 网关都必备的功能之一,它通过一些算法对入口流量进行疏导和控制,来保证上游的服务能够正常运行,从而让系统整体保持健康。
因为后端的处理能力是有限的,我们需要从成本、用户体验、系统稳定性等多个方面来综合考虑。不管使用哪一种算法,都不可避免地会造成正常用户请求变慢甚至被拒绝,牺牲部分的用户体验。所以,**流量控制是需要在业务稳定和用户体验之间做平衡的**。
其实,在现实的生活中,也经常会有流量控制的情况。比如春运等高峰期的地铁站、火车站、机场等交通枢纽,这些交通工具的处理能力是有上限的,那么,为了保证交通安全运转,就需要乘客排队等候、分批次进站。
这自然会影响乘客的体验,但从整体上看,却保证了系统的高效和安全运行。如果没有排队和分批次,而是让大家一窝蜂地进站,最后的结局估计就是整个系统瘫痪了。
回到技术上来说,举个例子,比如我们假定一个上游服务的设计上限是每分钟处理 1 万条请求。在高峰期的时候,如果入口处没有限流的控制,每分钟堆积的任务达到了 2 万条,那么这个上游服务的处理性能就会下降,可能只有每分钟 5000 条的处理速度,并且持续恶化,最终或许会导致服务不可用。这显然不是我们希望看到的结果。
应对这种突发的流量,我们常用的流量控制算法,便是漏桶和令牌桶。
## 漏桶算法
让我们先来看下漏桶算法,它的目的是让请求的速率保持恒定,把突发的流量变得平滑。不过,它是怎么做到的呢?
我们来看下面这张概念抽象图,来自维基百科中对于漏桶算法的介绍:
<img src="https://static001.geekbang.org/resource/image/6e/a9/6e36e9d5fff0aa58d8a9b4d34671fba9.jpg" alt="">
我们可以把客户端的流量想象成是从水管中流出来的水,水的流速不确定,忽快忽慢;而外层的流量处理模块,就是接水的桶子,并且这个水桶的底部有一个漏水用的洞眼。这其实也就是漏桶算法名字的由来,很明显,这种算法有下面几个好处。
第一,不管流入水桶的是涓涓细流还是滔天洪水,都可以保证,水桶中流出来的水速是恒定的。这种稳定的流量对于上游服务是很友好的,这也是流量整形的意义。
第二,水桶本身有一定容积,可以积累一定的水来等待流出水桶。这对于终端的请求来说,相当于是如果不能被立即处理,可以排队等待。
第三,超过水桶容积的水,不会被水桶接纳,而是会直接流走。这里对应的是,终端的请求如果太多,超过了排队的长度,就直接返回给客户端失败信息。这时候的服务端已经处理不过来了,自然,请求连排队的必要也就没有了。
说了这么多的优点,那么,这个算法应该如何来实现呢?我们以 OpenResty 中自带的 [`resty.limit.req` 库](https://github.com/openresty/lua-resty-limit-traffic/blob/master/lib/resty/limit/req.lua#L73)为例来看,它就是按照漏桶算法实现的限速模块,下节课我还会介绍更多内容。今天我们先来简单了解下,下面是它关键的几行代码:
```
local elapsed = now - tonumber(rec.last)
excess = max(tonumber(rec.excess) - rate * abs(elapsed) / 1000 + 1000,0)
if excess &gt; self.burst then
return nil, &quot;rejected&quot;
end
-- return the delay in seconds, as well as excess
return excess / rate, excess / 1000
```
我来简单解释一下这几行代码。其中, `elapsed` 是当前请求和上一次请求之间的毫秒数,`rate` 则是我们设定的每秒的速率。因为`rate`的最小单位是 0.001 s/r所以在上述实现的代码中都需要乘以 1000 以便计算。
`excess` 表示还在排队的请求数量,它为 0 表示水桶是空的,没有请求在排队,而`burst` 是指整个水桶的容积。如果 `excess` 已经大于 `burst`,也就意味着水桶已经满了,这时候再进来的流量就会被直接丢弃;如果 `excess` 大于 0 、小于 `burst`,就进入了排队来等待处理,这里最后返回的 `excess / rate` ,也就是要等待的时间。
这样,在后端服务处理能力不变的情况下,我们就可以通过调节 `burst` 的大小,来控制突发流量的排队时长了。是直接告诉用户现在请求量太大,稍后再重试,还是让用户多等待一段时间,这就要看你的业务场景了。
## 令牌桶算法
令牌桶算法和漏桶算法的目的都是一样的,用来保证后端服务不被突发流量打垮,不过这两者的实现方式并不相同。
在漏桶算法中,我们一般会使用终端 IP 作为 key ,来做限流限速的依据。这样,对于每一个终端用户而言,漏桶算法的出口速率就是固定的。不过,这就会存在一个问题:
>
如果 A 用户的请求频率很高,而其他用户的请求频率很低,即使此时的整体服务压力并不大,但漏桶算法就会把 A 的部分请求变慢或者拒绝掉,虽然这时候服务其实是可以处理的。
这时候就有令牌桶的用武之地了。
漏桶算法关注的是流量的平滑,而令牌桶则可以允许突发流量进入后端服务。令牌桶的原理,是以一个固定的速度向水桶内放入令牌,只要桶没有满就一直往里面放。这样,终端过来的请求都需要先到令牌桶中获取到令牌,才可以被后端处理;如果桶里面没有令牌,那么请求就会被拒绝。
不过OpenResty 自带的限流限速的库中没有实现令牌桶,所以,这里我用又拍云开源的、基于令牌桶的限速模块 `lua-resty-limit-rate` 的[代码](https://github.com/upyun/lua-resty-limit-rate)为例,为你做一个简单的介绍:
```
local limit_rate = require &quot;resty.limit.rate&quot;
-- global 20r/s 6000r/5m
local lim_global = limit_rate.new(&quot;my_limit_rate_store&quot;, 100, 6000, 2)
-- single 2r/s 600r/5m
local lim_single = limit_rate.new(&quot;my_limit_rate_store&quot;, 500, 600, 1)
local t0, err = lim_global:take_available(&quot;__global__&quot;, 1)
local t1, err = lim_single:take_available(ngx.var.arg_userid, 1)
if t0 == 1 then
return -- global bucket is not hungry
else
if t1 == 1 then
return -- single bucket is not hungry
else
return ngx.exit(503)
end
end
```
在这段代码中,我们设置了两个令牌桶:一个是全局的令牌桶,一个是以 `b ngx.var.arg_userid` 为key按照用户来划分的令牌桶。这里用两个令牌桶做了一个组合主要有这么一个好处
- 在全局令牌桶还有令牌的情况下,不用去判断用户的令牌桶,如果后端服务能够正常运行,就尽可能多地去服务用户的突发请求;
- 在全局令牌桶没有令牌的情况下,不能无差别地拒绝请求,这时候就需要判断下单个用户的令牌桶,把突发请求比较多的用户请求给拒绝掉。这样一来,就可以保证其他用户的请求不会受到影响。
显然,令牌桶和漏桶相比,更具有弹性,允许出现突发流量传递到后端服务的情况。当然,它们都各有利弊,你可以根据自己的情况来选择使用。
## Nginx 的限速模块
说完这两个算法,我们最后再来看下,在熟悉的 Nginx 中是如何来实现限流限速的。在Nginx 中,[`limit_req` 模块](http://nginx.org/en/docs/http/ngx_http_limit_req_module.html)是最常用的限速模块,下面是一个简单的配置:
```
limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;
server {
location /search/ {
limit_req zone=one burst=5;
}
}
```
这段代码是把终端的 IP 地址作为 key申请了一块名为 `one` 的 10M 的内存空间地址,并把速率限制为每秒 1 个请求。
在 server 的 location 中,还引用了 `one` 这个限速规则,并把 `brust` 设置为 5。这就表示在超过速率 1r/s 的情况下,同时允许有 5 个请求排队等待被处理,给出了一定的缓存区。要注意,如果没有设置 brust ,超过速率的请求是会被直接拒绝的。
Nginx 的这个模块是基于漏桶来实现的,所以和我们上面介绍过的 OpenResty 中的 `resty.limit.req` ,本质都是一样的。
## 写在最后
事实上Nginx 中设置限流限速的最大问题是,无法动态地修改。毕竟,修改完配置文件后,还需要重启才能生效,这在快速变化的环境下显然是无法接受的。下节课,我们就来看下,在 OpenResty 中如何动态地实现限流限速。
最后,给你留一个思考题。站在 WAF 和 API 网关的视角来看,是否有更好的方法来识别哪些是正常用户的请求,哪些是恶意的请求呢?因为,对于正常用户的突发流量,我们可以快速扩容后端服务,来增加服务的能力;而对于恶意的请求,最好可以在接入层就直接拒绝掉。
希望你可以认真思考这个问题,并且留言和我一起讨论。也欢迎你把这篇文章转发给你的同事、朋友,一起学习和进步。

View File

@@ -0,0 +1,173 @@
<audio id="audio" title="43 | 灵活实现动态限流限速,其实没有那么难" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1e/00/1e9f66a8020ff7deae491ed0eee45a00.mp3"></audio>
你好,我是温铭。
前面的课程中,我为你介绍了漏桶和令牌桶算法,它们都是应对突发流量的常用手段。同时,我们也学习了如何通过 Nginx 配置文件的方式,来实现对请求的限流限速。不过很显然,使用 Nginx 配置文件的方式,仅仅停留在可用的层面,距离好用还是有不小的距离的。
第一个问题便是,限速的 key 被限制在 Nginx 的变量范围内,不能灵活地设置。比如,根据不同的省份和不同的客户端渠道,来设置不同的限速阈值,这种常见的需求用 Nginx 就没有办法实现。
另外一个更大的问题是,不能动态地调整速率,每次修改都需要重载 Nginx 服务,这一点我们在上节课的最后也提到过。这样一来,根据不同的时间段限速这种需求,就只能通过外置的脚本来蹩脚地实现了。
要知道,技术是为业务服务的,同时,业务也在驱动着技术的进步。在 Nginx 诞生的时代,并没有什么动态调整配置的需求,更多的是反向代理、负载均衡、低内存占用等类似的需求,在驱动着 Nginx 的成长。在技术的架构和实现上并没有人能够预料到在移动互联网、IoT、微服务等场景下对于动态和精细控制的需求会大量爆发。
而 OpenResty 使用 Lua 脚本的方式,恰好能够弥补 Nginx 在这方面的缺失,形成了有效的互补。这也是 OpenResty 被广泛地用于替换 Nginx 的根源所在。在后面几节课中,我会为你继续介绍更多 OpenResty 中动态的场景和示例。今天,就让我们先来看下,如何使用 OpenResty 来实现动态限流和限速。
在 OpenResty 中,我们推荐使用 [lua-resty-limit-traffic](https://github.com/openresty/lua-resty-limit-traffic) 来做流量的限制。它里面包含了 `limit-req`(限制请求速率)、 `limit-count`(限制请求数) 和 `limit-conn` (限制并发连接数)这三种不同的限制方式;并且提供了`limit.traffic` ,可以把这三种方式进行聚合使用。
## 限制请求速率
让我们先来看下 `limit-req`,它使用的是漏桶算法来限制请求的速率。
在上一节中,我们已经简要介绍了这个 resty 库中漏桶算法的关键实现代码,现在我们就来学习如何使用这个库。我们来看下面这段示例代码:
```
resty --shdict='my_limit_req_store 100m' -e 'local limit_req = require &quot;resty.limit.req&quot;
local lim, err = limit_req.new(&quot;my_limit_req_store&quot;, 200, 100)
local delay, err = lim:incoming(&quot;key&quot;, true)
if not delay then
if err == &quot;rejected&quot; then
return ngx.exit(503)
end
return ngx.exit(500)
end
if delay &gt;= 0.001 then
ngx.sleep(delay)
end'
```
我们知道,`lua-resty-limit-traffic` 是使用共享字典来对 key 进行保存和计数的,所以在使用 `limit-req` 前,我们需要先声明 `my_limit_req_store` 这个 100m 的空间。这一点对于 `limit-conn``limit-count` 也是类似的,它们都需要自己单独的共享字典空间,以便区分开。
```
limit_req.new(&quot;my_limit_req_store&quot;, 200, 100)
```
上面这行代码,便是其中最关键的一行代码。它的含义,是使用名为 `my_limit_req_store` 的共享字典来存放统计数据,并把每秒的速率设置为 200。这样如果超过 200 但小于 300这个值是 200 + 100 计算得到的) 的话,就需要排队等候;如果超过 300 的话,就会直接拒绝。
在设置完成后,我们就要对终端的请求进行处理了,`lim: incoming("key", true)` 就是来做这件事情的。`incoming`这个函数有两个参数,我们需要详细解读一下。
第一个参数,是用户指定的限速的 key。在上面的示例中它是一个字符串常量这就意味着要对所有终端都统一限速。如果要实现根据不同省份和渠道来限速其实也很简单把这两个信息都作为 key 即可,下面是实现这一需求的伪代码:
```
local province = get_ province(ngx.var.binary_remote_addr)
local channel = ngx.req.get_headers()[&quot;channel&quot;]
local key = province .. channel
lim:incoming(key, true)
```
当然,你也可以举一反三,自定义 key 的含义以及调用 `incoming` 的条件,这样你就能收到非常灵活的限流限速效果了。
我们再来看`incoming` 函数的第二个参数,它是一个布尔值,默认是 false意味着这个请求不会被记录到共享字典中做统计这只是一次 `演习`。如果设置为 true就会产生实际的效果了。因此在大多数情况下你都需要显式地把它设置为 true。
你可能会纳闷儿,为什么会有这个参数的存在呢?我们不妨考虑一下这样的一个场景,你设置了两个不同的 `limit-req` 实例,针对不同的 key一个 key 是主机名,另外一个 key 是客户端的 IP 地址。那么,当一个终端请求被处理的时候,会按照先后顺序调用这两个实例的 `incoming` 方法,就像下面这段伪码表示的一样:
```
local limiter_one, err = limit_req.new(&quot;my_limit_req_store&quot;, 200, 100)
local limiter_two, err = limit_req.new(&quot;my_limit_req_store&quot;, 20, 10)
limiter_one :incoming(ngx.var.host, true)
limiter_two:incoming(ngx.var.binary_remote_addr, true)
```
如果用户的请求通过了 `limiter_one` 的阈值检测,但被 `limiter_two` 的检测拒绝,那么 `limiter_one:incoming` 这次函数调用就应该被认为是一次 `演习`,不应该真的去计数。
这样一来,上述的代码逻辑就不够严谨了。我们需要事先对所有的 limiter 做一次演习,如果有 limiter 的阈值被触发,可以 rejected 终端请求,就可以直接返回:
```
for i = 1, n do
local lim = limiters[i]
local delay, err = lim:incoming(keys[i], i == n)
if not delay then
return nil, err
end
end
```
这其实就是 `incoming` 函数第二个参数的意义所在。刚刚这段代码就是 `limit.traffic` 模块最核心的一段代码,专门用作多个限流器的组合所用。
## 限制请求数
再来看下 `limit.count` 这个限制请求数的库,它的效果和 GitHub API 的 Rate Limiting 一样,可以限制固定时间窗口内有多少次用户请求。老规矩,我们先来看一段示例代码:
```
local limit_count = require &quot;resty.limit.count&quot;
local lim, err = limit_count.new(&quot;my_limit_count_store&quot;, 5000, 3600)
local key = ngx.req.get_headers()[&quot;Authorization&quot;]
local delay, remaining = lim:incoming(key, true)
```
你可以看到,`limit.count``limit.req` 的使用方法是类似的,我们先在 Nginx.conf 中定义一个字典:
```
lua_shared_dict my_limit_count_store 100m;
```
然后 `new` 一个 limiter 对象,最后用 `incoming` 函数来判断和处理。
不过,不同的是,`limit-count` 中的`incoming` 函数的第二个返回值,代表着还剩余的调用次数,我们可以据此在响应头中增加字段,给终端更好的提示:
```
ngx.header[&quot;X-RateLimit-Limit&quot;] = &quot;5000&quot;
ngx.header[&quot;X-RateLimit-Remaining&quot;] = remaining
```
## 限制并发连接数
第三种方式,也就是`limit.conn` ,是用来限制并发连接数的库。它和前面提到的两个库有所不同,有一个特别的 `leaving` API这里我来简单介绍下。
前面所讲的限制请求速率和限制请求数,都是可以直接在 access 这一个阶段内完成的。而限制并发连接数则不同,它不仅需要在 access 阶段判断是否超过阈值,而且需要在 log 阶段调用 `leaving` 接口:
```
log_by_lua_block {
local latency = tonumber(ngx.var.request_time) - ctx.limit_conn_delay
local key = ctx.limit_conn_key
local conn, err = lim:leaving(key, latency)
}
```
不过,这个接口的核心代码其实也很简单,也就是下面这一行代码,实际上就是把连接数减一的操作。如果你没有在 log 阶段做这个清理的动作,那么连接数就会一直上涨,很快就会达到并发的阈值。
```
local conn, err = dict:incr(key, -1)
```
## 限速器的组合
到这里,这三种方式我们就分别介绍完了。最后,我们再来看看,怎么把 `limit.rate``limit.conn``limit.count` 组合起来使用。这就需要用到 `limit.traffic` 中的 `combine` 函数了:
```
local lim1, err = limit_req.new(&quot;my_req_store&quot;, 300, 200)
local lim2, err = limit_req.new(&quot;my_req_store&quot;, 200, 100)
local lim3, err = limit_conn.new(&quot;my_conn_store&quot;, 1000, 1000, 0.5)
local limiters = {lim1, lim2, lim3}
local host = ngx.var.host
local client = ngx.var.binary_remote_addr
local keys = {host, client, client}
local delay, err = limit_traffic.combine(limiters, keys, states)
```
有了刚刚的知识基础,这段代码你应该很容易看明白。`combine` 函数的核心代码,在我们上面分析 `limit.rate` 的时候已经提到了一部分,它主要是借助了演习功能和 uncommit 函数来实现。这样组合以后,你就可以为多个限流器设置不同的阈值和 key实现更复杂的业务需求了。
## 写在最后
`limit.traffic` 不仅支持今天所讲的这三种限速器,实际上,只要某个限速器有 `incoming``uncommit` 接口,都可以被 `limit.traffic``combine` 函数管理。
最后,给你留一个作业题。你可以写一个例子,把之前我们介绍过的基于令牌桶的[限速器](https://github.com/upyun/lua-resty-limit-rate)组合起来吗?欢迎在留言区写下你的答案与我讨论,也欢迎你把这篇文章分享给你的同事朋友,一起学习和交流。

View File

@@ -0,0 +1,138 @@
<audio id="audio" title="44 | OpenResty 的杀手锏:动态" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b1/44/b175c2d393a0cdabfec9081ce1afbc44.mp3"></audio>
你好,我是温铭。
到目前为止,和 OpenResty 性能相关的内容,我就差不多快要介绍完了。我相信,掌握并灵活运用好这些优化技巧,一定可以让你的代码性能提升一个数量级。今天,在性能优化的最后一个部分,我来讲一讲 OpenResty 中被普遍低估的一种能力:动态。
让我们先来看下什么是动态,以及它和性能之间有什么样的关系。
这里的动态,指的是程序可以在运行时、在不重新加载的情况下,去修改参数、配置,乃至修改自身的代码。具体到 Nginx 和 OpenResty 领域你去修改上游、SSL 证书、限流限速阈值,而不用重启服务,就属于实现了动态。至于动态和性能之间的关系,很显然,如果这几类操作不能动态地完成,那么频繁的 reload Nginx 服务,自然就会带来性能的损耗。
不过,我们知道,开源版本的 Nginx 并不支持动态特性所以你要对上游、SSL 证书做变更,就必须通过修改配置文件、重启服务的方式才能生效。而商业版本的 Nginx Plus 提供了部分动态的能力,你可以用 REST API 来完成更新,但这最多算是一个不够彻底的改良。
但是,在 OpenResty 中,这些桎梏都是不存在的,动态可以说就是 OpenResty 的杀手锏。你可能纳闷儿,为什么基于 Nginx 的 OpenResty 却可以支持动态呢原因也很简单Nginx 的逻辑是通过 C 模块来完成的,而 OpenResty 是通过脚本语言 Lua 来完成的——脚本语言的一大优势,便是运行时可以去做动态地改变。
## 动态加载代码
下面我们就来看看,如何在 OpenResty 中动态地加载 Lua 代码:
```
resty -e 'local s = [[ngx.say(&quot;hello world&quot;)]]
local func, err = loadstring(s)
func()'
```
你没有看错,只要短短的两三行代码,就可以把一个字符串变为一个 Lua 函数,并运行起来。我们进一步仔细看下这几行代码,我来简单解读一下:
- 首先,我们声明了一个字符串,它的内容是一段合法的 Lua 代码,把 `hello world` 打印出来;
- 然后,使用 Lua 中的 `loadstring` 函数,把字符串对象转为函数对象`func`
- 最后,在函数名的后面加上括号,把 `func` 执行起来,打印出 `hello world` 来。
当然,在这段代码的基础之上,我们还可以扩展出更多好玩和实用的功能。接下来,我就带你一起来“尝尝鲜”。
## 功能一FaaS
首先是函数即服务,这是近年来很热门的技术方向,我们看下在 OpenResty 中如何实现。在刚刚的代码中,字符串是一段 Lua 代码,我们还可以把它改成一个 Lua 函数:
```
local s = [[
return function()
ngx.say(&quot;hello world&quot;)
end
]]
```
我们讲过,函数在 Lua 中是一等公民,这段代码便是返回了一个匿名函数。在执行这个匿名函数时,我们使用 `pcall` 做了一层保护。`pcall` 会在保护模式下运行函数,并捕获其中的异常,如果正常就返回 true 和执行的结果,如果失败就返回 false 和错误信息,也就是下面这段代码:
```
local func1, err = loadstring(s)
local ret, func = pcall(func1)
```
自然,把上面的两部分结合起来,就会得到完整的、可运行的示例:
```
resty -e 'local s = [[
return function()
ngx.say(&quot;hello world&quot;)
end
]]
local func1 = loadstring(s)
local ret, func = pcall(func1)
func()'
```
更深入一步,我们还可以把 `s` 这个包含函数的字符串,改成可以由用户指定的形式,并加上执行它的条件,这样其实就是 FaaS 的原型了。这里,我提供了一个完整的[实现](https://github.com/apache/incubator-apisix/blob/master/apisix/plugins/serverless.lua)如果你对FaaS感兴趣想要继续研究推荐你通过这个链接深入学习。
## 功能二:边缘计算
OpenResty 的动态不仅可以用于 FaaS让脚本语言的动态细化到函数级别还可以在边缘计算上发挥动态的优势。
得益于 Nginx 和 LuaJIT 良好的多平台支持特性OpenResty 不仅能运行在 X86 架构下,对于 ARM 的支持也很完善。同时, OpenResty 支持七层和四层的代理,这样一来,常见的各种协议都可以被 OpenResty 解析和代理,这其中也包括了 IoT 中的几种协议。
因为这些优势,我们便可以把 OpenResty 的触角,从 API 网关、WAF、web 服务器等服务端的领域伸展到物联网设备、CDN 边缘节点、路由器等最靠近用户的边缘节点上去。
这并非只是一种畅想事实上OpenResty 已经在上述领域中被大量使用了。以 CDN 的边缘节点为例OpenResty 的最大使用者 CloudFlare 很早就借助 OpenResty 的动态特性,实现了对于 CDN 边缘节点的动态控制。
CloudFlare 的做法和上面动态加载代码的原理是类似的,大概可以分为下面几个步骤:
- 首先,从键值数据库集群中获取到有变化的代码文件,获取的方式可以是后台 timer 轮询,也可以是用“发布-订阅”的模式来监听;
- 然后,用更新的代码文件替换本地磁盘的旧文件,然后使用 `loadstring``pcall`的方式,来更新内存中加载的缓存;
这样,下一个被处理的终端请求,就会走更新后的代码逻辑。
当然,实际的应用要比上面的步骤考虑更多的细节,比如版本的控制和回退、异常的处理、网络的中断、边缘节点的重启等,但整体的流程是不变的。
如果把 CloudFlare 的这套做法,从 CDN 边缘节点挪移到其他边缘的场景下,那我们就可以把很多计算能力动态地赋予边缘节点的设备。这不仅可以充分利用边缘节点的计算能力,也可以让用户请求得到更快速的响应。因为边缘节点会把原始数据处理过后,再汇总给远端的服务器,这就大大减少了数据的传输量。
不过,要把 FaaS 和边缘计算做好OpenResty 的动态只是一个良好的基础,你还需要考虑自身周边生态的完善和厂商的加入,这就不仅仅是技术的范畴了。
## 动态上游
现在,让我们把思绪拉回到 OpenResty 上来,一起来看如何实现动态上游。`lua-resty-core` 提供了 `ngx.balancer` 这个库来设置上游,它需要放到 OpenResty 的 `balancer` 阶段来运行:
```
balancer_by_lua_block {
local balancer = require &quot;ngx.balancer&quot;
local host = &quot;127.0.0.2&quot;
local port = 8080
local ok, err = balancer.set_current_peer(host, port)
if not ok then
ngx.log(ngx.ERR, &quot;failed to set the current peer: &quot;, err)
return ngx.exit(500)
end
}
```
我来简单解释一下。`set_current_peer` 函数,就是用来设置上游的 IP 地址和端口的。不过要注意,这里并不支持域名,你需要使用 `lua-resty-dns` 库来为域名和 IP 做一层解析。
不过,`ngx.balancer` 还比较底层,虽然它有设置上游的能力,但动态上游的实现远非如此简单。所以,在 `ngx.balancer` 前面还需要两个功能:
- 一是上游的选择算法,究竟是一致性哈希,还是 roundrobin
- 二是上游的健康检查机制,这个机制需要剔除掉不健康的上游,并且需要在不健康的上游变健康的时候,重新把它加入进来。
而OpenResty 官方的 `lua-resty-balancer` [这个库](https://github.com/openresty/lua-resty-balancer)中,则包含了 `resty.chash``resty.roundrobin` 两类算法来完成第一个功能,并且有 `lua-resty-upstream-healthcheck` 来尝试完成第二个功能。
不过,这其中还是有两个问题。
第一点,缺少最后一公里的完整实现。把 `ngx.balancer``lua-resty-balancer``lua-resty-upstream-healthcheck` 整合并实现动态上游的功能,还是需要一些工作量的,这就拦住了大部分的开发者。
第二点,`lua-resty-upstream-healthcheck` 的实现并不完整,只有被动的健康检查,而没有主动的健康检查。
简单解释一下,这里的被动健康检查,是指由终端的请求触发,进而分析上游的返回值来作为健康与否的判断条件。如果没有终端请求,那么上游是否健康就无从得知了。而主动健康检查就可以弥补这个缺陷,它使用 `ngx.timer` 定时去轮询指定的上游接口,来检测健康状态。
所以,在实际的实践中,我们通常推荐使用 `lua-resty-healthcheck` 这个[](https://github.com/Kong/lua-resty-healthcheck),来完成上游的健康检查。它的优点是包含了主动和被动的健康检查,而且在多个项目中都经过了验证,可靠性更高。
更进一步,新兴的微服务 API 网关APISIX`lua-resty-healthcheck` 的基础之上,对动态上游做了完整的实现。我们可以参考它的[实现](https://github.com/iresty/apisix/blob/master/lua/apisix/http/balancer.lua),总共只有 200 多行代码,你可以很轻松地把它剥离出来,放到你的自己的项目中使用。
## 写在最后
讲了这么多最后给你留一个思考题。关于OpenResty 的动态,你觉得还可以在哪些领域和场景来发挥它的这种优势呢?提醒一下,这个章节中介绍的每部分的内容,你都可以展开来做更详细和深入的分析。
欢迎留言和我讨论,也欢迎你把这篇文章分享出去,和更多的人一起学习、进步。

View File

@@ -0,0 +1,173 @@
<audio id="audio" title="45 | 不得不提的能力外延OpenResty常用的第三方库" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/41/9b/41837000b5f03e3f0a3beb229a8fb19b.mp3"></audio>
你好,我是温铭。
对于开发语言和平台来讲,很多时候的学习,其实是对标准库和第三方库的学习,语法本身并不用花费很多的时间。这对 OpenResty 来说也是一样的,学完它自己的 API 和性能优化技巧后,就需要各种 lua-resty 库,来帮助我们把 OpenResty 的能力外延,以应用到更多的场景中去。
## 去哪里找 lua-resty 库?
和 PHP、Python、JavaScript 相比,当前 OpenResty 的标准库和第三方库还比较贫瘠,找出合适的 lua-resty 库还不是一件容易的事情。不过,这里仍然有两个推荐的渠道,可以帮你更快地找到它们。
我首先推荐的是由 Aapo 维护的 `awesome-resty` [仓库](https://github.com/bungle/awesome-resty),这个仓库分门别类地整理了和 OpenResty 相关的库,可以说是包罗万象,包括了 Nginx 的 C 模块、lua-resty 库、Web 框架、路由库、模板、测试框架等,是你寻找 OpenResty 资源的首选。
当然,如果你在 Aapo 的仓库中没有找到合适的库,那么还可以去 luarocks、opm和 GitHub 碰碰运气。有一些开源时间不长的、或者关注不多的库,可能就藏在其中。
在前面的课程中,我们已经接触了不少有用的库,比如 lua-resty-mlcache、lua-resty-traffic、lua-resty-shell 等。今天,在 OpenResty 性能优化部分的最后一节课,我们再来认识 3 个独具特色的周边库,它们都是由社区的开发者贡献的。
## ngx.var 的性能提升
首先让我们来看一个 C 模块:[lua-var-nginx-module](https://github.com/iresty/lua-var-nginx-module)。前面我曾经提到过,`ngx.var` 是一个性能损耗比较大的操作,在实际使用时,我们需要用 `ngx.ctx` 来做一层缓存。
那有没有什么方法,可以彻底解决 `ngx.var` 的性能问题呢?
这个 C 模块,就在这个方面做了一些尝试,效果也很显著,性能比起`ngx.var` 提升了 5 倍。它采用的是 FFI 的方式,所以,你需要在编译 OpenResty 的时候,先加上编译选项:
```
./configure --prefix=/opt/openresty \
--add-module=/path/to/lua-var-nginx-module
```
然后,使用 luarocks 的方式来安装 lua 库:
```
luarocks install lua-resty-ngxvar
```
这里调用的方法也很简单,只需要一行 `fetch` 函数的调用就可以了。它的效果完全等价于原有的 `ngx.var.remote_addr`,来获取到终端的 IP 地址:
```
content_by_lua_block {
local var = require(&quot;resty.ngxvar&quot;)
ngx.say(var.fetch(&quot;remote_addr&quot;))
}
```
知道了这些基本操作后,你可能更好奇的是,这个模块到底是怎么做到性能大幅度提升的呢?还是那句老话,源码面前无秘密,就让我们来看看 `remote_addr` 这个变量在其中是如何获取的吧:
```
ngx_int_t
ngx_http_lua_var_ffi_remote_addr(ngx_http_request_t *r, ngx_str_t *remote_addr)
{
remote_addr-&gt;len = r-&gt;connection-&gt;addr_text.len;
remote_addr-&gt;data = r-&gt;connection-&gt;addr_text.data;
return NGX_OK;
}
```
阅读这段代码后,你会发现,这种 Lua FFI 的方式和 lua-resty-core 的做法如出一辙。它的优点很明显,使用 FFI 的方式来直接获取变量,绕过了 `ngx.var` 原有的查找逻辑;同时,缺点也很明显,那就是要为每一个希望获取的变量,都增加对应的 C 函数和 FFI 调用,这其实是一个体力活。
有人可能会问,我为什么会说这是体力活呢?上面的 C 代码看上去不是还挺有含量的吗?我们不妨来看看这几行代码的源头,它们出自 Nginx 代码中的 `src/http/ngx_http_variables.c`
```
static ngx_int_t
ngx_http_variable_remote_addr(ngx_http_request_t *r,
ngx_http_variable_value_t *v, uintptr_t data)
{
v-&gt;len = r-&gt;connection-&gt;addr_text.len;
v-&gt;valid = 1;
v-&gt;no_cacheable = 0;
v-&gt;not_found = 0;
v-&gt;data = r-&gt;connection-&gt;addr_text.data;
return NGX_OK;
}
```
看到源码后,谜底揭开了!`lua-var-nginx-module` 其实是 Nginx 变量代码的搬运工,并在外层做了 FFI 的封装,用这种方式达到了性能优化的目的。这其实也是一个很好的思路和优化方向。
这里我再多说几句,我们学习某个库或者某个工具,一定不要仅仅停留在操作使用的层面,还应该多问问为什么,多看看源码,在底层原理的层面上,我们才能学到更多的设计思想和解决思路。当然,我也非常鼓励你去贡献代码,以支持更多的 Nginx 变量。
## JSON Schema
下面我介绍的是一个 lua-resty 库:[lua-rapidjson](https://github.com/xpol/lua-rapidjson) 。它是对 `rapidjson` 这个腾讯开源的 JSON 库的封装,以性能见长。这里,我们着重介绍下它和 `cjson` 的不同之处,也就是支持 JSON Schema。
JSON Schema 是一个通用的标准,借助这个标准,我们就可以精确地描述接口中参数的格式,以及如何校验的问题。下面是一个简单的示例:
```
&quot;stringArray&quot;: {
&quot;type&quot;: &quot;array&quot;,
&quot;items&quot;: { &quot;type&quot;: &quot;string&quot; },
&quot;minItems&quot;: 1,
&quot;uniqueItems&quot;: true
}
```
这段 JSON 准确地描述了 `stringArray` 这个参数的类型是字符串数组,并且数组不能为空,数组元素也不能重复。
`lua-rapidjson`,则是可以让我们在 OpenResty 中来使用 JSON Schema这能给接口的校验带来极大的便利。举个例子比如对于前面介绍过的 limit count 限流接口,我们就可以用下面的 schema 来描述:
```
local schema = {
type = &quot;object&quot;,
properties = {
count = {type = &quot;integer&quot;, minimum = 0},
time_window = {type = &quot;integer&quot;, minimum = 0},
key = {type = &quot;string&quot;, enum = {&quot;remote_addr&quot;, &quot;server_addr&quot;}},
rejected_code = {type = &quot;integer&quot;, minimum = 200, maximum = 600},
},
additionalProperties = false,
required = {&quot;count&quot;, &quot;time_window&quot;, &quot;key&quot;, &quot;rejected_code&quot;},
}
```
你会发现,这可以带来两个十分明显的收益:
- 对前端来说,前端可以直接复用这个 schema 描述,用于前端页面的开发和参数校验,而不用再去关心后端;
- 而对后端来说,后端直接使用 `lua-rapidjson` 的 schema 校验函数 `SchemaValidator` 就能完成接口合法性的判断,更是无须编写多余的代码。
## worker 间通信
最后,我要讲的是可以实现 OpenResty 中 worker 间通信的 [lua-resty](https://github.com/Kong/lua-resty-worker-events) 库。OpenResty 的 worker 之间,并没有机制可以直接通信,这显然会带来不少的问题。让我们设想这么一个场景:
>
一个 OpenResty 服务有 24 个 worker 进程,管理员通过 REST HTTP 接口更新了系统的某项配置,这时候只有一个 worker 收到了管理员的更新操作,并把结果写入了数据库,更新了共享字典和自己 worker 内的 lru 缓存。那么,其他 23 个 worker 怎么才能被通知去更新这项配置呢?
显然,多个 worker 之间需要一个通知的机制,才能完成上面的这个任务。在 OpenResty 自身不支持的情况下,我们就只能通过共享字典这个跨 worker 可以访问的空间,来曲线救国了。
`lua-resty-worker-events` 便是这个思路的具体实现。它在共享字典中维护了一个版本号,在有新消息需要发布的时候,给这个版本号加一,并把消息内容放到以版本号为 key 的字典中:
```
event_id, err = _dict:incr(KEY_LAST_ID, 1)
success, err = _dict:add(KEY_DATA .. tostring(event_id), json)
```
同时,在后台使用 `ngx.timer` 创建了一个默认间隔为 1 秒的 polling 循环,来不断地检测版本号是否有变化:
```
local event_id, err = get_event_id()
if event_id == _last_event then
return &quot;done&quot;
end
```
这样,一旦发现有新的事件通知需要处理时,就根据版本号从共享字典中获取消息内容:
```
while _last_event &lt; event_id do
count = count + 1
_last_event = _last_event + 1
data, err = _dict:get(KEY_DATA..tostring(_last_event))
end
```
总的来说,虽然 `lua-resty-worker-events` 会有 1 秒钟的延时,但还是实现了 worker 之间的事件通知机制,瑕不掩瑜。
不过在一些实时性要求比较高的场景下比如消息推送OpenResty 缺少 worker 进程间直接通信的这个问题,就可能会给你带来一些困扰了。这一点目前没有更好的解决方案,如果你有好的想法,欢迎在 Github 或者 OpenResty 的邮件列表中来一起探讨。OpenResty 的很多功能都是由社区用户来驱动的,这样才能构造一个良性的生态循环。
## 写在最后
今天我们介绍的这三个库,都各具特色,也都为 OpenResty 的应用带来了更多的可能性。最后是一个互动话题,你是否发现过一些 OpenResty 周边有意思的库呢或者对于今天提到的这几个库你有什么发现或疑惑呢欢迎留言和我分享也欢迎你把这篇文章发给你身边的OpenResty使用者一起交流和进步。

View File

@@ -0,0 +1,110 @@
<audio id="audio" title="46 | 答疑(四):共享字典的缓存是必须的吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/03/8a/03c78ad7c882bf0982dde3603abc3c8a.mp3"></audio>
你好,我是温铭。
专栏更新到现在OpenResty第四版块 OpenResty 性能优化篇,我们就已经学完了。恭喜你没有掉队,仍然在积极学习和实践操作,并且热情地留下了你的思考。
很多留言提出的问题很有价值大部分我都已经在App里回复过一些手机上不方便回复的或者比较典型、有趣的问题我专门摘了出来作为今天的答疑内容集中回复。另一方面也是为了保证所有人都不漏掉任何一个重点。
下面我们来看今天的这 5 个问题。
## 问题一:如何完成 Lua 模块的动态加载?
Q关于OpenResty 实现的动态加载,我有个疑问:在完成新文件替换后,如何用 loadstring 函数完成新文件的加载呢 我了解到loadstring 只能加载字符串,如果要重新加载一个 lua 文件/模块,在 OpenResty 中要如何做到呢?
A我们知道loadstring 是加载字符串使用的而loadfile 可以加载指定的文件,比如: `loadfile("foo.lua")`。事实上,这两个命令达到的效果是一样的。
至于如何加载 Lua 模块,下面是一个具体的示例:
```
resty -e 'local s = [[
local ngx = ngx
local _M = {}
function _M.f()
ngx.say(&quot;hello world&quot;)
end
return _M
]]
local lua = loadstring(s)
local ret, func = pcall(lua)
func.f()'
```
这里的字符串 `s`,它的内容就是一个完整的 Lua 模块。所以,在发现这个模块的代码有变化时,你可以用 loadstring 或者 loadfile 来重启加载。这样,其中的函数和变量都会随之更新。
更进一步,你也把可以把获取变化和重新加载,用名为 `code_loader` 函数做一层包装:
```
local func = code_loader(name)
```
这样一来,代码更新就会变得更为简洁;同时, `code_loader` 中我们一般会用 lru cache 对 `s` 做一层缓存,避免每一次都去调用 loadstring。这差不多就是一个完整的实现了。
## 问题二OpenResty 为什么不禁用阻塞操作?
Q这些年来我一直有个疑虑既然这些阻塞调用是官方极力不鼓励的为什么不直接禁用呢或者加一个 flag 让用户选择禁用呢?
A这里说一下我个人的看法。首先是因为 OpenResty 的周边生态还不够完善,有时候我们不得不调用阻塞的库来实现一些功能。比如 在1.15.8 版本之前,调用外部的命令行还需要走 Lua 库的 `os.execute`,而不是 `lua-resty-shell`;再如,在 OpenResty 中,读写文件至今还是只能走 Lua 的 I/O 库,并没有非阻塞的方式来替代。
其次OpenResty 在这种优化上的态度是很谨慎的。比如, `lua-resty-core` 已经开发完成很长时间了,但一直都没有默认开启,需要你手工来调用 `require 'resty.core'`。直到最新的 1.15.8版本,它才得以转正。
最后OpenResty 的维护者更希望,通过编译器和 DSL自动生成高度优化过的 Lua 代码,这种方式来规范阻塞方式的调用。所以,大家并没有在 OpenResty 平台本身上,去做类似 flag 选项的努力。当然,这种方向是否能够解决实际的问题,我是保留态度的。
站在外部开发者的角度,如何避免这种阻塞,才是更为实际的问题。我们可以扩展 Lua 代码的检测工具,比如 luacheck 等,发现并对常见的阻塞操作进行告警;也可以直接通过改写 `_G` 的方式,来侵入式地禁止或者改写某些函数,比如:
```
resty -e '_G.ngx.print = function()
ngx.say(&quot;hello&quot;)
end
ngx.print()'
hello
```
这样的示例代码,就可以直接改写 `ngx.print` 函数了。
## 问题三LuaJIT 的 NYI 的操作,是否会对性能有很大影响?
Qloadstring 在 LuaJIT 的 NYI 列表是 never会不会对性能有很大影响
A关于 LuaJIT 的 NYI我们不用矫枉过正。对于可以 JIT 的操作,自然是 JIT 的方式最好;但对于还不能 JIT 的操作,我们也不是不能使用。
对于性能优化,我们需要用基于统计的科学方法来看待,这也就是火焰图采样的意义。过早优化是万恶之源。对于那些调用次数频繁、消耗 CPU 很高的热代码,我们才有优化的必要。
回到loadstring 的问题,我们只会在代码发生变化的时候,才会调用它重新加载,和请求多少无关,所以它并不是一个频繁的操作。这个时候,我们就不用担心它对系统整体性能的影响。
结合第二个阻塞的问题,在 OpenResty 中,我们有些时候也会在 init 和 init worker 阶段,去调用阻塞的文件 I/O 操作。这种操作比 NYI 更加影响性能,但因为它只在服务启动的时候执行一次,所以也是可以被我们接受的。
还是那句话,性能优化要从宏观的视角来看待,这是你特别需要注意的一个点。否则,纠结于某一细节,就很有可能优化了半天,却并没有起到很好的效果。
## 问题四:动态上游可以自己来实现吗?
Q动态上游这块我的做法是为一个服务设置 2 个 upstream然后根据路由条件选择不同的 upstream当机器 IP 有变化时,直接修改 upstream 中的 IP 即可。这样的做法,和直接使用 `balancer_by_lua` 相比,有什么劣势或坑吗?
A单独看这个案例。`balancer_by_lua` 的优势是可以让用户选择负载均衡的算法比如是用roundrobin 还是 chash又或者是用户自己实现的其他算法都可以灵活而且性能很高。
如果按照路由规则的方式来做,从最终结果上来看是一样的。但上游健康检查需要你自己来实现,增加了不少额外的工作量。
我们也可以扩展下这个提问,对于 abtest 这种需要不同上游的场景,我们应该如何去实现呢?
你可以在 `balancer_by_lua` 阶段中,根据 uri、host、参数等来决定使用哪一个上游。你也可以使用 API 网关,把这些判断变为路由的规则,在最开始的 `access` 阶段,通过判断决定使用哪一个路由,再通过路由和上游的绑定关系找到指定的上游。这就是 API 网关的常见做法,后面在实战章节中,我们会更具体地聊到。
## 问题五:共享字典的缓存是必须的吗?
Q在实际的生产应用中我认为 shared dict 这一层缓存是必须的。貌似大家都只记得 lruca che 的好,数据格式没限制、不需要反序列化、不需要根据 k/v 体积算内存空间、worker 间独立不相互争抢、没有读写锁、性能高云云。
但是,却忘记了它最致命的一个弱点,就是 lru cache 的生命周期是跟着 worker 走的。每当Nginx reload 时,这部分缓存会全部丢失,这时候,如果没有 shared dict那 L3 的数据源分分钟被打挂。
当然,这是并发比较高的情况下,但是既然用到了缓存,就说明业务体量肯定不会小,也就是刚刚的分析仍然适用。不知道我的这个观点对吗?
A大部分情况下确实如你所说共享字典在 reload 的时候不会丢失,所以它有存在的必要性。但也有一种特例,那就是,如果在 `init` 阶段或者 `init_worker` 阶段,就能从 L3 也就是数据源主动获取到所有数据,那么只有 lru cache 也是可以接受的。
举例来说,比如开源 API 网关 [APISIX](https://github.com/iresty/apisix) 的数据源在 etcd 中,它只在 `init_worker` 阶段,从 etcd 中获取数据并缓存在lru cache 中,后面的缓存更新,都是通过 etcd 的 watch 机制来主动获取的。这样一来,即使 Nginx reload ,也不会有缓存风暴产生。
所以,对待技术的选择,我们可以有倾向,但还是不要一概而论绝对化,因为并没有一个可以适合所有缓存场景的银弹。根据实际场景的需要,构建一个最小化可用的方案,然后逐步地增加,是一个不错的法子。
今天主要解答这几个问题。最后,欢迎你继续在留言区写下你的疑问,我会持续不断地解答。希望可以通过交流和答疑,帮你把所学转化为所得。也欢迎你把这篇文章转发出去,我们一起交流、一起进步。