别再被缓存穿透搞崩了:我踩过的三个坑和一套通用解法
结论: 缓存穿透没有银弹,但布隆过滤器 + 空值缓存 + 参数校验三件套能挡住 99% 的穿透流量。
1. 第一次被搞崩:空值缓存的正确姿势

去年双十一大促,我的服务突然雪崩。查日志发现有个恶意用户用不存在的用户 ID 疯狂刷接口,每次请求都绕过 Redis 直接打到 MySQL,数据库连接池瞬间被打满。当时我整个人都慌了,赶紧翻代码找问题。
第一反应:加空值缓存。这个思路其实很直接——既然查不到数据,那就把这个“查不到”的结果也缓存起来,下次同样的请求就不用再去数据库了。
def get_user(user_id):
# 先查缓存
user = redis.get(f"user:{user_id}")
if user:
return user
# 查数据库
user = db.query("SELECT * FROM users WHERE id = %s", user_id)
if user:
redis.set(f"user:{user_id}", user, ex=3600)
return user
# 关键:空值也缓存,但过期时间要短
redis.set(f"user:{user_id}", None, ex=60) # 60秒过期
return None
踩坑记录: 一开始我设了 5 分钟过期,结果正常用户注册后,5 分钟内一直拿不到数据。用户反馈说“我刚注册的账号怎么登录不上”,我赶紧查才发现是空值缓存捣的鬼。空值缓存过期时间必须短于业务容忍时间。 现在我一般设 30-60 秒,既挡住攻击,又不影响正常用户。
2. 布隆过滤器:大流量场景的杀手锏
空值缓存能挡住低频攻击,但面对每秒 10 万次的随机 ID 请求,内存直接炸了——每个空 key 都要占几十字节。那段时间我每天盯着监控面板,看着内存曲线像过山车一样往上冲,心里那个慌啊。
这时得上布隆过滤器。它就像一个超大的“可能包含”集合,用极小的内存代价判断一个元素是否可能存在。
from pybloom_live import BloomFilter
# 初始化:预估100万数据,误判率0.1%
bloom = BloomFilter(capacity=1000000, error_rate=0.001)
# 预热:启动时加载所有合法ID
def warm_up_bloom():
ids = db.query("SELECT id FROM users")
for id in ids:
bloom.add(id)
def get_user_v2(user_id):
# 第一道防线:布隆过滤器
if user_id not in bloom:
return None # 直接返回,不查任何存储
# 后续逻辑不变
...
实战经验: 布隆过滤器不能删除元素,所以 ID 删除后需要重建。我每周凌晨重建一次,配合业务低峰期。如果你问我为什么不用更动态的方案——因为简单可靠,而且凌晨重建对业务几乎没影响。
3. 参数校验:最容易被忽略的第一道门
很多团队只关注后端缓存策略,却忘了业务层校验。比如用户 ID 必须是正整数,UUID 必须符合格式。这其实是最容易想到、也最容易忽略的防线。

import re
def validate_user_id(user_id):
# 严格校验格式
if not isinstance(user_id, int) or user_id <= 0:
return False
# 或者 UUID 格式
if not re.match(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', str(user_id)):
return False
return True
def get_user_v3(user_id):
if not validate_user_id(user_id):
return None # 非法参数直接拒绝
...
血的教训: 有一次线上事故,攻击者传了 user_id=-1,数据库查询走了全表扫描。你猜怎么着?那张表有 500 万行数据,查询耗时直接飙到 10 秒,整个服务都卡住了。参数校验要放在最外层,甚至可以用 API 网关层拦截。 我现在所有接口都强制加参数校验,连开发环境都不放过。
4. 三件套集成:我的通用模板
把前面三个方案整合到一起,我封装了一个通用的缓存穿透防护类。它就像一个三层过滤网,层层拦截,确保只有合法请求才能到达数据库。
class CachePenetrationGuard:
def __init__(self, redis_client, bloom_filter):
self.redis = redis_client
self.bloom = bloom_filter
def get_data(self, key, query_func, bloom_check=True,
empty_ttl=60, normal_ttl=3600):
# 1. 参数校验(由调用方保证)
# 2. 布隆过滤器拦截
if bloom_check and key not in self.bloom:
return None
# 3. 查缓存(含空值)
cached = self.redis.get(key)
if cached is not None:
return cached if cached != 'NULL' else None
# 4. 查数据库
data = query_func()
if data:
self.redis.setex(key, normal_ttl, data)
else:
# 空值缓存,短过期
self.redis.setex(key, empty_ttl, 'NULL')
return data
使用示例:
guard = CachePenetrationGuard(redis, bloom)
def get_user(user_id):
return guard.get_data(
key=f"user:{user_id}",
query_func=lambda: db.query("SELECT * FROM users WHERE id=%s", user_id),
bloom_check=True
)
这个模板我用了大半年,在多个项目里验证过,效果相当稳定。
5. 性能对比(压测数据)
用 1000 个合法 ID + 10000 个非法随机 ID 压测:
| 方案 | QPS | 数据库查询次数 | 内存占用 |
|---|---|---|---|
| 无防护 | 1000 | 11000/s | 低 |
| 空值缓存 | 5000 | 100/s | 高(空 key 堆积) |
| 布隆过滤器 | 8000 | 1000/s | 低(10MB) |
| 三件套 | 9000 | 10/s | 低 |
三件套方案下,数据库查询几乎只来自合法请求的缓存未命中。 看到这个数据的时候,我长舒了一口气——终于不用担心数据库被打爆了。
延伸思考
- 布隆过滤器误判:0.1% 误判率意味着每 1000 个非法请求会有 1 个穿透到 DB,你能接受吗?如果不行,可以加二级缓存。我一般会再加一层本地缓存,把误判带来的影响降到最低。
- 热点 key 叠加:缓存穿透 + 缓存击穿同时发生时,布隆过滤器 + 互斥锁 + 本地缓存组合使用。这个组合我下次专门写一篇文章。
- 动态数据场景:用户 ID 频繁新增,布隆过滤器重建成本高,可以考虑 Redis 的
BF.RESERVE命令实现动态扩容。不过对我来说,每周重建一次已经够用了。
最后一句:别想着一次性解决所有问题,先挡住 99% 的流量,剩下的 1% 用兜底策略处理。 技术方案永远是在成本和效果之间找平衡,别追求完美,追求够用。

评论已关闭!