Redis 缓存策略与实战:解决缓存击穿、穿透、雪崩
前言
缓存是提升系统性能的关键技术,几乎所有高并发系统都离不开缓存。但引入缓存的同时,也带来了缓存击穿、穿透、雪崩这三大经典问题。本文不仅讲解问题的成因和解决方案,还提供完整的缓存架构设计思路。
缓存模式:Cache-Aside 与 Read-Through
在深入三大问题之前,先理解两种主流缓存模式:
Cache-Aside(旁路缓存):应用先查缓存,缓存未命中再查数据库,然后写入缓存。读多写少场景首选。
Read-Through(穿透读):应用只查缓存,缓存未命中时由缓存层自动查数据库并写入缓存,对应用透明。
# Cache-Aside 模式(最常用)
def get_user(user_id):
cache_key = f"user:{user_id}"
# Step 1: 查缓存
cached = redis.get(cache_key)
if cached:
return json.loads(cached)
# Step 2: 缓存未命中,查数据库
user = db.query("SELECT * FROM users WHERE id = %s", (user_id,))
if user:
# Step 3: 写入缓存,设置过期时间
redis.setex(cache_key, 3600, json.dumps(user))
return user
def update_user(user_id, data):
# Step 1: 更新数据库
db.execute("UPDATE users SET ... WHERE id = %s", (user_id, data))
# Step 2: 删除缓存(删除而非更新,避免并发下的数据不一致)
redis.delete(f"user:{user_id}")
缓存击穿(Cache Breakdown)
问题描述:一个热点 key 突然过期或不存在,大量并发请求直接打到数据库,导致数据库瞬时压力过大甚至崩溃。
解决方案:互斥锁(双检锁定)
def get_user_with_lock(user_id):
cache_key = f"user:{user_id}"
# 第一检:查缓存
cached = r.get(cache_key)
if cached:
return json.loads(cached)
# 获取分布式锁(SET NX EX 组合,原子操作)
lock_key = f"lock:user:{user_id}"
lock_acquired = r.set(lock_key, "1", nx=True, ex=10)
if lock_acquired:
try:
# 获取锁成功后,再次检查缓存(第二检)
cached = r.get(cache_key)
if cached:
return json.loads(cached)
# 查数据库
user = db.query("SELECT * FROM users WHERE id = %s", (user_id,))
if user:
r.setex(cache_key, 3600, json.dumps(user))
return user
finally:
r.delete(lock_key)
else:
# 未获取到锁,短暂等待后重试
import time
for _ in range(50):
time.sleep(0.1)
cached = r.get(cache_key)
if cached:
return json.loads(cached)
return db.query("SELECT * FROM users WHERE id = %s", (user_id,))
缓存穿透(Cache Penetration)
问题描述:查询一个根本不存在的数据(数据库中也没有),每次请求都穿透到数据库。如果被恶意利用,可能导致数据库被打垮。
解决方案1:缓存空值
def get_user_or_null(user_id):
if user_id <= 0:
return None
cache_key = f"user:{user_id}"
cached = r.get(cache_key)
if cached is not None:
if cached == "NULL":
return None
return json.loads(cached)
user = db.query("SELECT * FROM users WHERE id = %s", (user_id,))
if user:
r.setex(cache_key, 3600, json.dumps(user))
else:
# 缓存空值,过期时间短一些(5 分钟)
r.setex(cache_key, 300, "NULL")
return user
解决方案2:Bloom Filter 布隆过滤器
# 使用 RedisBloom 模块或其他 Bloom Filter 实现
# 布隆过滤器判断 user_id 是否可能存在
# 可能存在(True):再查缓存和数据库
# 一定不存在(False):直接返回 None,不查数据库
# 初始化布隆过滤器(从数据库加载所有存在的 user_id)
def init_bloom_filter():
all_user_ids = db.query("SELECT id FROM users")
for uid in all_user_ids:
bf.add(str(uid))
缓存雪崩(Cache Avalanche)
问题描述:大量缓存 key 在同一时间过期,导致大量请求同时穿透到数据库。不同于缓存击穿(单个热点 key),缓存雪崩是大量 key 的问题。
解决方案1:随机过期时间
def get_user_with_jitter(user_id):
cache_key = f"user:{user_id}"
cached = r.get(cache_key)
if cached:
return json.loads(cached)
user = db.query("SELECT * FROM users WHERE id = %s", (user_id,))
if user:
import random
ttl = 3600 + random.randint(0, 720) -- 基础 1 小时 + 0~12 分钟随机偏移
r.setex(cache_key, ttl, json.dumps(user))
return user
解决方案2:多级缓存架构
# L1(本地缓存)+ L2(Redis)+ L3(MySQL)多级缓存
import time
class MultiLevelCache:
def __init__(self, redis_client):
self.r = redis_client
self.local_cache = {}
self.local_expire = {}
self.max_local = 10000
def get(self, key):
# L1: 查本地缓存
if key in self.local_cache:
if time.time() < self.local_expire[key]:
return self.local_cache[key]
del self.local_cache[key]
del self.local_expire[key]
# L2: 查 Redis
cached = self.r.get(key)
if cached:
# 回填本地缓存
if len(self.local_cache) >= self.max_local:
oldest_key = min(self.local_expire, key=self.local_expire.get)
del self.local_cache[oldest_key]
del self.local_expire[oldest_key]
self.local_cache[key] = cached
self.local_expire[key] = time.time() + 60
return cached
解决方案3:服务降级与熔断
def get_user_with_fallback(user_id):
cache_key = f"user:{user_id}"
try:
cached = r.get(cache_key)
if cached:
return json.loads(cached)
except RedisError:
# Redis 故障,降级到数据库
pass
# 降级:直接查数据库
user = db.query("SELECT * FROM users WHERE id = %s", (user_id,))
return user
缓存预热与更新策略
# 缓存预热:系统启动时将热点数据加载到缓存
def warmup_cache():
hot_products = db.query("""
SELECT * FROM products
ORDER BY view_count DESC
LIMIT 1000
""")
pipe = r.pipeline()
for product in hot_products:
cache_key = f"product:{product['id']}"
import random
pipe.setex(cache_key, 86400 + random.randint(0, 3600), json.dumps(product))
pipe.execute()
print(f"缓存预热完成,共加载 {len(hot_products)} 条数据")
常见问题
Q1:先更新数据库还是先删除缓存?
两种方案各有优缺。"先删缓存再更新数据库"可能造成短时间数据不一致,但通过延迟双删可以缓解。"先更新数据库再删除缓存"是更常见的选择,因为数据库是主数据源,缓存删除失败可以通过订阅 binlog 补偿。
Q2:如何确定缓存的过期时间?
根据数据变更频率和数据新鲜度要求决定。配置类数据可以设长一些(1-24 小时);用户相关数据适中(15-60 分钟);高频变更数据(库存、余额)建议不用 Redis 缓存。
Q3:Redis 内存满了会怎样?
如果 maxmemory 达到上限,Redis 会根据 maxmemory-policy 策略清理数据。常用策略包括:allkeys-lru(删除最近最少使用的 key)、volatile-lru(只删除设置了过期时间的 LRU key)、noeviction(拒绝写入)。生产环境建议设置 allkeys-lru 并监控内存使用率。
延伸阅读
- Redis 数据类型完全指南
- Redis 分布式锁实现
- Redis 持久化机制