云计算百科
云计算领域专业知识百科平台

Nginx外置缓存-nginx + redis

一、引言:为什么是 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

层级职责TTL一致性
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调优

十一、结语

感谢您的阅读!如果你有任何疑问或想要分享的经验,请在评论区留言交流!

赞(0)
未经允许不得转载:网硕互联帮助中心 » Nginx外置缓存-nginx + redis
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!