Nginx本地缓存瓶颈怎么破?外置缓存方案详解
作者:難釋懷
一、引言:当Nginx本地缓存成为架构短板
在单体或小型集群中,Nginx的 proxy_cache 是性价比极高的缓存方案。但当业务规模跨越某个临界点后,本地缓存的固有缺陷会集中爆发:
- 缓存孤岛:10台Nginx各自维护独立缓存,同一资源被重复回源10次,整体命中率远低于预期;
- 扩容即失效:新增Nginx节点后,所有新节点的缓存从零开始,预热期间后端压力骤增;
- 清理不同步:内容更新时,需逐台调用purge接口,遗漏任何一台都会导致用户看到脏数据;
- 容量天花板:单机磁盘/内存上限决定了缓存规模,无法随业务线性扩展;
- 故障域耦合:Nginx宕机不仅丢失连接,还丢失该节点全部缓存,恢复后引发回源风暴。
这些问题的根源在于:本地缓存将“计算”与“存储”强绑定在了同一个进程和物理机上。解决之道是将缓存层从Nginx中解耦,下沉到独立的分布式缓存集群——这就是“外置缓存”的核心思想。
本文将从架构选型、协议对接、一致性策略和生产调优四个维度,构建一套以Nginx为接入层、Redis/Memcached为存储层的分布式缓存体系。
二、架构全景:外置缓存的三种形态
2.1 形态对比
| 形态 | 实现方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| Nginx + Redis/MC | OpenResty/njs直连外部缓存 | 灵活、生态成熟、功能完整 | 需Lua/njs开发能力 | API网关、动态内容缓存 |
| Nginx + SRPCache | 专用共享缓存模块 | 高性能、协议原生支持 | 社区活跃度低、文档少 | 纯静态内容、CDN源站 |
| Nginx → Varnish/ATS | 前置专业缓存代理 | 功能强大、HTTP语义完整 | 多一跳延迟、运维复杂度↑ | 大规模CDN、复杂缓存逻辑 |
选型建议:90%的业务场景选择 OpenResty + Redis 组合。它兼顾了灵活性、性能和生态,且团队学习成本可控。Varnish适合已有专业缓存团队的超大规模场景;SRPCache仅推荐给对性能有极致要求且能接受维护风险的团队。
2.2 本文聚焦:OpenResty + Redis架构
客户端 → Nginx(OpenResty) → [Redis Cluster] → 源站
↓ ↑
Lua缓存逻辑 分布式存储
(get/set/stale) (一致性哈希)核心优势:
- 缓存共享:所有Nginx节点读写同一份缓存,命中率=集群级命中率
- 弹性伸缩:Redis Cluster扩缩容不影响Nginx,缓存自动重平衡
- 统一清理:一次DEL命令全局生效,无需逐台purge
- 持久化可选:AOF/RDB提供重启恢复能力,避免冷启动风暴
三、OpenResty + Redis生产级实现
3.1 环境准备
# 安装OpenResty(内置LuaJIT + ngx_lua模块) wget https://openresty.org/package/openresty-1.27.1.tar.gz tar xzf openresty-1.27.1.tar.gz && cd openresty-1.27.1 ./configure --with-luajit --with-http_redis2_module --with-http_lua_module make && make install # 安装lua-resty-redis库(若未内置) luarocks install lua-resty-redis
3.2 核心Lua缓存模块
创建 /usr/local/openresty/lualib/cache_handler.lua:
local redis = require "resty.redis"
local cjson = require "cjson.safe"
local _M = {}
-- Redis连接池配置
local REDIS_CONF = {
host = "redis-cluster.internal",
port = 6379,
pool_size = 100, -- 每worker连接池大小
backlog = 200, -- 等待队列
connect_timeout = 100, -- ms
read_timeout = 200, -- ms
}
-- 获取Redis连接(带连接池)
local function get_redis()
local red = redis:new()
red:set_timeouts(REDIS_CONF.connect_timeout,
REDIS_CONF.read_timeout,
REDIS_CONF.read_timeout)
local ok, err = red:connect(REDIS_CONF.host, REDIS_CONF.port)
if not ok then
ngx.log(ngx.ERR, "redis connect failed: ", err)
return nil, err
end
return red, nil
end
-- 释放连接到池中
local function release_redis(red)
local ok, err = red:set_keepalive(10000, REDIS_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)
if not res or res == ngx.null then
return nil, "miss"
end
return cjson.decode(res), nil
end
-- 写入缓存(带TTL)
function _M.set(key, value, ttl)
local red, err = get_redis()
if not red then return false, err end
local encoded = cjson.encode(value)
local ok, err = red:setex(key, ttl, encoded)
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
return _M3.3 Nginx配置集成
http {
# Lua共享字典:用于本地二级缓存和锁
lua_shared_dict local_cache 100m;
lua_shared_dict cache_locks 10m;
init_by_lua_block {
cache_handler = require "cache_handler"
}
server {
listen 80;
location /api/ {
content_by_lua_block {
local key = ngx.var.scheme .. ":" .. ngx.var.host .. ngx.var.uri
-- 第1层:本地共享字典缓存(微秒级)
local local_cache = ngx.shared.local_cache
local val = local_cache:get(key)
if val then
ngx.header["X-Cache"] = "LOCAL-HIT"
ngx.say(val)
return
end
-- 第2层:Redis外置缓存
local data, err = cache_handler.get(key)
if data then
-- 回填本地缓存(短TTL,防热点穿透)
local_cache:set(key, cjson.encode(data), 5)
ngx.header["X-Cache"] = "REDIS-HIT"
ngx.say(cjson.encode(data))
return
end
-- 第3层:缓存击穿防护(分布式锁)
local locks = ngx.shared.cache_locks
local lock_key = "lock:" .. key
local elapsed, err = locks:add(lock_key, true, 3)
if elapsed then
-- 获得锁,回源
local res = ngx.location.capture("/internal/backend")
if res.status == 200 then
local body = res.body
-- 写入Redis(长TTL)
cache_handler.set(key, cjson.decode(body), 300)
-- 写入本地缓存
local_cache:set(key, body, 5)
ngx.header["X-Cache"] = "MISS"
ngx.say(body)
else
ngx.status = res.status
ngx.say(res.body)
end
locks:delete(lock_key)
else
-- 未获得锁,短暂等待后重试或直接回源
ngx.sleep(0.1)
local retry_data = cache_handler.get(key)
if retry_data then
ngx.header["X-Cache"] = "LOCK-WAIT-HIT"
ngx.say(cjson.encode(retry_data))
else
-- 降级:直接回源(不缓存)
local res = ngx.location.capture("/internal/backend")
ngx.header["X-Cache"] = "BYPASS"
ngx.status = res.status
ngx.say(res.body)
end
end
}
}
# 内部回源location
location /internal/backend {
internal;
proxy_pass http://backend;
proxy_set_header Host $host;
}
}
}3.4 三层缓存架构解析
| 层级 | 存储位置 | TTL | 作用 | 延迟 |
|---|---|---|---|---|
| L1 | lua_shared_dict | 5s | 拦截热点请求,避免Redis网络开销 | <10μs |
| L2 | Redis Cluster | 5min | 集群共享缓存,保证一致性 | 0.1~0.5ms |
| L3 | 源站 | - | 数据权威来源 | 1~50ms |
设计精髓:L1用极短TTL换取零网络延迟,L2用合理TTL换取集群一致性。两者配合,既避免了Redis成为新瓶颈,又解决了本地缓存的一致性问题。
四、关键生产调优要点
4.1 Redis连接池是性能命脉
-- ❌ 错误:每次请求新建连接 local red = redis:new() red:connect(...) -- 请求结束连接销毁 -- ✅ 正确:使用set_keepalive复用连接 red:set_keepalive(10000, 100) -- 空闲10s,池大小100
每个Nginx worker维护独立连接池。若worker数=8、pool_size=100,则最大并发Redis连接=800。务必确保Redis maxclients > Nginx workers × pool_size。
4.2 序列化格式选择
| 格式 | 编码速度 | 解码速度 | 体积 | 跨语言 | 推荐场景 |
|---|---|---|---|---|---|
| JSON (cjson) | 快 | 快 | 大 | ✅ | 通用API响应 |
| MessagePack | 更快 | 更快 | 小 | ✅ | 高频内部通信 |
| Protobuf | 最快 | 最快 | 最小 | ✅ | 结构化数据、带宽敏感 |
| Lua table | 最快 | 最快 | 最小 | ❌ | 仅OpenResty内部 |
避坑:不要用 cjson.encode 缓存包含二进制数据的响应体。JSON无法安全表示任意字节序列,会导致数据损坏。二进制内容应使用Base64编码或直接存Redis binary string。
4.3 缓存Key设计规范
-- ✅ 推荐:命名空间 + 版本 + 业务标识 local key = "api:v2:user:profile:" .. user_id -- ❌ 避免:直接使用URI local key = ngx.var.uri -- 参数顺序变化导致重复缓存
Key设计原则:
- 可读性:便于调试和手动清理
- 唯一性:包含影响响应内容的所有变量
- 可演进性:嵌入版本号,发布时可平滑切换
- 长度控制:<256字节,过长浪费Redis内存和网络带宽
4.4 故障降级策略
-- Redis不可用时,降级到本地缓存或直接回源
local data, err = cache_handler.get(key)
if err and (err == "timeout" or err == "connection refused") then
ngx.log(ngx.WARN, "redis degraded, fallback to backend")
-- 跳过缓存,直接回源
-- 或尝试读取过期的本地缓存作为兜底
end永远不要让缓存故障变成服务故障。外置缓存是加速手段,不是可用性依赖。
五、缓存一致性保障
5.1 主动失效 vs 被动过期
| 策略 | 实现 | 一致性 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 短TTL | Redis SETEX 30s | 最终一致(≤30s) | 低 | 容忍短暂延迟的内容 |
| 主动DEL | 业务写操作后调用cache_handler.delete | 准实时一致 | 中 | 用户数据、配置项 |
| 版本号 | Key含version,发布时递增 | 发布级一致 | 低 | 静态资源、API文档 |
| 双删 | 先删缓存→写DB→延迟再删 | 强一致 | 高 | 金融级数据 |
5.2 批量清理方案
-- 按前缀批量删除(Redis SCAN + DEL,非KEYS)
function _M.delete_pattern(pattern)
local red, err = get_redis()
if not red then return false, err end
local cursor = "0"
repeat
local res, err = red:scan(cursor, "MATCH", pattern, "COUNT", 100)
if not res then break end
cursor = res[1]
local keys = res[2]
if #keys > 0 then
red:del(unpack(keys))
end
until cursor == "0"
release_redis(red)
return true, nil
end⚠️ 严禁在生产使用KEYS命令。SCAN游标遍历是唯一安全的批量操作方式。
六、监控体系
6.1 必采指标
| 指标 | 采集方式 | 健康阈值 |
|---|---|---|
| L1 HIT率 | lua_shared_dict stats | >30%(热点接口) |
| L2 HIT率 | Redis INFO stats | >60% |
| Redis P99延迟 | redis_exporter | <1ms |
| 连接池使用率 | ngx.shared.stats | <80% |
| 缓存操作错误率 | error.log聚合 | <0.1% |
| Redis内存使用率 | redis_exporter | <75% |
6.2 Grafana面板核心视图
- 缓存漏斗图:Request → L1 HIT → L2 HIT → Backend,直观展示各层拦截效果
- Redis延迟热力图:按时间段和命令类型分布,定位慢查询
- 连接池水位曲线:峰值是否接近pool_size上限
- 错误率趋势:突增是否关联发布或Redis故障
七、常见踩坑速查表
| 现象 | 根因 | 解决方案 |
|---|---|---|
| Redis连接耗尽 | 未使用连接池或pool_size过小 | set_keepalive + 增大pool_size |
| 缓存命中但数据错乱 | Key设计缺少区分变量 | 补全scheme/host/method/args |
| 热点Key打爆单分片 | 未做本地缓存 | L1 lua_shared_dict拦截 |
| 批量删除超时 | 使用KEYS而非SCAN | 改用SCAN游标遍历 |
| 二进制响应缓存损坏 | JSON序列化二进制数据 | 改用MessagePack或Base64 |
| Redis故障时全站500 | 无降级逻辑 | try-catch包裹缓存操作 |
| 扩容后命中率骤降 | 未预热 | 部署前执行预热脚本 |
| Lua代码修改不生效 | 未reload或未启用code_cache | lua_code_cache on + reload |
| 内存泄漏 | Lua闭包持有大对象引用 | 及时置nil + GC调优 |
| 锁竞争严重 | lock TTL过长或粒度过粗 | 缩短TTL + 细化key粒度 |
八、总结
以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。
