一、引言:当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粒度 |
八、总结
以上为个人经验,希望能给大家一个参考,也希望大家多多支持代码网。
发表评论