上周五线上告警疯了,监控群一分钟弹了几十条消息。排查下来是缓存穿透——大量请求查的都是数据库里根本不存在的数据,Redis 全部 miss,请求直接打到 MySQL,连接池瞬间被打满。

干了这么多年后端,缓存穿透这种"教科书级别"的问题,真到生产环境还是能把人搞崩溃。花了两天时间把方案梳理清楚、代码跑通、压测验证,把实战经验都整理出来。

先搞清楚:缓存穿透和缓存击穿、雪崩的区别

很多人分不清这仨,面试也经常问。先用一张图搞清楚:

没有

没有

客户端请求

Redis 有缓存?

返回缓存数据

DB 有数据?

缓存击穿/雪崩的问题域

缓存穿透!请求打到DB但查不到

DB 也返回空

下次还会继续穿透

写入缓存后返回

三者的区别:

问题 核心原因 危害 典型场景
缓存穿透 查询的数据在 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 也不查 DB

查 Redis

返回缓存

查 DB

结果写入 Redis

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

踩坑记录:

  1. 布隆过滤器不支持删除。用户注销了,布隆过滤器里还有这个 ID,不过没关系,最多就是多查一次 DB,配合空值缓存就行。如果业务上确实需要删除,可以用 Cuckoo Filter(CF.RESERVE),内存占用会大一些。

  2. 新增数据记得同步写入布隆过滤器。我一开始只在启动时初始化,结果新注册的用户全被拦住了……在用户注册接口里加一行 BF.ADD 就好。

  3. 误判率别设太低。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));
}

我最终用的组合方案

生产环境我不会只用一种方案,分层防御:

超限

通过

非法

合法

一定不存在

可能存在

命中

未命中

请求进来

第1层: 接口限流 - IP限频

返回 429

第2层: 参数校验 - ID范围检查

返回 400

第3层: 布隆过滤器 - 判断ID是否存在

返回空

第4层: Redis 缓存 - 含空值缓存

返回结果

查 DB

结果写入 Redis

四层防线,越往后过滤到的请求越少。正常业务 99% 的请求在 Redis 那层就返回了,布隆过滤器挡掉恶意请求,参数校验和限流负责兜底。

不同方案怎么选

方案 实现成本 适用场景 缺点
缓存空值 ⭐ 低 ID 范围可控、偶发穿透 随机 ID 攻击时内存爆炸
布隆过滤器 ⭐⭐ 中 恶意攻击、大规模穿透 不支持删除、有微小误判率
参数校验 ⭐ 低 所有场景(必做) 只能挡住明显非法参数
接口限流 ⭐⭐ 中 所有场景(兜底) 不能从根本上解决穿透
组合方案 ⭐⭐⭐ 高 生产环境推荐 需要维护多层逻辑

小结

缓存穿透说起来简单,真碰上了还是挺糟心的。我的经验:参数校验和限流是基本功,缓存空值够应付大部分场景,布隆过滤器是对抗恶意攻击的杀手锏。

另外提一嘴,Redis 7.x 对布隆过滤器的支持更完善了,如果你还在用 Redis 5.x 记得先升级或者装 RedisBloom 模块。

这篇整理完也算是给自己做个备忘,下次线上再出问题不至于又排查半天。有遇到过类似坑的欢迎评论区交流。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