OpenResty 网关开发实战:用 Lua 构建高性能 API 网关

从执行阶段、cosocket 非阻塞 IO、共享内存缓存讲起,用 Lua 在 OpenResty 上逐步实现限流、JWT 鉴权、动态路由与灰度分流,并对比 Kong、APISIX 等现成网关,给出性能调优与排错指南。

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
initinit_by_lua_block预加载模块、初始化全局只读数据
init_workerinit_worker_by_lua_block启动定时器、初始化每 worker 连接是(timer 中)
rewriterewrite_by_lua_blockURL 重写、内部跳转、改写请求参数
accessaccess_by_lua_block鉴权、限流、IP 黑白名单
contentcontent_by_lua_block生成响应体;或与 proxy_pass 二选一
header_filterheader_filter_by_lua_block改写响应头、注入 trace id
body_filterbody_filter_by_lua_block流式改写响应体
loglog_by_lua_block异步日志、指标上报

两个要点:

  • 阶段越靠前,拦截成本越低。鉴权放在 access 阶段而非后端应用,非法请求在 Nginx 内就被 401 终结,连上游连接都不会建立。
  • 阶段决定了能用哪些 APIngx.sleepngx.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 支持 connectsendreceive,每次调用都可能让出协程,但代码看起来完全是同步的。实际开发很少用裸 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 memoryshared_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」更多文章