Redis 缓存穿透怎么解决?踩坑 2 天,我把 3 种方案都试了一遍

上周线上出了个事故,凌晨 2 点被告警电话叫醒。打开 Grafana 一看,MySQL 的 QPS 从平时的 200 飙到了 8000+,CPU 直接打满。排查下来原因很离谱------有人拿一批根本不存在的用户 ID 疯狂请求我们的用户详情接口。

这些 ID 在 Redis 里查不到(因为压根不存在),于是每次请求都穿透到了 MySQL。Redis 形同虚设,MySQL 被打成了筛子。

这就是经典的缓存穿透问题。说实话这个概念面试背了无数遍,真到线上出事才发现自己只会嘴上说说。花了两天把几种方案都跑了一遍,把实操过程和踩的坑全记下来。

先搞清楚:穿透、击穿、雪崩,别搞混了

面试时这三个总被放一起问,但它们是完全不同的场景:

问题类型 触发条件 本质原因 危害程度
缓存穿透 查询的数据在 DB 中也不存在 恶意攻击 / 业务 bug ⭐⭐⭐⭐⭐
缓存击穿 热点 key 过期瞬间被大量并发请求 热点数据过期 ⭐⭐⭐
缓存雪崩 大量 key 同时过期 / Redis 宕机 过期时间设置不当 ⭐⭐⭐⭐

这篇只聊穿透。击穿和雪崩改天再写。

缓存穿透的请求链路

正常情况下,一个查询请求的链路是这样的:

graph LR A[客户端请求] --> B{Redis 有缓存?} B -->|命中| C[返回缓存数据] B -->|未命中| D[查询 MySQL] D --> E{DB 有数据?} E -->|有| F[写入 Redis + 返回] E -->|没有| G[返回空 / 错误] G -->|下次还会穿透!| B

问题出在最后那个环节:DB 也没数据时,什么都没往 Redis 写,下次同样的请求还是会打到 DB。攻击者用大量不存在的 key 来请求,Redis 就成了摆设。

方案一:缓存空值(最简单,但有坑)

最直觉的方案------DB 查不到数据时,也往 Redis 里写一个空值,下次就能被缓存拦住了。

java 复制代码
public User getUserById(Long userId) {
 String key = "user:" + userId;
 
 // 1. 先查 Redis
 String cached = redisTemplate.opsForValue().get(key);
 
 // 注意:这里要区分 "key不存在" 和 "key存在但值为空标记"
 if (cached != null) {
 if ("NULL".equals(cached)) {
 return null; // 命中空值缓存,直接返回
 }
 return JSON.parseObject(cached, User.class);
 }
 
 // 2. 查 MySQL
 User user = userMapper.selectById(userId);
 
 if (user != null) {
 // 正常数据,缓存 30 分钟
 redisTemplate.opsForValue().set(key, JSON.toJSONString(user), 30, TimeUnit.MINUTES);
 } else {
 // 关键:空值也缓存,但过期时间要短!
 redisTemplate.opsForValue().set(key, "NULL", 2, TimeUnit.MINUTES);
 }
 
 return user;
}

踩坑记录

坑 1:空值的过期时间不能太长。 我一开始图省事设了 30 分钟,结果业务那边新注册了一个用户,发现 30 分钟内怎么查都查不到。因为这个 ID 之前被缓存了空值,新数据进 DB 后缓存还没过期。最后改成了 2 分钟,算是在防护效果和数据一致性之间找了个平衡。

坑 2:攻击者如果每次用不同的随机 ID,这方案就废了。 假设攻击者每次请求都用一个新的随机 UUID,Redis 里会被灌入海量的空值 key,内存很快就爆了,而且每个新 ID 第一次还是会穿透到 DB。

所以缓存空值只能防「用少量固定 ID 反复请求」的场景,对随机 ID 攻击基本没用。

方案二:布隆过滤器(真正的银弹)

布隆过滤器的思路完全不同:在 Redis 前面再加一层,预先把所有合法的 ID 存进去。请求进来先问布隆过滤器「这个 ID 存在吗」,如果回答不存在,直接拒绝,连 Redis 都不用查。

graph LR A[客户端请求] --> B{布隆过滤器判断} B -->|一定不存在| C[直接返回空] B -->|可能存在| D{查 Redis} D -->|命中| E[返回缓存] D -->|未命中| F[查 MySQL] F --> G[写回 Redis + 返回]

Redis 4.0 之后可以用 RedisBloom 模块,操作起来很简单:

python 复制代码
import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

# 创建布隆过滤器,预计 100 万个元素,误判率 0.01%
r.execute_command('BF.RESERVE', 'user_filter', 0.0001, 1000000)

# 初始化:把所有已有用户 ID 灌进去
# 实际项目中这步一般在数据初始化脚本或定时任务里做
def init_bloom_filter():
 # 假设从 DB 批量拉取所有用户 ID
 all_user_ids = fetch_all_user_ids_from_db()
 pipe = r.pipeline()
 for uid in all_user_ids:
 pipe.execute_command('BF.ADD', 'user_filter', str(uid))
 pipe.execute()
 print(f"布隆过滤器初始化完成,共 {len(all_user_ids)} 个 ID")

# 查询时先过布隆过滤器
def get_user(user_id):
 # 第一关:布隆过滤器
 exists = r.execute_command('BF.EXISTS', 'user_filter', str(user_id))
 if not exists:
 # 布隆过滤器说不存在,那就一定不存在
 return None
 
 # 第二关:查 Redis 缓存
 cache_key = f"user:{user_id}"
 cached = r.get(cache_key)
 if cached:
 return json.loads(cached)
 
 # 第三关:查 DB
 user = db.query_user(user_id)
 if user:
 r.setex(cache_key, 1800, json.dumps(user))
 return user

