
个人主页 for_ever_love__ 欢迎各位大佬莅临其他栏目: 大模型开发从0到1 其他栏目: iOS项目总结大全 其他栏目: 我想学python了 其他栏目: iOS UI 文章目录缓存穿透、击穿、雪崩三个经典问题与完整解决方案一、先分清三个概念二、缓存穿透2.1 成因2.2 解决方案一缓存空值2.3 解决方案二布隆过滤器2.4 解决方案三参数校验 限流2.5 三种方案对比三、缓存击穿3.1 成因3.2 解决方案一互斥锁最常用3.3 解决方案二逻辑过期永不过期3.4 两种方案怎么选四、缓存雪崩4.1 成因4.2 解决方案一TTL 加随机值4.3 解决方案二多级缓存4.4 解决方案三Redis 高可用4.5 解决方案四缓存预热五、一个完整的缓存封装六、三个问题的排查方法七、小结缓存穿透、击穿、雪崩三个经典问题与完整解决方案这三个问题是 Redis 面试必考也是线上事故高发区。名字相似但成因和解决方式完全不同混着说一定是没真懂。本篇把三者的成因、区别、解决方案讲透每种方案都给可直接用的代码。一、先分清三个概念问题一句话关键特征穿透查询根本不存在的数据缓存和 DB 都没有每次都打到 DB击穿单个热点 key 过期某一个 key 失效瞬间大量请求涌向 DB雪崩大批 key 同时过期同一时间大量 key 失效DB 压力陡增快速区分查的是不存在的 id比如 id -1→ 穿透查的是存在的热点只是恰好过期 → 击穿一大批key 一起没了 → 雪崩二、缓存穿透2.1 成因defget_user(user_id):dataredis.get(fuser:{user_id})ifdataisNone:datadb.query(SELECT * FROM users WHERE id %s,user_id)ifdata:redis.setex(fuser:{user_id},3600,json.dumps(data))returndata# ← 查不到时不写缓存直接返回 Nonereturnjson.loads(data)问题在最后一步DB 查不到时不写缓存。于是每次请求同一个不存在的 id都会打到 DB。正常业务中这类请求很少但如果是恶意攻击用脚本遍历不存在的 idDB 会被打垮。2.2 解决方案一缓存空值defget_user(user_id):keyfuser:{user_id}dataredis.get(key)ifdataisnotNone:returnNoneifdatab__NULL__elsejson.loads(data)datadb.query(SELECT * FROM users WHERE id %s,user_id)ifdata:redis.setex(key,3600,json.dumps(data))else:# ✅ 空值也缓存但 TTL 要短比如 60 秒redis.setex(key,60,__NULL__)returndata要点用一个特殊标记__NULL__表示确实不存在TTL 要短60 秒 vs 正常的 3600 秒否则万一后来真插入了这条数据会长时间读不到代价会占用一些内存存空值。如果攻击者用海量随机 id仍会产生大量空 key所以还要配合下面的方案。2.3 解决方案二布隆过滤器原理把所有存在的 key预先放进一个位数组查询时先过一遍——布隆过滤器说不存在那就一定不存在直接返回不用查 DB。# pip install pybloom-live 或用 Redis 的 BF.* 模块frompybloom_liveimportScalableBloomFilter bfScalableBloomFilter(initial_capacity100000,error_rate0.001)# 初始化把所有有效 id 加进去foruidindb.query_all_ids():bf.add(uid)defget_user(user_id):ifuser_idnotinbf:# ✅ 一定不存在直接返回returnNone# 后续照常走缓存逻辑...用 Redis 原生布隆过滤器需要 RedisBloom 模块BF.RESERVE user_filter0.0011000000# 误判率 0.1%容量 100 万BF.ADD user_filter1001BF.EXISTS user_filter1001# 1 可能存在BF.EXISTS user_filter-1# 0 一定不存在关键特性✅说不存在就一定不存在不会漏判⚠️说存在可能其实不存在有误判率通常设 0.1%~1%❌不支持删除标准布隆过滤器要删除得用 Counting Bloom Filter适用场景id 集合相对稳定不会频繁新增。如果要频繁新增需要维护过滤器和 DB 的一致性。2.4 解决方案三参数校验 限流defget_user(user_id):# 1. 基础校验id 必须为正整数且在合理范围ifnotisinstance(user_id,int)oruser_id0oruser_id10**9:returnNone# 2. 对同一 IP 的请求限流ifnotrate_limiter.allow(request.ip):raiseTooManyRequests()...这是第一道防线成本最低。比如 id 不可能是负数、不可能超过 10 亿直接在接口层拦掉。2.5 三种方案对比方案实现难度效果副作用缓存空值低好占内存TTL 要短布隆过滤器中最好有误判不支持删除参数校验 限流低兜底只能挡规则明确的攻击生产建议参数校验 缓存空值成本最低覆盖大多数场景。数据量特别大且 id 稳定时再加布隆过滤器。三、缓存击穿3.1 成因某个极热点的 key比如首页推荐、爆款商品在某一刻过期此时成千上万个请求同时发现缓存没了全部涌向 DB 去重建缓存。区别穿透的关键这个数据是存在的只是缓存恰好失效。3.2 解决方案一互斥锁最常用只允许一个线程去 DB 查并重建缓存其他线程等待。importuuid,timeimportredis rredis.Redis()defget_with_mutex(key,ttl3600,lock_ttl3):datar.get(key)ifdata:returndata lock_keyflock:{key}tokenstr(uuid.uuid4())# 尝试获取锁NX EX 是原子操作ifr.set(lock_key,token,nxTrue,exlock_ttl):try:datadb_query(key)# 只有拿到锁的去查 DBifdata:r.setex(key,ttl,data)else:r.setex(key,60,__NULL__)# 顺带防穿透returndatafinally:# ⚠️ 必须用 Lua 保证判断 value 再删的原子性r.eval( if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) end return 0 ,1,lock_key,token)else:# 没拿到锁短暂等待后重试time.sleep(0.05)returnget_with_mutex(key,ttl,lock_ttl)三个关键点SET lock_key token NX EX 3——NX不存在才设 EX过期时间必须一起用否则进程崩溃锁永不释放死锁释放锁必须用 Lua 脚本比对 token 再删。否则可能删掉别人的锁自己的锁超时了别人已经拿到锁你一删把别人的删了lock_ttl要大于 DB 查询耗时但不要太大3.3 解决方案二逻辑过期永不过期不给 key 设物理 TTL而是把过期时间存进 value 里。importjson,timedefset_logical(key,value,logical_ttl3600):payload{value:value,expire_at:time.time()logical_ttl,}r.set(key,json.dumps(payload))# 注意不设 EXdefget_logical(key):rawr.get(key)ifrawisNone:returnNone# 真的没有走正常流程payloadjson.loads(raw)iftime.time()payload[expire_at]:returnpayload[value]# 没过期直接返回# 逻辑上过期了返回旧值同时异步重建ifr.set(flock:{key},1,nxTrue,ex3):threading.Thread(targetrebuild,args(key,)).start()returnpayload[value]# ✅ 先返回旧数据不阻塞优点永远不会有大量请求同时等待的情况请求不会阻塞体验好缺点会短暂返回过期数据最终一致实现复杂要处理异步重建的并发适用对一致性要求不高、但并发极高的场景比如商品详情、排行榜。3.4 两种方案怎么选互斥锁逻辑过期一致性强拿到的一定是新的弱可能返回旧数据性能有等待吞吐受限无等待吞吐高实现简单复杂适用一般场景极热 key允许短暂陈旧四、缓存雪崩4.1 成因大批 key 在同一时刻集体过期或者Redis 实例直接挂了导致所有请求瞬间打到 DB。常见触发场景上线时批量预热缓存全都设了相同的 TTL定时任务在整点刷新缓存Redis 集群宕机4.2 解决方案一TTL 加随机值importrandomdefset_with_jitter(key,value,base_ttl3600):# 基础 TTL 随机 0~300 秒的抖动ttlbase_ttlrandom.randint(0,300)r.setex(key,ttl,value)就这么简单但极其有效。原本 10 万个 key 都在 3600 秒后同时失效加上随机抖动后它们会分散在 3600~3900 秒之间陆续过期DB 的压力从一瞬间的尖峰变成平滑的小坡。4.3 解决方案二多级缓存请求 → 本地缓存(Caffeine/Guava) → Redis → DB即使 Redis 全挂本地缓存还能挡一部分。代价是一致性更难保证本地缓存无法主动失效只能靠短 TTL 或消息通知。4.4 解决方案三Redis 高可用这是根本解法主从 哨兵主库挂了自动切从库Redis Cluster分片 高可用限流降级Redis 挂了就限流保住 DB# 降级Redis 不可用时直接返回兜底数据不去打 DBdefget_with_degrade(key):try:returnr.get(key)exceptredis.ConnectionError:log.error(Redis 不可用走降级)returnget_fallback(key)# 返回静态兜底数据或空4.5 解决方案四缓存预热系统启动/大促前主动把热点数据加载到缓存并错开 TTL。defwarmup():foriteminhot_items:set_with_jitter(fitem:{item.id},item.to_json())五、一个完整的缓存封装把上面所有方案组合起来importjson,time,random,uuid,threadingimportredis rredis.Redis()classCache:NULL__NULL__def__init__(self,base_ttl3600,jitter300,null_ttl60,lock_ttl3):self.base_ttl,self.jitterbase_ttl,jitter self.null_ttl,self.lock_ttlnull_ttl,lock_ttldef_ttl(self):returnself.base_ttlrandom.randint(0,self.jitter)# 防雪崩defget(self,key,loader): loader: 缓存未命中时的 DB 查询函数返回 None 表示不存在 rawr.get(key)# 命中含空值标记ifrawisnotNone:returnNoneifraw.decode()self.NULLelsejson.loads(raw)# 未命中 → 互斥锁重建防击穿lock_keyflock:{key}tokenstr(uuid.uuid4())ifr.set(lock_key,token,nxTrue,exself.lock_ttl):try:dataloader()# 空值也缓存防穿透ifdataisNone:r.setex(key,self.null_ttl,self.NULL)else:r.setex(key,self._ttl(),json.dumps(data))returndatafinally:r.eval( if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) end return 0 ,1,lock_key,token)# 没拿到锁 → 等一下重试time.sleep(0.05)returnself.get(key,loader)# 使用cacheCache()usercache.get(fuser:{uid},lambda:db_find_user(uid))这一个get方法同时解决了穿透空值缓存击穿互斥锁雪崩TTL 随机抖动六、三个问题的排查方法现象判断排查DB 有大量查不到的查询穿透看 DB 慢日志里失败的 id某个 key 的 DB 查询集中爆发击穿监控缓存命中率的突变点DB QPS整体陡增雪崩看是不是批量 key 同时过期# 监控缓存命中率INFO stats# keyspace_hits: 1000000# keyspace_misses: 50000# 命中率 hits / (hits misses) 95%命中率突然下降是最直接的信号。正常业务应该在 90% 以上。七、小结问题核心解法备选穿透缓存空值短 TTL布隆过滤器、参数校验限流击穿互斥锁SET NX EX Lua 释放逻辑过期允许旧数据雪崩TTL 加随机抖动多级缓存、高可用、预热、降级三个必须记住的细节空值缓存的 TTL 要短60 秒否则新增数据读不到释放锁必须用 Lua 比对 token否则可能删掉别人的锁TTL 随机抖动是成本最低、收益最高的雪崩防护下一篇讲分布式锁——本篇的互斥锁只是一个简化版真正的分布式锁还要考虑可重入、自动续期、Redlock 等一堆问题。