Redis缓存是提升系统吞吐量和降低数据库压力的核心手段,但缓存引入了新的故障模式:缓存穿透、缓存击穿和缓存雪崩。这三种异常场景如果缺乏防护,会导致大量请求穿透到数据库,引发数据库过载甚至服务雪崩。Redis缓存策略的设计不仅需要考虑正常场景的命中率,更要针对异常场景构建防护机制,配合热点Key治理确保缓存层的稳定性。
缓存异常场景分类与危害分析
缓存穿透:查询不存在的数据,缓存和数据库中都没有对应记录,每次请求都穿透缓存直达数据库。常见于恶意攻击(如查询不存在的ID)或业务异常请求。危害是数据库持续承受无效查询负载。
缓存击穿:热点Key在缓存中过期的瞬间,大量并发请求同时访问该Key,全部穿透到数据库。区别于雪崩,击穿是单个Key的并发问题。常见于秒杀商品的库存信息在过期时恰好被大量请求命中。
缓存雪崩:大量Key在同一时间集中过期,导致请求批量穿透到数据库。常见原因是缓存过期时间设置相同(如所有数据缓存1小时,1小时后全部过期)或Redis节点宕机导致缓存层整体不可用。
缓存穿透防护:布隆过滤器与空值缓存
空值缓存是最简单的穿透防护方案。查询数据库未命中时,将空结果(null或特殊标记)写入缓存,设置较短的过期时间:
def get_user_with_null_cache(user_id):
cache_key = f"user:{user_id}"
cached = redis.get(cache_key)
if cached is not None:
if cached == "NULL_CACHE":
return None
return json.loads(cached)
user = db.query("SELECT * FROM users WHERE id = %s", user_id)
if user:
redis.setex(cache_key, 3600, json.dumps(user))
else:
redis.setex(cache_key, 300, "NULL_CACHE")
return user
空值缓存的局限:如果攻击者使用大量不同的不存在ID进行请求,每个ID都会在缓存中占一个空值条目,内存消耗持续增长。布隆过滤器解决了这个问题。
布隆过滤器(Bloom Filter)是一种空间效率极高的概率型数据结构,用于判断元素是否在集合中。它的特点是:存在误判率(判断存在可能实际不存在),但不会漏判(判断不存在则一定不存在)。将所有合法ID加入布隆过滤器,请求先经过布隆过滤器过滤,不存在的ID直接返回:
from redisbloom.client import Client as RedisBloomClient
rb = RedisBloomClient(host='localhost', port=6379)
def init_bloom_filter():
rb.bfCreate("user_ids", 0.001, 1000000)
all_user_ids = db.query("SELECT id FROM users")
for batch in chunk(all_user_ids, 1000):
rb.bfMAdd("user_ids", *[uid for uid in batch])
def get_user_with_bloom(user_id):
exists = rb.bfExists("user_ids", user_id)
if not exists:
return None
cache_key = f"user:{user_id}"
cached = redis.get(cache_key)
if cached is not None:
if cached == "NULL_CACHE":
return None
return json.loads(cached)
user = db.query("SELECT * FROM users WHERE id = %s", user_id)
if user:
redis.setex(cache_key, 3600, json.dumps(user))
else:
redis.setex(cache_key, 300, "NULL_CACHE")
return user
布隆过滤器的关键参数:容量和误判率。容量需略大于实际数据量,误判率越低内存占用越大。100万数据量、0.1%误判率下,布隆过滤器仅需约1.4MB内存。新增数据需要同步加入布隆过滤器,删除数据时不能从布隆过滤器移除(不支持删除操作),可使用Counting Bloom Filter替代。
缓存击穿防护:热点Key互斥锁与逻辑过期
互斥锁方案在热点Key过期时,只允许一个请求查询数据库并重建缓存,其他请求等待或返回旧数据:
import time
import uuid
def get_hot_product_with_mutex(product_id):
cache_key = f"product:hot:{product_id}"
cached = redis.get(cache_key)
if cached is not None:
return json.loads(cached)
lock_key = f"lock:product:{product_id}"
lock_value = str(uuid.uuid4())
acquired = redis.set(lock_key, lock_value, nx=True, ex=10)
if acquired:
try:
# 双重检查
cached = redis.get(cache_key)
if cached is not None:
return json.loads(cached)
product = db.query("SELECT * FROM products WHERE id = %s", product_id)
redis.setex(cache_key, 3600, json.dumps(product))
return product
finally:
lua_release = '''
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
end
'''
redis.eval(lua_release, 1, lock_key, lock_value)
else:
time.sleep(0.05)
cached = redis.get(cache_key)
if cached is not None:
return json.loads(cached)
return None
互斥锁方案的要点是双重检查(DCL):获取锁后再次检查缓存是否存在,避免重复查库。等待时间不宜过长,50-100ms足够缓存重建完成。锁过期时间设为10秒,略大于数据库查询时间,防止持锁线程异常导致锁无法释放。
逻辑过期方案不设置Redis TTL,在数据中携带逻辑过期时间,过期后异步重建缓存:
import json
import time
import threading
def get_product_logical_expire(product_id):
cache_key = f"product:logical:{product_id}"
cached = redis.get(cache_key)
if cached is not None:
data = json.loads(cached)
if data.get("expire_time", 0) > time.time():
return data["product"]
# 已逻辑过期,异步重建
lock_key = f"lock:rebuild:{product_id}"
acquired = redis.set(lock_key, "1", nx=True, ex=30)
if acquired:
thread = threading.Thread(target=rebuild_cache, args=(product_id, cache_key), daemon=True)
thread.start()
return data["product"]
# 冷启动,同步查库
product = db.query("SELECT * FROM products WHERE id = %s", product_id)
cache_data = {"product": product, "expire_time": time.time() + 3600}
redis.set(cache_key, json.dumps(cache_data))
return product
def rebuild_cache(product_id, cache_key):
try:
product = db.query("SELECT * FROM products WHERE id = %s", product_id)
cache_data = {"product": product, "expire_time": time.time() + 3600}
redis.set(cache_key, json.dumps(cache_data))
except Exception as e:
log.error(f"缓存重建失败: {e}")
finally:
redis.delete(f"lock:rebuild:{product_id}")
缓存雪崩防护:随机过期时间与集群容灾
随机过期时间是最直接的雪崩防护措施。为每个Key的过期时间添加随机偏移量:
def set_cache_with_random_expire(key, value, base_expire=3600):
random_offset = random.randint(-300, 300)
expire_seconds = base_expire + random_offset
redis.setex(key, expire_seconds, value)
def batch_set_cache(items, base_expire=3600):
pipe = redis.pipeline()
for key, value in items:
random_expire = base_expire + random.randint(-600, 600)
pipe.setex(key, random_expire, value)
pipe.execute()
Redis集群容灾是雪崩防护的另一关键。容灾方案包括:Redis Sentinel哨兵模式实现自动故障转移;Redis Cluster分片集群单节点故障只影响部分Key;本地缓存兜底,Redis不可用时降级到本地缓存:
from cachetools import TTLCache
local_cache = TTLCache(maxsize=10000, ttl=300)
def get_with_fallback(key):
try:
value = redis.get(key)
if value:
local_cache[key] = value
return json.loads(value)
except redis.RedisError:
if key in local_cache:
return local_cache[key]
value = db.query(key)
if value:
try:
redis.setex(key, 3600, json.dumps(value))
except redis.RedisError:
pass
local_cache[key] = json.dumps(value)
return value
热点Key发现与治理方案
热点Key是指访问量远高于平均水平的缓存Key,如秒杀活动的商品Key、热门文章Key。热点Key会导致单节点Redis的CPU和网络带宽打满,即使集群模式下也会导致单个分片过载。
热点Key发现方法。Redis 4.0+提供hotkeys命令,需要在配置中启用LFU淘汰策略:
# redis.conf
maxmemory-policy allkeys-lfu
# 执行热点Key分析
redis-cli --hotkeys
监控层面,通过Redis的INFO commandstats和INFO latencystats分析各命令的执行频率和延迟。
热点Key治理方案。发现热点Key后,通过Key分散和本地缓存两个方向治理:
def get_hot_product_distributed(product_id):
sub_keys = [f"product:hot:{product_id}:{i}" for i in range(10)]
selected = random.choice(sub_keys)
cached = redis.get(selected)
if cached:
return json.loads(cached)
product = db.query("SELECT * FROM products WHERE id = %s", product_id)
pipe = redis.pipeline()
for key in sub_keys:
pipe.setex(key, 3600, json.dumps(product))
pipe.execute()
return product
Key分散方案将热点访问分散到多个Redis分片,降低单节点压力。读取时随机选择子Key,保证各子Key的访问概率均等。
缓存防护方案压测验证与监控指标
防护方案上线前需要压测验证。使用wrk或Locust模拟缓存过期瞬间的并发请求:
from locust import HttpUser, task, between
class CacheBreakdownUser(HttpUser):
wait_time = between(0.01, 0.05)
@task
def access_hot_key(self):
self.client.get(f"/api/product/hot/12345")
关键监控指标包括:缓存命中率(不低于95%为健康)、缓存穿透率(缓存未命中+数据库命中的比例)、数据库QPS(缓存生效时应远低于业务QPS)、Redis内存使用率和Key数量、热点Key访问频次分布。通过Prometheus采集RedisExporter指标和业务指标,设置告警:缓存命中率低于90%、数据库QPS突增超过阈值、Redis内存使用率超过80%时触发告警。
缓存预热是避免冷启动雪崩的有效手段。系统发布或重启后,缓存为空,大量请求直接打到数据库。预热方案是在系统启动时批量加载热点数据到缓存:
def warmup_cache():
hot_products = db.query("""
SELECT product_id, COUNT(*) as access_count
FROM access_log
WHERE created_at > NOW() - INTERVAL 7 DAY
GROUP BY product_id
ORDER BY access_count DESC
LIMIT 1000
""")
pipe = redis.pipeline()
for item in hot_products:
product = db.query("SELECT * FROM products WHERE id = %s", item.product_id)
expire = 3600 + random.randint(-300, 300)
pipe.setex(f"product:{item.product_id}", expire, json.dumps(product))
pipe.execute()
log.info(f"缓存预热完成,加载 {len(hot_products)} 条数据")
预热过程应在系统接收流量前完成。对于全量预热耗时较长的场景,可以分批预热:先预热Top 100热点Key,开放流量后再异步预热剩余数据。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/redis-huan-cun-chuan-tou-ji-chuan-xue-beng-fang-hu-fang-an/