如何用 Nginx lua_shared_dict 实现跨进程的高性能限流同步?
- 内容介绍
- 文章标签
- 相关推荐
本文共计1106个文字,预计阅读时间需要5分钟。
直接说结论:
为什么 lua_shared_dict 适合限流而不是 Redis
因为它是 Nginx worker 进程直接 mmap 映射的同一块内存,没有序列化、没有网络 IO、没有上下文切换。一次 incr 调用平均耗时在 100ns 级别,而 Redis 单次 INCR 至少是毫秒级——尤其在每秒上万请求的网关场景,差两个数量级。
但它不是万能的:数据随 Nginx reload 清空;不支持复杂查询;value 不能存函数或循环 table;key 长度不能超过 255 字节。
- 适合场景:IP/Token/URI 粒度的计数、令牌桶、滑动窗口(需自己维护时间戳)
- 不适合场景:需要持久化、跨机器同步、模糊匹配或聚合统计
lua_shared_dict 必须放在 http 块里,且不能重复定义
这是最常踩的坑:有人把 lua_shared_dict rate_limit 5m; 写在 server 或 location 块里,Nginx 启动直接报错 nginx: [emerg] "lua_shared_dict" directive is not allowed here。
另一个隐性问题:同名字典只能定义一次。比如你在多个 include 文件里都写了 lua_shared_dict my_dict 2m;,启动时会失败,错误信息是 duplicate "lua_shared_dict" directive。
- 正确定义位置:
http { lua_shared_dict rate_limit 10m; } - 名称只允许字母、数字、下划线,大小写敏感(
RateLimit和ratelimit是两个字典) - 大小单位必须是
k或m,写成10mb或10M会启动失败
限流逻辑必须用 incr,别手写 get + set
看似简单的两步操作,在多 worker 下就是竞态漏洞。比如:
local dict = ngx.shared.rate_limit local val = dict:get("ip:1.2.3.4") if not val or val < 100 then dict:set("ip:1.2.3.4", (val or 0) + 1, 60) end
当两个请求同时读到 val == 99,都会执行 set(..., 100),结果变成 100,但实际已处理 101 次请求。
正确做法是用原子 incr:
local dict = ngx.shared.rate_limit local key = "ip:" .. ngx.var.binary_remote_addr local newval, err = dict:incr(key, 1, 60) -- 第三次参数是初始值+过期时间 if err == "not found" then newval = dict:incr(key, 1, 60) -- 确保初始化 end if newval > 100 then return ngx.exit(429) end
-
incr(key, delta, exptime)是真正原子的:不存在则设初值并设 TTL,存在则加 delta - 返回
nil+"no memory"表示共享内存满,此时应降级(如记录日志、放行或 fallback 到本地计数) - 避免对同一 key 在单次请求中多次
incr,会导致精度漂移(底层是 CAS,但 Lua 层无法保证中间状态)
内存预估不准,会导致限流失效或 OOM
10MB 看似很多,但按默认 slab 分配粒度(最小 128 字节),如果 key 平均长度 64 字节、value 是 number(8 字节),加上节点元数据,一个 key 实际占约 200 字节。10MB 最多存 5 万个 key —— 如果你按秒级窗口统计 10 万个 IP,就必然触发 LRU 驱逐,旧 key 被清掉,限流变“漏斗”。
更危险的是存大 value:比如把整个 JSON 响应体(5KB)塞进 shared dict,几个 key 就能把 slab 撑爆,后续所有 set 都返回 "no memory"。
- 估算公式:
字典大小 ≈ (平均 key 长度 + 平均 value 大小 + 64) × 预估最大 key 数量,再加 20% 余量 - key 过长?用
ngx.md5_bin()哈希压缩,比如md5("user:long_token_xxx")固定 16 字节 - value 超过 1KB?别硬塞,改存摘要或 ID,查库补全
真正难的不是写对那几行 Lua,而是想清楚你的 key 空间规模、value 生命周期、以及 reload 后如何平滑过渡——这些决定了限流到底靠不靠谱。
本文共计1106个文字,预计阅读时间需要5分钟。
直接说结论:
为什么 lua_shared_dict 适合限流而不是 Redis
因为它是 Nginx worker 进程直接 mmap 映射的同一块内存,没有序列化、没有网络 IO、没有上下文切换。一次 incr 调用平均耗时在 100ns 级别,而 Redis 单次 INCR 至少是毫秒级——尤其在每秒上万请求的网关场景,差两个数量级。
但它不是万能的:数据随 Nginx reload 清空;不支持复杂查询;value 不能存函数或循环 table;key 长度不能超过 255 字节。
- 适合场景:IP/Token/URI 粒度的计数、令牌桶、滑动窗口(需自己维护时间戳)
- 不适合场景:需要持久化、跨机器同步、模糊匹配或聚合统计
lua_shared_dict 必须放在 http 块里,且不能重复定义
这是最常踩的坑:有人把 lua_shared_dict rate_limit 5m; 写在 server 或 location 块里,Nginx 启动直接报错 nginx: [emerg] "lua_shared_dict" directive is not allowed here。
另一个隐性问题:同名字典只能定义一次。比如你在多个 include 文件里都写了 lua_shared_dict my_dict 2m;,启动时会失败,错误信息是 duplicate "lua_shared_dict" directive。
- 正确定义位置:
http { lua_shared_dict rate_limit 10m; } - 名称只允许字母、数字、下划线,大小写敏感(
RateLimit和ratelimit是两个字典) - 大小单位必须是
k或m,写成10mb或10M会启动失败
限流逻辑必须用 incr,别手写 get + set
看似简单的两步操作,在多 worker 下就是竞态漏洞。比如:
local dict = ngx.shared.rate_limit local val = dict:get("ip:1.2.3.4") if not val or val < 100 then dict:set("ip:1.2.3.4", (val or 0) + 1, 60) end
当两个请求同时读到 val == 99,都会执行 set(..., 100),结果变成 100,但实际已处理 101 次请求。
正确做法是用原子 incr:
local dict = ngx.shared.rate_limit local key = "ip:" .. ngx.var.binary_remote_addr local newval, err = dict:incr(key, 1, 60) -- 第三次参数是初始值+过期时间 if err == "not found" then newval = dict:incr(key, 1, 60) -- 确保初始化 end if newval > 100 then return ngx.exit(429) end
-
incr(key, delta, exptime)是真正原子的:不存在则设初值并设 TTL,存在则加 delta - 返回
nil+"no memory"表示共享内存满,此时应降级(如记录日志、放行或 fallback 到本地计数) - 避免对同一 key 在单次请求中多次
incr,会导致精度漂移(底层是 CAS,但 Lua 层无法保证中间状态)
内存预估不准,会导致限流失效或 OOM
10MB 看似很多,但按默认 slab 分配粒度(最小 128 字节),如果 key 平均长度 64 字节、value 是 number(8 字节),加上节点元数据,一个 key 实际占约 200 字节。10MB 最多存 5 万个 key —— 如果你按秒级窗口统计 10 万个 IP,就必然触发 LRU 驱逐,旧 key 被清掉,限流变“漏斗”。
更危险的是存大 value:比如把整个 JSON 响应体(5KB)塞进 shared dict,几个 key 就能把 slab 撑爆,后续所有 set 都返回 "no memory"。
- 估算公式:
字典大小 ≈ (平均 key 长度 + 平均 value 大小 + 64) × 预估最大 key 数量,再加 20% 余量 - key 过长?用
ngx.md5_bin()哈希压缩,比如md5("user:long_token_xxx")固定 16 字节 - value 超过 1KB?别硬塞,改存摘要或 ID,查库补全
真正难的不是写对那几行 Lua,而是想清楚你的 key 空间规模、value 生命周期、以及 reload 后如何平滑过渡——这些决定了限流到底靠不靠谱。

