OpenResty + Lua 实战:用共享内存字典给 Nginx 做动态限流与网关聚合全流程

为什么个人站长要用 Lua 给 Nginx 补能力

标准 Nginx 能做反向代理、缓存、限流、重写,但它有一个根本性的短板:配置语言是「声明式」的,只能做静态的分支判断,一旦需求里出现「根据变量动态计算」「查一次外部数据再决定放不放行」「把结果写回缓存供后续请求复用」这类逻辑,config 就无能为力了。很多站长遇到这种情况的第一反应是「上一个后端接口吧」,于是为了一个简单的鉴权或限速,硬是搭了一套 Node/PHP 服务,多了一个 502 的可能,也多了几十兆内存。

OpenResty 解决的正是这个问题。它把 Nginx 与 LuaJIT 打包在一起,让你能在请求处理的各个阶段(access、content、log 等)直接插入 Lua 代码。它不是一个新服务器,而是「Nginx + 脚本」:编译产物就是 nginx,配置语法完全兼容,你现有的站点配置可以原封不动搬过去,然后按需在几个 location 里加 access_by_lua_block 就行。对于个人站长,它的价值在于把本来需要另起一个服务的逻辑(简单鉴权、动态限流、令牌桶、接口聚合、灰度分流)压回网关层,用几十行 Lua 换掉一个后端进程。

本文不讲 Lua 语法入门,而是讲个人站在真实场景下最常用、也最容易踩坑的一套实践:如何用 Lua 实现一个基于共享内存的令牌桶限流,如何做接口聚合(把三个上游请求在网关层合并成一次),如何安全地引入 resty.redis 与 resty.http 这些 OpenResty 生态库,以及在生产上必须知道的坑——Lua 阻塞的代价、共享内存大小怎么估算、热更新时怎么不丢流量。全程基于 apt install openresty 的 Debian/Ubuntu 环境,命令可直接复制执行。

安装与从 Nginx 平滑切换

不要急着把系统里的 Nginx 卸掉,先并存安装 OpenResty,确认行为一致后再切换端口和 systemd 服务。OpenResty 官方提供了 apt 源,比源码编译省事得多,也便于日后升级。

# 1. 添加官方源(Debian/Ubuntu)
wget -qO - https://openresty.org/package/pubkey.gpg | \
  gpg --dearmor -o /usr/share/keyrings/openresty.gpg

echo "deb [signed-by=/usr/share/keyrings/openresty.gpg] \
http://openresty.org/package/debian $(lsb_release -sc) openresty" \
  > /etc/apt/sources.list.d/openresty.list

apt update
apt install -y openresty

# 2. 确认版本与 LuaJIT
/usr/local/openresty/bin/openresty -v
# nginx version: openresty/1.25.3.1
/usr/local/openresty/luajit/bin/luajit -v
# LuaJIT 2.1.0-beta3

安装后 OpenResty 的目录结构与系统 Nginx 不同:主程序在 /usr/local/openresty/nginx/sbin/nginx,配置在 /usr/local/openresty/nginx/conf/nginx.conf,默认监听 8080(避免与系统 Nginx 抢占 80)。把原有 server 块整段复制过来,先只改 listen 8081 做并行验证:

# 把现有站点配置拷过来先跑在 8081
cp /etc/nginx/sites-enabled/mysite.conf \
   /usr/local/openresty/nginx/conf/conf.d/mysite.conf
sed -i 's/listen 80;/listen 8081;/' \
   /usr/local/openresty/nginx/conf/conf.d/mysite.conf

# 校验并启动
/usr/local/openresty/bin/openresty -t
systemctl enable --now openresty
curl -sI http://127.0.0.1:8081/ | head -3

确认 8081 上的页面和缓存行为与 80 完全一致后,再停系统 Nginx、把 listen 改回 80。这个「并行验证」步骤能避免一次切换把线上打死——尤其是当你的 config 里用了某些旧版本才支持的指令时,OpenResty 的 -t 会直接报出来,而不是等到请求进来才 500。

共享内存:OpenResty 的「进程间全局变量」

Lua 在 Nginx 里的每个 worker 进程是独立沙箱,普通的 package.loaded 全局变量在 worker 之间并不共享。要让多个 worker 看到同一份状态(限流计数、拦截名单、缓存),必须用 lua_shared_dict。这是 OpenResty 最核心的构件,也是新手最容易配错的地方。

# 在 http {} 块内声明共享内存区
http {
    # 语法: lua_shared_dict <名称> <大小>;
    lua_shared_dict limit_counter 10m;   # 限流计数
    lua_shared_dict blocklist     20m;   # 封禁名单
    lua_shared_dict resp_cache    50m;   # 自定义响应缓存
    ...
}

