你好,我是温铭。

专栏更新到现在,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("hello world")
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("hello")
end
ngx.print()'
hello

这样的示例代码,就可以直接改写 ngx.print 函数了。

问题三:LuaJIT 的 NYI 的操作,是否会对性能有很大影响?

Q:loadstring 在 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 的数据源在 etcd 中,它只在 init_worker 阶段,从 etcd 中获取数据并缓存在lru cache 中,后面的缓存更新,都是通过 etcd 的 watch 机制来主动获取的。这样一来,即使 Nginx reload ,也不会有缓存风暴产生。

所以,对待技术的选择,我们可以有倾向,但还是不要一概而论绝对化,因为并没有一个可以适合所有缓存场景的银弹。根据实际场景的需要,构建一个最小化可用的方案,然后逐步地增加,是一个不错的法子。

今天主要解答这几个问题。最后,欢迎你继续在留言区写下你的疑问,我会持续不断地解答。希望可以通过交流和答疑,帮你把所学转化为所得。也欢迎你把这篇文章转发出去,我们一起交流、一起进步。

评论