Redis 缓存穿透怎么解决?踩坑 2 天,我总结了 4 种实战方案
上周五线上告警疯了,监控群一分钟弹了几十条消息。排查下来是缓存穿透——大量请求查的都是数据库里根本不存在的数据,Redis 全部 miss,请求直接打到 MySQL,连接池瞬间被打满。
干了这么多年后端,缓存穿透这种"教科书级别"的问题,真到生产环境还是能把人搞崩溃。花了两天时间把方案梳理清楚、代码跑通、压测验证,把实战经验都整理出来。
先搞清楚:缓存穿透和缓存击穿、雪崩的区别
很多人分不清这仨,面试也经常问。先用一张图搞清楚:
三者的区别:
| 问题 | 核心原因 | 危害 | 典型场景 |
|---|---|---|---|
| 缓存穿透 | 查询的数据在 DB 中根本不存在 | 每次请求都打到 DB | 恶意攻击、无效 ID 查询 |
| 缓存击穿 | 热点 Key 过期瞬间被大量并发请求 | 瞬时大量请求打到 DB | 秒杀商品缓存过期 |
| 缓存雪崩 | 大量 Key 同时过期 | DB 瞬时压力暴增 | 批量设置相同 TTL |
我这次遇到的就是穿透——有人在疯狂刷一个不存在的用户 ID,每次请求 Redis 都查不到,然后去查 MySQL,MySQL 也查不到,但也不缓存这个"空结果",下一次请求又来一遍。
方案一:缓存空值(最简单,但有坑)
最直觉的方案:DB 查不到数据时,也在 Redis 里存一个空值,这样下次就能命中缓存了。
public User getUserById(Long id) {
String key = "user:" + id;
// 1. 先查 Redis
String cached = redisTemplate.opsForValue().get(key);
// 注意:这里要区分 "没缓存" 和 "缓存了空值"
if (cached != null) {
if ("NULL".equals(cached)) {
return null; // 缓存的空值,直接返回
}
return JSON.parseObject(cached, User.class);
}
// 2. 查 DB
User user = userMapper.selectById(id);
if (user != null) {
// 正常数据,缓存 30 分钟
redisTemplate.opsForValue().set(key, JSON.toJSONString(user), 30, TimeUnit.MINUTES);
} else {
// 关键:空值也缓存,但 TTL 要短!
redisTemplate.opsForValue().set(key, "NULL", 2, TimeUnit.MINUTES);
}
return user;
}
踩坑:空值的 TTL 不能设太长。我一开始脑抽设了 30 分钟跟正常数据一样,结果运营那边刚注册的新用户死活查不到——注册前被查过一次,空值缓存还没过期。2-5 分钟比较合适。
另一个坑:攻击者用随机 ID 疯狂刷,每个 ID 都不一样,Redis 里会堆满空值 Key,内存直接炸。这个方案只适合 ID 范围可控的场景,扛不住恶意攻击。
方案二:布隆过滤器(面对恶意攻击的正解)
布隆过滤器用很少的内存判断一个元素"一定不存在"或"可能存在"。把所有合法的 ID 在启动时(或数据变更时)写入布隆过滤器,请求来了先过滤一道,不存在的直接拒掉。
Redis 从 4.0 开始通过 RedisBloom 模块原生支持布隆过滤器:
import redis
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
# 创建布隆过滤器:预计 100 万元素,误判率 0.01%
# 实际内存占用约 2.3MB,非常省
r.execute_command('BF.RESERVE', 'user_filter', 0.0001, 1000000)
# 批量灌入已有的用户 ID(启动时或定时任务跑)
def init_bloom_filter():
# 假设从 DB 分批捞所有用户 ID
batch_size = 5000
offset = 0
while True:
ids = db.execute(f"SELECT id FROM users LIMIT {batch_size} OFFSET {offset}")
if not ids:
break
pipe = r.pipeline()
for uid in ids:
pipe.execute_command('BF.ADD', 'user_filter', str(uid))
pipe.execute()
offset += batch_size
print(f"布隆过滤器初始化完成,共加载 {offset} 个 ID")
# 查询前先过滤
def get_user(user_id: int):
# 布隆过滤器判断
exists = r.execute_command('BF.EXISTS', 'user_filter', str(user_id))
if not exists:
# 一定不存在,直接返回
return None
# 可能存在,走正常缓存逻辑
cache_key = f"user:{user_id}"
cached = r.get(cache_key)
if cached:
if cached == "NULL":
return None
return json.loads(cached)
user = db.get_user_by_id(user_id)
if user:
r.setex(cache_key, 1800, json.dumps(user))
else:
# 布隆过滤器说可能存在但实际不存在(误判)
r.setex(cache_key, 120, "NULL")
return user
踩坑记录:
-
布隆过滤器不支持删除。用户注销了,布隆过滤器里还有这个 ID,不过没关系,最多就是多查一次 DB,配合空值缓存就行。如果业务上确实需要删除,可以用 Cuckoo Filter(
CF.RESERVE),内存占用会大一些。 -
新增数据记得同步写入布隆过滤器。我一开始只在启动时初始化,结果新注册的用户全被拦住了……在用户注册接口里加一行
BF.ADD就好。 -
误判率别设太低。0.01% 已经足够了,设成 0.0001% 内存翻倍但实际效果差不多。
方案三:接口层参数校验(最容易被忽略的一层)
说起来丢人,我排查了半天才发现——攻击者用的 ID 是负数和 0。我们的用户 ID 是自增长的正整数,直接在接口层拦掉不就完了?
@GetMapping("/user/{id}")
public Result<User> getUser(@PathVariable Long id) {
// 参数校验:ID 必须为正整数
if (id == null || id <= 0) {
return Result.fail("无效的用户ID");
}
// 业务上 ID 不会超过某个范围(根据实际情况设)
if (id > 100_000_000L) {
return Result.fail("无效的用户ID");
}
return Result.ok(userService.getUserById(id));
}
这一层太基础了以至于很多人忘了做。参数校验做好能挡掉一大半无效请求,而且零成本。
方案四:限流 + 熔断(最后一道防线)
前面三个方案是防穿透本身,但真遇到大规模恶意攻击,还需要限流兜底。我用的 Sentinel,也可以用 Guava RateLimiter:
// 基于 IP 的限流:每个 IP 每秒最多 10 次查询
private final LoadingCache<String, RateLimiter> rateLimiters = CacheBuilder.newBuilder()
.maximumSize(10000)
.expireAfterAccess(5, TimeUnit.MINUTES)
.build(CacheLoader.from(key -> RateLimiter.create(10.0)));
@GetMapping("/user/{id}")
public Result<User> getUser(@PathVariable Long id, HttpServletRequest request) {
String clientIp = getClientIp(request);
// 限流检查
if (!rateLimiters.get(clientIp).tryAcquire()) {
return Result.fail(429, "请求太频繁,请稍后再试");
}
// 参数校验
if (id == null || id <= 0 || id > 100_000_000L) {
return Result.fail("无效的用户ID");
}
return Result.ok(userService.getUserById(id));
}
我最终用的组合方案
生产环境我不会只用一种方案,分层防御:
四层防线,越往后过滤到的请求越少。正常业务 99% 的请求在 Redis 那层就返回了,布隆过滤器挡掉恶意请求,参数校验和限流负责兜底。
不同方案怎么选
| 方案 | 实现成本 | 适用场景 | 缺点 |
|---|---|---|---|
| 缓存空值 | ⭐ 低 | ID 范围可控、偶发穿透 | 随机 ID 攻击时内存爆炸 |
| 布隆过滤器 | ⭐⭐ 中 | 恶意攻击、大规模穿透 | 不支持删除、有微小误判率 |
| 参数校验 | ⭐ 低 | 所有场景(必做) | 只能挡住明显非法参数 |
| 接口限流 | ⭐⭐ 中 | 所有场景(兜底) | 不能从根本上解决穿透 |
| 组合方案 | ⭐⭐⭐ 高 | 生产环境推荐 | 需要维护多层逻辑 |
小结
缓存穿透说起来简单,真碰上了还是挺糟心的。我的经验:参数校验和限流是基本功,缓存空值够应付大部分场景,布隆过滤器是对抗恶意攻击的杀手锏。
另外提一嘴,Redis 7.x 对布隆过滤器的支持更完善了,如果你还在用 Redis 5.x 记得先升级或者装 RedisBloom 模块。
这篇整理完也算是给自己做个备忘,下次线上再出问题不至于又排查半天。有遇到过类似坑的欢迎评论区交流。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)