如何编写正确且高效的 OpenResty 应用

本文内容,由我在 OpenResty Con 2018 上的同名演讲的演讲稿整理而来。nginx

PPT 能够在 这里 下载,由于内容比较多,我就不在这里一张张贴出来了。有些内容须要结合 PPT 才能理解,请多包涵。git

编写正确且高效的应用,最为关键是一系列软件工程上的实践,像测试、code review、灰度、监控、压测等等。不过因为这是 OpenResty 大会上的演讲,我会专一于讲讲 OpenResty 和 LuaJIT 的一些小细节,帮助各位听众避免线上踩坑。github

自我介绍

按惯例,得先自我介绍下。spacewander,这个是个人 GitHub 昵称。我目前在 OpenResty Inc. 公司工做。算法

第一部分

先从 OpenResty 开始讲吧。数组

init_by_lua*

init_by_lua* 是 OpenResty 目前惟一运行在 master 进程里的阶段。它运行的时机很是靠前,就在 Nginx 刚解析完配置以后。缓存

这就意味着,只要运行 Nginx 可执行文件,init_by_lua* 里面的代码就会被调用。有些时候,咱们运行 Nginx 可执行文件,并非想启动它的服务。好比在调用 nginx -t 检查配置文件是否正确,或者在调用 nginx -s 控制当前的 master 进程的时候。若是你的代码里包含对本进程之外的资源的修改,这种意料以外的执行是不受欢迎的。那怎么避免呢?对于 -s,能够经过直接用 kill 发送信号的方式代替。对于 -t,略微复杂一点,能够经过 FFI 的方式获取当前 Nginx 进程的命令行参数,判断其中是否包含 -t 选项。PPT 上面包含了这么作的代码。服务器

若是 init_by_lua* 里面的代码执行时间过长,好比启动时会先从远程服务器加载数据,可能会带来另外一个问题。大多数部署脚本里面,检测 Nginx 进程是否顺利启动,是经过查看 nginx.pid 这个文件实现的。因为 Nginx 是在执行完这一部分 Lua 代码后才会建立 nginx.pid 文件,若是执行时间过长,可能会在部署时形成误判。这时候可能须要恰当地增长查看 nginx.pid 的时间间隔。网络

ngx.worker.id()

要想区分不一样 worker 进程,一般的作法是用 ngx.worker.id()。须要注意的是,有些时候多个 worker 进程可能会有一样的 id,好比在 reload 或者 binary upgrade 的时候。socket

Nginx 在 reload 的时候,会有两组 worker 进程。新的 worker 们会接替老的 worker,但直到老 worker 退出以前,这两组 worker 是同时运行的。若是 shdict 的配置不变,这两组进程甚至有一样的共享内存空间,因此在用 worker id 做为 shdict key 的时候,这种边界状况须要考虑下。ide

Nginx 在 binary upgrade 的时候,则会有两组 master+workers。这两组进程并不共享内存空间,因此用 worker id 做为 shdict key 时,不用关心这种状况。可是当你用 worker id 做为外部服务或者文件系统的 key 时,仍是要注意下的。一种可能的解决方案是引入 parent pid 做为前缀,而后处理好 key 变化时,数据迁移的逻辑。

shdict is a LRU cache

若是 shdict 里面的数据超过了事先分配好的内存大小限制,OpenResty 会根据 LRU 算法,清除现有的数据。我发现许多状况下,咱们会忽视这一事实,认为 shdict 里面的空间必定足以容纳全部数据。不过倘若你是一个厌恶风险的人,能够考虑只用 safe_ 开头的一系列 API 来操做 shdict。

这一类 API 在 shdict 不够空间时,会失败,而不是默默地挤掉当前的数据。

update time

Nginx 对时间的缓存很是激进,只有在开始新的一个事件循环时才会更新缓存的时间。对于服务器,这彷佛足够了;但对于应用代码,一不当心可能就踩坑了。

通俗易懂的说法,若是你的 Lua 代码不 yield,那么从头至尾获取到的时间都是缓存的结果。另外,若是多个请求同处一个事件循环里面,而其中一个请求产生了阻塞操做,那么执行剩下的请求时,缓存的时间跟真正的时间就会有明显的差异。因此,在进行了耗时操做以后,可能须要调用下 ngx.update_time。若是须要准确的时间戳,不该直接调用 ngx.now()

接下来让咱们比较下几种 ngx.now() 的替代品。