大小怎么估?共享内存里存的是 key-value,每个 key 有固定开销(约几十字节,取决于 key 长度与 LRU 元数据)。一个「计数器」场景,假设是 ip:路径 做 key,平均 40 字节,加上 value 和开销按 120 字节算,10MB 大约能放 8 万多个 key。对于限流,如果只记录活跃窗口(如 1 分钟)内的 IP,几千到几万 QPS 也够用。当共享内存写满时,OpenResty 会按 LRU 淘汰——对计数器来说这不是问题,但对「封禁名单」就要注意:如果内存太小,会把还在封禁期的 IP 挤掉,导致漏放。经验值是给名单类字典留足 20MB 以上。

共享内存是内存态,重启即清空。所有「需要持久化」的数据(真实封禁名单、黑白名单库)都应该在启动时从文件或 Redis 加载进 init_by_lua_block,共享内存只当高速缓存用。

实战一:基于共享内存的滑动窗口限流

Nginx 自带的 limit_req 是固定窗口计数器,边界处会有突发(1 秒的第 999ms 和第 1.001s 各打满一次,等于瞬时 2 倍速率)。用 Lua 加共享内存,可以实现更平滑的滑动窗口,而且能动态根据用户等级给不同的限额——这在纯 config 里做不到。

# rate_limit.lua —— 放在 /usr/local/openresty/nginx/lua/ 下
local dict   = ngx.shared.limit_counter
local limit  = 60        -- 每窗口允许次数
local window = 60        -- 窗口秒数

local ip  = ngx.var.remote_addr
-- 取「当前窗口起点」作为 key 后缀,实现计数滚动
local now    = ngx.time()
local bucket = math.floor(now / window)
local key    = ip .. ":" .. bucket

local count, err = dict:incr(key, 1, 0, window * 2)
if not count then
    ngx.log(ngx.ERR, "shared dict incr failed: ", err)
    return
end

if count > limit then
    ngx.header["Retry-After"] = window - (now % window)
    ngx.status = 429
    ngx.say("too many requests")
    return ngx.exit(429)
end

挂到想要保护的路径上。注意 access_by_lua_block 是「准入阶段」,跑在任何 content 处理之前,所以它拦截的请求不会走到 PHP/后端,省下真正的资源。

location /api/ {
    access_by_lua_block {
        dofile("/usr/local/openresty/nginx/lua/rate_limit.lua")
    }
    proxy_pass http://127.0.0.1:9000;
}

# 更常见:用 content_by_lua_file 在独立 location 里定义逻辑
location = /limited {
    content_by_lua_block {
        local dict, limit = ngx.shared.limit_counter, 30
        local key = ngx.var.remote_addr
        local c = dict:incr(key, 1, 0, 120)
        if c and c > limit then
            return ngx.exit(429)
        end
        ngx.say("ok ", c)
    }
}

dict:incr(key, value, init, init_ttl) 的第三个参数 init=0 表示 key 不存在时按 0 起算,第四个参数是给新 key 设置的过期时间(秒),这样窗口外的旧 key 会自动被回收,不用手写清理逻辑。这是共享内存限流比「自己维护过期时间戳」更省事的地方。压测验证限流是否生效:

# 用 ab 或 wrk 快速打 100 个请求,看有多少 429
apt install -y apache2-utils
ab -n 100 -c 10 http://127.0.0.1/limited/ 2>/dev/null | grep -E "Non-2xx|429"
# 期望:约 70 个请求返回 429(100 - 30 限额)

实战二:在网关层做接口聚合,减少一次往返

个人站常见场景:一个页面需要「最新文章 + 站点公告 + 友链」三个接口,前端分别请求等于三次 TCP 握手三次往返。用 OpenResty 的 resty.http 可以在网关层并发拉取再合并,前端只发一次请求。需要先装这个生态库:

# 用 opm 安装 resty.http
/usr/local/openresty/bin/opm get ledgetech/lua-resty-http

# 或手动放置
# wget ... -O /usr/local/openresty/lualib/resty/http.lua
location = /api/dashboard {
    content_by_lua_block {
        local http = require "resty.http"
        local cjson = require "cjson.safe"

        local function fetch(url)
            local c = http.new()
            c:set_timeout(800)                    -- 毫秒,必须显式设
            local res, err = c:request_uri(url)
            if not res then return nil, err end
            return cjson.decode(res.body)
        end

        -- 串行示例(简单直观);要并发用 ngx.thread.spawn
        local news,  e1 = fetch("http://127.0.0.1:9000/api/news")
        local notice,e2 = fetch("http://127.0.0.1:9000/api/notice")

        if not news then ngx.log(ngx.ERR, "news fail: ", e1) end

        ngx.header["Content-Type"] = "application/json"
        ngx.say(cjson.encode {
            news   = news  or {},
            notice = notice or {},
        })
    }
}

