一、引言:为什么是 Nginx + Redis?
在《Nginx外置缓存》系列前文中,我们探讨了外置缓存的架构选型、Memcached的轻量级场景以及error_page降级体系。而在企业级生产环境中,Nginx + Redis 组合占据了外置缓存90%以上的份额。原因很简单:Redis不仅是一个KV存储,更是一个支持丰富数据结构、持久化、集群模式和发布订阅的通用数据平台。
但“能用”和“用好”之间隔着巨大的鸿沟。很多团队直接将Nginx对接Redis后,反而引入了新的性能瓶颈和稳定性风险:
- 每个请求新建TCP连接,Redis maxclients 迅速耗尽;
- Lua脚本中未正确处理ngx.null,导致缓存穿透被误判为命中;
- Key设计缺少命名空间和版本标识,发布时缓存无法平滑切换;
- Redis Cluster重定向(MOVED/ASK)未在Lua层处理,集群扩缩容期间大量500;
- 序列化格式选择不当,JSON编码开销吞噬了缓存带来的延迟收益。
这些问题的根源在于:把Redis当作一个“远程变量”来用,而非一个需要精细管理的分布式基础设施。本文将从连接管理、数据交互、集群适配和生产调优四个维度,给出经过大规模流量验证的Nginx + Redis外置缓存完整方案。
二、架构定位:Nginx + Redis在缓存体系中的角色
2.1 三层缓存模型回顾
客户端 → Nginx (L1: lua_shared_dict) → Redis (L2: 集群共享) → 源站 (L3)
<10μs 0.1~0.5ms 1~50ms
| L1 | 拦截热点请求,避免网络开销 | 3~10s | 节点内最终一致 |
| L2 | 集群共享缓存,保证全局命中率 | 30s~30min | 集群级最终一致 |
| L3 | 数据权威来源 | – | 强一致 |
📌 核心认知:Redis在外置缓存体系中是L2集群共享层。它解决的是“多Nginx节点缓存孤岛”问题,而非替代L1本地缓存。跳过L1直连Redis,会让Redis成为新的单点瓶颈。
2.2 为什么不用Nginx原生redis_module?
原生ngx_http_redis_module仅支持只读GET操作,无连接池、无写入、无删除、无Cluster支持。生产环境必须使用OpenResty生态的lua-resty-redis库,它提供了:
- ✅ 完整的Redis命令支持(GET/SET/HGET/DEL/SCAN等)
- ✅ 基于cosocket的非阻塞IO
- ✅ 连接池复用(set_keepalive)
- ✅ Redis Cluster MOVED/ASK自动重定向(需配合lua-resty-redis-cluster)
- ✅ Pipeline批量操作
三、lua-resty-redis 生产级封装
3.1 核心模块设计
创建 /usr/local/openresty/lualib/redis_cache.lua:
local redis = require "resty.redis"
local cjson = require "cjson.safe"
local _M = {}
— ⭐ 连接池配置(根据实际压测调整)
local CONF = {
host = "redis-cluster.internal",
port = 6379,
pool_size = 100, — 每worker连接池大小
backlog = 200, — 等待队列长度
connect_timeout = 100, — ms
send_timeout = 200, — ms
read_timeout = 200, — ms
}
— 获取Redis连接(带连接池)
local function get_redis()
local red, err = redis:new()
if not red then
ngx.log(ngx.ERR, "redis new failed: ", err)
return nil, err
end
red:set_timeouts(CONF.connect_timeout, CONF.send_timeout, CONF.read_timeout)
local ok, err = red:connect(CONF.host, CONF.port)
if not ok then
ngx.log(ngx.ERR, "redis connect failed: ", err)
return nil, err
end
— 如需密码认证
— local auth_ok, auth_err = red:auth("your_password")
— if not auth_ok then return nil, auth_err end
return red, nil
end
— ⭐ 释放连接到池中(关键!)
local function release_redis(red)
local ok, err = red:set_keepalive(10000, CONF.pool_size)
if not ok then
ngx.log(ngx.ERR, "redis keepalive failed: ", err)
end
end
— 读取缓存
function _M.get(key)
local red, err = get_redis()
if not red then return nil, err end
local res, err = red:get(key)
release_redis(red)
— ⭐ 正确区分MISS和ERROR
if not res then
return nil, err — 网络/协议错误
end
if res == ngx.null then
return nil, "MISS" — Key不存在
end
return res, nil
end
— 写入缓存(带TTL)
function _M.set(key, value, ttl)
local red, err = get_redis()
if not red then return false, err end
local ok, err = red:setex(key, ttl or 300, value)
release_redis(red)
return ok ~= nil, err
end
— 删除缓存
function _M.delete(key)
local red, err = get_redis()
if not red then return false, err end
local ok, err = red:del(key)
release_redis(red)
return ok ~= nil, err
end
— ⭐ 安全批量删除(SCAN替代KEYS)
function _M.delete_pattern(pattern)
local red, err = get_redis()
if not red then return false, err end
local cursor = "0"
local total = 0
repeat
local res, err = red:scan(cursor, "MATCH", pattern, "COUNT", 100)
if not res then
release_redis(red)
return false, err
end
cursor = res[1]
local keys = res[2]
if #keys > 0 then
local del_ok, del_err = red:del(unpack(keys))
if del_ok then total = total + del_ok end
end
until cursor == "0"
release_redis(red)
return true, nil, total
end
return _M
3.2 三个致命细节解析
① ngx.null ≠ nil
— ❌ 错误:将MISS当作成功
local res = red:get(key)
if res then — ngx.null 是truthy!会进入此分支
ngx.say(res) — 输出 "null" 字符串
end
— ✅ 正确:显式判断ngx.null
if res == ngx.null then
— MISS
elseif not res then
— ERROR
else
— HIT
end
⚠️ 这是Nginx+Redis最常见的Bug。ngx.null是Lua中的特殊对象,表示Redis返回的NIL值。它在条件判断中为true,直接当字符串使用会导致下游解析失败。
② set_keepalive必须在每次操作后调用
— ❌ 错误:异常路径未释放连接
local res, err = red:get(key)
if not res then
return nil, err — 连接泄漏!
end
release_redis(red)
— ✅ 正确:所有退出路径都释放
local res, err = red:get(key)
release_redis(red) — 无论成功失败都释放
if not res then return nil, err end
③ SCAN替代KEYS是铁律
KEYS *pattern* 是O(N)全量扫描,在生产Redis上执行等同于DDoS攻击。SCAN游标遍历是唯一安全的批量操作方式,即使匹配结果为空也必须完整遍历直到cursor返回"0"。
四、Nginx配置集成与两层缓存联动
4.1 完整配置示例
http {
# L1本地缓存 + 分布式锁
lua_shared_dict l1_cache 100m;
lua_shared_dict cache_locks 10m;
init_by_lua_block {
redis_cache = require "redis_cache"
cjson = require "cjson.safe"
}
server {
listen 80;
location /api/ {
content_by_lua_block {
local key = "api:v2:" .. ngx.var.uri
— ===== L1: 本地共享字典 =====
local l1 = ngx.shared.l1_cache
local l1_val = l1:get(key)
if l1_val then
ngx.header["X-Cache"] = "L1-HIT"
ngx.say(l1_val)
return
end
— ===== L2: Redis =====
local data, err = redis_cache.get(key)
if err and err ~= "MISS" then
ngx.log(ngx.WARN, "redis error, fallback to backend: ", err)
— 降级:跳过缓存直接回源
goto backend
end
if err ~= "MISS" then
— Redis HIT → 回填L1
l1:set(key, data, 5)
ngx.header["X-Cache"] = "L2-HIT"
ngx.say(data)
return
end
— ===== MISS: 防击穿 + 回源 =====
::backend::
local locks = ngx.shared.cache_locks
local lock_key = "lock:" .. key
local elapsed, lerr = locks:add(lock_key, true, 3)
if elapsed then
local res = ngx.location.capture("/internal/backend")
if res.status == 200 then
redis_cache.set(key, res.body, 300)
l1:set(key, res.body, 5)
ngx.header["X-Cache"] = "MISS"
ngx.say(res.body)
else
ngx.status = res.status
ngx.say(res.body)
end
locks:delete(lock_key)
else
— 未获锁:短暂等待后重试
ngx.sleep(0.1)
local retry, rerr = redis_cache.get(key)
if retry and rerr ~= "MISS" then
ngx.header["X-Cache"] = "LOCK-WAIT-HIT"
ngx.say(retry)
else
— 降级回源(不缓存)
local res = ngx.location.capture("/internal/backend")
ngx.header["X-Cache"] = "BYPASS"
ngx.status = res.status
ngx.say(res.body)
end
end
}
}
location /internal/backend {
internal;
proxy_pass http://backend;
proxy_set_header Host $host;
}
}
}
4.2 goto语法的合理使用
OpenResty的LuaJIT支持goto,在缓存逻辑这种多分支跳转场景中,比嵌套if-else或重复代码更清晰。但仅限缓存流程控制使用,业务逻辑中应避免。
五、Redis Cluster适配
5.1 为什么需要专门处理?
Redis Cluster通过哈希槽分片,当Key所在槽迁移时,服务端返回MOVED或ASK重定向。lua-resty-redis原生不处理重定向,需使用lua-resty-redis-cluster库:
local redis_cluster = require "resty.rediscluster"
local config = {
name = "my_cluster",
serv_list = {
{ ip = "10.0.0.1", port = 6379 },
{ ip = "10.0.0.2", port = 6379 },
{ ip = "10.0.0.3", port = 6379 },
},
keepalive_timeout = 10000,
keepalive_poolsize = 100,
connect_timeout = 100,
read_timeout = 200,
max_redirection = 5, — ⭐ 最大重定向次数
}
local cluster, err = redis_cluster:new(config)
— 后续API与lua-resty-redis兼容:cluster:get(key), cluster:set(key, val, ttl)
5.2 Cluster注意事项
| 不支持多Key跨槽操作 | MGET/MSET的Key必须在同一槽,否则报错 |
| SCAN按节点执行 | 需遍历所有master节点才能完成全量扫描 |
| 连接池按节点独立 | 每个节点维护独立pool,总连接=节点数×pool_size |
| 拓扑变更自动感知 | 库会自动刷新slot映射,无需重启Nginx |
⚠️ 避坑:若你的Key设计天然分散在不同槽,避免使用MGET。改为Pipeline单节点请求或应用层并发获取。
六、序列化格式选型
6.1 性能对比实测(1KB JSON对象)
| cjson | 基准 | 基准 | 基准 | ✅ | 通用API响应 |
| MessagePack | -30% | -25% | -20% | ✅ | 高频内部通信 |
| Protobuf | -60% | -50% | -40% | ✅ | 结构化数据、带宽敏感 |
| Lua table序列化 | -70% | -60% | -50% | ❌ | 仅OpenResty内部 |
6.2 二进制安全警告
— ❌ JSON无法安全存储二进制数据
cjson.encode({ body = "\\xff\\xfe\\x00" }) — 可能损坏
— ✅ 二进制内容使用MessagePack或直接存raw string
mp.encode({ body = binary_data })
— 或
redis:set(key, binary_data, ttl) — Memcached/Redis均二进制安全
七、Key设计规范
7.1 命名规范
— ✅ 推荐格式:{namespace}:{version}:{entity}:{id}
"api:v2:user:profile:10086"
"sess:v1:token:abc123def456"
"cnt:v1:api:/users/list:daily:20260802"
— ❌ 避免
"/api/user/profile?id=10086" — URI作Key,参数顺序变化导致重复
"user_10086" — 无命名空间,易冲突
"a"*300 — 超长Key浪费内存和网络
7.2 设计原则
| 可读性 | 便于调试、手动清理和监控分析 |
| 唯一性 | 包含影响响应内容的所有变量(method/args/header) |
| 可演进性 | 嵌入版本号,发布时可平滑切换旧缓存 |
| 长度控制 | <256字节,过长浪费Redis内存和网络带宽 |
| 可枚举性 | 支持SCAN按前缀批量操作 |
八、生产调优清单
8.1 连接池Sizing
总并发Redis连接 = Nginx worker数 × pool_size
示例:worker=8, pool_size=100 → 总连接=800
Redis maxclients 应 ≥ 800 × 1.5 = 1200
通过INFO clients监控connected_clients,峰值接近maxclients时需扩容或调大pool。
8.2 超时设置策略
| connect_timeout | 100ms | 快速失败,避免阻塞worker |
| read_timeout | 200ms | P99应<1ms,200ms已是容忍上限 |
| send_timeout | 200ms | 大Value写入时适当放宽 |
| keepalive_idle | 10s | 空闲连接保活时间 |
⚠️ 铁律:Redis操作超时必须远小于Nginx对客户端的超时。若客户端超时5s,Redis超时设为200ms,留出足够的降级和重试窗口。
8.3 故障降级
local data, err = redis_cache.get(key)
if err and err ~= "MISS" then
ngx.log(ngx.WARN, "redis degraded: ", err)
— 选项1:跳过缓存,直接回源
— 选项2:返回L1过期数据作为兜底
— 选项3:返回预设默认值
— ⭐ 永远不要让缓存故障变成服务故障
end
九、监控指标体系
9.1 必采指标
| L1 HIT率 | lua_shared_dict stats | 热点接口>30% | P3 |
| L2 HIT率 | Redis INFO stats | >60% | P2 |
| Redis P99延迟 | redis_exporter | <1ms | P2 |
| 连接池使用率 | shared_dict stats | <80% | P2 |
| Redis错误率 | error.log聚合 | <0.1% | P1 |
| Redis内存使用率 | redis_exporter | <75% | P2 |
| Redis驱逐率 | INFO stats evicted_keys | =0 | P1 |
9.2 Grafana核心面板
- 缓存漏斗图:Request → L1 HIT → L2 HIT → Backend,直观展示各层拦截效果
- Redis延迟热力图:按时间段和命令类型分布,定位慢查询
- 连接池水位曲线:峰值是否接近pool_size上限
- 错误率趋势:突增是否关联发布或Redis故障
十、常见踩坑速查表
| Redis连接耗尽 | 未用连接池或pool_size过小 | set_keepalive + 增大pool_size |
| 缓存命中但数据错乱 | ngx.null未正确处理 | 显式判断res == ngx.null |
| 热点Key打爆单分片 | 未做L1本地缓存 | lua_shared_dict拦截 |
| 批量删除超时 | 使用KEYS而非SCAN | 改用SCAN游标遍历 |
| 二进制响应缓存损坏 | JSON序列化二进制数据 | 改用MessagePack或raw string |
| Redis故障时全站500 | 无降级逻辑 | try-catch包裹,fallback到源站 |
| Cluster扩缩容大量500 | 未处理MOVED/ASK | 使用lua-resty-redis-cluster |
| MGET跨槽报错 | Key不在同一哈希槽 | 拆分请求或应用层并发 |
| Lua代码修改不生效 | lua_code_cache未开启 | 确认lua_code_cache on; |
| 内存泄漏 | Lua闭包持有大对象引用 | 及时置nil + GC调优 |
十一、结语
感谢您的阅读!如果你有任何疑问或想要分享的经验,请在评论区留言交流!
网硕互联帮助中心




评论前必须登录!
注册