Redis 缓存三大坑:穿透、击穿、雪崩 — 我踩过的坑和解决方案

结论先行:缓存穿透、击穿、雪崩是分布式系统中最常见的性能杀手,90% 的缓存问题都可以通过合理的数据校验、互斥锁和分布式锁来解决,但每种场景的应对策略有本质区别。
作为一个写了 5 年 Redis 的老手,我在这三个坑上栽过跟头。今天不扯理论,直接上实战,把我踩坑的过程和修复代码都贴出来。
1. 缓存穿透:空值也能把数据库打崩
踩坑实录

去年做电商商品详情页,用户搜索一个不存在的商品 ID(比如 -1 或 999999),请求直接穿透 Redis 打到 MySQL。上线当天,DBA 就报警说数据库连接数暴涨。当时我还在想,怎么会有这么闲的用户,后来才知道,有些攻击者就是专门对着这些不存在的 ID 狂刷请求。
原因分析
缓存穿透是指查询一个根本不存在的数据,缓存和数据库都查不到,每次请求都直接访问数据库。如果恶意攻击者批量请求不存在的 key,数据库直接 GG。
解决方案
方案一:缓存空值(最简单有效)
def get_product(product_id):
# 先从 Redis 查
product = redis.get(f"product:{product_id}")
if product:
return product
# 查数据库
product = db.query("SELECT * FROM product WHERE id = %s", product_id)
if product:
redis.set(f"product:{product_id}", product, ex=3600)
else:
# 缓存空值,过期时间设短一点
redis.set(f"product:{product_id}", "NULL", ex=60) # 60秒过期
return product
注意: 空值过期时间一定要短,否则数据库新增了数据,用户还是查不到。这个教训我也是后来才学到的,第一次设成了 1 小时,结果新增商品后用户等了整整一小时才看到。
方案二:布隆过滤器(防御型方案)
# 初始化布隆过滤器(使用 Redis 模块)
from redisbloom.client import Client
rb = Client()
rb.bfCreate("product_filter", 0.01, 1000000) # 1% 误判率,100万容量
# 启动时加载所有合法 ID
for product_id in get_all_product_ids():
rb.bfAdd("product_filter", product_id)
# 查询时先过滤
def safe_get_product(product_id):
if not rb.bfExists("product_filter", product_id):
return None # 直接返回,不查数据库
# 继续正常查询流程
这个方案我后来用在了用户 ID 校验上,效果非常好,把无效请求挡在了第一道防线之外。
2. 缓存击穿:热点 key 突然失效
踩坑实录
双十一秒杀活动,某个爆款商品的缓存突然过期,瞬间几万请求同时打到数据库。MySQL 连接池被打满,整个服务瘫痪了 3 分钟。那 3 分钟我度秒如年,看着监控面板上的红色报警,手都在抖。
原因分析
缓存击穿是指一个热点 key 在失效的瞬间,大量并发请求同时发现缓存为空,全部去数据库查询。区别在于:穿透是查不存在的数据,击穿是查存在但缓存刚好过期。
解决方案
方案一:互斥锁(我最常用的方案)
import redis
import time
def get_hot_product(product_id):
cache_key = f"hot_product:{product_id}"
lock_key = f"lock:{product_id}"
# 尝试从缓存获取
product = redis.get(cache_key)
if product:
return product
# 尝试获取分布式锁
if redis.setnx(lock_key, "1"):
redis.expire(lock_key, 5) # 锁过期时间
try:
# 双重检查,防止第一个线程已经重建缓存
product = redis.get(cache_key)
if product:
return product
# 查数据库
product = db.query("SELECT * FROM product WHERE id = %s", product_id)
redis.set(cache_key, product, ex=3600)
return product
finally:
redis.delete(lock_key)
else:
# 没拿到锁的线程等待并重试
time.sleep(0.1)
return get_hot_product(product_id)
这个方案的关键在于双重检查,我第一次写的时候没加,结果第一个线程还没重建完缓存,第二个线程又去查数据库了,锁形同虚设。
方案二:逻辑过期(适合极端高并发)
def get_hot_product_v2(product_id):
cache_key = f"hot_product:{product_id}"
# 从缓存获取包含逻辑过期时间的数据
cached_data = redis.get(cache_key)
if cached_data:
data, expire_time = cached_data
if expire_time > time.time():
return data # 逻辑过期时间未到,直接返回
else:
# 逻辑过期,异步重建缓存
async_rebuild_cache(product_id)
return data # 先返回旧数据,不阻塞用户
return None
注意: 逻辑过期方案会返回旧数据,适用于对数据一致性要求不高的场景(比如商品详情页)。如果数据必须实时更新,还是老老实实用互斥锁。
3. 缓存雪崩:缓存集体失效
踩坑实录
某次上线时,我偷懒把所有缓存 key 的过期时间都设成了 1 小时。结果整点一到,几万个 key 同时过期,数据库直接被打挂。后来花了 2 小时恢复数据。那天晚上我写了个脚本,把所有 key 的过期时间都加上了随机值,然后盯着监控一直看到凌晨 3 点。
原因分析
缓存雪崩是指大量缓存 key 在同一时间过期,或者 Redis 实例宕机,导致所有请求直接打到数据库。
解决方案
方案一:过期时间加随机值(我的血泪教训)
def set_cache_with_jitter(key, value, base_expire=3600):
# 在基础过期时间上加随机偏移(0-300秒)
jitter = random.randint(0, 300)
redis.set(key, value, ex=base_expire + jitter)
就这么一个简单的随机值,能避免 90% 的缓存雪崩问题。我现在写任何缓存代码,都会条件反射地加上这个函数。
方案二:多级缓存(高可用方案)
# 本地缓存(Caffeine) + Redis 缓存
from cachetools import TTLCache
local_cache = TTLCache(maxsize=1000, ttl=60) # 本地缓存60秒
def get_product_multi_level(product_id):
# 第一级:本地缓存
product = local_cache.get(product_id)
if product:
return product
# 第二级:Redis
product = redis.get(f"product:{product_id}")
if product:
local_cache[product_id] = product # 回填本地缓存
return product
# 第三级:数据库
product = db.query("SELECT * FROM product WHERE id = %s", product_id)
redis.set(f"product:{product_id}", product, ex=3600)
local_cache[product_id] = product
return product
多级缓存的好处是,即使 Redis 挂了,本地缓存还能撑一会儿,给运维留出恢复时间。
方案三:Redis 高可用
# 使用 Redis Sentinel 或 Cluster,避免单点故障
sentinel:
master: mymaster
nodes:
- 192.168.1.1:26379
- 192.168.1.2:26379
- 192.168.1.3:26379
总结对比
| 问题 | 原因 | 核心解决思路 | 推荐方案 |
|---|---|---|---|
| 穿透 | 查询不存在的数据 | 缓存空值 / 布隆过滤器 | 空值缓存(简单) |
| 击穿 | 热点 key 过期 | 互斥锁 / 逻辑过期 | 分布式锁(通用) |
| 雪崩 | 大量 key 同时过期 | 过期时间随机化 / 多级缓存 | 随机过期时间(低成本) |
延伸思考
- 缓存预热:上线前把热点数据提前加载到缓存,避免首次请求击穿。我一般会在项目启动脚本里加一个预热任务。
- 降级策略:当缓存和数据库都挂了,返回静态数据或错误提示,不要无限重试。有一次我忘了加降级,结果请求全部堆积,把整个服务拖垮了。
- 监控告警:对 Redis 命中率、数据库 QPS 设置阈值,及时发现问题。我现在用 Prometheus 加 Grafana 做监控,命中率低于 80% 就告警。
这三个坑我每个都踩过,最痛的还是缓存雪崩那次。现在写代码,过期时间必加随机值,热点 key 必加互斥锁。缓存不是银弹,但用好了是神器,用不好是毒药。

评论已关闭!