在上图中,咱们以 os.time() 做为基准,比较下各方的性能。咱们看到,ngx.update_time() + ngx.now() 的组合是最耗时的,由于 ngx.update_time() 除了要获取当前的时间外,还要更新一系列时间字符串。值得一提的是,咱们本身实现 current_ms_time() 不管是性能仍是时间的精准度都比 os.time() 要好,可见在 JIT 下,FFI 实现能够击败没法被 JIT 的内置函数。另外咱们也会看到,resty.core版本的 ngx.now()ngx.update_time() 至关地快。在本演讲的最后,我会解释为何会这样。

有限的 timer

在 OpenResty 里面,timer 的个数是有限的。

首先每一个 timer 都是一个 fake request,在 Nginx 这边看来,每一个 timer 其实都是一个请求。跟真实的 request 同样,它也会占据一个链接。因此你的 worker_connections 要足够大,即便 timer 并不会真的创建一个网络链接。另外,OpenResty 还有两个参数限制了 timer 的总数,lua_max_pending_timerslua_max_running_timers,须要保证它们够大。另外若是启动 timer 时没有足够的内存,也是会失败的。若是能够的话,尽量用 ngx.timer.every 来启动按期的 timer。用 ngx.timer.at 反复启动 timer 的话,一旦每次启动失败,那就真的失败了。

既然每一个 timer 都是一个请求,那么若是你每一个网络请求都会启动一个甚至多个 timer,性能天然好不到哪儿去。最简单的优化办法是引入批处理,避免不断建立 timer,你也能够考虑下队列,甚至更为复杂的时间轮。不过要想复用 timer,还要面对额外的挑战……

第一个挑战来自于 Nginx 的每请求内存池。只有在请求结束时,Nginx 才会释放这一内存池内全部的内存。而前面已经说了,每一个 timer 在 Nginx 看来都是一个请求,因此某种意义上,一个 timer 就像是一个长链接,尤为当这个 timer 会一直运行到进程结束时。长时间运行的 timer 天然会带来内存的持续上涨,但其上涨的速度通常而言并不显著。缘由有二:

  1. 许多 OpenResty API 实际上只会分配 Lua 层面上的内存。而这部份内存是在 LuaJIT GC 管理下的。在引入了 lua-resty-core 后更是如此。
  2. cosocket 的 send/receive 操做,会有 buffer 复用机制,使得其内存占用不会无限制增长。

另外一个挑战来自于较为隐晦的地方。当前 entry thread 会把它所建立的每一个协程,记录到一个链表里。而各类协程 API,大都须要访问这个链表。若是 timer 或者长链接持续大量地建立协程,会致使协程 API 变得愈来愈慢。就目前的状况,要想解决这个问题,须要对协程进行复用,避免无限制地建立协程。

第二部分

讲完 OpenResty,让咱们看看 LuaJIT。

不可变的 string

Lua 跟其余大部分语言有一点不同,就是它的字符串是不可变的。不变字符串天然有些优势,好比减低内存占用、比较字符串时能够直接比较它的内存地址等等。可是缺点也很多。在其余语言里面,当咱们想修改一个字符串部份内容,好比大小写转换,咱们能够直接改变对应的位置上的 byte。毕竟字符串一般就是一个字节数组(byte array)。可是这事要在 Lua 里面作,非得拷贝一个新字符串不可。并且因为要保证每种字符串都只有一个实例,lj_str_new 须要对实际的字符串内容作 hash,而后用它查找该内容是否已经建立了对应的实例。

既然说到作 hash,那么天然得提到 hash 碰撞。对于那些 hash 值同样的字符串,LuaJIT 把它们存储在链表里。若是许多字符串有着同样的 hash 值,那么这个链表就会很长,原来 O(1) 的开销会退化为 O(n)。这就是所谓的 hash 碰撞。不幸的是,LuaJIT 的默认的字符串 hash 函数就有这样的问题。在网上你能找到一些相关的报告。

OpenResty 自带的 LuaJIT 用硬件加速的 CRC32 函数替换了默认的字符串 hash 函数,下降了发生 hash 碰撞的风险。须要说明的是,只有在支持 SSE 4.2 指令集的 x64 平台上才会启用这一函数。

即便 hash 碰撞的问题能够避免,lj_str_new 依然是一个既频繁又耗时的函数。
最好的优化就是不作。好比若是只是想查看字符串里面的字符,能够用 string.byte 代替 string.sub
OpenResty 里面,也有许多 API 支持在 C 层面上完成字符串的拼接,无需调用 lj_str_new,好比 cosocket 的 send、ngx.sayngx.log
它们接受多个参数,或者数组 table,在 C 层面上拼接成字符串。这里的数组 table 甚至能够是嵌套的。

谁能够代替字节数组

LuaJIT 缺少字节数组,这是个痛点,尤为是在作协议转换的时候。一个一般的代替品是用数组 table。另外一个是借助 FFI,申请一块名符其实的字节数组。

