一、缓存失效为什么这么难?
每次提到缓存失效,很多开发者的第一反应就是头疼。明明已经设置了过期时间,为什么用户还是看到了旧数据?明明调用了清除缓存的接口,为什么新请求还是命中了缓存?这些问题就像打地鼠游戏一样,解决一个又冒出来一个。
缓存失效之所以困难,关键在于"一致性"这个魔鬼。我们既希望缓存能减轻数据库压力,又希望用户看到的数据永远是最新的。这就像想让马儿跑得快又不让马儿吃草一样矛盾。
举个实际例子:电商平台的商品详情页。假设我们使用OpenResty做缓存层,商品信息缓存在Redis中。当后台修改了商品价格后,理论上应该立即失效缓存。但现实往往是:
- 缓存失效命令发出后存在延迟
- 多个服务实例可能不同步失效缓存
- 高并发下可能出现缓存击穿
二、OpenResty的智能缓存失效方案
OpenResty的强大之处在于它把Nginx和Lua完美结合,让我们可以在请求处理的各个阶段插入自定义逻辑。基于这个特性,我们可以设计一套智能缓存失效机制。
核心思路是:通过Lua脚本实现"标记-清除"策略。具体来说:
- 当数据变更时,先标记数据为"脏数据"
- 在请求处理时检查这个标记
- 如果发现是脏数据,则重新加载并更新缓存
来看一个完整的Lua实现示例(技术栈:OpenResty + Redis):
-- 定义共享字典,用于存储脏数据标记
local dirty_flags = ngx.shared.dirty_flags
-- 设置数据为脏数据的接口
location /api/mark_dirty {
content_by_lua_block {
local key = ngx.var.arg_key
if not key then
ngx.exit(ngx.HTTP_BAD_REQUEST)
end
-- 设置脏数据标记,有效期60秒
dirty_flags:set(key, true, 60)
ngx.say("标记成功")
}
}
-- 获取数据的接口,带智能缓存失效
location /api/get_data {
content_by_lua_block {
local key = ngx.var.arg_key
if not key then
ngx.exit(ngx.HTTP_BAD_REQUEST)
end
-- 检查是否是脏数据
local is_dirty = dirty_flags:get(key)
-- 从Redis获取缓存
local redis = require "resty.redis"
local red = redis:new()
local ok, err = red:connect("127.0.0.1", 6379)
if not ok then
ngx.log(ngx.ERR, "连接Redis失败: ", err)
ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
end
local cached_data = red:get(key)
-- 如果是脏数据或者缓存不存在,则从数据库加载
if is_dirty or not cached_data then
-- 这里模拟从数据库加载数据
local db_data = load_from_database(key)
-- 更新Redis缓存
red:set(key, db_data)
-- 清除脏数据标记
dirty_flags:delete(key)
ngx.say(db_data)
else
ngx.say(cached_data)
end
-- 将Redis连接放回连接池
red:set_keepalive()
}
}
这个方案有几个巧妙之处:
- 使用ngx.shared.DICT作为脏数据标记存储,性能极高
- 标记有过期时间,避免因程序bug导致标记永远存在
- 即使标记丢失(比如Nginx重启),最多也只是60秒的数据不一致
三、进阶优化策略
基础方案虽然能用,但在高并发场景下还需要更多优化。下面介绍几个进阶策略:
3.1 批量标记与失效
当需要失效大量相关缓存时(比如用户信息更新导致所有包含该用户信息的页面都需要更新),逐个标记效率太低。我们可以设计一个批量标记方案:
-- 批量标记接口
location /api/batch_mark_dirty {
content_by_lua_block {
local prefix = ngx.var.arg_prefix
if not prefix then
ngx.exit(ngx.HTTP_BAD_REQUEST)
end
-- 使用Redis的SCAN命令找到所有匹配前缀的key
local redis = require "resty.redis"
local red = redis:new()
local ok, err = red:connect("127.0.0.1", 6379)
if not ok then
ngx.log(ngx.ERR, "连接Redis失败: ", err)
ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
end
-- 查找所有匹配的key
local cursor = "0"
local keys = {}
repeat
local res, err = red:scan(cursor, "MATCH", prefix.."*")
if not res then
ngx.log(ngx.ERR, "SCAN失败: ", err)
break
end
cursor = res[1]
for _, key in ipairs(res[2]) do
table.insert(keys, key)
end
until cursor == "0"
-- 批量设置脏数据标记
for _, key in ipairs(keys) do
dirty_flags:set(key, true, 60)
end
ngx.say("批量标记完成,共标记", #keys, "个key")
}
}
3.2 多级缓存失效
对于特别重要的数据,可以采用多级缓存策略。比如:
- 第一层:Nginx共享内存缓存(超快但容量小)
- 第二层:Redis缓存(速度较快容量较大)
- 第三层:数据库(速度慢但数据最全)
对应的失效策略也需要分层处理:
-- 多级缓存失效示例
location /api/update_data {
content_by_lua_block {
local key = ngx.var.arg_key
local value = ngx.var.arg_value
if not key or not value then
ngx.exit(ngx.HTTP_BAD_REQUEST)
end
-- 1. 更新数据库
update_database(key, value)
-- 2. 设置多级脏数据标记
-- Nginx共享字典标记
dirty_flags:set(key, true, 60)
-- Redis特殊标记(使用不同的命名空间)
local redis = require "resty.redis"
local red = redis:new()
local ok, err = red:connect("127.0.0.1", 6379)
if ok then
red:set("dirty:"..key, "1", "EX", 60)
red:set_keepalive()
end
ngx.say("更新成功,已标记缓存失效")
}
}
四、实战中的注意事项
在实际项目中应用这套方案时,有几个坑需要特别注意:
标记的粒度选择:不是所有数据都适合细粒度标记。对于频繁更新的数据,可以考虑按时间窗口批量失效。
失效风暴问题:当大量缓存同时失效时,可能导致数据库压力激增。解决方案是:
- 错峰失效:给失效操作添加随机延迟
- 提前加载:在缓存过期前主动刷新
分布式一致性:在集群环境下,需要确保所有节点都能感知到失效标记。可以通过Redis Pub/Sub实现跨节点通知:
-- 发布失效通知
local redis = require "resty.redis"
local red = redis:new()
red:connect("127.0.0.1", 6379)
red:publish("cache_invalidations", "user:123")
-- 订阅端
local function subscribe_invalidation()
local red_sub = redis:new()
red_sub:connect("127.0.0.1", 6379)
red_sub:subscribe("cache_invalidations")
while true do
local res, err = red_sub:read_reply()
if res then
local key = res[3]
dirty_flags:set(key, true, 60)
end
end
end
-- 启动订阅协程
ngx.timer.at(0, subscribe_invalidation)
- 监控与报警:缓存命中率、失效操作频率等指标需要重点监控。可以使用OpenResty的prometheus库暴露这些指标。
五、总结与最佳实践
经过上面的探讨,我们可以总结出几个智能缓存失效的最佳实践:
- 分层设计:根据数据重要性和访问频率设计多级缓存架构
- 失效策略多样化:结合时间过期、主动标记、事件通知等多种方式
- 降级方案:当缓存系统出现问题时,要有自动降级到直接读数据库的能力
- 监控完善:缓存命中率、加载延迟等核心指标必须监控
- 容量规划:合理设置缓存大小和过期时间,避免内存溢出
最后分享一个完整的实用代码片段,实现了带降级功能的智能缓存获取:
local function get_data_with_fallback(key)
-- 1. 检查本地缓存
local cache = ngx.ctx.local_cache or {}
if cache[key] then
return cache[key]
end
-- 2. 检查是否是脏数据
local is_dirty = dirty_flags:get(key)
-- 3. 尝试从Redis获取
local redis = require "resty.redis"
local red = redis:new()
local ok, err = red:connect("127.0.0.1", 6379)
if ok then
local cached_data = red:get(key)
red:set_keepalive()
if not is_dirty and cached_data then
cache[key] = cached_data
ngx.ctx.local_cache = cache
return cached_data
end
end
-- 4. 从数据库加载(带互斥锁避免缓存击穿)
local lock_key = "lock:"..key
local lock = require "resty.lock"
local locker = lock:new("locks")
local elapsed, err = locker:lock(lock_key)
if elapsed then
-- 再次检查,可能其他请求已经加载了
local cached_data = red:get(key)
if cached_data then
locker:unlock()
return cached_data
end
-- 从数据库加载
local ok, db_data = pcall(load_from_database, key)
if ok and db_data then
-- 更新Redis
pcall(red.set, red, key, db_data)
-- 清除脏数据标记
dirty_flags:delete(key)
-- 更新本地缓存
cache[key] = db_data
ngx.ctx.local_cache = cache
end
locker:unlock()
return db_data
else
-- 获取锁失败,降级策略
ngx.log(ngx.WARN, "获取锁失败,使用降级数据")
return get_fallback_data(key)
end
end
这套方案在我们电商平台的商品服务中运行良好,将缓存不一致的平均时间从原来的分钟级降低到了秒级,同时数据库负载下降了40%。希望这些经验对你有所帮助!
评论