exists + set 无法防穿透因三步非原子,lua脚本靠redis单线程串行执行才能真正兜住,但须严守“只做原子决策”边界,避免http/db调用。

为什么直接用 EXISTS + SET 无法防穿透
缓存穿透本质是大量请求查一个根本不存在的 key(比如恶意构造的负数 ID),导致请求全部打到后端 DB。你可能试过先 EXISTS 再查 DB,再写缓存——但这个“判断 → 查询 → 写入”三步不是原子的,中间仍可能有并发请求重复穿透。Lua 脚本在 Redis 单线程中执行,天然串行,这才是能真正兜住的关键。
常见错误是把整个业务逻辑塞进 Lua:比如在脚本里调用 HTTP、查 MySQL,这完全违背了 Lua 脚本只做“Redis 端原子决策”的定位——脚本执行时间一长,会阻塞其他命令,甚至触发 SCRIPT KILL 或 TIMEOUT 错误。
- 脚本只负责:检查 key 是否存在、是否为占位空值(如
"nil")、是否需要写入缓存 - DB 查询、序列化、业务逻辑必须由客户端完成
- 空值缓存建议用短 TTL(如 60s),避免长期占用内存
EVAL 中如何安全区分“真缺失”和“已缓存空值”
不能只靠 redis.call("GET", KEYS[1]) == false 判断缺失——因为 GET 对空值("nil" 字符串)和真正不存在的 key 都返回 false。必须用 TYPE 或 EXISTS 结合 GET 做二次确认。
推荐做法:先 EXISTS,为 0 表示真缺失;为 1 再 GET,若值等于预设空标记(如 "NULL"),说明已缓存空值,直接返回;否则是有效数据。
Redis 缓存和数据结构管理技能。通过自然语言操作 Redis,支持 String、Hash、List、Set、ZSet、Stream 等数据结构操作。当用户提到 Redis、缓存、消息队列、会话存储时使用此技能。
if redis.call("EXISTS", KEYS[1]) == 0 then
-- 真缺失:允许穿透,但需客户端后续写入(可选加布隆过滤器前置拦截)
return {0, "MISS"}
elseif redis.call("GET", KEYS[1]) == "NULL" then
-- 已缓存空值
return {1, "NULL"}
else
-- 有效数据
return {2, redis.call("GET", KEYS[1])}
end
- 返回数组结构便于客户端解析状态码
- 空值标记统一用字符串(如
"NULL"),不用nil(Lua 中nil在 Redis 返回时会被丢弃) - 不建议在脚本里自动写入空值——应由客户端控制何时写、写多久 TTL,避免误缓存
如何让空值写入也具备原子性且不阻塞主流程
客户端拿到“真缺失”响应后,要查 DB 并写缓存。这时如果直接 SET key value EX 60,并发请求仍可能在写入前再次进入穿透路径。解决方案是用 SET key value EX 60 NX:仅当 key 不存在时才设置,利用 Redis 对 NX 的原子保障。
但注意:NX 不适用于空值场景——多个请求同时发现缺失,只有一个能成功写入 "NULL",其余请求应等待或重试,而不是各自去查 DB。
- 推荐组合:先用
SET key "NULL" EX 60 NX尝试占位 - 若返回
1,说明你是第一个,去查 DB,再用SET key real_value EX 3600覆盖 - 若返回
0,说明别人已占位,此时可 sleep 后重试 GET,或直接返回默认值 - 避免在 Lua 脚本里调用
SET写空值——它会让脚本变重,且无法动态控 TTL
实际部署时最容易被忽略的三个点
Lua 脚本本身不校验参数类型,KEYS[1] 若传入空字符串或非字符串,会静默失败或报 ERR Error running script。生产环境必须在客户端层做 key 格式校验(如非空、长度限制、不含空格)。
- 脚本加载后用
SCRIPT LOAD缓存 SHA1,后续用EVALSHA执行,降低网络和解析开销 - Redis Cluster 下,所有
KEYS必须落在同一个 slot,否则报CROSSSLOT错误;key 名需带固定哈希标签,如"user:{123}" - 本地开发用
redis-cli --eval测试时,注意参数顺序:脚本文件、KEYS 用空格分隔、--后才是 ARGV
空值缓存只是兜底,长期看得配合布隆过滤器或查询参数合法性校验,否则攻击者绕过 key 构造仍可压垮 DB。