这里有些操做数组 table 的方法。有两个须要解释下:

table.new 是 LuaJIT 独有的方法,容许在建立 table 时指定大小,减小后面 resize 的成本。

table.clone 是 OpenResty 自带的 LuaJIT 的方法,容许对一个 table 作浅复制。它内部调用了 lj_tab_dup 这个 LuaJIT 内部函数。

buffer 复用

前面讲到,咱们能够给某些 API 传递 table 而不是 concat 以后的字符串。可能有人会怀疑,建立 table 开销不会比 concat 字符串大吗?
其实这里的 table,是能够复用的,无需每次都建立。

若是你的函数里面没有 yield,你能够直接拿个 local 变量,每次都复用这个变量。为了不影响到其余函数,咱们这里用了个 do block 把相关的变量都包起来。
若是你的函数里面有 yield,你能够经过 lua-tablepool 这个库实现 table 的回收复用。

避免在 table 中间存储 nil

一个众所周知的事实:若是数组 table 中间有 nil,获取到的长度可能会不许。Lua 可能会把某个 nil 的位置做为这个 table 的结尾。

不过较少为人所知的是,nil 也会影响 unpack 的结果。因为 unpack 返回的结果个数取决于 table 的长度,因此若是获取的长度不许,unpack 返回的结果数也会不许。若是咱们 unpack 前面的 table,就只会返回第一个数 0. 另外,Lua 里经常使用的两种迭代数组的方式,for i in ipairs()for i = 1, #table,在处理数组中的 nil 的方式上有所不一样。前者每次迭代时都会检查当前元素是否是 nil,若是是的话结束迭代。

尽量不要把数组 table 中的某个元素置为 nil,应该用 ngx.null 做为占位符。

有上限的 unpack

既然提到了 unpack,顺便提下 unpack 也是有大小限制的。若是 unpack 的数组大小超过 8000,unpack 会抛异常。

FFI buffer 做为 byte[]

除了用 table,也能够考虑下用 FFI buffer 做为字节数组。FFI buffer 的好处在于内存占用少。坏处呢,一个是周边的 API 支持少,用起来不像 table 那么方便;另外一个是,若是不能被 JIT 编译的话,FFI 操做很昂贵。
固然 FFI buffer 也是能够复用的,复用方法跟 table 差很少。有兴趣的听众能够看看 lua-resty-core 的 get_string_buf 这个方法。

LUAJIT_NUMMODE

LuaJIT 有一个编译选项 LUAJIT_NUMMODE,控制对 number 类型的处理方式。它的默认值为 1。当咱们把它在编译时设置为 2 时,对于可以用 32 位整数表示的 number,LuaJIT 会用 int32 表示,而不是一律用 double 来表示。一般来讲,设置 LUAJIT_NUMMODE=2 会让程序快一点,由于 CPU 更擅长对整数进行计算。可是也不必定,由于影响性能的因素很是复杂,具体问题须要具体分析。后面我会给你们看一个例子,LUAJIT_NUMMODE=2 会让程序更慢。

JIT

终于讲到重头戏,LuaJIT 的 JIT 编译。LuaJIT 采用 Tracing JIT 来记录并实时编译字节码。当某个循环或者函数调用足够热时,LuaJIT 会开始记录执行的字节码,进行优化后生成 IR,而后把 IR 编译成 mcode。你能够在上面两个文档中找到对 字节码 和 IR 的一些说明。

你能够在 LuaJIT 代码中添加下面两行代码,把这一过程 dump 到指定文件中:

local dump = require "jit.dump"
dump.on("abimsrtx", filepath)

让咱们看一个实际的例子。

这个例子是为了展现 JIT 过程而设计的,咱们能够从 dump 输出中看到很多信息。

从 Trace 2 的 bytecode 部分能够看到,Tracing JIT 在 tracing 的时候是跨函数的。
从 Trace 2 的 IR 部分能够看到,string.rep 等操做被移到了 LOOP 之外,由于它的结果在整个循环中是不变的。

在 IR 里面有一个有意思的输出:

CALLXS [0x7f248ac41180]

从对应的 base_encoding.lua 代码能够看出,这里实际上是经过 FFI 调用了某个 so 里面的函数。
在最终生成的 mcode 里面,咱们也能找到对应的 call 0x7f248ac41180

为何 FFI 在 JIT 下性能会比解释器模式下快不少呢?缘由在于解释器模式下,LuaJIT FFI 须要实现 Lua 和 C 数据间的 marshal 和 unmarshal。而在 JIT 模式下,二者的交互都是汇编层面上的。

