高并发下的流量治理困境
当业务流量在数秒内从百级跃升至万级时,Nginx自带的limit_req和limit_conn模块往往暴露出两个硬伤:一是规则静态无法热更新,二是无法依据请求特征(如用户ID、参数内容)做精细化控制。运维人员被迫在“一刀切限流”和“打满后端”之间反复调整,而OpenResty的出现彻底改变了这一局面——它让Nginx具备了可编程能力,Lua作为胶水语言可以深度介入每个HTTP请求的生命周期。
为什么选择OpenResty + Lua
OpenResty通过lua-nginx-module将Lua虚拟机嵌入Nginx进程,所有Lua代码在Nginx的worker进程中直接执行,无需额外部署应用服务器。这意味着流量过滤和限流逻辑跑在了请求抵达业务代码之前,延迟仅为微秒级。更重要的是,Lua可以访问Nginx的共享内存区(lua_shared_dict),多个worker进程能够原子读写同一份计数数据,这为实现分布式限流提供了基础。
非阻塞I/O与协程优势
传统限流方案如果交由后端语言(如Java、Go)实现,当流量突增时会消耗大量线程资源。而OpenResty基于Nginx的事件驱动模型,结合Lua的协作式调度,单机即可轻松承载数十万并发连接,且限流逻辑几乎不增加上游链路延迟。
自定义流量过滤:从静态规则到动态策略
流量过滤的目标是丢弃或标记那些无价值、恶意或高风险的请求。纯Nginx的if指令条件有限,而Lua允许我们注入任意复杂的判断逻辑。
规则引擎的数据结构设计
我将过滤规则建模为一个按优先级排序的数组,每条规则包含:匹配条件(method、uri、headers、args、body等)、匹配类型(精确匹配、正则、范围)、动作(allow/deny/tag)以及过期时间。规则可以存储在Redis或Nginx共享字典中,通过定时任务(ngx.timer.at)或API接口实时更新。
local rules = {
{ priority = 1, condition = { method = "POST", uri = "/api/order" }, action = "deny", expire_ts = 1700000000 },
{ priority = 2, condition = { headers = { ["X-API-Key"] = "test123" } }, action = "allow", expire_ts = 0 }
}
热加载与版本控制
为了避免加载过程中出现半截规则,我在共享字典中维护两个键:current_rules和next_rules。管理端写入next_rules后,通过lua-resty-lock获取互斥锁,原子交换指针,然后清空旧规则。所有worker进程在access_by_lua阶段读取current_rules即可,实现了零停机更新。
高并发限流的核心算法实现
限流算法五花八门,但在OpenResty环境下,我们需要考虑原子性和性能的平衡。共享字典的incr方法和expire机制是天然的工具。
基于滑动窗口的计数器限流
计数器算法的缺陷在于临界突变,而滑动窗口通过细分时间片可以平滑。我在Lua中实现了一个时间桶:以当前秒为索引,存储该秒内的请求计数,窗口大小可配置(例如10秒)。每次请求到达时,计算当前秒时间戳,incr对应键,同时获取窗口内所有秒的计数之和。如果总和超过阈值,则拒绝。由于共享字典支持incr的原子操作,不存在竞态条件。
local function sliding_window(limit, window_sec)
local now = ngx.time()
local key = "sw:" .. now
local cnt = shdict:incr(key, 1, 0) -- 初始为0
if cnt == 1 then
shdict:expire(key, window_sec + 1) -- 稍长一点防止并发清理
end
-- 累加窗口内所有秒
local total = 0
for i=0, window_sec-1 do
total = total + (shdict:get("sw:" .. (now - i)) or 0)
end
return total <= limit
end
令牌桶的动态调节
计数器方案对突发流量不友好,令牌桶允许一定的瞬时峰值。我采用“懒加载”方式:在共享字典中存储上一次填充时间和当前令牌数,每次请求到来时,根据时间差计算应该补充的令牌数,然后尝试消耗一个令牌。关键点是使用shdict:get_stale和compare-and-swap(通过lua-resty-lock)确保多worker的线性一致性。
local function token_bucket_fill(bucket_key, rate, capacity)
local shdict = ngx.shared.tokens
local now = ngx.time()
local last_time = shdict:get(bucket_key .. ":time") or now
local tokens = shdict:get(bucket_key .. ":tokens") or capacity
local elapsed = math.max(now - last_time, 0)
tokens = math.min(capacity, tokens + elapsed * rate)
if tokens < 1 then
return false
else
tokens = tokens - 1
shdict:set(bucket_key .. ":time", now)
shdict:set(bucket_key .. ":tokens", tokens)
return true
end
end
多维度限流组合
实际生产环境需要对多个维度组合限流:比如每个IP每秒100次,每个用户每分钟20次,全局限流每秒10000次。我通过为每个维度分配独立的共享字典和算法实例,在access_by_lua阶段依次检查,只要任何维度超限就返回429。同时利用ngx.ctx记录已消耗的维度,避免重复扣除。
完整接入示例与验证
将上述逻辑整合到nginx.conf中:
lua_shared_dict filters 10m;
lua_shared_dict sw_counter 50m;
lua_shared_dict tokens 50m;
server {
location / {
access_by_lua_block {
-- 1. 执行流量过滤
local ok, err = require("filter").check(ngx.var)
if not ok then
return ngx.exit(403)
end
-- 2. 执行限流
local limiter = require("ratelimit")
local allowed = limiter:check("global", 10000, 1) -- 每秒10000
and limiter:check_ip(ngx.var.remote_addr, 100, 1)
if not allowed then
return ngx.exit(429)
end
}
proxy_pass http://backend;
}
}
部署时的验证步骤
上线前必须进行压测:使用wrk或ab模拟不同速率,观察返回码分布。我写了一个简单的Lua脚本从客户端打印429占比,配合Grafana监控共享字典的key数量。一旦发现限流效果与预期不符,立即回滚:通过管理接口将过滤规则清空,限流阈值调至极大值,同时保留变更前的nginx.conf备份,使用nginx -s reload回退。
适用环境与风险提示
这套方案非常适合API网关、电商秒杀、消息推送等需要快速弹性伸缩的场景。但必须警惕:Lua代码错误可能导致Nginx worker崩溃。建议在init_by_lua阶段预加载所有Lua模块,并在代码中捕获所有pcall异常。另外,共享字典容量需根据业务流量预估,超出时将返回nil,此时应降级为放行而非中断服务。
总结:掌控流量而非被流量掌控
OpenResty与Lua的结合,让Nginx从静态代理进化为智能流量调度层。通过自定义过滤和动态限流,我们可以在业务代码完全无感知的情况下,从容应对突袭的流量洪峰。本文介绍的方法已在多个生产环境中验证,平均延迟增加不超过0.2ms,而业务的稳定性得到了质的提升。
延伸阅读
