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

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
Lua 复制代码
-- ❌ 错误:将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最常见的Bugngx.null是Lua中的特殊对象,表示Redis返回的NIL值。它在条件判断中为true,直接当字符串使用会导致下游解析失败。

② set_keepalive必须在每次操作后调用
Lua 复制代码
-- ❌ 错误:异常路径未释放连接
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所在槽迁移时,服务端返回MOVEDASK重定向。lua-resty-redis原生不处理重定向,需使用lua-resty-redis-cluster库:

Lua 复制代码
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 二进制安全警告

Lua 复制代码
-- ❌ 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 命名规范

Lua 复制代码
-- ✅ 推荐格式:{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调优

十一、结语

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

相关推荐
Valora17 小时前
简单学会redis
redis
☆凡尘清心☆18 小时前
CentOS Stream 9 专用 LNMP 全自动一键脚本
linux·运维·mysql·nginx·lnmp·centos stream 9
踏着七彩祥云的小丑19 小时前
忘记Redis是否安装过时查看
数据库·redis·缓存
一嘴一个橘子21 小时前
redis 通用命令详解
redis
☆凡尘清心☆1 天前
CentOS Stream 9 Redis 7.2.7 源码编译一键安装脚本
linux·运维·redis·centos
麻瓜code1 天前
【Redis 】数据类型、持久化与过期删除
数据库·redis·缓存
☆凡尘清心☆1 天前
CentOS Stream 9 编译安装 Redis 7.2.7 完整版详细步骤
linux·redis·centos
晚安code1 天前
RocketMQ 延迟消息实战:延迟双删策略解决 Redis 缓存一致性
redis·rocketmq
山城码农笑松哥1 天前
Redis 8.8 新特性实操笔记:原生数组、INCREX 限流、XNACK 流处理
redis·笔记·junit