咱们能够看到,很多 IR 左边有个 >,这表示这个 IR 是做为 guard 存在的。Trace 是没有分支的,一旦发生 guard 不能知足的状况,会退出当前 trace 进入解释器模式。
看下 LOOP 里面这个 NE 0069 这个 IR。结合上一个 IR,能够知道它的意思是判断 % 5 != 4。咱们能够找到对应的 mcode:

7f24b63bfeca  mov ebx, eax
7f24b63bfecc  mov esi, 0x5
7f24b63bfed1  mov edi, ebp
7f24b63bfed3  call 0x7f248c2e68a0    ->lj_vm_modi
7f24b63bfed8  mov rdi, [rsp+0x8]
7f24b63bfedd  cmp eax, +0x04
7f24b63bfee0  jz 0x7f24b63b002c    ->7

咱们能够看到,这里面插了个 jz 0x7f24b63b002c 的判断。也就是若是不符合 != 4 的条件,就会跳到 0x7f24b63b002c 这个地址,而不是继续执行下去。旁边有一个 ->7 的标记,表示退出时用 snapshot 7 里面的数据恢复解释器模式。snapshot 7 就在 NE 0069 的上面。须要解释下,snapshot 的输出和 IR 的输出是并行的,只是刚好在 NE 0069 上面,二者输出的位置并没有因果性。

再往下拉,咱们会看到 TRACE 2 屡次 exit 7。当另外一个分支足够热时,会从原来的 TRACE 里面生成一个 side trace,也就是这里的 TRACE 3. 而后 TRACE 3 追踪到 unpack 这里的没了。由于 unpack 是 NYI 的,JIT 无法 tracing 下去。不过好在 LuaJIT 支持 stitch,能够绕过 NYI 语句,生成新的 TRACE 4.有点像下了高速,开了段路后又重上高速。

side trace 有一个问题,就是它们在结束后,会跳回到 root trace 的开头。像 TRACE 4 的最后一个指令,就是跳到 TRACE 2 的开头。咱们知道,TRACE 4 是从 LOOP 里面长出来的,然而 TRACE 4 结束后会跳到 TRACE 2 开头,也就是像 string.rep 这样的操做,每次在 TRACE 4 执行完以后都会再走一遍,哪怕它的结果在整个循环里是不变的。

让咱们看下第二个例子。这是段在 Lua 里面算 CRC32 的程序。而后改动了两行代码,用 FFI buffer 替换了 table,它的性能是原来的 2.5 倍。我会从 jit.dump 输出的角度解释为何先后差异那么大。

why_byte_level_slow 是 table 版本的 dump,而 crc32_ffi 是 FFI 版本的 dump。这两个 dump 的 TRACE 1,都是同样的字节码,可是二者 IR 的 LOOP 中间部分不同。抛去类似的部分不谈,能够看出 table 版本多了个 ABC,也就是 array boundary check。而后比较下 mcode 对应部分,table 版本有 23 个指令,而 FFI 版本只有 17 个指令。

可是 LOOP 部分从 23 个指令减小到 17 个跟 2.5 倍提高对不上。显然还有第二个因素在起做用。

看下 table 版本的 dump,你会发现它的 TRACE 数量不少,并且类似。仔细看,你会发现,有些地方从 table 中加载的数据类型是 num,而有些地方是 int。好比 TRACE 1 的 ALOAD 是 num,而 TRACE 2 的 ALOAD 是 int。这个 dump 是在 LUAJIT_NUMMODE=2 的状况下生成的。前面提到,这种模式下,LuaJIT 会尽量把数值看成 int32 处理。可是 CRC32 表里面,有些数字超过了 int32,只能做为 double 处理。因为这两种类型须要生成不一样的 mcode,致使大量 side trace 的生成。在 FFI 版本里,因为咱们指定 CRC32 表的类型为 unsigned int,就没有这个问题。

why lua-resty-core is faster

最后咱们来看下为何一样的函数, lua-resty-core 里面的版本会更快。这是一样一段使用了 ngx.re.find 的代码,在 CFunction 和 FFI+JIT 两个版本下生成的火焰图。咱们能够看到,CFunction 版本的火焰图里面有大量 lua_xxxx 这样的函数的开销,而 FFI+JIT 版本里面,就没有这些函数。

因为 JIT 时能够优化掉 FFI 调用的数据交换过程,因此当一个 API 在数据交换上耗费的比重越多,改写成 FFI 时带来的性能提高越大。好比 ngx.re.find (数据交换复杂)好比 ngx.time (C 部分的逻辑简单,大部分耗时在数据交换上)反之,若是一个 API 耗费在数据交换的比重小,则 FFI 化带来的提高就小,好比 ngx.md5。FFI 改造还能减小 stitch,这方面的提高须要结合具体上下文分析。

相关文章
相关标签/搜索