OpenResty 的 shdict 共享内存:小白也能懂的动态限流指南

本文用通俗语言拆解 OpenResty 中基于 shdict 共享内存实现分布式高并发动态 Rate Limiting 的核心原理与简单实战。不讲复杂公式,只讲清楚“为什么需要共享内存”“怎么动态调整限流阈值”,适合刚接触 Nginx 和限流概念的后端开发者。

OpenResty 的 shdict 共享内存:小白也能懂的动态限流指南
封面图:ZuCDN · ZuCDN 原创

限流到底是什么?为什么要动态调整?

先看关键判断

说到OpenResty shdict,很多问题都出在细节上。假设你运营一个社区论坛,平时每秒 100 人访问,服务器扛得住。但某天一条热搜把流量推到每秒 5000 人,服务器直接雪崩。限流就是给入口装一个阀门:只允许每秒最多 100 个请求通过,多余的排队或直接返回“稍后再试”。

传统 Nginx 的 limit_req_module 能做到这一点,但它的限流参数写在配置文件里,改一次就得 reload 整个 Nginx。业务流量波动的场景下(比如促销、秒杀),你希望限流阈值能实时调整,而不是停机改配置。这时候就需要 动态限流

OpenResty 和它的“共享内存”是什么?——OpenResty shdict

配置前的检查

OpenResty 是 Nginx + LuaJIT 的组合,让你能用 Lua 脚本直接操作 Nginx 内部的数据结构。shdict(共享字典)是 OpenResty 提供的一种进程间共享内存机制。Nginx 默认是多 worker 进程的,每个 worker 的内存是隔离的。shdict 在所有 worker 之间共享同一段内存,并且自带原子操作和过期机制。

你可以把 shdict 想象成一个高性能的[Redis]但跑在 Nginx 内部——不需要网络开销,读写速度极快,而且天然支持并发安全。

用 shdict 实现限流的核心思路

3.1 计数 + 时间窗口

最常见的限流算法是“滑动窗口”或“令牌桶”。这里我们用最简单的“固定窗口”作为例子:每秒为一个窗口,窗口内最多处理 N 个请求。如果超过 N,则拒绝。

在 shdict 里,只需要一个键值对:键是当前秒的时间戳,值是该秒内的请求计数。每次请求到来,原子递增这个计数,然后判断是否超过阈值。

3.2 动态阈值存储

动态限流的核心在于阈值可以随时修改。我们把阈值也存到 shdict 里,比如键 rate_limit_threshold。通过外部 API(比如一个简单的 HTTP 接口)修改这个值,所有 worker 立刻生效,无需 reload。

一步一步实现:从 0 到能跑的代码与OpenResty shdict

第一步:配置共享字典

nginx.confhttp 块里声明一个 shdict:

lua_shared_dict my_limit 10m;

10m 代表 10MB 内存,足够存储数十万条计数器记录。

第二步:写限流 Lua 脚本

access_by_lua_block 里插入以下逻辑:

local limit = require "resty.limit.req"
local dict = ngx.shared.my_limit
local key = ngx.var.binary_remote_addr -- 按 IP 限流,也可以改成 URL 或其他

local threshold = dict:get("rate_limit_threshold") or 100  -- 动态读取阈值,默认 100

-- 使用 resty.limit.req 库的 sliding window 实现
local lim, err = limit.new("my_limit", threshold, 1)  -- 第一个参数是共享字典名,第二个是阈值,第三个是窗口秒数
if not lim then
    ngx.log(ngx.ERR, "failed to instantiate limiter: ", err)
    return ngx.exit(500)
end

local delay, err = lim:incoming(key, true)
if not delay then
    if err == "rejected" then
        return ngx.exit(503)  -- 拒绝请求
    end
    ngx.log(ngx.ERR, "failed to limit req: ", err)
    return ngx.exit(500)
end

if delay >= 0.001 then
    -- 需要限流等待(比如令牌桶模式),这里选择直接拒绝以简化
    ngx.sleep(delay)
end

注意:上面使用了 OpenResty 官方库 resty.limit.req,它基于 shdict 实现了滑动窗口。如果你不想依赖库,也可以用纯 Lua 手动实现计数器递增:

local key = ngx.var.binary_remote_addr .. ":" .. os.time()
local count, err = dict:incr(key, 1)
if count > threshold then
    dict:delete(key)  -- 可选,清理
    return ngx.exit(503)
end
-- 设置过期时间,避免内存泄露
dict:expire(key, 2)  -- 2 秒后自动删除

这个手动版本更直观,但需要处理过期和精度问题。

第三步:提供动态修改阈值的 API

server 块里加一个 location,用于接收管理请求:

location /admin/set_rate_limit {
    content_by_lua_block {
        local new_threshold = tonumber(ngx.var.arg_threshold)
        if not new_threshold or new_threshold < 1 then
            ngx.say("invalid threshold")
            return
        end
        local dict = ngx.shared.my_limit
        dict:set("rate_limit_threshold", new_threshold)
        ngx.say("OK, new threshold: " .. new_threshold)
    }
    access_by_lua_block {
        -- 这里可以加 token 验证,防止被滥用
    }
}

现在,只要请求 /admin/set_rate_limit?threshold=500,系统立刻使用新的限流阈值,无需重启。

为什么是“分布式”?

先看关键判断

如果只有一台服务器,shdict 足以。但多台服务器的情况下,每台机器的 worker 共享同一块 shdict,但跨机器的请求不互通。真正的分布式限流需要集中式存储,比如 Redis。然而,在某些场景下(比如同机房的 Nginx 集群),可以通过 一致性哈希 将相同 key 路由到同一台机器,这样每台机器内部再用 shdict 做限流,也能达到近似分布式的效果,且延迟比 Redis 低得多。

本文的“分布式”指的是:多 worker 进程之间天然通过 shdict 实现了数据的共享和同步,对于单机多 worker 来说就是分布式(相对进程隔离而言)。如果你需要多机分布式,可以在 Nginx 前面加一层哈希负载均衡,确保同一个客户端 IP 始终打到同一台后端 Nginx。

进阶技巧与注意事项

内存安全

shdict 满了会触发 LRU 淘汰。如果计数器不设过期,短时间内大量不同 key 可能撑爆内存。建议为每个计数器设置短过期时间(比如 2 秒),确保滑动窗口外的数据自动释放。

原子操作

dict:incr 是原子操作,不需要加锁。但 dict:setdict:get 组合时,如果有并发修改,可能读到旧值。动态阈值的场景中,读取阈值然后判断,会存在一个极小的时间窗口,但对限流场景来说,不一致的阈值是可以接受的。

避免性能陷阱

每次请求都进行 Lua 解释执行,如果业务逻辑很重,建议打开 Lua 代码缓存(lua_code_cache on)。另外 shdict 操作本身很快(微秒级),瓶颈通常在 Nginx 的 IO。

总结:什么时候用 shdict 限流?

先看关键判断

如果你的系统满足以下条件,本方案性价比极高:

  • 单机或同机房单集群(多机通过哈希路由到同一台)
  • 需要实时调整限流参数,不想 reload Nginx
  • 延迟敏感,无法忍受每次请求都访问 Redis
  • 愿意写一点 Lua 代码

而如果是大规模跨机房分布式限流,请直接上 Redis + 令牌桶,shdict 更适合做本地二级缓存或第一道防线。

动态限流不是银弹,但理解它背后的 shdict 原理,能让你在面对流量冲击时多一把趁手的工具。后续只要定期检查关键指标,OpenResty shdict就不会变成维护负担。

延伸阅读