在高并发场景下,缓存是保护数据库的第一道防线。但缓存本身也有几个经典的失效场景,如果不处理好,缓存非但帮不了忙,反而会让系统雪上加霜。
缓存穿透:查不存在的数据
缓存穿透是指查询一个一定不存在的数据。由于缓存不命中(缓存里没有这个 key),请求直接打到数据库。如果有人恶意用大量不存在的 ID 发请求,数据库会被瞬间打垮。
典型场景:攻击者用 id=-1 或者随机 UUID 疯狂请求用户接口。
解决方案:
- 布隆过滤器:在缓存前面加一层布隆过滤器,把所有合法 key 提前写入。查询前先过布隆过滤器,不存在的 key 直接拦截。
- 空值缓存:查数据库返回 null 时,也把这个 null 结果缓存起来(设一个较短的 TTL,比如 30 秒),下次同样的查询直接返回缓存的 null。
def get_user(user_id):cache_key = f"user:{user_id}"cached = redis.get(cache_key)if cached is not None:return None if cached == "NULL" else json.loads(cached)user = db.query_user(user_id)if user is None:redis.setex(cache_key, 30, "NULL") # 缓存空值 30 秒else:redis.setex(cache_key, 3600, json.dumps(user))return user
缓存击穿:热点 key 过期
缓存击穿是指一个热点 key 在过期的瞬间,大量并发请求同时到来,全部穿透到数据库。和穿透的区别是:击穿是 key 本来存在但刚好过期了。
典型场景:某个商品做秒杀活动,缓存的商品详情刚好到期,几千个请求同时查数据库。
解决方案:
- 互斥锁:第一个请求发现缓存过期后,先获取一个分布式锁,只让一个请求去查数据库并更新缓存,其他请求等待。
- 永不过期 + 后台续期:热点数据不设过期时间,用后台线程定时续期更新。逻辑上有过期时间,但物理上不让 Redis 自动删除。
def get_product_with_lock(product_id):cache_key = f"product:{product_id}"lock_key = f"lock:{cache_key}"cached = redis.get(cache_key)if cached:return json.loads(cached)if redis.setnx(lock_key, 1):redis.expire(lock_key, 5)try:product = db.query_product(product_id)redis.setex(cache_key, 3600, json.dumps(product))return productfinally:redis.delete(lock_key)else:time.sleep(0.1)return get_product_with_lock(product_id)
缓存雪崩:大面积同时过期
缓存雪崩是指大量 key 在同一时间过期,导致所有请求瞬间涌入数据库。和击穿的区别是:击穿是一个 key,雪崩是一大批 key。
典型场景:系统启动时批量预热缓存,所有 key 设了相同的 TTL,到期时间一样。
解决方案:
- TTL 加随机值:给每个 key 的过期时间加一个随机偏移(比如 ±300 秒),避免同时过期。
- 多级缓存:本地缓存(如 Caffeine)+ Redis 两级缓存。即使 Redis 大面积过期,本地缓存还能挡住一部分请求。
- 熔断降级:当检测到数据库 QPS 突增,自动熔断返回兜底数据或错误提示,保护数据库不被打死。
总结
| 类型 | 原因 | 核心区别 | 推荐方案 |
|---|---|---|---|
| 穿透 | 查不存在的数据 | key 从来不存在 | 布隆过滤器 + 空值缓存 |
| 击穿 | 热点 key 过期 | 单个 key,高并发 | 互斥锁 + 永不过期 |
| 雪崩 | 大批 key 同时过期 | 大面积同时失效 | TTL 随机化 + 多级缓存 |
这三个问题看起来类似但本质不同,解决方案也完全不同。实际项目中它们可能同时出现,所以最好的做法是三层都加防护:布隆过滤器在最前面拦截无效请求,互斥锁保护热点 key,TTL 随机化防止批量过期。
