OpenResty 性能优化
1 性能下降10倍的真凶:阻塞函数
OpenResty 之所以可以保持很高的性能,简单来说,是因为它借用了 Nginx 的事件处理和 Lua 的协程机制,所以:
- 在遇到网络 I/O 等需要等待返回才能继续的操作时,就会先调用 Lua 协程的 yield 把自己挂起,然后在 Nginx 中注册回调;
- 在 I/O 操作完成(也可能是超时或者出错)后,由 Nginx 回调 resume,来唤醒 Lua 协程。
这样的流程,保证了 OpenResty 可以一直高效地使用 CPU 资源,来处理所有的请求。
在这个处理流程中,如果没有使用 cosocket 这种非阻塞的方式,而是用阻塞的函数来处理 I/O,那么 LuaJIT 就不会把控制权交给 Nginx 的事件循环。这就会导致,其他的请求要一直排队等待阻塞的事件处理完,才会得到响应。
下面,我再来介绍几个常见的坑,也就是一些经常会被误用的阻塞函数;
1.1 执行外部命令
在业务中通常可以执行一些外部的命令和工具,来辅助完成一些操作。
比如杀掉某个进程:
1 | os.execute("kill -HUP " .. pid) |
或者拷贝文件、使用OpenSSL生成秘钥等耗时更久的一些操作:
1 | os.execute(" cp test.exe /tmp ") |
表面上看, os.execute 是 Lua 的内置函数,而在 Lua 世界中,也确实是用这种方式来调用外部命令的。
但是在 OpenResty 的环境中,os.execute 会阻塞当前请求。所以,如果这个命令的执行时间特别短,那么影响还不是很大。
有两个解决方案如下:
- 方案一:如果有 FFI 库可以使用,那么我们就优先使用 FFI 的方式来调用
上面用 OpenSSL 的命令行来生成秘钥,就可以改为,用 FFI 调用 OpenSSL 的 C 函数的方式来绕过。
杀掉某个进程的示例,你可以使用 lua-resty-signal 这个 OpenResty 自带的库,来非阻塞地解决。代码实现如下,当然,这里的lua-resty-signal ,其实也是用 FFI 去调用系统函数来解决的。
1 | local resty_signal = require "resty.signal" |
在 LuaJIT 的官方网站上,专门有一个页面,里面分门别类地介绍了各种 FFI 的绑定库。当你在处理图片、加解密等 CPU 密集运算的时候,可以先去里面看看,是否有已经封装好的库,可以拿来直接使用。
- 方案二:使用基于 ngx.pipe 的 lua-resty-shell 库
可以在 shell.run 中运行你自己的命令,它就是一个非阻塞的操作:
1 | $ resty -e 'local shell = require "resty.shell" |
1.2 磁盘 I/O
再来看下处理磁盘I/O的场景。在服务端程序中,读取本地的配置文件是一个很常见的操作,如下边的代码:
1 | local path = "/conf/apisix.conf" |
上边的io.open
就是一个阻塞的操作,但是也需要考虑实际场景,如果以上操作是在init 和 init worker中调用,那么它其实是一个一次性操作,并没有影响任何终端用户的请求,是完全可以被接受的。
但是如果每一个用户的请求,都会触发磁盘的读写,那就变得不可接受了。这时,你就需要认真地考虑解决方案了。
第一种方式,我们可以使用 lua-io-nginx-module 这个第三方的 C 模块。它为 OpenResty 提供了“非阻塞”的 Lua API,这种方式的原理是,lua-io-nginx-module 利用了 Nginx 的线程池,把磁盘 I/O 操作从主线程转移到另外一个线程中处理,这样,主线程就不会因为磁盘 I/O 操作而被阻塞。
使用这个库时,你需要重新编译 Nginx,因为它是一个 C 模块。它的使用方法如下,和 Lua 的 I/O 库基本是一致的:
1 | local ngx_io = require "ngx.io" |
第二种方式,则是尝试架构上的调整。对于这类磁盘 I/O,我们是否可以换种方式,不再读写本地磁盘呢?比如下边的记录日志操作:
1 | ngx.log(ngx.WARN, "info") |
调用这个API,即使有缓冲区,大量而频繁的磁盘写入,也会严重地影响性能。
要实现的需求是记录日志,我们可以考虑把日志发送到远端的日志服务器上,这样就可以用 cosocket 来完成非阻塞的网络通信了,也就是把阻塞的磁盘 I/O 丢给日志服务,不要阻塞对外的服务。你可以使用 lua-resty-logger-socket
,来完成这样的工作:
1 | local logger = require "resty.logger.socket" |
上面两个方法的本质都是一样的:如果阻塞不可避免,那就不要阻塞主要的工作线程,丢给外部的其他线程或者服务就可以了。
1.3 luasocket
luasocket 也是容易被开发者用到的一个 Lua 内置库,经常有人分不清 luasocket 和 OpenResty 提供的 cosocket。luasocket 也可以完成网络通信的功能,但它并没有非阻塞的优势。如果你使用了 luasocket,那么性能也会急剧下降。
前面讲过的cosocket在不少阶段无法使用,这时可以考虑使用luasocket。
lua-resty-socket 是一个二次封装的开源库,它做到了 luasocket 和 cosocket 的兼容。
2 让人又恨又爱的字符串操作
2.1 性能优化背后
- 理念一:处理请求要短、平、快
OpenResty 是一个 Web 服务器,所以经常会同时处理几千、几万甚至几十万的终端请求。想要在整体上达到最高性能,我们就一定要保证单个请求被快速地处理完成,并回收内存等各种资源。
- 这里提到的“短”,是指请求的生命周期要短,不要长时间占用资源而不释放;即使是长连接,也要设定一个时间或者请求次数的阈值,来定期地释放资源。
- 第二个字“平”,则是指在一个 API 中只做一件事情。要把复杂的业务逻辑拆散为多个 API,保持代码的简洁。
- 最后的“快”,是指不要阻塞主线程,不要有大量 CPU 运算。即使是不得不有这样的逻辑,也别忘了咱们上节课介绍的方法,要配合其他的服务去完成。
其实,这种架构上的考虑,不仅适合 OpenResty,在其他的开发语言和平台上也都是适用的,希望你能认真理解和思考。
- 理念二:避免产生中间数据
避免中间的无用数据,可以说是 OpenResty 编程中最为主要的优化理念。这里,我先给你举一个小例子,来讲解下什么是中间的无用数据。我们来看下面这段代码:
1 | $ resty -e 'local s= "hello" |
这段代码,我们对s 这个变量做了多次拼接操作,才得到了hello world! 对结果。但很显然,只有 s 的最终状态,也就是 hello world! 这个状态是有用的。而 s 的初始值和中间的赋值,都属于中间数据,应该尽量少生成。
因为这些临时数据,会带来初始化和 GC 的性能损耗。不要小看这些损耗,如果这出现在循环等热代码中,就会带来非常明显的性能下降了。
2.2 字符串是不可变的
在LUA中,字符串是不可改变的,在修改字符串的时候,并不是改变原有的字符串,而是会生成一个新的字符串对象,并改变了对字符串的引用。如果原有字符串没有其他的任何引用,就会给Lua的GC给回收掉。
字符串不可变的好处显而易见,那就是节省内存。这样一来,同样内容的字符串在内存中就只有一份了,不同的变量都会指向同一个内存地址。
至于这样设计的缺点,那就是涉及到字符串的新增和 GC 时,每当你新增一个字符串,LuaJIT 都得调用 lj_str_new,去查询这个字符串是否已经存在;没有的话,便需要再创建新的字符串。如果操作很频繁,自然就会对性能有非常大的影响。
我们来看一个具体的例子,类似这个例子中的字符串拼接操作,在很多 OpenResty 的开源项目中都会出现:
1 | $ resty -e 'local begin = ngx.now() |
这段示例代码的作用,是对s 变量做十万次字符串拼接,并把运行时间打印出来。虽然例子有些极端,但却能很好地体现出性能优化前后的差异。未经优化时,这段代码在我的笔记本上跑了 0.4 秒钟,还是比较慢的。那么应该如何优化呢?
使用table做一次封装,去掉所有临时的中间字符串,只保留原始数据和最终结果。
1 | $ resty -e 'local begin = ngx.now() |
用 table 依次保存了每一个字符串,下标由 #t + 1
来决定,也就是用 table 的当前长度加 1;最后,使用 table.concat 函数,把数组的每一个元素进行拼接,直接得到最终结果。这样自然就跳过了所有的临时字符串,避免了 10 万次 lj_str_new 和 GC。
刚刚是我们对于代码的分析,那么优化的具体效果如何呢?很明显,优化后的代码耗时只有 0.007 秒,也就是说,性能提升了五十多倍。
刚刚这段 0.007 秒的代码,是否就已经足够好了呢?其实不然,它还有继续优化的空间。我们不妨再来修改一行代码,然后来看下效果:
1 | $ resty -e 'local begin = ngx.now() |
以上通过自己维护下标的操作,避免了计算十万此数组长度的函数调用。
2.3 减少其他临时字符串
在OpenResty中有些操作存在着一些更隐蔽的临时字符串的产生,它们就更不容易被发现。string.sub
函数的作用是截取字符串的指定部分。正如我们前面所提到的,Lua 中的字符串是不可变的,那么截取出来的新字符串,就会涉及到 lj_str_new 和后续的 GC 操作。
1 | resty -e 'print(string.sub("abcd", 1, 1))' |
上面这段代码的作用,是获取字符串的第一个字符,并打印出来。自然,它不可避免会生成临时字符串。要完成同样的效果,还有别的更好的办法吗?
1 | resty -e 'print(string.char(string.byte("abcd")))' |
第二段代码,我们先用 string.byte 获取到第一个字符的数字编码,再用 string.char 把数字转为对应的字符。这个过程中并没有生成任何临时的字符串。因此,使用 string.byte 来完成字符串相关的扫描和分析,是效率最高的。
2.4 利用 SDK 对 table 类型的支持
下边是响应体内容输出到客户端的操作:
1 | $ resty -e 'local begin = ngx.now() |
在 ngx.say、ngx.print 、ngx.log、cosocket:send 等这些可能接受大量字符串的 API 中,它不仅接受 string 作为参数,也同时接受 table 作为参数:
1 | resty -e 'local begin = ngx.now() |
在最后这段代码中,我们省略掉了 local response = table.concat(t, “”), 这个字符串拼接的步骤,直接把 table 传给了 ngx.say。这样,就把字符串拼接的任务,从 Lua 层面转移到了 C 层面,又避免了一次字符串的查找、生成和 GC。对于比较长的字符串而言,这又是一次不小的性能提升。
OpenResty 的性能优化,很多都是在抠各种细节。所以,你需要对 LuaJIT 和 OpenResty 的 Lua API 了如指掌,才能达到最优的性能。
3 性能提升10倍的秘诀:必须用好 table
table 相关的优化,有一个自己的简单原则:尽量复用,避免不必要的 table 创建。
3.1 预先生成数组
使用LuaJIT 中的 table.new(narray, nhash) 函数预先创建table,这个函数,会预先分配好指定的数组和哈希的空间大小,而不是在插入元素时自增长,这也是它的两个参数 narray 和 nhash 的含义。
使用示例:
1 | local new_tab = require "table.new" |
另外,因为之前的 OpenResty 并没有完全绑定 LuaJIT,还支持标准 Lua,所以有些旧的代码会做这方面的兼容。如果没有找到 table.new 这个函数,就会模拟出来一个空的函数,来保证调用方的统一。
1 | local ok, new_tab = pcall(require, "table.new") |
3.2 自己计算table下标
其实上边性能优化的时候也讲过这种思路,以下操作会经常计算table的长度,会影响性能
1 | local new_tab = require "table.new" |
下边是lua-resty-redis官方库的代码:
1 | local function _gen_req(args) |
这个函数预先生成了数组 req,它的大小由函数的入参来决定,这样就可以保证尽量不浪费空间。
然后,它使用 nbits 这个变量,来自己维护 req 的下标,自然就抛弃了 Lua 内置的 table.insert 函数和获取长度的操作符 #
。你可以看到,在 for 循环中,nbits + 1 等一些运算,就是直接用下标的方式插入元素;并在最后用 nbits = nbits + 5
,让下标保持一个正确的值。
这种的好处很明显,它省略了获取数组大小这个 O(n) 的操作,而是直接用下标访问,时间复杂度也变成了 O(1) 。当然,缺点也一样明显,那就是降低了代码的可读性,并且出错概率大大提高,可以说,这是一把双刃剑。
3.3 循环使用单个table
就是通过使用table.clear清空table,然后复用之前的table
1 | local ok, clear_tab = pcall(require, "table.clear") |
一般来说,我们会把这种循环使用的 table,放在一个模块的 top level 中。这样,在你使用模块中的函数的时候,就可以根据自己的实际情况来决定,到底是直接使用,还是 clear 后再使用。
3.4 tablepool池
可以用缓存池的方式来保存多个 table,以便随用随取,官方提供的 lua-tablepool 正是出于这个目的。
下面这段代码,展示了 table 池的基本使用方法。我们可以从指定的池子中获取一个 table,使用完以后再释放回去:
1 | local tablepool = require "tablepool" |
第一个是 fetch 方法,它的参数和 table.new 基本一样,只是多了一个 pool_name。如果池子中没有空闲的数组,fetch 方法就会调用 table.new 来新建一个数组。
1 | tablepool.fetch(pool_name, narr, nrec) |
第二个是 release 这个把 table 放回池子的函数。在它的参数中,最后的 no_clear ,用来配置是否要调用 table.clear 把数组清空。
1 | tablepool.release(pool_name, tb, [no_clear]) |
4 高性能的关键:shareddict缓存和lru缓存
4.1 缓存
一般来说,缓存有两个原则。
- 一是越靠近用户的请求越好。比如,能用本地缓存的就不要发送 HTTP 请求,能用 CDN 缓存的就不要打到源站,能用 OpenResty 缓存的就不要打到数据库。
- 二是尽量使用本进程和本机的缓存解决。因为跨了进程和机器甚至机房,缓存的网络开销就会非常大,这一点在高并发的时候会非常明显。
OpenResty 中有两个缓存的组件:shared dict 缓存和 lru 缓存。
- shared dict缓存:只能缓存字符串对象,缓存的数据有且只有一份,每一个 worker 都可以进行访问,所以常用于 worker 之间的数据通信。
- lru缓存:可以缓存所有的 Lua 对象,但只能在单个 worker 进程内访问,有多少个 worker,就会有多少份缓存数据。
下边的两个表格,可以说明 shared dict 和 lru 缓存的区别:
shared dict 和 lru 缓存,并没有哪一个更好的说法,而是应该根据你的场景来配合使用。
- 如果你没有 worker 之间共享数据的需求,那么 lru 可以缓存数组、函数等复杂的数据类型,并且性能最高,自然是首选。
- 但如果你需要在 worker 之间共享数据,那就可以在 lru 缓存的基础上,加上 shared dict 的缓存,构成两级缓存的架构。
4.2 共享字典缓存
使用方法
1 | resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs |
你需要事先在 Nginx 的配置文件中,声明一个内存区 dogs,然后在 Lua 代码中才可以使用。如果你在使用的过程中,发现给 dogs 分配的空间不够用,那么是需要先修改 Nginx 配置文件,然后重新加载 Nginx 才能生效的。因为我们并不能在运行时进行扩容和缩容。
- 缓存数据的序列化
第一个问题,缓存数据的序列化。由于共享字典中只能缓存字符串对象,所以,如果你想要缓存数组,就少不了要在 set 的时候要做一次序列化,在 get 的时候做一次反序列化:
1 | resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs |
不过,这类序列化和反序列化操作是非常消耗 CPU 资源的。如果每个请求都有那么几次这种操作,那么,在火焰图上你就能很明显地看到它们的消耗。
大部分的序列化都是可以在业务层面进行拆解的。你可以把数组的内容打散,分别用字符串的形式存储在共享字典中。如果还不行的话,那么也可以把数组缓存在 lru 中,用内存空间来换取程序的便捷性和性能。
此外,缓存中的 key 也应该尽量选择短和有意义的,这样不仅可以节省空间,也方便后续的调试。
- stale数据
共享字典中还有一个 get_stale 的读取数据的方法,相比 get 方法,多了一个过期数据的返回值:
1 | resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs |
在上面的这个示例中,数据只在共享字典中缓存了 0.01 秒,在 set 后的 0.02 秒后,数据就已经超时了。这时候,通过 get 接口就不会获取到数据了,但通过 get_stale
还可能获取到过期的数据。这里我之所以用“可能”两个字,是因为过期数据所占用的空间,是有一定几率被回收,再给其他数据使用的,这也就是 LRU 算法。
举个例子,数据源存储在 MySQL 中,我们从 MySQL 中获取到数据后,在 shared dict 中设置了 5 秒超时,那么,当这个数据过期后,我们就会有两个选择:
- 当这个数据不存在时,重新去 MySQL 中再查询一次,把结果放到缓存中;
- 判断 MySQL 的数据是否发生了变化,如果没有变化,就把缓存中过期的数据读取出来,修改它的过期时间,让它继续生效。
很明显,后者是更优化的方案,这样可以尽可能少地去和 MySQL 交互,让终端的请求都从最快的缓存中获取数据。
那怎么判断数据是否发生变化呢,下边以lru缓存为例,看下怎么解决这个问题。
4.3 lru缓存
lru 缓存的接口只有 5 个:new、set、get、delete 和 flush_all。和上面问题相关的就只有 get 接口,让我们先来了解下这个接口是如何使用的:
1 | resty -e 'local lrucache = require "resty.lrucache" |
你可以看到,在 lru 缓存中, get 接口的第二个返回值直接就是 stale_data,而不是像 shared dict 那样分为了 get 和 get_stale 两个不同的 API。这样的接口封装,对于使用过期数据来说显然更加友好。
可以对数据添加版本号的概念,有了版本号后,我们就可以对 lru 缓存做一个简单的二次封装,比如来看下面的伪码,摘自apisix的lrucache.lua
1 | local function (key, version, create_obj_fun, ...) |
从这段代码中你可以看到,我们通过引入版本号的概念,在版本号没有变化的情况下,充分利用了过期数据来减少对数据源的压力,达到了性能的最优。
除此之外,在上面的方案中,其实还有一个潜在的很大优化点,那就是我们把 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 以及缓存的机制都有很深入的了解才可以。
4.4 缓存风暴
4.4.1 什么是缓存风暴?
下边这个场景:
数据源在 MySQL 数据库中,缓存的数据放在共享字典中,超时时间为 60 秒。在这 60 秒内的时间里,所有的请求都从缓存中获取数据,MySQL 没有任何的压力。但是,一旦到达 60 秒,也就是缓存数据失效的那一刻,如果正好有大量的并发请求进来,在缓存中没有查询到结果,就要触发查询数据源的函数,那么这些请求全部都将去查询 MySQL 数据库,直接造成数据库服务器卡顿,甚至卡死。
下边是一个有缓存风暴隐患的伪代码:
1 | local value = get_from_cache(key) |
这段伪代码看上去逻辑都是正常的,你使用单元测试或者端对端测试,都不会触发缓存风暴。只有长时间的压力测试才会发现这个问题,每隔 60 秒的时间,数据库就会出现一次查询的峰值,非常有规律。不过,如果你这里的缓存失效时间设置得比较长,那么缓存风暴问题被发现的几率就会降低。
4.4.2 如何避免缓存风暴?
4.4.2.1 主动更新缓存
上边的示例是被动更新缓存,只有在终端请求发现缓存失效时,才会去数据库查询新的数据,如果从被动改为主动,也就可以直接绕开缓存风暴得问题了。
在 OpenResty 中,我们可以这样来实现。首先,使用 ngx.timer.every 来创建一个定时任务,每分钟运行一次,去 MySQL 数据库中获取最新的数据,并放入共享字典中:
1 | local function query_db(premature, sql) |
然后,在终端请求的代码逻辑中,去掉查询 MySQL 的部分,只保留获取共享字典缓存的代码:
1 | local value = get_from_cache(key) |
通过这样两段伪码的操作,缓存风暴的问题就被绕过去了。但这种方式也并非完美,因为这样的每一个缓存都要对应一个周期性的任务(OpenResty 中 timer 是有上限的,不能太多);而且缓存过期时间和计划任务的周期时间还要对应好,如果这中间出现了什么纰漏,终端就可能一直获取到的都是空数据。
在实际项目中一般都是使用加锁的方式来解决缓存风暴问题。
4.4.2.2 lua-resty-lock
使用 OpenResty 中的 lua-resty-lock 这个库来加锁,lua-resty-lock 是 OpenResty 自带的 resty 库,它底层是基于共享字典,提供非阻塞的 lock API。我们先来看一个简单的示例:
1 | resty --shdict='locks 1m' -e 'local resty_lock = require "resty.lock" |
因为 lua-resty-lock 是基于共享字典来实现的,所以我们需要事先声明 shdict 的名字和大小;然后,再使用 new 方法来新建 lock 对象。你可以看到,这段代码中,我们只传了第一个参数 shdict 的名字。其实, new 方法还有第二个参数,可以用来指定锁的过期时间、等待锁的超时时间等多个参数。不过这里,我们使用的是默认值,它们就是用来避免死锁等各种异常问题的。
接着,我们就可以调用 lock 方法尝试获取锁。如果成功获取到锁的话,那就可以保证只有一个请求去数据源更新数据;而如果因为锁已经被抢占、超时等导致加锁失败,那就需要从陈旧的缓存中获取数据,返回给终端。这个过程是不是听起来很熟悉?没错,这里就正好用到了我们上节课介绍过的的 get_stale API:
1 | local elapsed, err = lock:lock("my_key") |
如果 lock 成功,那么就可以安全地去查询数据库,并把结果更新到缓存中。最后,我们再调用 unlock 接口,把锁释放掉就可以了。
lua-resty-lock
每当遇到一些有趣的实现,我们总是希望能够看看它的源码是如何实现的,这也是开源的好处之一。这里,我们再深入一步,看看 lock 这个接口是如何加锁的,下面便是它的源码:
1 | local ok, err = dict:add(key, true, exptime) |
在共享字典章节中,我曾经提到过,shared dict 的所有 API 都是原子操作,不用担心出现竞争,所以用 shared dict 来标记锁的状态是个不错的主意。
这里 lock 接口的实现,便使用了 dict:add 接口来尝试设置 key。如果 key 在共享内存中不存在,add 接口就会返回成功,表示加锁成功;其他并发的请求走到 dict:add 这一行的代码逻辑时,就会返回失败,然后根据返回的 err 信息,选择是直接返回,还是多次重试。
4.4.2.3 lua-resty-shcache
在上面 lua-resty-lock 的实现中,你需要自己来处理加锁、解锁、获取过期数据、重试、异常处理等各种问题,还是相当繁琐的。所以,这里我再给你介绍一个简单的封装:lua-resty-shcache。
lua-resty-shcache是 CloudFlare 开源的一个 lua-resty 库,它在共享字典和外部存储之上,做了一层封装;并且额外提供了序列化和反序列化的接口,让你不用去关心上述的各种细节:
1 | local shcache = require("shcache") |
4.4.2.4 Nginx配置指令
即使你没有使用 OpenResty 的 lua-resty 库,你也可以用 Nginx 的配置指令,来实现加锁和获取过期数据——即proxy_cache_lock 和 proxy_cache_use_stale。不过,这里我并不推荐使用 Nginx 指令这种方式,它显然不够灵活,性能也比不上 Lua 代码。
4.5 lua-resty-* 封装,让你远离多级缓存之痛
4.5.1 lua-resty-memcached-shdict
让我们回到缓存的封装上来。lua-resty-memcached-shdict 是 OpenResty 官方的一个项目,它使用 shared dict 为 memcached 做了一层封装,处理了缓存风暴和过期数据等细节。如果你的缓存数据正好存储在后端的 memcached 中,那么你可以尝试使用这个库。
如果你想在本地测试,需要先把它的源码下载到本地 OpenResty 的查找路径下。
这个封装库,其实和我们上节课中提到的解决方案是一样的。它使用 lua-resty-lock 来做到互斥,在缓存失效的情况下,只有一个请求去 memcached 中获取数据,避免缓存风暴。如果没有获取到最新数据,则使用 stale 数据返回给终端。
在这个封装库的文档中,其实也提到了进一步的优化方向:
- 一是使用 lua-resty-lrucache ,来增加 worker 层的缓存,而不仅仅是 server 级别的 shared dict 缓存;
- 二是使用 ngx.timer ,来做异步的缓存更新操作。
4.5.2 lua-resty-mlcache
OpenResty 中被普遍使用的缓存封装: lua-resty-mlcache。它使用 shared dict 和 lua-resty-lrucache ,实现了多层缓存机制。我们下面就通过两段代码示例,来看看这个库如何使用:
1 | local mlcache = require "resty.mlcache" |
先来看第一段代码。这段代码的开头引入了 mlcache 库,并设置了初始化的参数。我们一般会把这段代码放到 init 阶段,只需要做一次就可以了。
除了缓冲名和字典名这两个必填的参数外,第三个参数是一个字典,里面 12 个选项都是选填的,不填的话就使用默认值。这种方式显然就比 lua-resty-memcached-shdict 要优雅很多。其实,我们自己来设计接口的话,也最好采用 mlcache 这样的做法——让接口尽可能地简单,同时还保留足够的灵活性。
下面再来看第二段代码,这是请求处理时的逻辑代码:
1 | local function fetch_user(id) |
你可以看到,这里已经把多层缓存都给隐藏了,你只需要使用 mlcache 的对象去获取缓存,并同时设置好缓存失效后的回调函数就可以了。这背后复杂的逻辑,就可以被完全地隐藏了。
说到这里,你可能好奇,这个库内部究竟是怎么实现的呢?接下来,再让我们来看下这个库的架构和实现。下面这张图,来自 mlcache 的作者 thibault 在 2018 年 OpenResty 大会上演讲的幻灯片:
从图中你可以看到,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 时对数据的处理。我们可以来看下面的示例代码,它是我从测试案例集中摘选出来的:
1 | local mlcache = require "resty.mlcache" |
简单解释一下。在这个案例中,回调函数返回数字 123456;而在 new 中,我们设置的 l1_serializer 函数会在设置 L1 缓存前,把传入的数字加 2,也就是变成 123458。通过这样的序列化函数,数据在 L1 和 L2 之间转换的时候,就可以更加灵活了。
可以说,mlcache 是一个功能很强大的缓存库,而且文档也写得非常详尽。今天这节课我只提到了核心的一些原理,更多的使用方法,建议你一定要自己花时间去探索和实践。
5 应对突发流量:漏桶和令牌桶的概念
5.1 流量控制
我们需要从成本、用户体验、系统稳定性等多个方面来综合考虑。不管使用哪一种算法,都不可避免地会造成正常用户请求变慢甚至被拒绝,牺牲部分的用户体验。所以,流量控制是需要在业务稳定和用户体验之间做平衡的。
举个例子,比如我们假定一个上游服务的设计上限是每分钟处理 1 万条请求。在高峰期的时候,如果入口处没有限流的控制,每分钟堆积的任务达到了 2 万条,那么这个上游服务的处理性能就会下降,可能只有每分钟 5000 条的处理速度,并且持续恶化,最终或许会导致服务不可用。这显然不是我们希望看到的结果。
应对这种突发的流量,我们常用的流量控制算法,便是漏桶和令牌桶。
5.2 漏桶算法
它的目的是让请求的速率保持恒定,把突发的流量变得平滑。
我们可以把客户端的流量想象成是从水管中流出来的水,水的流速不确定,忽快忽慢;而外层的流量处理模块,就是接水的桶子,并且这个水桶的底部有一个漏水用的洞眼。这其实也就是漏桶算法名字的由来,很明显,这种算法有下面几个好处。
- 第一,不管流入水桶的是涓涓细流还是滔天洪水,都可以保证,水桶中流出来的水速是恒定的。这种稳定的流量对于上游服务是很友好的,这也是流量整形的意义。
- 第二,水桶本身有一定容积,可以积累一定的水来等待流出水桶。这对于终端的请求来说,相当于是如果不能被立即处理,可以排队等待。
- 第三,超过水桶容积的水,不会被水桶接纳,而是会直接流走。这里对应的是,终端的请求如果太多,超过了排队的长度,就直接返回给客户端失败信息。这时候的服务端已经处理不过来了,自然,请求连排队的必要也就没有了。
算法实现,OpenResty自带的resty.limit.req为例来看,它就是按照漏桶算法实现的限速模块
1 | local elapsed = now - tonumber(rec.last) |
简单解释一下这几行代码。其中, elapsed 是当前请求和上一次请求之间的毫秒数,rate 则是我们设定的每秒的速率。因为rate的最小单位是 0.001 s/r,所以在上述实现的代码中,都需要乘以 1000 以便计算。
excess 表示还在排队的请求数量,它为 0 表示水桶是空的,没有请求在排队,而burst 是指整个水桶的容积。如果 excess 已经大于 burst,也就意味着水桶已经满了,这时候再进来的流量就会被直接丢弃;如果 excess 大于 0 、小于 burst,就进入了排队来等待处理,这里最后返回的 excess / rate ,也就是要等待的时间。
这样,在后端服务处理能力不变的情况下,我们就可以通过调节 burst 的大小,来控制突发流量的排队时长了。是直接告诉用户现在请求量太大,稍后再重试,还是让用户多等待一段时间,这就要看你的业务场景了。
5.3 令牌桶算法
令牌桶算法与漏桶算法的实现不同,漏桶算法中,我们一般会使用终端 IP 作为 key ,来做限流限速的依据。这样,对于每一个终端用户而言,漏桶算法的出口速率就是固定的。不过,这就会存在一个问题:
如果 A 用户的请求频率很高,而其他用户的请求频率很低,即使此时的整体服务压力并不大,但漏桶算法就会把 A 的部分请求变慢或者拒绝掉,虽然这时候服务其实是可以处理的。
漏桶算法关注的是流量的平滑,而令牌桶则可以允许突发流量进入后端服务。令牌桶的原理,是以一个固定的速度向水桶内放入令牌,只要桶没有满就一直往里面放。这样,终端过来的请求都需要先到令牌桶中获取到令牌,才可以被后端处理;如果桶里面没有令牌,那么请求就会被拒绝。
OpenResty 自带的限流限速的库中没有实现令牌桶,所以,这里我用又拍云开源的、基于令牌桶的限速模块 lua-resty-limit-rate 的代码为例,为你做一个简单的介绍:
1 | local limit_rate = require "resty.limit.rate" |
在这段代码中,我们设置了两个令牌桶:一个是全局的令牌桶,一个是以 b ngx.var.arg_userid 为 key,按照用户来划分的令牌桶。这里用两个令牌桶做了一个组合,主要有这么一个好处:
- 在全局令牌桶还有令牌的情况下,不用去判断用户的令牌桶,如果后端服务能够正常运行,就尽可能多地去服务用户的突发请求;
- 在全局令牌桶没有令牌的情况下,不能无差别地拒绝请求,这时候就需要判断下单个用户的令牌桶,把突发请求比较多的用户请求给拒绝掉。这样一来,就可以保证其他用户的请求不会受到影响。
显然,令牌桶和漏桶相比,更具有弹性,允许出现突发流量传递到后端服务的情况。当然,它们都各有利弊,你可以根据自己的情况来选择使用。
5.4 Nginx的限速模块
说完这两个算法,我们最后再来看下,在熟悉的 Nginx 中是如何来实现限流限速的。在 Nginx 中,limit_req 模块是最常用的限速模块,下面是一个简单的配置:
1 | limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s; |
这段代码是把终端的 IP 地址作为 key,申请了一块名为 one 的 10M 的内存空间地址,并把速率限制为每秒 1 个请求。
在 server 的 location 中,还引用了 one 这个限速规则,并把 brust 设置为 5。这就表示在超过速率 1r/s 的情况下,同时允许有 5 个请求排队等待被处理,给出了一定的缓存区。要注意,如果没有设置 brust ,超过速率的请求是会被直接拒绝的。
Nginx 的这个模块是基于漏桶来实现的,所以和我们上面介绍过的 OpenResty 中的 resty.limit.req ,本质都是一样的。
5.5 动态限流限速
在 OpenResty 中,我们推荐使用 lua-resty-limit-traffic 来做流量的限制。它里面包含了 limit-req(限制请求速率)、 limit-count(限制请求数) 和 limit-conn (限制并发连接数)这三种不同的限制方式;并且提供了limit.traffic ,可以把这三种方式进行聚合使用。
5.5.1 限制请求速率
让我们先来看下 limit-req,它使用的是漏桶算法来限制请求的速率。
示例代码:
1 | resty --shdict='my_limit_req_store 100m' -e 'local limit_req = require "resty.limit.req" |
我们知道,lua-resty-limit-traffic 是使用共享字典来对 key 进行保存和计数的,所以在使用 limit-req 前,我们需要先声明 my_limit_req_store 这个 100m 的空间。这一点对于 limit-conn 和 limit-count 也是类似的,它们都需要自己单独的共享字典空间,以便区分开。
1 | limit_req.new("my_limit_req_store", 200, 100) |
上面这行代码,便是其中最关键的一行代码。它的含义,是使用名为 my_limit_req_store 的共享字典来存放统计数据,并把每秒的速率设置为 200。这样,如果超过 200 但小于 300(这个值是 200 + 100 计算得到的) 的话,就需要排队等候;如果超过 300 的话,就会直接拒绝。
在设置完成后,我们就要对终端的请求进行处理了,lim: incoming(“key”, true) 就是来做这件事情的。incoming这个函数有两个参数,我们需要详细解读一下。
第一个参数,是用户指定的限速的 key。在上面的示例中它是一个字符串常量,这就意味着要对所有终端都统一限速。如果要实现根据不同省份和渠道来限速,其实也很简单,把这两个信息都作为 key 即可,下面是实现这一需求的伪代码:
1 | local province = get_ province(ngx.var.binary_remote_addr) |
当然,你也可以举一反三,自定义 key 的含义以及调用 incoming 的条件,这样你就能收到非常灵活的限流限速效果了。
我们再来看incoming 函数的第二个参数,它是一个布尔值,默认是 false,意味着这个请求不会被记录到共享字典中做统计,这只是一次 演习。如果设置为 true,就会产生实际的效果了。因此,在大多数情况下,你都需要显式地把它设置为 true。
你可能会纳闷儿,为什么会有这个参数的存在呢?我们不妨考虑一下这样的一个场景,你设置了两个不同的 limit-req 实例,针对不同的 key,一个 key 是主机名,另外一个 key 是客户端的 IP 地址。那么,当一个终端请求被处理的时候,会按照先后顺序调用这两个实例的 incoming 方法,就像下面这段伪码表示的一样:
1 | local limiter_one, err = limit_req.new("my_limit_req_store", 200, 100) |
如果用户的请求通过了 limiter_one 的阈值检测,但被 limiter_two 的检测拒绝,那么 limiter_one:incoming 这次函数调用就应该被认为是一次 演习,不应该真的去计数。
这样一来,上述的代码逻辑就不够严谨了。我们需要事先对所有的 limiter 做一次演习,如果有 limiter 的阈值被触发,可以 rejected 终端请求,就可以直接返回:
1 | for i = 1, n do |
这其实就是 incoming 函数第二个参数的意义所在。刚刚这段代码就是 limit.traffic 模块最核心的一段代码,专门用作多个限流器的组合所用。
5.5.2 限制请求数
limit.count 这个限制请求数的库,它的效果和 GitHub API 的 Rate Limiting 一样,可以限制固定时间窗口内有多少次用户请求。老规矩,我们先来看一段示例代码:
1 | local limit_count = require "resty.limit.count" |
limit.count 和 limit.req 的使用方法是类似的,我们先在 Nginx.conf 中定义一个字典:
1 | lua_shared_dict my_limit_count_store 100m; |
然后 new 一个 limiter 对象,最后用 incoming 函数来判断和处理。
不过,不同的是,limit-count 中的incoming 函数的第二个返回值,代表着还剩余的调用次数,我们可以据此在响应头中增加字段,给终端更好的提示:
1 | ngx.header["X-RateLimit-Limit"] = "5000" |
5.5.3 限制并发连接数
前面所讲的限制请求速率和限制请求数,都是可以直接在 access 这一个阶段内完成的。而限制并发连接数则不同,它不仅需要在 access 阶段判断是否超过阈值,而且需要在 log 阶段调用 leaving 接口:
1 | log_by_lua_block { |
不过,这个接口的核心代码其实也很简单,也就是下面这一行代码,实际上就是把连接数减一的操作。如果你没有在 log 阶段做这个清理的动作,那么连接数就会一直上涨,很快就会达到并发的阈值。
1 | local conn, err = dict:incr(key, -1) |
5.5.4 限速器组合
到这里,这三种方式我们就分别介绍完了。最后,我们再来看看,怎么把 limit.rate、limit.conn 和 limit.count 组合起来使用。这就需要用到 limit.traffic 中的 combine 函数了:
1 | local lim1, err = limit_req.new("my_req_store", 300, 200) |
combine 函数的核心代码,在我们上面分析 limit.rate 的时候已经提到了一部分,它主要是借助了演习功能和 uncommit 函数来实现。这样组合以后,你就可以为多个限流器设置不同的阈值和 key,实现更复杂的业务需求了。
limit.traffic 不仅支持今天所讲的这三种限速器,实际上,只要某个限速器有 incoming 和 uncommit 接口,都可以被 limit.traffic 的 combine 函数管理。
6 OpenResty的杀手锏:动态
6.1 动态加载代码
在OpenResty中动态加载lua代码
1 | resty -e 'local s = [[ngx.say("hello world")]] |
只要短短的两三行代码,就可以把一个字符串变为一个 Lua 函数,并运行起来。我们进一步仔细看下这几行代码,我来简单解读一下:
- 首先,我们声明了一个字符串,它的内容是一段合法的 Lua 代码,把 hello world 打印出来;
- 然后,使用 Lua 中的 loadstring 函数,把字符串对象转为函数对象func;
- 最后,在函数名的后面加上括号,把 func 执行起来,打印出 hello world 来。
6.2 FaaS(函数即服务)
通过loadstring方式加载函数
1 | resty -e 'local s = [[ |
更深入一步,我们还可以把 s 这个包含函数的字符串,改成可以由用户指定的形式,并加上执行它的条件,这样其实就是 FaaS 的原型了。
6.3 边缘计算
OpenResty 的动态不仅可以用于 FaaS,让脚本语言的动态细化到函数级别,还可以在边缘计算上发挥动态的优势。
得益于 Nginx 和 LuaJIT 良好的多平台支持特性,OpenResty 不仅能运行在 X86 架构下,对于 ARM 的支持也很完善。同时, OpenResty 支持七层和四层的代理,这样一来,常见的各种协议都可以被 OpenResty 解析和代理,这其中也包括了 IoT 中的几种协议。
因为这些优势,我们便可以把 OpenResty 的触角,从 API 网关、WAF、web 服务器等服务端的领域,伸展到物联网设备、CDN 边缘节点、路由器等最靠近用户的边缘节点上去。
以 CDN 的边缘节点为例,OpenResty 的最大使用者 CloudFlare 很早就借助 OpenResty 的动态特性,实现了对于 CDN 边缘节点的动态控制。
CloudFlare 的做法和上面动态加载代码的原理是类似的,大概可以分为下面几个步骤:
- 首先,从键值数据库集群中获取到有变化的代码文件,获取的方式可以是后台 timer 轮询,也可以是用“发布 - 订阅”的模式来监听;
- 然后,用更新的代码文件替换本地磁盘的旧文件,然后使用 loadstring 和 pcall的方式,来更新内存中加载的缓存;
这样,下一个被处理的终端请求,就会走更新后的代码逻辑。
当然,实际的应用要比上面的步骤考虑更多的细节,比如版本的控制和回退、异常的处理、网络的中断、边缘节点的重启等,但整体的流程是不变的。
6.4 动态上游
现在,让我们把思绪拉回到 OpenResty 上来,一起来看如何实现动态上游。lua-resty-core 提供了 ngx.balancer 这个库来设置上游,它需要放到 OpenResty 的 balancer 阶段来运行:
1 | balancer_by_lua_block { |
set_current_peer 函数,就是用来设置上游的 IP 地址和端口的。不过要注意,这里并不支持域名,你需要使用 lua-resty-dns 库来为域名和 IP 做一层解析。
不过,ngx.balancer 还比较底层,虽然它有设置上游的能力,但动态上游的实现远非如此简单。所以,在 ngx.balancer 前面还需要两个功能:
- 上游选择算法
- 上游健康检查
OpenResty 官方的 lua-resty-balancer 这个库中,则包含了 resty.chash 和 resty.roundrobin 两类算法来完成第一个功能,并且有 lua-resty-upstream-healthcheck 来尝试完成第二个功能。其中resty-upstream-healthcheck只支持被动的健康检查,可以使用lua-resty-healthcheck 包含主动和被动健康检查。
新兴的微服务 API 网关 APISIX,在 lua-resty-healthcheck 的基础之上,对动态上游做了完整的实现。我们可以参考它的实现,总共只有 200 多行代码,你可以很轻松地把它剥离出来,放到你的自己的项目中使用。
7 OpenResty常用第三方库
7.1 lua-resty库推荐
首先推荐的是由 Aapo 维护的 awesome-resty 仓库,这个仓库分门别类地整理了和 OpenResty 相关的库,可以说是包罗万象,包括了 Nginx 的 C 模块、lua-resty 库、web 框架、路由库、模板、测试框架等,是你寻找 OpenResty 资源的首选。
还可以去 luarocks、opm 和 GitHub 碰碰运气。有一些开源时间不长的、或者关注不多的库,可能就藏在其中。
还可以去 luarocks、opm 和 GitHub 碰碰运气。有一些开源时间不长的、或者关注不多的库,可能就藏在其中。
7.2 lua-var-nginx-module
ngx.var是一个性能损耗比较大的操作,在实际使用时,我们需要用ngx.ctx来做一层缓存。
lua-var-nginx-module相比ngx.var性能提升了5倍。
它采用的是 FFI 的方式,所以,你需要在编译 OpenResty 的时候,先加上编译选项:
1 | ./configure --prefix=/opt/openresty \ |
调用的方法也很简单,只需要一行 fetch 函数的调用就可以了。它的效果完全等价于原有的 ngx.var.remote_addr,来获取到终端的 IP 地址:
1 | content_by_lua_block { |
这个模块到底是怎么做到性能大幅度提升的呢?还是那句老话,源码面前无秘密,就让我们来看看 remote_addr 这个变量在其中是如何获取的吧:
1 | ngx_int_t |
它的优点很明显,使用 FFI 的方式来直接获取变量,绕过了 ngx.var 原有的查找逻辑;同时,缺点也很明显,那就是要为每一个希望获取的变量,都增加对应的 C 函数和 FFI 调用,这其实是一个体力活。
7.3 JSON Schema
lua-rapidjson 。它是对 rapidjson 这个腾讯开源的 JSON 库的封装,以性能见长。这里,我们着重介绍下它和 cjson 的不同之处,也就是支持 JSON Schema。
JSON Schema 是一个通用的标准,借助这个标准,我们就可以精确地描述接口中参数的格式,以及如何校验的问题。下面是一个简单的示例:
1 | "stringArray": { |
这段 JSON 准确地描述了 stringArray 这个参数的类型是字符串数组,并且数组不能为空,数组元素也不能重复。
而lua-rapidjson,则是可以让我们在 OpenResty 中来使用 JSON Schema,这能给接口的校验带来极大的便利。举个例子,比如对于前面介绍过的 limit count 限流接口,我们就可以用下面的 schema 来描述:
1 | local schema = { |
你会发现,这可以带来两个十分明显的收益:
- 对前端来说,前端可以直接复用这个 schema 描述,用于前端页面的开发和参数校验,而不用再去关心后端;
- 而对后端来说,后端直接使用 lua-rapidjson 的 schema 校验函数 SchemaValidator 就能完成接口合法性的判断,更是无须编写多余的代码。
7.4 worker 间通信
假设如下一个场景:
一个 OpenResty 服务有 24 个 worker 进程,管理员通过 REST HTTP 接口更新了系统的某项配置,这时候只有一个 worker 收到了管理员的更新操作,并把结果写入了数据库,更新了共享字典和自己 worker 内的 lru 缓存。那么,其他 23 个 worker 怎么才能被通知去更新这项配置呢?
显然,多个 worker 之间需要一个通知的机制,才能完成上面的这个任务。在 OpenResty 自身不支持的情况下,我们就只能通过共享字典这个跨 worker 可以访问的空间,来曲线救国了。
lua-resty-worker-events便是这个思路的具体实现。它在共享字典中维护了一个版本号,在有新消息需要发布的时候,给这个版本号加一,并把消息内容放到以版本号为 key 的字典中:
1 | event_id, err = _dict:incr(KEY_LAST_ID, 1) |
同时,在后台使用 ngx.timer 创建了一个默认间隔为 1 秒的 polling 循环,来不断地检测版本号是否有变化:
1 | local event_id, err = get_event_id() |
这样,一旦发现有新的事件通知需要处理时,就根据版本号从共享字典中获取消息内容:
1 | while _last_event < event_id do |
总的来说,虽然 lua-resty-worker-events 会有 1 秒钟的延时,但还是实现了 worker 之间的事件通知机制,瑕不掩瑜。
8 答疑
8.1 问题一:如何完成Lua模块的动态加载?
示例:
1 | resty -e 'local s = [[ |
这里的字符串 s,它的内容就是一个完整的 Lua 模块。所以,在发现这个模块的代码有变化时,你可以用 loadstring 或者 loadfile 来重启加载。这样,其中的函数和变量都会随之更新。
更进一步,你也把可以把获取变化和重新加载,用名为 code_loader 函数做一层包装:
1 | local func = code_loader(name) |
这样一来,代码更新就会变得更为简洁;同时, code_loader 中我们一般会用 lru cache 对 s 做一层缓存,避免每一次都去调用 loadstring。这差不多就是一个完整的实现了。
8.2 问题二:LuaJIT 的 NYI 的操作,是否会对性能有很大影响?
Q:loadstring 在 LuaJIT 的 NYI 列表是 never,会不会对性能有很大影响?
A:关于 LuaJIT 的 NYI,我们不用矫枉过正。对于可以 JIT 的操作,自然是 JIT 的方式最好;但对于还不能 JIT 的操作,我们也不是不能使用。
对于性能优化,我们需要用基于统计的科学方法来看待,这也就是火焰图采样的意义。过早优化是万恶之源。对于那些调用次数频繁、消耗 CPU 很高的热代码,我们才有优化的必要。
回到 loadstring 的问题,我们只会在代码发生变化的时候,才会调用它重新加载,和请求多少无关,所以它并不是一个频繁的操作。这个时候,我们就不用担心它对系统整体性能的影响。
结合第二个阻塞的问题,在 OpenResty 中,我们有些时候也会在 init 和 init worker 阶段,去调用阻塞的文件 I/O 操作。这种操作比 NYI 更加影响性能,但因为它只在服务启动的时候执行一次,所以也是可以被我们接受的。
还是那句话,性能优化要从宏观的视角来看待,这是你特别需要注意的一个点。否则,纠结于某一细节,就很有可能优化了半天,却并没有起到很好的效果。
8.3 问题三:共享字典的缓存是必须的吗?
Q:在实际的生产应用中,我认为 shared dict 这一层缓存是必须的。貌似大家都只记得 lrucache 的好,数据格式没限制、不需要反序列化、不需要根据 k/v 体积算内存空间、worker 间独立不相互争抢、没有读写锁、性能高云云。
但是,却忘记了它最致命的一个弱点,就是 lrucache 的生命周期是跟着 worker 走的。每当 Nginx reload 时,这部分缓存会全部丢失,这时候,如果没有 shared dict,那 L3 的数据源分分钟被打挂。当然,这是并发比较高的情况下,但是既然用到了缓存,就说明业务体量肯定不会小,也就是刚刚的分析仍然适用。不知道我的这个观点对吗?
A:大部分情况下,确实如你所说,共享字典在 reload 的时候不会丢失,所以它有存在的必要性。但也有一种特例,那就是,如果在 init 阶段或者 init_worker 阶段,就能从 L3 也就是数据源主动获取到所有数据,那么只有 lrucache 也是可以接受的。
举例来说,比如开源 API 网关 APISIX 的数据源在 etcd 中,它只在 init_worker 阶段,从 etcd 中获取数据并缓存在 lrucache 中,后面的缓存更新,都是通过 etcd 的 watch 机制来主动获取的。这样一来,即使 Nginx reload ,也不会有缓存风暴产生。
所以,对待技术的选择,我们可以有倾向,但还是不要一概而论绝对化,因为并没有一个可以适合所有缓存场景的银弹。根据实际场景的需要,构建一个最小化可用的方案,然后逐步地增加,是一个不错的法子。