NGINX + LUA 过载保护方案研究
问题背景
由于近期电商促销活动频繁、力度大,商城流量明显升高,偶发因为负载过高导致访问延迟高、 甚至发生 50x 错误的情况。
解决思路
主要分三方面考虑:
- 商城程序调优,数据库查询优化。由于新版商城已经在开发中,所以老商城以稳定为主。并不会做过多的调整。
- 硬件扩展。为应对促销季节的大流量,临时提升服务器带宽;增加数据库服务器的 CPU 核心数和内存容量。
- 必要的时候做服务降级和过载保护。
方案探索
lua-nginx-module 是一个 nginx 非官方模块,提供了 nginx 服务器中内嵌 lua 脚本的能力。通过在运行时将控制权交给 lua 解释器来实现弹性扩展。
lua-nginx-module 可以单独安装,也可以通过集成环境 openresty 来安装。这里推荐使用后者,因为该套件集成了众多组件,包括 nginx、lua module、redis module mysql module 等等,使用起来更加方便。
S1 安装 openresty
以我们生产环境应用最多的 CentOS 为例:
1 2 3 4 5 6 |
# 添加 yum 源 sudo yum install yum-utils sudo yum-config-manager --add-repo https://openresty.org/package/centos/openresty.repo # 安装 sudo yum install -y openresty openresty-resty openresty-opm openresty-doc |
安装位置为 /usr/local/openresty. 看一下目录下的文件:
可以看到,openresty 已经集成了 nginx、lua、luajit、openssl 和 pcre 等组件。再进去 bin 目录看一下:
openresty 被软连接到 nginx。那么理论上我们也就可以通过 openresty 来执行对 nginx 的操作。
S2 配置 nginx
编辑 /usr/local/openresty/nginx/conf/nginx.conf 文件,在 http 块中添加两行:
1 2 |
lua_code_cache off; lua_shared_dict global 30m; |
第一行的作用是关闭 lua 代码缓存。这样我们每次修改 lua 脚本后就不需要重新 reload nginx,方便调试。但是这样会极大地影响性能,生产环境需要关闭。第二行代码的作用是开辟一个共享内存区域,用于存储 lua 共享变量,变量的名字是 global,空间大小为 30m。
在 server 块中,我们添加一个 location 配置:
1 2 3 4 5 6 |
location / { default_type text/html; content_by_lua ' ngx.say("<p>hello, world</p>") '; } |
保存,通过 openresty -t 测试配置文件。然后通过 openresty -s reload 来重载配置。
这时,通过浏览器打开网站,就可以正常访问了。如下图所示:
这里的 hello world 正是 lua 脚本通过 ngx.say API 响应的。
S3 制定策略
在请求到达后,我们通过 access_by_lua* 系列指令,将请求控制权拿到。对请求进行统计。如果某一时间段内的请求数超过我们设定的某个阈值,即拦截该请求,输出系统繁忙页面。从而将用户请求拦截在应用服务器之前。
我们通过上面开辟的内存共享区,对于放行的请求,将请求数 + 1,以此计数。放行前,如果过去一秒请求数超过限制,则对该请求进行拦截。
在 nginx.conf 的 server 块中,添加配置:
1 |
access_by_lua_file /path/to/access.lua; |
在请求进行到 access 阶段的时候,将控制权交给 access.lua 脚本。这样我们就可以在请求被真正处理之前,做一些统计工作。
S4 编写 lua 脚本
下面是 access.lua 脚本的内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
-- 系统繁忙页 function busy(path) local file = io.open(path) if not file then return nil end local content = file:read "*a" file:close() return content end -- 读取共享区数据 local global = ngx.shared.global local current = global:get("current") local key = "req_" .. os.time(os.date("!*t")) local req, err = global:get(current) -- 非同一秒请求,将用最新的 key 替换老的 current -- 同时将数据初始化为 0 if key ~= current then global:set("current", key) global:set(key, 0) end -- 如果当前时间请求超过 300 则进行拦截,输出系统繁忙页 if req > 300 then ngx.say(busy("/tmp/work/busy.html")) ngx.exit(ngx.HTTP_OK) end -- 对于放行的情况,请求数 + 1 global:incr(key, 1, 0) |
S5 测试
开启 apache ab 对网站进行并发请求,同时刷新浏览器,请求网站。当并发请求数很小的时候,刷新浏览器,页面正常输出 hello world 内容。随着并发数的提高,刷新浏览器,页面出现系统繁忙的比例越来越高:
待解决问题
虽然测试效果实现了预期的目标,但是该方案应用到生产环境还有很多问题要解决。比如任意滑动窗口的访问数统计算法(队列?)、共享变量的线程安全、如何科学设置阈值等。
为何禁止在 HTTP HEADER 的字段名中使用下划线 vivo 手机浏览器下载 apk 修改为 html 文件,导致应用无法安装解决方案