5.2 缓存穿透击穿雪崩的工程对策


文档摘要

5.2 缓存穿透击穿雪崩的工程对策 本节摘要:穿透、击穿、雪崩是缓存层三大经典故障,成因都是"该在缓存里的数据不在了",区别在于不存在的位置:查不存在的数据(穿透)、热点键恰好过期(击穿)、大批键同时失效(雪崩)。对症的对策分别是布隆过滤器、互斥重建与过期打散。 先把三种故障分清 故障 | 一句话成因 | 危害路径 穿透 | 查询根本不存在的数据,缓存永远未命中 | 每次请求都打到数据库 击穿 | 单个热点键过期瞬间,高并发全量回源 | 数据库瞬间被一个键的流量打垮 雪崩 | 大批键同一时刻集体失效或缓存整体宕机 | 数据库被全量流量淹没 三者递进关系画出来一目了然: 穿透击穿雪崩对比 穿透击穿雪崩对比 穿透:布隆过滤器加空值缓存 布隆过滤器的结构与 HyperLogLog

5.2 缓存穿透击穿雪崩的工程对策

本节摘要:穿透、击穿、雪崩是缓存层三大经典故障,成因都是"该在缓存里的数据不在了",区别在于不存在的位置:查不存在的数据(穿透)、热点键恰好过期(击穿)、大批键同时失效(雪崩)。对症的对策分别是布隆过滤器、互斥重建与过期打散。

先把三种故障分清

故障 一句话成因 危害路径
穿透 查询根本不存在的数据,缓存永远未命中 每次请求都打到数据库
击穿 单个热点键过期瞬间,高并发全量回源 数据库瞬间被一个键的流量打垮
雪崩 大批键同一时刻集体失效或缓存整体宕机 数据库被全量流量淹没

三者递进关系画出来一目了然:

穿透击穿雪崩对比

穿透击穿雪崩对比

穿透:布隆过滤器加空值缓存

布隆过滤器的结构与 HyperLogLog 是亲戚:一个大位数组加 k 个哈希函数。写入时把存在的 key 的 k 个哈希位标 1;查询时 k 位全 1 才放行,任一位为 0 就断定"不存在"——它能确定地说"没有",只能说"可能有"。误判率由位数组大小决定,1% 误判率下每 key 约耗 10 bit。

误判方向的成因值得多看一眼:哈希冲突只会把"不存在的 key"误判成"可能存在"(某些位被别的 key 置了 1),绝不会把"存在的 key"判成不存在(它的位写入时全被标过)。所以防线放在缓存之前是安全的——真实数据永远放行,只有漏进来的少量误判 key 还要靠下一层的空值缓存兜住。这个"单向出错"的特性是布隆过滤器能当守门员的全部底气。

def get_user(uid): if not bloom.might_contain(uid): # 明确不存在,直接拒绝 return None val = r.get(f"user:{uid}") if val is None: # 缓存未命中 val = db.query(uid) if val is None: # 数据库也没有:缓存空值 r.setex(f"user:{uid}", 60, "") return None r.setex(f"user:{uid}", 3600, val) return val

空值缓存 TTL 要短(几十秒),防止攻击者换着花样打;布隆过滤器要随新数据增补,删除需要计数式变体,一般用定期重建解决。

击穿:单飞重建或逻辑过期

def get_hot(key): val = r.get(key) if val is not None: return val lock = r.set(f"lock:{key}", 1, nx=True, ex=10) if lock: # 抢到重建权:单飞 val = db.query(key) r.setex(key, 3600 + random.randint(0, 300), val) r.delete(f"lock:{key}") return val time.sleep(0.05) # 没抢到:稍等再读 return r.get(key)

互斥重建把"万人回源"压成"一人回源、万人等待"。更强的方案是逻辑过期:缓存永不过期,值里带一个过期时间字段,发现逻辑过期后由异步线程刷新,读请求始终返回旧值——用短暂的数据陈旧换零抖动。热点数据(库存展示、榜单头名)我更倾向后者,因为等待本身在大促时就是风险。

逻辑过期的值结构长什么样,看一眼就明白:

import json, time, threading def get_hot(key): raw = r.get(key) # 键本身没有TTL,永不失效 if raw is None: return rebuild(key) # 冷启动,同步建一次 val = json.loads(raw) if val["expire_at"] < time.time(): # 逻辑上已过期 if r.set(f"refreshing:{key}", 1, nx=True, ex=10): threading.Thread( # 抢到刷新权,异步去重建 target=rebuild, args=(key,)).start() return val["data"] # 无论新旧,先返回数据 def rebuild(key): data = db.query(key) r.set(key, json.dumps({"data": data, "expire_at": time.time() + 300})) # 新值带新的逻辑期限 r.delete(f"refreshing:{key}")

解读三处设计:refreshing 锁保证同一时刻只有一个异步重建在跑,避免重建风暴;读路径永远不等待、永远有返回,抖动为零;代价是过期后到重建完成前,所有人拿到的是上一版数据。变式:对"新旧差异敏感"的字段(价格)保留短 TTL,对"可短暂陈旧"的字段(库存余量级)用逻辑过期,一个键内分而治之。

雪崩:打散、多级、限流

# 过期时间统一加随机抖动,把集体到期摊开 ttl = 3600 + random.randint(0, 600)

三道防线按成本从低到高:TTL 加随机抖动(零成本,必做);本地缓存做一级、Redis 做二级(挡住 Redis 整体故障的流量);回源侧限流加熔断(数据库保命的最后闸门,哪怕牺牲部分请求)。第 7 章的哨兵与集群解决"缓存实例本身倒下"的那一半雪崩。

三层防线各自守一段流量曲线:抖动挡的是"整点批量到期"的人造尖峰;本地一级挡的是"Redis 不可用"的整段缺口;限流熔断挡的是"前两层都失效"的极端时刻。配置示例给个具体形状:

# 回源侧的令牌兜底:单机每秒最多放行50个回源,其余直接降级 if not rate_limiter.acquire("db_query", 50): return stale_default() # 返回兜底值或友好降级页 val = db.query(key)

⚠️ 常见坑:对策叠加要防"缓存雪崩式的一致性"——抖动 TTL 后不同实例的同一键过期时间不同,重建结果短暂不一致是正常代价,业务要能容忍秒级陈旧再上这套方案。

本节要点回顾

  • 三灾同源:该在的不在,区别是位置与规模
  • 穿透靠布隆过滤器(说不存在是准的)加空值短缓存
  • 击穿靠互斥单飞或逻辑过期,热点用后者更稳
  • 雪崩靠 TTL 抖动必做,多级缓存与回源限流按成本递进
  • 一致性换可用性是所有对策共同的隐含交易

作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U