所谓「代码热更新」,是指代码发生变化后,不用 reload 或者 graceful restart 进程就能生效。比如有一个聊天服务,连接着一百万个用户的长连接,所谓代码热更新就是在长连接不断的前提下完成代码更新。实际上因为所有的 require 操作都是通过 package.loaded 来加载模块的,只要代码是以 module 的形式组织的,那么就可以通过 package.loaded 实现代码热更新,并且基本不影响性能。
下面让我们做个实验来说明一下如何实现代码热更新的,首先设置如下配置:
lua_code_cache on;
worker_processes 1;
location /run {
content_by_lua_block {
ngx.say(require("test").run())
}
}
location /unload {
allow 127.0.0.1;
deny all;
content_by_lua_block {
package.loaded[ngx.var.arg_m] = nil
}
}
需要说明的是,之所以把 worker_processes 设置为 1,是因为每个 worker 进程都有一个独立的 lua vm,设置成 1 更方便测试,稍后我会说明大于 1 的时候怎么办。此外,有两个 location,其中 run 是用来运行模块的,unload 的是用来卸载模块的。
接着在 package.path 所包含的某个路径上创建 test 模块:
local _M = {}
function _M.run()
return 1
end
return _M
逻辑很简单,就是返回一个数字。一切准备就绪后,reload 一下 ngx,让我们开始实验:
由此可见,在模块内容被修改后,我们没有 reload 进程,只是通过卸载 package.loaded 中对应的模块,就实现了代码热更新。
看起来实现代码热更新非常简单。打住!有例外,让我们修改一下 test 模块:
local ffi = require("ffi")
ffi.cdef[[
struct test { int v; };
]]
local _M = {}
function _M.run()
local test = ffi.new("struct test", {1})
return test.v
end
return _M
还是打印一个数字,只是用 ffi 实现的,让我们再来重复一下实验步骤,结果报错了:
attempt to redefine …
究其原因,是因为当我们通过 package.loaded 卸载模块的时候,如果用到了 ffi.cdef 之类的 ffi 操作,那么其中的 C 语言类型声明是无法卸载的。
好在我们可以通过条件判断来决定是否要执行 ffi.cdef 语句:
if not pcall(ffi.typeof, "struct test") then
ffi.cdef[[
struct test { int v; };
]]
end
说明:如果我们要修改原始定义的话,那么就只能 reload 了。好在这种情况不多。
最后,让我来说一说多进程的问题,在测试过程中,我只使用了一个进程,并且通过一个特定的 location 来实现卸载 package.loaded 中指定模块的功能,但是在实际情况中, worker_processes 多半是大于 1 的,也就说有多个 worker 进程,此时,如果再使用特定 location 来操作的话,你是无法确定到底是操作在哪个 worker 上的。
比较直观的解决方案是:
如此可以解决问题,但是不爽的是每个请求都要检查版本号。看看另一个方案:
补充:如果自定义 loader 的话,那么在通过 loadstring 加载模块的时候,不一定非要从本地磁盘加载模块,思维发散一下,可以通过读取远程数据来加载。比如说有一百台服务器需要更新代码,那么可以把新代码发送到某个 redis 上,然后所有服务器通过请求 redis 拿到新代码,并把 loadstring 缓存到 package.loaded 中去,如此避免了部署的麻烦。