Redis 缓存穿透怎么解决?3 种方案实测 + 踩坑全记录(2026)

张开发
2026/5/17 12:00:17 15 分钟阅读
Redis 缓存穿透怎么解决?3 种方案实测 + 踩坑全记录(2026)
上周线上出了事故凌晨两点被报警电话叫醒——某个查询接口的 QPS 突然飙到平时的 20 倍MySQL 直接扛不住了慢查询堆满了。排查下来是有人拿一堆不存在的 ID 疯狂请求所有请求全部穿透 Redis 打到了数据库。缓存穿透这个概念谁都知道但真正线上被打一次才知道有多疼。事后把三种主流方案都实测了一遍踩了不少坑这篇把完整过程分享出来。先说结论缓存穿透 查询的 key 在 Redis 和数据库中都不存在每次请求都打穿到 DB。这和缓存击穿热 key 过期、缓存雪崩大量 key 同时过期是完全不同的问题。三种方案的适用场景直接列一下缓存空值最简单适合偶发性穿透、数据量不大的场景布隆过滤器内存占用小适合海量数据场景但有误判率请求校验 限流防恶意攻击治标不治本但必须做最终选择是布隆过滤器 缓存空值组合拳下面展开说。为什么会出现缓存穿透正常的缓存流程大家都熟命中未命中存在不存在客户端请求Redis 有缓存?返回缓存数据查询 MySQL数据存在?写入 Redis 返回数据返回空 / 直接返回下次请求还是打到 DB问题出在最后那个循环——key 不存在时不会写缓存导致每次都穿透到 DB。如果有人故意拿user_id -1或者一堆随机 UUID 来查Redis 形同虚设DB 直接被打穿。我那次线上事故攻击者用的是递增的负数 ID简单粗暴但很有效。方案一缓存空值Null Caching最直觉的方案查询结果为空也缓存起来设一个较短的 TTL。代码实现importredisimportjson rredis.Redis(host127.0.0.1,port6379,db0,decode_responsesTrue)# 空值标记不要用 None 或空字符串容易和正常值混淆EMPTY_CACHE_FLAGEMPTYEMPTY_CACHE_TTL120# 空值缓存 2 分钟NORMAL_CACHE_TTL3600# 正常数据缓存 1 小时defget_user_by_id(user_id:int)-dict|None:cache_keyfuser:{user_id}# 1. 先查 Rediscachedr.get(cache_key)ifcachedisnotNone:ifcachedEMPTY_CACHE_FLAG:# 命中空值缓存直接返回 None不打 DBreturnNonereturnjson.loads(cached)# 2. Redis 没有查 DBuserquery_user_from_db(user_id)ifuserisNone:# 3. DB 也没有缓存空值r.setex(cache_key,EMPTY_CACHE_TTL,EMPTY_CACHE_FLAG)returnNone# 4. DB 有数据正常缓存r.setex(cache_key,NORMAL_CACHE_TTL,json.dumps(user))returnuserdefquery_user_from_db(user_id:int)-dict|None:模拟数据库查询# 实际项目中这里是 SQL 查询# SELECT * FROM users WHERE id %spass实测效果用 wrk 模拟了 1000 个不存在的 ID 并发请求不加空值缓存MySQL QPS 直接拉满到 3000响应时间飙到 2s加了空值缓存只有第一波请求会打到 DB之后全部被 Redis 挡住DB QPS 降到个位数踩坑点坑 1空值标记选错了。一开始直接缓存空字符串结果有个接口正好会返回空字符串作为合法值线上出了 bug。后来改成特殊标记字符串在序列化层统一处理。坑 2TTL 设太长导致数据不一致。如果一个用户刚注册ID 之前被缓存了空值那在 TTL 过期前用户查不到自己的数据。解决方案是写入数据库时主动删除对应的空值缓存defcreate_user(user_data:dict)-int:user_idinsert_user_to_db(user_data)# 创建成功后主动删除可能存在的空值缓存r.delete(fuser:{user_id})returnuser_id坑 3内存爆炸。攻击者用随机 key 来打时每个 key 都会在 Redis 里存一条空值缓存Redis 内存会被撑爆。这个方案对随机 key 攻击基本无效。方案二布隆过滤器Bloom Filter布隆过滤器的核心思想用一个 bit 数组 多个 hash 函数快速判断一个元素「一定不存在」或「可能存在」。注意这个「可能存在」——布隆过滤器有误判率会把不存在的判断为存在false positive但不会把存在的判断为不存在no false negative。这个特性刚好适合缓存穿透场景。架构变化一定不存在可能存在命中未命中客户端请求布隆过滤器判断直接返回空, 不查 DBRedis 有缓存?返回缓存数据查询 MySQL写入 Redis 返回代码实现Redis 4.0 支持 RedisBloom 模块用起来很方便。如果 Redis 没装这个模块也可以用 Python 的pybloom_live库在应用层实现但更推荐用 Redis 原生的省得维护内存中的状态。importredis rredis.Redis(host127.0.0.1,port6379,db0,decode_responsesTrue)BLOOM_KEYbf:user_idsdefinit_bloom_filter(): 初始化布隆过滤器 预计元素数量 100万误判率 0.011% 实际内存占用约 1.2MB非常省 try:# BF.RESERVE key error_rate capacityr.execute_command(BF.RESERVE,BLOOM_KEY,0.01,1000000)print(布隆过滤器创建成功)exceptredis.ResponseErrorase:ifitem existsinstr(e):print(布隆过滤器已存在跳过创建)else:raisedefload_existing_ids():把数据库中已有的 ID 全量灌入布隆过滤器# 分批加载别一次性全捞出来batch_size5000offset0whileTrue:idsquery_user_ids_batch(offset,batch_size)ifnotids:break# BF.MADD 批量添加比循环 BF.ADD 快很多r.execute_command(BF.MADD,BLOOM_KEY,*ids)offsetbatch_sizeprint(f已加载{offset}条 ID)defget_user_by_id(user_id:int)-dict|None:cache_keyfuser:{user_id}# 1. 布隆过滤器前置拦截existsr.execute_command(BF.EXISTS,BLOOM_KEY,user_id)ifnotexists:# 布隆过滤器说不存在那就一定不存在returnNone# 2. 可能存在走正常缓存逻辑cachedr.get(cache_key)ifcachedisnotNone:returnjson.loads(cached)# 3. 查 DBuserquery_user_from_db(user_id)ifuser:r.setex(cache_key,3600,json.dumps(user))returnuserdefcreate_user(user_data:dict)-int:新增用户时同步更新布隆过滤器user_idinsert_user_to_db(user_data)# 别忘了把新 ID 加入布隆过滤器r.execute_command(BF.ADD,BLOOM_KEY,user_id)returnuser_id实测效果同样的 1000 个不存在的 ID 并发压测DB 请求数0。全部被布隆过滤器拦截了Redis 内存增量1.2MB100 万数据量下比方案一的空值缓存动不动几十 MB 省多了额外延迟BF.EXISTS 命令耗时约 0.1ms几乎可以忽略踩坑点坑 1布隆过滤器不支持删除。标准布隆过滤器只能添加不能删除。删了一条用户数据布隆过滤器里还是会判断为「可能存在」结果是多查一次 DB 返回空。删除频繁的业务可以考虑 Cuckoo FilterCF.RESERVE/CF.ADD/CF.DELRedisBloom 也支持。坑 2全量初始化太慢。线上 800 万用户 ID第一次灌数据花了快 3 分钟。后来改成 pipeline 批量操作 后台异步加载不阻塞主服务启动defload_existing_ids_with_pipeline():用 pipeline 批量加载速度提升 10 倍batch_size5000offset0whileTrue:idsquery_user_ids_batch(offset,batch_size)ifnotids:breakpiper.pipeline()foruidinids:pipe.execute_command(BF.ADD,BLOOM_KEY,uid)pipe.execute()offsetbatch_size坑 3误判率的取舍。误判率设 1% 意味着每 100 个不存在的 key有 1 个会穿透到 DB。对穿透零容忍的话可以调到 0.0010.1%但内存会翻倍。最终选了 0.01再配合方案一的空值缓存兜底效果不错。方案三请求校验 限流这个方案严格来说不算缓存层的解决方案但在防恶意攻击场景下是必须做的。importrefromfunctoolsimportwrapsfromcollectionsimportdefaultdictimporttime# 简单的滑动窗口限流request_counterdefaultdict(list)defrate_limit(max_requests100,window_seconds60):每个 IP 每分钟最多 100 次请求defdecorator(func):wraps(func)defwrapper(*args,**kwargs):client_ipget_client_ip()nowtime.time()# 清理过期记录request_counter[client_ip][tfortinrequest_counter[client_ip]ifnow-twindow_seconds]iflen(request_counter[client_ip])max_requests:return{error:rate limited},429request_counter[client_ip].append(now)returnfunc(*args,**kwargs)returnwrapperreturndecoratordefvalidate_user_id(user_id)-bool:参数校验在缓存之前就拦截明显非法的请求ifnotisinstance(user_id,int):returnFalseifuser_id0:# 我们的 ID 都是正整数returnFalseifuser_id10_000_000_000:# 业务上不可能有这么大的 IDreturnFalsereturnTruerate_limit(max_requests100,window_seconds60)defapi_get_user(user_id):# 1. 参数校验ifnotvalidate_user_id(user_id):return{error:invalid user_id},400# 2. 正常走缓存逻辑userget_user_by_id(user_id)ifuserisNone:return{error:user not found},404returnuser,200就是在最外层把明显不合法的请求干掉减少穿透到缓存层和 DB 层的请求量。最终方案组合拳单一方案都有短板线上实际是这么组合的非法请求合法请求一定不存在可能存在命中未命中数据存在数据不存在客户端请求参数校验 IP 限流直接拒绝 400/429布隆过滤器判断返回 404Redis 缓存返回数据查 MySQL写缓存 返回缓存空值 2min 返回 404三层防线最外层参数校验 限流拦截恶意流量中间层布隆过滤器拦截不存在的 key兜底层空值缓存处理布隆过滤器误判导致的穿透上线后同样的攻击流量下DB QPS 从 3000 降到了个位数Redis 内存增量控制在 2MB 以内。三种方案对比维度缓存空值布隆过滤器请求校验限流实现复杂度⭐ 低⭐⭐ 中⭐ 低内存开销高随攻击 key 增长低固定大小无对随机 key 攻击❌ 基本无效✅ 有效⚠️ 部分有效数据一致性需处理 TTL不支持删除无影响误判率无有可控无推荐场景穿透量小key 范围有限海量数据key 范围大所有场景必选项小结缓存穿透说起来简单线上真碰到了排查 修复 验证一套下来搞了快两天。几个关键经验限流是底线不管用不用布隆过滤器请求校验和限流必须做布隆过滤器 空值缓存组合性价比最高单用哪个都有明显短板布隆过滤器初始化要考虑增量更新的问题新增数据别忘了同步 BF.ADD空值缓存的 TTL 别设太长2-5 分钟够了设长了数据一致性问题会让你头疼面试被问到「缓存穿透和缓存击穿的区别」别只背概念了——能讲出线上踩坑经历和组合方案的细节比背八股管用多了。

更多文章