要点:set_timeout 必须显式设置,默认值可能长到让你在压测时才发现在等上游;用 cjson.safe 而不是 cjson,因为前者解码失败返回 nil + err,而后者会直接 ngx.error 抛异常,把 500 甩给用户。上游失败时用 or {} 兜底,宁可返回空数组也不要整个接口 500——聚合接口的可用性应该等于各分片可用性的「或」,而不是「与」。

并发聚合:ngx.thread.spawn 与必须知道的阻塞代价

上面是串行写法,两个上游各 200ms 就是 400ms。OpenResty 提供了轻量级协程 ngx.thread.spawn,可以在一个请求里并发发起多个上游调用,总耗时取决于最慢的那个而不是求和:

local http = require "resty.http"
local cjson = require "cjson.safe"

local function fetch(url)
    return function()
        local c = http.new()
        c:set_timeout(800)
        local res, err = c:request_uri(url)
        if not res then return nil, err end
        return cjson.decode(res.body)
    end
end

-- 并发发起,threads 数量 = 上游数量
local t1 = ngx.thread.spawn(fetch("http://127.0.0.1:9000/api/news"))
local t2 = ngx.thread.spawn(fetch("http://127.0.0.1:9000/api/notice"))

local ok1, news   = ngx.thread.wait(t1)
local ok2, notice = ngx.thread.wait(t2)
ngx.say(cjson.encode { news = news or {}, notice = notice or {} })

这里有一个极其重要但常被忽略的坑:Lua 里「阻塞式」的操作会独占当前 worker 的一个连接处理名额,但不阻塞其他请求。真正会拖垮服务器的是在 Lua 里做同步 CPU 密集或阻塞的系统调用,例如用 os.execute 跑命令、用标准库 io.open 读写大文件、死循环计算。这些会让 worker 长时间卡住,期间新请求排队。OpenResty 提供的所有 *_by_lua 之外的库(如 resty.http、resty.redis)都是非阻塞的,放心用;但一旦你要读文件,应该用 lua-resty-io 或把逻辑挪到后端,而不是 io.open。

另一个常见错误是 ngx.sleep 之外用 os.time 循环等结果。ngx.sleep 是会让出 worker 的非阻塞睡眠,其他协程可继续跑;while true do end 这种忙等则是纯粹的灾难。写 Lua 前先问自己:「这个函数会不会把 worker 占住?」凡是涉及磁盘、外部命令、同步网络的,都要换成 OpenResty 的异步等价物。

部署与热更新:改完 Lua 怎么不丢流量

Lua 代码加载后缓存在 worker 内存里,改文件不会自动生效。生产环境有两种更新方式:

# 方式一:reload(优雅,不丢连接)
# 新 worker 起来、旧 worker 处理完现有请求后退出
systemctl reload openresty
# 等价于
/usr/local/openresty/bin/openresty -s reload

# 方式二:完全重启(会瞬断,谨慎)
systemctl restart openresty

reload 是 OpenResty 的强项:它先启动新 worker(加载你改过的 Lua),再给旧 worker 发退出信号让它们优雅收尾。对于配置和 Lua 文件的更新,一律用 reload,不要 restart。但要注意 lua_shared_dict 里的数据在 reload 时不会丢失(共享内存由 master 持有),而 init_by_lua_block 会重新执行——所以别在 init 里做「累加」类操作,否则每次 reload 都会叠加一次。

建议把 Lua 逻辑做成独立文件(*.lua)用 content_by_lua_file 引用,而不是写死在 nginx.conf 里。这样升级逻辑只加一行文件替换,nginx.conf 保持稳定,也便于放进 Git 做版本管理。

常见坑位速查表

现象根因修复
shared dict not found字典没在 http 块声明,或名字打错在 http {} 内加 lua_shared_dict name 10m;
限流计数「各自为政」用了 Lua 全局变量而非共享内存改用 ngx.shared.*,worker 间才共享
接口偶发 500 且上游正常cjson.decode 遇到非 JSON 响应抛异常换 cjson.safe,失败返回 nil 判空
压测时 QPS 上不去Lua 里用了 io.open/os.execute 阻塞 worker改异步库或挪走后端
reload 后计数翻倍共享内存没清但 init 又累加了一次init 里只做「赋值/加载」,不做累加
改了 Lua 无效果worker 缓存未刷新openresty -s reload

最后提醒一句:OpenResty 不是「为了用而用」。如果你的需求已经被标准 Nginx 的 limit_req、map、return 覆盖了,就别引入 Lua——每多一层动态逻辑就多一份出故障的面。只有当配置语言真的表达不了(动态限流策略、网关聚合、基于外部数据的准入判断)时,OpenResty 才是那个「用几十行 Lua 换掉一个后端服务」的正确工具。先想清楚必要性,再动手写 block。

Last modification:October 11th, 2026 at 08:24 pm

Leave a Comment