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

5 阅读7分钟

上周线上出了个事故,凌晨 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 里写一个空值,下次就能被缓存拦住了。

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 模块,操作起来很简单:

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 了半天才想起来。

# 新用户注册时的完整流程
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),进程重启就没了,必须重新构建。

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

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

@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 的组合,省不少事。