上周线上出了个事故,凌晨 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 空间极大 | 所有场景 |
最终方案是三层都上:
- 最外层做参数校验和 IP 限流,挡掉明显的垃圾请求
- 中间用布隆过滤器,拦截不存在的 ID
- 最后缓存空值兜底,处理布隆过滤器的误判漏网
三层加上之后,同样的攻击流量再来,MySQL 的 QPS 基本没波动。
小结
缓存穿透这个问题,面试题和实战差距挺大。面试时说一句「用布隆过滤器」就过了,实际上布隆过滤器的初始化、新数据同步、重启恢复、参数调优全是坑。
我现在的实践是:防御要做多层,别指望一招鲜。参数校验是第一道防线但很多人懒得写,布隆过滤器是核心防御但运维成本不低,缓存空值是兜底但不能单独用。三层叠起来才靠谱。
对了,如果你还在用 Redis 6 以下的版本,RedisBloom 模块装起来比较折腾。Redis 7 之后模块管理好了很多,建议直接上 Redis 7 + RedisBloom 的组合,省不少事。