为什么 Nginx 的 limit_req 不够用
大部分人给站点做限速,第一反应是 Nginx 自带的 limit_req。它能用,但真正上了流量会发现三个绕不过去的坎:
第一,limit_req 是「按请求数」限的,不能按「价格」限。一个首页请求消耗的资源和一张 3MB 的原图请求完全不是一回事,但令牌桶眼里都是 1 个请求。第二,它的桶是固定速率的,改速率只能改配置、nginx -s reload,想做到「白天宽松、夜间收紧」或者「这个 IP 刚被投诉过就收紧」就得频繁改配置。第三,也是最要命的:limit_req 在共享内存里维护状态,默认 zone 只存 16 万个 key,被大量 IPv6 地址一冲就满了,超出的直接按「拒绝」处理,正规用户也跟着遭殃。
OpenResty 的思路不一样:它把 Lua 直接嵌进 Nginx 的请求处理流程,限速逻辑可以写成一段代码,状态放在 Redis 或共享内存字典里,能按 URL、按用户、按价格加权,还能读数据库动态调整。这篇文章讲的是把漏桶算法在 OpenResty 里落地,包括代码、参数取舍和几个我踩过的坑。
先分清三种限流语义
动手写代码之前,必须先想清楚你要的是哪一种。这三种语义在工程上完全不同,选错了,配置再漂亮也没用。
固定窗口计数
最简单:把时间切成 1 分钟的格子,每格计数,超过阈值就拒。问题是格子边界会「双倍放行」——用户在 59 秒和 61 秒各打满一次,实际两秒内过了两倍流量。对于反爬、防刷这类场景,边界抖动是可以接受的;但对于保护后端数据库这种「不能被瞬间打爆」的场景,就不能接受。
滑动窗口
用两个相邻格子的加权平均来平滑边界,Redis 里用 ZSET 存时间戳是最直观的做法。代价是内存,一个高频 IP 的 ZSET 可能有几千个成员,得不偿失。常见折中是 Redis 的 INCR 加过期时间,也就是固定窗口,再加上一点惩罚机制。
漏桶与令牌桶
漏桶是把请求排进一个队列,以恒定速率流出,桶满了就拒;令牌桶相反,按速率往桶里放令牌,请求消耗令牌,桶里没令牌就拒,但允许短时突发(因为桶可以预先装满)。做网站防护用令牌桶更合适——正常用户打开一个页面会并发请求十来个静态资源,这时候需要突发能力,不然第一个请求过了、后面十个全被拒,反而让用户觉得站坏了。
下面这段实现是令牌桶,状态存在 Nginx 共享内存字典里,适合单机;后面再讲多机怎么改成 Redis 版本。
最小可用的 Lua 令牌桶
先看一段可以跑的实现。关键点是用毫秒时间戳算「上次补充到现在过了多久」,按速率补令牌,再扣一个,扣不动就返回 429。
lua_shared_dict limit_pool 20m;
location /api/ {
access_by_lua_block {
local dict = ngx.shared.limit_pool
local key = "tb:" .. ngx.var.binary_remote_addr
local rate = 20 -- 每秒补充多少令牌
local burst = 60 -- 桶容量,允许的瞬时突发
local now = ngx.now() * 1000 -- 毫秒
local data = dict:get(key)
local tokens, last
if data then
tokens, last = data.tokens, data.last
else
tokens, last = burst, now
end
-- 按流逝时间补充令牌,上限为桶容量
tokens = math.min(burst, tokens + (now - last) / 1000 * rate)
if tokens < 1 then
dict:set(key, {tokens = tokens, last = now}, 10)
ngx.header["Retry-After"] = 2
ngx.exit(429)
end
dict:set(key, {tokens = tokens - 1, last = now}, 10)
-- 把剩余令牌透出给上游,便于排查
ngx.req.set_header("X-RateLimit-Remaining", math.floor(tokens - 1))
}
}这段代码里有两个参数是必须自己算的,不能抄别人的:
rate:先按「正常用户每秒最多发几个请求」定,宁可宽松。判断方法是在 access 日志里按 IP 统计每秒请求数的 95 分位,取一个比它高 20% 的值。上来就写 5 是典型错误,会把正常浏览的用户打死。burst:一般取 rate 的 3 到 5 倍。首页加载会并发拉十几个资源,如果静态资源也走这个 location,burst 至少要覆盖单页资源数。
另外,dict:set 的第三个参数是过期时间,这里写 10 秒。它的作用不是限速,而是清理长期不活跃的 IP,避免共享内存被几万个一次性 IP 撑满。如果不设过期,20MB 的字典在流量上来后会持续报 no memory,这个错误只写进 error_log,前台表现是「限速突然全部放行」——反而比不限速更危险。
共享字典的大小怎么定
一个 key 在 64 位平台上大约占 100 字节左右(含 key 字符串、字典节点、Lua table 序列化的值)。20MB 大约能放 15 到 20 万个条目。这个数量级足够应付大多数个人站,但如果你的站点主要是移动端、并且有大量 IPv6 用户,就要留意:
IPv6 地址不是一个人一个。同一个 /64 前缀下有 2 的 64 次方个地址,攻击者只要换地址就能绕过按 IP 的限速。所以对于 IPv6,key 必须做前缀聚合:
local ip = ngx.var.binary_remote_addr
local key
if ngx.var.remote_addr:find(":") then
-- 简单聚合:取前 4 段,等价于 /64 的前 64 位
local parts = {}
for seg in ngx.var.remote_addr:gmatch("[^:]+") do
parts[#parts + 1] = seg
end
key = "tb6:" .. table.concat(parts, ":", 1, 4)
else
key = "tb4:" .. ip
end注意这里用的是文本解析而不是二进制截断,对压缩写法的 IPv6(比如 ::1、2001:db8::1)会算错。生产环境更稳的做法是用 resty.iputils 或者自己实现一个归一化函数。这件事看着琐碎,但它是限速有效性的前提——我在一个站上就吃过亏,攻击者用同一个 /64 下的地址轮换,日志上看是几百个不同 IP,实际上是一个人。
分层限速:按 URL 定价
一刀切的速率一定会两头不讨好:搜索页重、首页轻,如果都用同一个速率,要么搜索页被打穿,要么首页被误伤。正确做法是给不同 location 设不同的桶,而桶的参数集中写在一张表里,方便以后调整。
local RULES = {
["/search"] = {rate = 2, burst = 8, ttl = 30},
["/api/login"]= {rate = 0.5,burst = 5, ttl = 300},
["/api/"] = {rate = 20, burst = 60, ttl = 10},
["/"] = {rate = 30, burst = 120,ttl = 10},
}
local function pick(uri)
-- 按路径长度从长到短匹配,先精确后前缀
local best, bestlen = nil, -1
for prefix, rule in pairs(RULES) do
if uri:sub(1, #prefix) == prefix and #prefix > bestlen then
best, bestlen = rule, #prefix
end
end
return best or RULES["/"]
end这里要注意一个 Lua 的细节:pairs 的遍历顺序是随机的,所以不能「找到第一个匹配就 break」,必须比长度。我第一次写的时候直接用 break,导致 /api/login 有时被当成 /api/ 处理,登录限速形同虚设——这种 bug 不会报错,只会在某次压测里偶然暴露。
登录接口单独限速的价值最大。它是最容易被撞库的地方,而且正常用户一分钟内登录几次就要怀疑了。把 rate 设到 0.5(也就是每两秒一个令牌)、burst 给 5,正常用户毫无感知,脚本小子两分钟就被拦死。
多机部署:换成 Redis 计数
如果你有两台以上机器,共享内存字典的方案会立刻失效——每台机器各算各的,实际可用额度是单机的 N 倍。这种情况要么在 CDN 层做限速,要么把状态放到 Redis。
Redis 版本的漏桶不需要自己算时间差,直接用 INCR 加 EXPIRE 的固定窗口就够了,配合一个「连续超限就延长封禁」的逻辑,效果比纯令牌桶更符合防刷需求:
location /api/ {
access_by_lua_block {
local redis = require "resty.redis"
local red = redis:new()
red:set_timeouts(100, 100, 100) -- 三处超时都要设,默认值会拖垮 worker
local ok, err = red:connect("127.0.0.1", 6379)
if not ok then
ngx.log(ngx.ERR, "redis connect: ", err)
return -- 失败放行,不要因为缓存故障打死站点
end
local key = "rl:" .. ngx.var.binary_remote_addr
local n, e = red:incr(key)
if n == 1 then red:expire(key, 60) end
if n > 120 then
-- 超限之后进入惩罚期,key 改成 10 分钟
red:expire(key, 600)
ngx.header["Retry-After"] = 600
ngx.exit(429)
end
red:set_keepalive(10000, 100) -- 必须归还连接到池
}
}三个容易忽略的点:
- Redis 连不上时必须放行。限速是保护措施,不是安全边界。Redis 挂了就拒全部请求,等于自己把自己打下线。同理,
set_keepalive忘了写会导致每次请求新建连接,几百 QPS 就把 Redis 的maxclients打满,报「too many connections」。 - 计数器最好带前缀和库号隔离。用
database 2而不是 0,避免和业务缓存的 key 撞名。我们站曾经就因为 key 名字太通用(rl:1.2.3.4),和另一个服务的余额计数撞了。 - 惩罚期会长到超出预期。每次超限都
expire 600,等于「一直被刷就一直封」。这个策略对攻击者有效,但也可能误伤共享出口 IP 的公司或校园网用户。折中做法是给惩罚设上限,比如连续三次超限后改成永久 429 并写日志通知,人工看重不重。
怎么验证限速真的生效了
限速这种东西最容易出现「配置在、实际没跑」的状态。上线前必须做四件事:
第一,用 curl 打穿阈值。并发打一百个请求,看返回码分布。正常应该是一部分 200、一部分 429,如果全是 200,说明 Lua 块没进;如果全是 429,说明参数设错了或者 key 算错了(比如所有请求共用一个空 key)。
seq 1 200 | xargs -P 20 -I{} curl -s -o /dev/null -w "%{http_code}\n" \
"https://你的域名/api/health" | sort | uniq -c第二,验证不同 IP 互不影响。如果你在本地测试,所有请求都来自同一个出口 IP,看起来就像「限速生效了」。要模拟不同来源,得在 set_real_ip_from 里临时加上测试机房段,或者直接用 curl --interface 切换本机多个地址。这一步不做,「按 IP 限速」这个前提根本没被验证过。
第三,看日志里有没有 no memory。共享字典满了以后 dict:set 会静默失败,dict:get 返回 nil,代码就把每个请求都当成新 IP,发满一整桶。表现是「限速忽然全放行」。所以要定期 grep "no memory" error.log | wc -l,为 0 才算健康。
第四,验证回源压力确实下降了。限速的目的是保护后端,不是让 429 好看。上线前后分别看 PHP-FPM 的 active processes 峰值和 MySQL 的 Threads_running,如果 429 一堆但后端压力没降,说明限速拦的是不该拦的请求,真正打后端的请求还在跑。
开放重定向与日志留证
限速本身没有安全问题,但配套的两件事常有遗漏。一是 429 响应里不要把 X-RateLimit-Remaining 之类的内部指标暴露给不可信来源,尤其是当这个值能反推出你的限速参数时——那等于告诉对方「再打 5 个就过」。二是必须给被拦的请求留日志,否则事后分析攻击时,只能看到 access_log 里的 429,看不出请求的是哪个路径、带什么 UA。
log_by_lua_block {
if ngx.var.status == "429" then
local f = io.open("/var/log/nginx/ratelimit.log", "a")
if f then
f:write(string.format("%s %s %s %s %s\n",
ngx.var.time_iso8601, ngx.var.remote_addr,
ngx.var.request_method, ngx.var.request_uri,
ngx.var.http_user_agent))
f:close()
end
end
}这段代码有个性能隐患:每个被拦请求都开一次文件句柄,量大的时候IO会成为瓶颈。生产上更合适的是写进主 access_log,用 map $status $rl_tag 打标记,再在离线分析时过滤。第一次写建议先用 io.open 验证逻辑,确认有效后再改成日志格式的方案。
什么时候不该上 OpenResty 限速
最后说个反方向的话。OpenResty 限速适合的场景是:单站点、有一定自定义需求、你愿意维护一段 Lua 代码。不适合的场景也有三个:
一是流量还没起来的个人站。几十个 IP 的访问量,用 Nginx 的 limit_req 加 limit_conn 组合就足够,多写一百行 Lua 只是给自己增加维护负担和出错面。二是有成熟 CDN 的站点。现在主流 CDN 都提供边缘限速规则,在边缘拦掉比在源站拦掉便宜得多,也省得自己算 IP 段。三是团队里没人看得懂 Lua 的站。限速代码一旦出事,症状是「站点有时快有时慢」或者「部分用户打不开」,没有 Lua 基础的人根本不知道怎么排查。
判断标准很简单:如果你已经能用 limit_req 加合理参数把问题压住,就别上 OpenResty;只有当你要「按 URL 定价」「动态调整速率」「和 Redis 状态打通」这三件事中的任意一件时,Lua 方案才划算。
小结
OpenResty 限速的核心不是那段 Lua 代码,而是三个决策:选令牌桶还是固定窗口、rate 和 burst 怎么从自己的日志里算出来、单机用共享字典还是多机用 Redis。代码在所有方案里都是最短的部分,参数才是真正决定成败的地方。上线后用 curl 打穿、验证不同 IP 隔离、盯住 no memory、看后端压力是否真降,这四步做完,才算限速真的上线了。