为什么需要自定义流量过滤与限流
生产环境中,Nginx 作为反向代理经常要承受每秒数万甚至数十万的请求。传统 ngx_http_limit_req_module 和 ngx_http_limit_conn_module 虽然能解决简单限流,但面对动态规则(如根据响应时间调整限流阈值、按地理位置或 User-Agent 做精细过滤)时,配置方式显得僵化。OpenResty 将 Lua 虚拟机嵌入 Nginx,允许在请求处理的每个阶段执行 Lua 代码,从而用极少的代码实现高度定制化的流量控制。
OpenResty 的核心能力:阶段与共享内存
OpenResty 的流量控制依赖两个基础:请求处理阶段和共享内存字典(shared dict)。
- 阶段钩子:init_by_lua(初始化)、access_by_lua(权限验证)、balancer_by_lua(负载均衡)、log_by_lua(日志)等。限流和过滤最常挂在
access_by_lua阶段,此时请求还未传给上游,驳回成本最低。 - 共享内存:通过
lua_shared_dict声明,所有 worker 进程可原子操作。它天然是进程安全的计数器与缓存池,适合存储限流状态(如令牌桶剩余令牌、滑动窗口计数器)。
理解这两个概念,后续实现才能得心应手。
流量过滤:不仅仅是 IP 黑名单
过滤是限流前的第一道筛子。除了 IP 白名单/黑名单,实际场景常需要更复杂的规则:
- User-Agent 指纹:屏蔽 curl、python-requests 等无浏览器特征的客户端。
- 请求路径与参数:对 /api/sensitive 路径实施更严格的检查,或对带特定参数(如 ?debug=1)的请求直接拒绝。
- Referer 来源:禁止未授权的第三方域名盗链。
- 动态名单:从 Redis 或 MySQL 加载最新的黑名单。
Lua 实现:多条件组合过滤
-- nginx.conf 中声明共享字典
lua_shared_dict filter_rules 10m;
-- access_by_lua_block 内
local method = ngx.req.get_method()
local uri = ngx.var.uri
local ua = ngx.var.http_user_agent or ""
local ip = ngx.var.remote_addr
-- 动态黑名单(假设来自 Redis)
local blacklist = require("resty.redis").new()
-- ... 省略连接逻辑
if blacklist:sismember("blacklist:ip", ip) == 1 then
ngx.exit(ngx.HTTP_FORBIDDEN)
end
-- 禁止非浏览器 UA 访问
if ua:match("^curl") or ua:match("^python") then
ngx.exit(ngx.HTTP_FORBIDDEN)
end
-- 敏感路径限制
if uri:match("^/api/private") and method ~= "POST" then
ngx.exit(ngx.HTTP_FORBIDDEN)
end
注意:过滤逻辑应尽量前置,避免不必要的计算。IP 白名单可放到 geo 模块配合 Lua 变量。
高并发限流:从计数器到令牌桶
限流算法有很多,但生产环境经得起考验的常用三种:固定窗口计数、滑动窗口计数、令牌桶。固定窗口实现简单但存在“边界突发”问题;滑动窗口更平滑;令牌桶允许一定程度的突发,且能平滑整形流量。
示例1:基于共享内存的滑动窗口计数器
滑动窗口将时间划分为多个子槽,每个槽记录请求数。以秒级窗口为例,分 10 个子槽(每 100ms 一个)。Lua 实现要点:
local limiter = {}
local dict = ngx.shared.my_limit
function limiter:check(key, max_requests, window_sec)
local now = ngx.time()
local slot = math.floor(now * 10) % 10 -- 10 个槽
local slot_key = key .. ":" .. slot
local current = dict:incr(slot_key, 1, 0)
-- 清理过期槽(与当前时间差超过窗口的槽)
local total = 0
for i = 0, 9 do
local k = key .. ":" .. slot
local v = dict:get(k) or 0
total = total + v
end
if total > max_requests then
return false, "rate exceeded"
end
return true
end
此方法浪费内存,但无需定时器。实际可以用 lua-resty-limit-traffic 库提供的计数器。
示例2:令牌桶实现(推荐)
令牌桶算法需要两个变量:当前令牌数和上次更新时间。每次请求时先计算这段时间应生成的令牌数,再决定是否消耗一个令牌。用 shared dict 的 add() 或 incr() 保证原子性。
local token_bucket = {}
function token_bucket:init(rate, burst)
-- rate: 每秒生成令牌数, burst: 桶容量
self.key = "bucket:" .. self.rate .. ":" .. self.burst
local dict = ngx.shared.token_buckets
if not dict:get(self.key) then
dict:set(self.key, 0) -- 当前令牌
dict:set(self.key .. ":ts", ngx.now())
end
end
function token_bucket:acquire()
local dict = ngx.shared.token_buckets
local now = ngx.now()
local tokens = dict:get(self.key) or 0
local last_ts = dict:get(self.key .. ":ts") or now
-- 计算新增令牌
local elapsed = now - last_ts
tokens = math.min(self.burst, tokens + elapsed * self.rate)
-- 更新时间和令牌数
dict:set(self.key .. ":ts", now)
if tokens < 1 then
ngx.log(ngx.WARN, "令牌不足,请求被拒")
return false
end
dict:set(self.key, tokens - 1)
return true
end
注意:使用 ngx.now() 获取浮点数时间。令牌桶允许短时间内突发到峰值速率(burst),适合 API 网关场景。
部署配置:在 nginx.conf 中串联过滤与限流
把上述逻辑放到 access_by_lua_block 中,顺序执行过滤、限流,任意一步拒绝则直接 exit。
location /api/ {
access_by_lua_block {
-- 第一步:过滤
local ok, err = filter_by_ip(ngx.var.remote_addr)
if not ok then
ngx.exit(ngx.HTTP_FORBIDDEN)
end
-- 第二步:限流(令牌桶)
local bucket = token_bucket:new({rate=100, burst=200})
if not bucket:acquire() then
ngx.status = ngx.HTTP_TOO_MANY_REQUESTS
ngx.say("{"code":429,"msg":"rate limit"}")
ngx.exit(ngx.HTTP_OK) -- 返回 429 但依旧调用 exit
end
}
proxy_pass http://backend;
}
建议将 Lua 代码存为外部 .lua 文件,通过 access_by_lua_file 引入,便于维护和热加载(配合 lua_code_cache on;修改文件后 reload Nginx 即可生效)。
验证、调优与监控
部署后务必进行压力测试。推荐 wrk 或 hey,关注以下指标:
- 正确性:是否在预期阈值处开始拒绝?滑动窗口是否平滑?
- 性能损耗:Lua 脚本执行时间应 < 1ms,否则用
lua_package_cpath加载 C 拓展。共享字典操作 O(1)。 - 内存占用:shared dict 要足够大(例如 10m 可存储数万个 key)。避免因内存不足导致新请求失败。
- 日志:在
log_by_lua中记录被拒绝的请求详情,用于分析恶意模式。
log_by_lua_block {
if ngx.status == 429 or ngx.status == 403 then
local logger = ngx.log
logger(ngx.WARN, "blocked: ", ngx.var.remote_addr, " ", ngx.var.uri)
end
}
常见陷阱与规避
- 原子操作:
get+set组合不是原子的!必须使用incr、add、replace或lua-resty-lock。上面的令牌桶代码用了两次 set,在高并发下会导致竞争。最佳实践是使用dict:get_stale()加锁或改用lua-resty-limit-traffic封装好的 API。 - 时间同步:多 worker 使用 ngx.now() 时间基本一致,但若 Nginx 机器时间跳变会导致限流失效,建议使用 NTP 服务。
- 缓存穿透:过滤时若从 Redis 加载黑名单,应本地缓存并设置过期时间,避免每次请求都打 Redis。
- 优雅降级:当 Redis 连接失败时,应放行而非全部拒绝(fail open 还是 fail closed 需业务决定)。
总结
OpenResty 的高并发流量控制能力远超静态配置。通过灵活的 Lua 脚本,可以实现 IP 黑白名单、动态 UA 过滤、基于令牌桶或滑动窗口的精准限流,并且所有操作在 Nginx worker 内完成,无需外部服务。写好脚本后,务必进行原子性检查和压力验证。掌握这些技能,便能应对多数突发流量与 CC 攻击场景,构建出高效、可编程的流量网关。
延伸阅读
