You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

109 lines
8.2 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 46 | 答疑(四):共享字典的缓存是必须的吗?
你好,我是温铭。
专栏更新到现在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 的操作,是否会对性能有很大影响?
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 ,也不会有缓存风暴产生。
所以,对待技术的选择,我们可以有倾向,但还是不要一概而论绝对化,因为并没有一个可以适合所有缓存场景的银弹。根据实际场景的需要,构建一个最小化可用的方案,然后逐步地增加,是一个不错的法子。
今天主要解答这几个问题。最后,欢迎你继续在留言区写下你的疑问,我会持续不断地解答。希望可以通过交流和答疑,帮你把所学转化为所得。也欢迎你把这篇文章转发出去,我们一起交流、一起进步。