OpenResty限速实战:Lua令牌桶与分层保护

为什么 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、看后端压力是否真降,这四步做完,才算限速真的上线了。

Last modification:November 3rd, 2026 at 01:30 pm

Leave a Comment