Redis 缓存几乎是每个高并发系统的标配,但它不是”加上就快”的银弹。缓存用不好,反而会引发数据不一致甚至雪崩宕机。本文系统拆解缓存三大经典问题——穿透、击穿、雪崩,并给出可直接落地的解决方案。
一、缓存读取的标准流程
先建立统一认知:读请求优先查缓存,命中直接返回;未命中再查数据库,并把结果写回缓存。
def get_user(uid):
data = redis.get(f"user:{uid}")
if data is not None:
return json.loads(data) # 缓存命中
row = db.query("SELECT * FROM user WHERE id=?", uid)
if row:
redis.setex(f"user:{uid}", 3600, json.dumps(row)) # 回填缓存
return row
二、缓存穿透:查不存在的数据
现象:攻击者或 bug 持续请求一个数据库里根本没有的 ID,缓存永远不命中,请求全部打到 DB,把数据库压垮。
方案 1:缓存空值
查不到也写一个短过期(如 60s)的空标记,挡住重复查询:
if not row:
redis.setex(f"user:{uid}", 60, "NULL") # 空值标记
return None
方案 2:布隆过滤器(推荐)
在缓存前加一层布隆过滤器,所有合法 ID 提前放入。不存在的 ID 直接被拦截,连 Redis 都不用查:
# 使用 RedisBloom
BF.ADD user_filter 1001
BF.ADD user_filter 1002
BF.EXISTS user_filter 9999 # 返回 0 → 直接拒绝
三、缓存击穿:热点 key 过期瞬间
现象:某个超热 key(如首页爆款商品)突然过期,瞬间成千上万个请求同时击穿到数据库。
方案 1:互斥锁(Mutex)
只有一个线程去查 DB 并重建缓存,其余线程等待重试:
def get_with_lock(key):
data = redis.get(key)
if data:
return data
if redis.set(f"lock:{key}", 1, nx=True, ex=3): # 抢到锁
row = db.query(key)
redis.setex(key, 3600, row)
redis.delete(f"lock:{key}")
return row
time.sleep(0.05)
return get_with_lock(key) # 没抢到锁,稍后重试
方案 2:逻辑过期
不为 key 设物理 TTL,而在值里放过期时间戳;发现”已过期”时异步重建,期间返回旧值(容忍短暂旧数据)。适合只读一致性要求不极端的场景。
四、缓存雪崩:大量 key 同时失效
现象:批量 key 在同一时刻过期,或 Redis 宕机,请求全部落到数据库,瞬间打挂。
- 过期时间加随机值:
expire = base + random(0, 300),避免集体失效。 - 多级缓存:本地缓存(Caffeine)+ Redis,Redis 挂了还有本地兜底。
- 高可用架构:Redis 主从 + 哨兵 / 集群,防止单点宕机。
- 限流降级:DB 前加熔断,缓存全挂时返回默认页而非击穿数据库。
五、三大问题对比与选型
| 问题 | 触发条件 | 首选方案 |
|---|---|---|
| 穿透 | 查不存在的数据 | 布隆过滤器 + 空值缓存 |
| 击穿 | 单个热点 key 过期 | 互斥锁 / 逻辑过期 |
| 雪崩 | 大量 key 同时失效 | 随机过期 + 多级缓存 + 集群 |
六、Redis 内存与淘汰策略
缓存不是无限大。maxmemory-policy 决定内存满时怎么淘汰:
- allkeys-lru:所有 key 里淘汰最久未用(最常用)。
- volatile-lru:仅淘汰设了 TTL 的 key。
- allkeys-lfu:按访问频率淘汰(Redis 4.0+)。
- noeviction:不淘汰,写满报错(慎用)。
总结
缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略与高可用架构,Redis 才能真正成为系统的性能护城河。和数据库优化(如我们讲过的 PostgreSQL 慢查询调优)双管齐下,整体性能才会稳。




