OpenResty 是基于 Nginx 与 LuaJIT 构建的高性能 Web 平台,它把 Lua 虚拟机嵌入 Nginx 的每个 worker 进程,让开发者用 Lua 脚本直接介入请求处理的各个阶段,在网关层完成鉴权、限流、路由、缓存等逻辑而无需转发到后端应用。本文从执行阶段模型讲起,逐步落地 cosocket 非阻塞编程、共享内存缓存,以及限流、鉴权、动态路由、灰度发布四大网关核心能力,最后讨论 Kong、APISIX 等生态方案与排错技巧。
为什么 OpenResty 快:事件模型 + LuaJIT
OpenResty 的高性能源自两层设计的叠加。
第一层是 Nginx 的事件驱动模型:每个 worker 是单线程进程,通过 epoll/kqueue 处理数万并发连接,没有线程切换和锁竞争。
第二层是 LuaJIT 的协程化非阻塞 IO。lua-nginx-module 为每个请求创建一个 Lua 协程:当 Lua 代码发起网络 IO(查 Redis、调下游 HTTP)时,协程自动 yield,worker 转去处理其他请求;IO 就绪后协程被唤醒继续执行。开发者写的是同步风格的代码,底层跑的却是完全非阻塞的 IO。这正是 Lua 协程 在工业界最成功的一次落地。
加上 LuaJIT 的即时编译能力(不同 Lua 实现的性能差异见 Lua 版本对比),热点代码被编译为机器码。三者组合使得 OpenResty 单核即可支撑数万 QPS 的网关流量。
执行阶段(phases)详解
理解 OpenResty 的关键,是理解一个请求会依次经过哪些阶段、每个阶段能挂什么 Lua 代码。整体顺序如下:
Nginx 启动
└─ init_by_lua (master 进程加载配置时)
└─ init_worker_by_lua (每个 worker 启动时)
单个请求生命周期:
rewrite_by_lua → access_by_lua → content_by_lua
→ header_filter_by_lua → body_filter_by_lua → log_by_lua
| 阶段 | 指令 | 典型用途 | 能否发起网络 IO |
|---|---|---|---|
| init | init_by_lua_block | 预加载模块、初始化全局只读数据 | 否 |
| init_worker | init_worker_by_lua_block | 启动定时器、初始化每 worker 连接 | 是(timer 中) |
| rewrite | rewrite_by_lua_block | URL 重写、内部跳转、改写请求参数 | 是 |
| access | access_by_lua_block | 鉴权、限流、IP 黑白名单 | 是 |
| content | content_by_lua_block | 生成响应体;或与 proxy_pass 二选一 | 是 |
| header_filter | header_filter_by_lua_block | 改写响应头、注入 trace id | 否 |
| body_filter | body_filter_by_lua_block | 流式改写响应体 | 否 |
| log | log_by_lua_block | 异步日志、指标上报 | 是 |
两个要点:
- 阶段越靠前,拦截成本越低。鉴权放在 access 阶段而非后端应用,非法请求在 Nginx 内就被 401 终结,连上游连接都不会建立。
- 阶段决定了能用哪些 API。
ngx.sleep、ngx.socket.tcp等会 yield 的 API 在 header_filter/body_filter 阶段不可用,这是新手最常踩的坑。
快速上手:最小可用配置
macOS 上安装 OpenResty:
brew install openresty
最小 nginx.conf,用 content_by_lua_block 返回 JSON:
worker_processes auto;
events { worker_connections 1024; }
http {
server {
listen 8080;
location /hello {
default_type application/json;
content_by_lua_block {
ngx.say('{"code":0,"msg":"hello openresty"}')
}
}
}
}
启动并验证:
openresty -p `pwd` -c nginx.conf
curl http://127.0.0.1:8080/hello
# {"code":0,"msg":"hello openresty"}
ngx.say 输出内容并自动追加换行;响应头用 ngx.header["Content-Type"] 设置。Lua 基础语法可回看 Lua 快速上手教程。
cosocket 非阻塞编程
cosocket 是 OpenResty 非阻塞能力的入口。ngx.socket.tcp() 创建的 socket 支持 connect、send、receive,每次调用都可能让出协程,但代码看起来完全是同步的。实际开发很少用裸 cosocket,而是用封装好的库。用 lua-resty-http 调下游接口:
local http = require "resty.http"
local httpc = http.new()
httpc:set_timeout(3000)
local res, err = httpc:request_uri("http://user-service:9001/profile", {
method = "GET",
headers = { ["X-Request-Id"] = ngx.var.request_id },
})
if not res then
ngx.log(ngx.ERR, "upstream error: ", err)
ngx.exit(ngx.HTTP_BAD_GATEWAY)
end
ngx.say(res.body)
用 lua-resty-redis 查询缓存(Lua 与 Redis 的配合是网关缓存的经典组合,Redis 侧的脚本写法见 Redis Lua 脚本实战):
local redis = require "resty.redis"
local red = redis.new()
red:set_timeouts(1000, 1000, 1000) -- connect/send/read
local ok, err = red:connect("127.0.0.1", 6379)
if not ok then
ngx.log(ngx.ERR, "redis connect failed: ", err)
return nil
end
local cached = red:get("user:1001")
red:set_keepalive(10000, 100) -- 关键:放回连接池而非 close
务必使用 set_keepalive 而不是 close:连接池让 TCP 握手成本摊薄到微秒级,这是网关高 QPS 的前提。
共享内存与缓存
网关上大量数据(token 黑名单、限流计数、路由表)需要缓存,OpenResty 提供两级方案:
| 方案 | 作用域 | 特点 |
|---|---|---|
lua-resty-lrucache | 单 worker 进程内 | 无锁、最快,各 worker 数据独立 |
lua_shared_dict | 跨 worker 共享 | 加锁访问,容量固定,支持过期 |
在 http 块声明共享字典(lua_shared_dict my_cache 10m;),配合 lrucache 做二级缓存:
local lrucache = require "resty.lrucache"
local c = lrucache.new(200) -- 每 worker 缓存 200 条
local function get_config(key)
-- 第一级:worker 内 LRU,命中即返回
local data = c:get(key)
if data then return data end
-- 第二级:跨 worker 共享字典
local shared = ngx.shared.my_cache
data = shared:get(key)
if data then
c:set(key, data, 60) -- 回填 LRU,60 秒过期
return data
end
-- 回源加载(略),写入两级缓存
data = load_from_db(key)
shared:set(key, data, 300)
c:set(key, data, 60)
return data
end
注意 lua_shared_dict 只能存字符串/数字,表需要先序列化(如 cjson)。
网关核心能力实战
限流:lua-resty-limit-req
漏桶算法的现成实现,基于共享字典做跨 worker 计数:
lua_shared_dict my_limit_req_store 100m;
server {
location /api/ {
access_by_lua_block {
local limit_req = require "resty.limit.req"
-- 每秒 20 个请求,突发允许 10 个排队
local lim, err = limit_req.new("my_limit_req_store", 20, 10)
if not lim then
ngx.log(ngx.ERR, "failed to init limit_req: ", err)
ngx.exit(500)
end
-- 以客户端 IP 为限流键
local delay, err = lim:incoming(ngx.var.binary_remote_addr, true)
if not delay then
if err == "rejected" then
ngx.exit(503) -- 超出突发容量,直接拒绝
end
ngx.exit(500)
end
if delay >= 0.001 then
ngx.sleep(delay) -- 在突发容量内,延迟放行
end
}
proxy_pass http://backend;
}
}
如果需要令牌桶或多级限流,可改用 lua-resty-limit-traffic 套件。
鉴权:access 阶段校验 Token
在 access 阶段校验请求头中的 token,非法请求根本到不了上游:
access_by_lua_block {
local token = ngx.req.get_headers()["Authorization"]
-- 查共享缓存校验 token(生产环境可换 JWT 验签)
local uid = token and ngx.shared.my_cache:get("token:" .. token)
if not uid then
ngx.header["Content-Type"] = "application/json"
ngx.status = 401
ngx.say('{"code":401,"msg":"invalid token"}')
return ngx.exit(401)
end
-- 把解析出的用户 ID 透传给上游
ngx.req.set_header("X-User-Id", uid)
}
用 JWT 时推荐 lua-resty-jwt,验签结果也可缓存到共享字典,避免每请求都做非对称验签。
动态路由与反向代理
根据路由表(可存共享字典或从 etcd 同步)动态选择 upstream,最简单的方式是用变量化的 proxy_pass:
location /api/ {
rewrite_by_lua_block {
local routes = ngx.shared.my_cache
local host = routes:get("route:" .. ngx.var.uri)
if not host then
ngx.exit(404)
end
ngx.var.target = host
}
proxy_pass http://$target;
}
路由表更新只需改写共享字典,无需 reload Nginx,这就是"动态"的含义。
灰度发布 / AB 分流
按用户 ID 哈希把固定比例流量切到新版本:
access_by_lua_block {
local uid = ngx.req.get_headers()["X-User-Id"] or ""
-- 一致性哈希取模,同一用户永远落在同一版本
local bucket = ngx.crc32_short(uid) % 100
ngx.var.upstream = (bucket < 5) and "backend_gray" or "backend_stable"
}
配合 Cookie(ab_group=gray)可实现强制进灰度白名单,方便测试验证新版本。
生态项目:Kong 与 APISIX
如果上面这些能力你全部需要,大概率不需要从零自研:
- Kong:基于 OpenResty 的老牌网关,插件生态最丰富(鉴权、限流、监控一应俱全),配置存 PostgreSQL 或声明式 DB-less 模式。
- APISIX:同样基于 OpenResty,用 etcd 做配置中心,全动态路由(改路由无需 reload),性能更好,云原生集成更现代。
选择标准很简单:需要成熟插件体系、开箱即用的管理面,直接上 Kong 或 APISIX;需要深度嵌入业务逻辑(复杂协议转换、与内部系统强耦合的鉴权链),则自研 OpenResty 网关更灵活。常见实践是:用 APISIX 做流量入口,再以少量自研 Lua 插件承载业务特有逻辑。
性能与排错
定时任务:ngx.timer.at 用于周期性任务(如从配置中心拉路由表),只能运行在 init_worker 等无请求上下文的地方:
init_worker_by_lua_block {
local function sync_routes(premature)
if premature then return end
-- 从 etcd/DB 拉取路由写入共享字典
ngx.timer.at(10, sync_routes) -- 10 秒后再次执行
end
ngx.timer.at(0, sync_routes)
}
常见错误速查:
| 错误信息 | 原因 | 解决 |
|---|---|---|
API disabled in the context of ... | 在 header_filter/body_filter 阶段调用了会 yield 的 API(cosocket、ngx.sleep) | 把网络 IO 移到 rewrite/access/content/log 阶段 |
lua entry thread aborted | 请求处理中客户端提前断连 | 正常现象,按需用 ngx.on_abort 清理资源 |
共享字典 no memory | shared_dict 写满且 key 未过期 | 设置合理的 exptime,或用 set 的强制覆盖语义 |
性能要点:避免在热路径上做字符串拼接和全局变量访问(详见 Lua 性能优化);开启 lua_code_cache on(生产必须,否则每请求重新编译);外部调用统一用 pcall 包裹(参考 Lua 错误处理),避免单请求异常打挂整个 location。
常见问题(FAQ)
OpenResty 和 Kong、APISIX 是什么关系?
Kong 和 APISIX 都构建在 OpenResty 之上:OpenResty 提供"Nginx + Lua 脚本能力"这个地基,Kong/APISIX 在其上盖好了插件体系、管理 API 和配置存储。你可以直接用它们,也可以基于 OpenResty 自研,二者是层次关系而非竞争关系。
为什么 OpenResty 用 LuaJIT 而不是 Lua 5.4?
LuaJIT 兼容 Lua 5.1 语法并带 JIT 编译器,性能比解释执行的 5.4 高出数倍,这对每请求都跑 Lua 的网关场景至关重要。此外 lua-nginx-module 与 LuaJIT 的 FFI、协程实现深度绑定,官方并未提供对标准 Lua 5.x 的支持。
content_by_lua 里能直接用 io 库读文件吗?
技术上可以,但强烈不建议。io.read、luasocket 等都是同步阻塞调用,会卡住整个 worker 进程,让所有并发请求排队。请求阶段的网络读写必须用 cosocket 生态(resty.http、resty.redis),本地小文件应在 init 阶段一次加载到内存。
改了 Lua 代码为什么不生效?
生产配置开启了 lua_code_cache on,Lua 模块在 worker 进程内被缓存。开发时可临时设为 lua_code_cache off(代价是每请求重新编译,严禁上线),生产环境需 openresty -s reload 或使用热更新方案(见 Lua 热更新技术)。
共享字典里的数据会丢吗?
会。lua_shared_dict 是纯内存存储,Nginx reload 通常保留,但重启进程即丢失;写满后旧 key 会被强制淘汰。它只适合缓存类数据,持久化状态必须落 Redis 或数据库。
相关阅读
- Lua 在 Web 开发中的应用场景与优势
- Lua 协程深入解析:协作式多任务的实现
- Lua 性能优化:从代码到 GC 的全面指南
- Lua 错误处理:pcall、xpcall 与 error 最佳实践
- Lua 版本对比:5.1 到 5.4 与 LuaJIT
- Lua 专题导航
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。