本文在其基础上修改、补充而来。
Nginx 是一个高性能的、支持高并发的轻量级 Web 服务器。Nginx 采用模块化架构,在官方版本的 Nginx 中,大部分功能都通过模块的方式提供,比如 http 模块、mail 模块等。通过开发模块扩展 Nginx,可以将其打造成全能的应用服务器,如此一来就可以将一些功能在反向代理层解决,比如登录校验、数据库访问等。但是,Nginx 模块需要用 C 开发,而且必须符合一系列复杂规则。最重要的是,用 C 开发模块必须要熟悉 Nginx 的源代码,使得开发者对其望而生畏。淘宝的 agentzh 和 chaoslawful 开发的 ngx_lua 模块,通过将 Lua 解释器集成进 Nginx 的方式,采用 Lua 脚本实现业务逻辑。由于 Lua 紧凑、快速以及内建协程,所以在保证高并发服务能力的同时,极大地降低业务逻辑的实现成本。
本文向大家介绍 ngx_lua,以及在使用它开发项目的过程中可能遇到的问题。
Nginx 采用多进程模型,单 Master 多 Worker。Master 负责处理外部信号,读取配置文件,以及初始化 Worker。Worker 采用单线程、非阻塞的事件模型(Event Loop,事件循环),负责监听端口,处理和响应客户端请求,同时 Worker 还要处理来自 Master 的信号。由于 Worker 使用单线程处理各种事件,所以必须保证主循环是非阻塞的,否则会大大降低 Worker 的响应能力。
表面上看,当 Nginx 处理客户端的请求时,先根据 Host 请求头,确定由哪个 server 处理;确定 server 之后,再根据请求的 uri 找到对应的 location;最后将请求交给 location 处理。实际上,Nginx 将请求的处理划分为若干个阶段(phase),这些阶段按照先后顺序依次执行,也就是说 NGX_HTTP_POST_READ_PHASE 最先执行,NGX_HTTP_LOG_PHASE 最后执行。
typedef enum {
NGX_HTTP_POST_READ_PHASE = 0, // 接收到完整的 HTTP 头后处理的阶段
NGX_HTTP_SERVER_REWRITE_PHASE, // URI 与 location 匹配前,修改 URI 的阶段,用于重定向
NGX_HTTP_FIND_CONFIG_PHASE, // 根据 URI 寻找匹配的 location 块,然后根据 loc_conf 设置 r 的相应变量
NGX_HTTP_REWRITE_PHASE, // 在上一阶段找到 location 块后,再修改 URI
NGX_HTTP_POST_REWRITE_PHASE, // 防止重写 URL 导致死循环
NGX_HTTP_PREACCESS_PHASE, // 下一阶段之前的准备
NGX_HTTP_ACCESS_PHASE, // 判断是否允许请求进入 Nginx
NGX_HTTP_POST_ACCESS_PHASE, // 向用户发送拒绝服务的错误码
NGX_HTTP_TRY_FILES_PHASE, // 为访问静态资源而设置
NGX_HTTP_CONTENT_PHASE, // 处理 HTTP 请求内容的阶段
NGX_HTTP_LOG_PHASE // 处理完请求后的日志记录阶段
} ngx_http_phases;
在每个阶段上,可以注册 handler,处理请求就是运行每个阶段上运行的 handler。Nginx 模块提供的配置指令一般只会注册到某一个处理阶段。比如,set 指定运行在 rewrite 阶段;deny 和 allow 指令运行在 access 阶段。
在 Nginx 的世界里,有两种类型的“请求”,一种叫“主请求”(main request),另一种叫“子请求”(subrequest)。“主请求”是由 HTTP 客户端从 Nginx 外部发起的请求。比如,从浏览器访问 Nginx 就是一个“主请求”。 而“子请求”则是由 Nginx 正在处理的请求在 Nginx 内部发起的级联请求。“子请求”在外观上很像 HTTP 请求,但实现上却和 HTTP 协议以及网络通信毫无关系。它是 Nginx 内部的抽象调用,目的是方便用户把“主请求”的任务分解为多个较小粒度的“内部请求”,以便并发或串行地访问多个 location 接口,由这些 location 接口通力协作,共同完成整个“主请求”。当然,“子请求”的概念是相对的,任何“子请求”也可以发起更多的“子子请求”,甚至可以递归调用。
当一个请求发起一个“子请求”时,按照 Nginx 的术语,习惯把前者称为后者的“父请求”(parent request)。
location /main {
# echo_location 发送子请求到指定的 location
echo_location /foo;
echo_location /bar;
}
location /foo {
echo foo;
}
location /bar {
echo bar;
}
这里 main location 分别向 foo 和 bar 发送子请求,类似函数调用。
“子请求”方式的通信在同一个虚拟主机内部进行,所以 Nginx 核心在实现“子请求”时,只调用若干个 C 函数,完全不涉及任何网络或者 UNIX 套接字(socket)通信。由此可以看出“子请求”的执行效率极高。
协程与线程的区别有:
Nginx 的每个 Worker 进程都是在 epoll 或 kqueue 之类的事件模型之上,封装成协程,每个请求由一个协程进行处理。这正好与 Lua 内建的协程模型一致,所以即使 ngx_lua 需要执行 Lua,相对于 C 有一定的开销,但依然能保证高并发能力。
ngx_lua 将 Lua 解释器嵌入 Nginx(类似 Apache 的 mod_perl、mod_wsgi 等模块),可以让 Nginx 执行 Lua 脚本,高并发、非阻塞地处理各种请求。由于 Lua 内建协程,所以可以很好地将异步回调转换成顺序调用的形式。ngx_lua 将 Lua 中进行的 IO 操作委托给 Nginx 的事件模型,从而实现非阻塞调用。开发者可以采用串行的方式编写程序,ngx_lua 会自动地在进行阻塞 IO 操作时中断、保存上下文;然后将 IO 操作委托给 Nginx 事件处理机制,在 IO 操作完成后,ngx_lua 恢复上下文,程序继续执行,这些操作对用户程序都是透明的。每个 Nginx Worker 进程持有一个 Lua 解释器或 LuaJIT 实例,被一个 Worker 处理的所有请求共享相同的解释器实例。每个请求的 Context 会被 Lua 的轻量级协程分割,从而保证各个请求独立。 ngx_lua 采用“one-coroutine-per-request”处理模型,对于每个用户请求,ngx_lua 会唤醒一个协程,执行用户代码处理请求,当请求处理完成协程会被销毁。每个协程拥有独立的全局环境(变量空间),继承于全局共享的、只读的“common data”。所以被用户代码注入到全局空间的任何变量都不会影响其它请求的处理,并且这些变量在请求处理完成后会被释放,这样可以保证所有用户代码都运行在“sandbox”(沙箱)中,该沙箱与请求具有相同的生命周期。 得益于 Lua 协程的支持,ngx_lua 在处理 10K 个并发请求时,只需少量内存。根据测试,ngx_lua 处理每个请求只需要 2KB 内存,如果使用 LuaJIT 则更少。所以 ngx_lua 非常适合实现可扩展的、高并发的服务。
ngx_lua 的开发者做过 ngx_lua 与 Nginx + FPM + PHP 和 NodeJS 的对比,得出的结果是 ngx_lua 可以达到 28000 rps,而 NodeJS 可以达到 10000+,PHP 最差只有 6000。
可以通过下载模块源代码,编译 Nginx 的方式,安装 ngx_lua。但是推荐使用 Openresty,Openresty 是一个 bundle 程序,包含大量第三方 Nginx 模块,比如 HttpLuaModule、HttpRedis2Module、HttpEchoModule 等。Openresty 的 ngx_lua 模块默认采用标准的 Lua 解释器,可以通过指定 --with-luajit
选项,使用 LuaJIT。
ngx_lua 模块提供配置指令和 Nginx API:
nginx.conf:
worker_processes 1;
events {
worker_connections 1024;
}
http {
server {
listen 80;
server_name localhost;
location /adder-1 {
add_header Content-Type "text/plain";
set_by_lua $res "local a = tonumber(ngx.arg[1]) local b = tonumber(ngx.arg[2]) return a + b" $arg_a $arg_b;
echo $res;
}
location /adder-2 {
add_header Content-Type "text/plain";
set_by_lua_file $res adder.lua $arg_a $arg_b;
echo $res;
}
location /auth {
add_header Content-Type "text/plain";
access_by_lua_file auth.lua;
echo "Welcome to Openresty";
}
}
}
adder.lua:
local a = tonumber(ngx.arg[1])
local b = tonumber(ngx.arg[2])
return a + b
auth.lua:
if ngx.var.arg_user == "ntes" then
return
else
ngx.exit(ngx.HTTP_FORBIDDEN)
end
说明:
测试效果:
# curl "http://localhost:80/adder-1?a=1&b=2"
3
# curl "http://localhost:80/adder-2?a=2&b=3"
5
# curl "http://localhost:80/auth?user=ntes"
Welcome to Openresty
# curl "http://localhost:80/auth?user=UNKNOWM"
<html>
<head><title>403 Forbidden</title></head>
<body>
<center><h1>403 Forbidden</h1></center>
<hr><center>openresty/1.19.9.1</center>
</body>
</html>
Nginx API 被封装在 ngx 和 ndk 两个 package 中。比如 ngx.var.NGX_VAR_NAME 可以访问 Nginx 变量。这里着重介绍 ngx.location.capture 和 ngx.location.capture_multi。
语法:res= ngx.location.capture(uri, options?)
。用于发出同步的、非阻塞的 Nginx subrequest(子请求)。可以通过 Nginx subrequest 向其它 location 发出非阻塞的内部请求,这些 location 可以是其它 C 模块,比如 ngx_proxy、ngx_fastcgi、ngx_memc、ngx_postgres、ngx_drizzle,甚至是 ngx_lua 自己。Subrequest 只是模拟 HTTP 接口,没有额外的 TCP 传输开销,它在运行在 C 层次上,非常高效。Subrequest 不同于 HTTP 301/302 重定向,以及内部重定向(通过 ngx.redirection)。
实际上,location 既可以被外部 HTTP 请求调用,也可以被内部的子请求调用。每个 location 相当于一个函数,发送子请求类似于函数调用,而且这种调用是非阻塞的,这就构造了一个非常强大的编程模型,后面我们会看到如何通过 location 和后端 Redis 进行非阻塞通信。
语法:res1, res2, ... = ngx.location.capture_multi({{uri, options?}, {uri, options?}, ...})
。与 ngx.location.capture 的功能类似,但是可以并行地、非阻塞地发出多个子请求。该方法在所有子请求都处理完成后返回,整个方法的运行时间取决于运行时间最长的子请求,而非所有子请求的运行时间之和。
在 Lua 代码中,网络 IO 操作只能通过 Nginx Lua API 完成,使用标准的 Lua API 将导致 Nginx 的事件循环被阻塞,性能将急剧下降。在进行数据量相当小的磁盘 IO 时,可以采用标准的 Lua io 库,但是当读写大文件时,会阻塞整个 Nginx Worker 进程。为获得更好的性能,强烈建议将所有网络 IO 和磁盘 IO 委托给 Nginx 子请求完成(通过 ngx.location.capture)。
nginx.conf:
x
worker_processes 1;
events {
worker_connections 1024;
}
http {
server {
listen 80;
server_name localhost;
default_type text/plain;
location /capture {
content_by_lua 'local res = ngx.location.capture("@internal") if res.status == 200 then ngx.print(res.body) end';
}
location = {
internal;
echo "internal";
}
location /capture-multi {
content_by_lua 'local res1, res2 = ngx.location.capture_multi({{"@moon"}, {"@earth"}}) if res1.status == 200 then ngx.print(res1.body) end if res2.status == 200 then ngx.print(res2.body) end';
}
location = {
internal;
echo "moon";
}
location = {
internal;
echo "earth";
}
}
}
测试效果:
x
# curl "http://localhost/capture"
internal
# curl "http://localhost/capture-multi"
moon
earth
访问 Redis 需要 HttpRedis2Module 模块的支持,它可以同 Redis 进行非阻塞通信。不过,HttpRedis2Module 模块的响应是 Redis 的原生响应,因此可以使用 LuaRedisModule 模块在 Lua 中解析其响应。
nginx.conf:
xxxxxxxxxx
worker_processes 1;
events {
worker_connections 1024;
}
http {
server {
listen 80;
server_name localhost;
default_type text/plain;
location = {
internal;
redis2_query get $arg_key;
redis2_pass 127.0.0.1:6379;
}
location /lua-redis {
content_by_lua 'local parser = require("redis.parser") local res = ngx.location.capture("@redis", {args={key=ngx.var.arg_key}}) if res.status == 200 then reply = parser.parse_reply(res.body) ngx.say(reply) end';
}
}
}
测试效果:
xxxxxxxxxx
# curl "http://localhost:80/lua-redis?key=1"
2
# curl "http://localhost:80/lua-redis?key=2"
nil
在前面访问 Redis 的例子中,每次处理请求时,都会和后端 Server 建立连接,请求处理完成后,会释放连接。该过程中有三次握手、time_wait 等开销,这对于高并发应用是不可容忍的。在这里引入 Connection Pool 来消除该开销。
nginx.conf:
x
worker_processes 1;
events {
worker_connections 1024;
}
http {
upstream redis-pool {
server 127.0.0.1:6379;
keepalive 1024;
}
server {
listen 80;
server_name localhost;
default_type text/plain;
location = {
internal;
redis2_query get $arg_key;
redis2_pass redis-pool;
}
location /lua-redis {
content_by_lua 'local parser = require("redis.parser") local res = ngx.location.capture("@redis", {args={key=ngx.var.arg_key}}) if res.status == 200 then reply = parser.parse_reply(res.body) ngx.say(reply) end';
}
}
}
keepalive <connections>
指令中的 <connections> 参数用于设置到上游服务器的空闲保活连接的最大数量,它们被保存在每个 Worker 进程的缓存中。当超出该数量时,关闭最近最少使用的连接。使用连接池可以显著提升处理能力。
安装依赖包:
xxxxxxxxxx
apt-get install -y libpcre3-dev libssl-dev perl make build-essential curl libpq-dev libpq5 zlib1g zlib1g-dev
去官网的 Download 页面,下载 Openrestry 的源码包。我使用的是 openresty-1.19.9.1.tar.gz
安装
# ./configure --with-luajit \
--with-http_iconv_module \
--with-http_postgres_module
# make -j8
# make install