布隆过滤器参数怎么选

这个我纠结了挺久,最后总结了一个参考表:

数据规模 误判率 占用内存(约) 适用场景
10 万 1% ~120 KB 小项目、内部系统
100 万 0.1% ~2.4 MB 中型业务
100 万 0.01% ~3.2 MB 对准确度要求高
1000 万 0.1% ~24 MB 大型业务
1 亿 0.01% ~320 MB 超大规模

误判率设太低会占更多内存,但布隆过滤器本身就很省内存。1 亿条数据 0.01% 误判率才 320MB,比把 1 亿个完整 ID 存 Redis 省太多了。

踩坑记录

坑 1:布隆过滤器不支持删除。 用户注销、数据删除后,这个 ID 在布隆过滤器里还是「存在」的。如果业务有删除需求,要么用 Cuckoo Filter(CF.RESERVE),要么定期重建布隆过滤器。我选了后者,每天凌晨跑个定时任务重建一次。

坑 2:新增数据别忘了同步加进去。 新用户注册后,除了写 DB,还得 BF.ADD 到布隆过滤器。我一开始漏了这步,导致新注册用户查自己信息时被布隆过滤器拦掉了,debug 了半天才想起来。

python 复制代码
# 新用户注册时的完整流程
def register_user(user_data):
 # 1. 写入数据库
 user_id = db.insert_user(user_data)
 
 # 2. 同步写入布隆过滤器(别漏了!)
 r.execute_command('BF.ADD', 'user_filter', str(user_id))
 
 # 3. 预热缓存(可选)
 r.setex(f"user:{user_id}", 1800, json.dumps(user_data))
 
 return user_id

坑 3:Redis 重启后布隆过滤器数据会丢。 用 RedisBloom 模块的话,布隆过滤器的数据跟普通 key 一样会持久化到 RDB/AOF,没问题。但如果用的是内存实现的布隆过滤器(比如 Guava 的 BloomFilter),进程重启就没了,必须重新构建。

方案三:接口层限流 + 参数校验(别忘了最基本的)

前两个方案都是在缓存层做防御,但最容易被忽略的往往是最前面的入口层。

java 复制代码
@RestController
public class UserController {

 @GetMapping("/api/user/{id}")
 public Result<User> getUser(@PathVariable("id") Long id) {
 // 1. 参数合法性校验 ------ 最基本但很多人不做
 if (id == null || id <= 0 || id > 999999999L) {
 return Result.fail("参数非法");
 }
 
 // 2. 限流:同一个 IP 每秒最多 10 次请求
 // 这里用 Guava 的 RateLimiter 或者 Redis + Lua 实现都行
 
 // 3. 走正常的缓存查询逻辑
 User user = userService.getUserById(id);
 return Result.success(user);
 }
}

我们的用户 ID 是自增的 Long 类型,合法范围很明确。攻击者用的那批 ID 里有负数、有超大数、甚至有带字母的字符串(被框架自动转换失败后报了一堆 400)。如果一开始就做了参数校验,至少能挡掉一大半无效请求。

三种方案怎么选

维度 缓存空值 布隆过滤器 接口层校验 + 限流
实现难度 ⭐⭐⭐ ⭐⭐
防随机 ID 攻击 部分 ✅
内存开销 可能很大 很小
数据一致性 有短暂不一致 不支持删除 无影响
适用场景 key 空间有限 key 空间极大 所有场景

最终方案是三层都上

  1. 最外层做参数校验和 IP 限流,挡掉明显的垃圾请求
  2. 中间用布隆过滤器,拦截不存在的 ID
  3. 最后缓存空值兜底,处理布隆过滤器的误判漏网

三层加上之后,同样的攻击流量再来,MySQL 的 QPS 基本没波动。

小结

缓存穿透这个问题,面试题和实战差距挺大。面试时说一句「用布隆过滤器」就过了,实际上布隆过滤器的初始化、新数据同步、重启恢复、参数调优全是坑。

我现在的实践是:防御要做多层,别指望一招鲜。参数校验是第一道防线但很多人懒得写,布隆过滤器是核心防御但运维成本不低,缓存空值是兜底但不能单独用。三层叠起来才靠谱。

对了,如果你还在用 Redis 6 以下的版本,RedisBloom 模块装起来比较折腾。Redis 7 之后模块管理好了很多,建议直接上 Redis 7 + RedisBloom 的组合,省不少事。

相关推荐
wuhen_n2 小时前
ReAct模式理论:让AI学会“思考-行动-观察”
前端·javascript·ai编程
wuhen_n2 小时前
错误处理与容错机制:让AI学会“从失败中学习”
前端·javascript·ai编程
踩着两条虫3 小时前
VTJ.PRO 在线应用开发平台的LLM模型管理与配置
低代码·llm·ai编程
踩着两条虫3 小时前
VTJ.PRO 在线应用开发平台的Agent与LLM集成
低代码·agent·ai编程
峡谷电光马仔3 小时前
要成为AI的主人,而不是被它所绑架
人工智能·chatgpt·ai编程·ai红线·清醒的使用ai
IvanCodes3 小时前
ClaudeCode 源码泄露,事情没那么简单
人工智能·ai编程·claude
weixin_425023004 小时前
Spring Boot 2.7+JDK8+WebSocket对接阿里云百炼Qwen3.5-Plus 实现流式对话+思考过程实时展示
java·spring boot·websocket·ai编程
candyTong4 小时前
Claude Code 是怎么跑起来的:从 Agent Loop 理解代理循环实现
前端·后端·ai编程
AwesomeDevin12 小时前
AI时代,我们的任务不应沉溺于与 AI 聊天 - 🤔 从“对话式编程”迈向“数字软件工厂”
ai编程