华为云 DCS for Redis:缓存穿透、击穿、雪崩的真实解法
一、故障复盘
凌晨 2:17,数据库连接数打满,接口全面超时。监控显示 QPS 从平时的 800 涨到 12000,但缓存命中率从 95% 掉到了 3%。
日志里翻出真相:一个爬虫脚本在遍历用户主页,ID 从 1 递增到 100 万,其中绝大部分是不存在的 ID。每个请求都穿透缓存打到数据库,全表扫描查一个不存在的记录。
这个故障里其实同时发生了三种问题:
- 穿透:查不存在的数据,缓存永远不命中,每次都打库
- 击穿:热门 key 恰好过期,瞬间大量并发同时回源
- 雪崩:因为所有 key 的 TTL 都设成同样的 30 分钟,在某个时刻集体过期
当时我们只做了"加缓存"这一件事,其他防护一条没有。这篇文章就是把缺的东西补上。
二、三个问题先分清楚
很多人记不住这三个词,因为名字太像。用一个物理类比就清楚了:
| 问题 | 类比 | 本质 | 攻击面 |
|---|---|---|---|
| 穿透 | 你要的书图书馆压根没有,每次都得跑去总馆问 | 查询不存在的数据 | 缓存层完全失效 |
| 击穿 | 唯一一本热门书被借走,一百个人同时在门口等 | 单个热点 key 过期瞬间 | 一个 key 引发大量回源 |
| 雪崩 | 图书馆所有书同时闭架整理 | 大量 key 同时过期 | 整体缓存层失效 |
差别是范围和触发条件:穿透针对不存在的数据(永远不命中),击穿是单个热点 key 过期,雪崩是批量 key 同时过期。
搞清这个区别很重要,因为解法完全不同,用错药不但无效,还会引入新问题。
三、穿透:两个解法,各有前提
3.1 解法一:缓存空值
最直接的做法——查不到也缓存,值存成特殊标记。
import json
import redis
r = redis.Redis(host=REDIS_HOST, port=6379, password=REDIS_PASS, decode_responses=True)
NULL_FLAG = "__NULL__"
def get_user(user_id: int):
key = f"user:{user_id}"
cached = r.get(key)
if cached is not None:
# 命中了空值标记,直接返回
if cached == NULL_FLAG:
return None
return json.loads(cached)
# 缓存未命中,回源查库
user = db.query("SELECT * FROM users WHERE id = %s", user_id)
if user is None:
# 关键:空结果也要缓存,但 TTL 要短
r.setex(key, 60, NULL_FLAG)
return None
r.setex(key, 3600, json.dumps(user))
return user
这个方案有两个必须注意的细节:
空值的 TTL 必须短(我们设 60 秒,正常数据 1 小时)。原因很简单:如果缓存了空值而用户恰好在这时注册了,长 TTL 会导致新用户"明明注册成功却查不到",看起来像数据丢失。
空值缓存本身会占内存。如果被随机 ID 狂刷,Redis 里会塞满垃圾键,挤掉真正有价值的热数据。所以需要配合请求频率限制——同一个 IP 每秒查 50 个不存在的 ID,这件事本身就是异常行为,应该在网关层就拦掉。
3.2 解法二:布隆过滤器
空值缓存的问题是"刷 100 万个 ID 就要存 100 万个空值"。布隆过滤器用几十 MB 内存就能判断"这个 ID 一定不存在",而且能在缓存之前就拦住。
from bloom_filter2 import BloomFilter
# 预期 1000 万条数据,误判率 0.1%
# 内存占用约 18 MB
bloom = BloomFilter(max_elements=10_000_000, error_rate=0.001)
def warm_up_bloom():
"""启动时把所有有效 ID 灌进过滤器"""
for uid in db.query_iter("SELECT id FROM users"):
bloom.add(str(uid))
print(f"bloom ready, capacity={len(bloom)}")
def get_user_with_bloom(user_id: int):
# 布隆过滤器说不存在,就一定不存在,直接返回
if str(user_id) not in bloom:
return None
# 可能存在(有小概率误判),继续走正常缓存流程
return get_user(user_id)
布隆过滤器的两个特性必须记住:
说"不存在"一定准确,说"存在"可能是误判。 这是它的数学性质决定的——所以它只能用来"否定",不能用来"确认"。误判的后果是少量请求仍会穿透,但相比 100% 穿透已经好了几个数量级。
标准布隆过滤器不支持删除。 用户被删除后,过滤器里仍有记录,会继续放行无效请求。如果需要删除支持,得用计数布隆过滤器或定期重建。我们的做法是每天凌晨重建一次,用增量数据补,成本可接受。
3.3 该选哪个
| 场景 | 推荐 | 理由 |
|---|---|---|
| 无效 ID 攻击为主 | 布隆过滤器 | 内存效率高,能在最前面拦住 |
| 偶发查询不存在的 key | 缓存空值 | 实现简单,不需要额外组件 |
| 数据实时增删频繁 | 缓存空值 | 布隆过滤器删除支持差 |
| 数据量大且稳定 | 布隆过滤器 | 几十 MB 覆盖千万级 |
我们的实际选择是两个都上:布隆过滤器做第一道闸(拦住 99% 的无效请求),缓存空值做第二道(兜住过滤器误判和新增数据)。这个组合的成本很低,效果是穿透量从 12000 QPS 降到接近 0。
四、击穿:互斥锁与逻辑过期
击穿只发生在单个热点 key 过期的那一瞬间。比如首页配置缓存,TTL 到期时若有 1000 个并发请求正在等它,全部会同时回源查库。
4.1 解法一:互斥锁(只让一个去查)
import time
import uuid
def get_hot_data(key: str, ttl: int = 300, mutex_ttl: int = 10):
cached = r.get(key)
if cached is not None:
return json.loads(cached)
lock_key = f"lock:{key}"
token = str(uuid.uuid4())
# 抢锁,过期时间防死锁
got_lock = r.set(lock_key, token, nx=True, ex=mutex_ttl)
if got_lock:
try:
# 抢到锁:查库并回填缓存
data = db.query_expensive(key)
r.setex(key, ttl, json.dumps(data))
return data
finally:
# 只删自己的锁(防止误删别人的)
if r.get(lock_key) == token:
r.delete(lock_key)
else:
# 没抢到锁:短暂等待后重试,不直接打库
for _ in range(30): # 最多等 3 秒
time.sleep(0.1)
cached = r.get(key)
if cached is not None:
return json.loads(cached)
# 等超时了,降级返回旧数据或默认值
return get_fallback(key)
三个细节决定这个方案是否可靠:
必须用 nx=True 原子加锁,不能先 get 判断再 set——那之间有竞态窗口,两端可能同时拿到锁。
锁必须有过期时间,否则进程拿到锁后崩了,锁永远不释放,所有请求都卡在等待上。ex=mutex_ttl 就是这个保险。
释放锁必须校验 token。否则会出现这种情况:A 的锁超时自动释放,B 拿到新锁,A 执行完后直接把 B 的锁删了——锁的保护就失效了。
4.2 解法二:逻辑过期(永不真正过期)
互斥锁的代价是未抢到锁的请求要等,在高并发下这个等待会累积成延迟。
另一种思路是让缓存物理上永不过期,把过期时间写在 value 里:
import json, time
def get_with_logical_expire(key: str, stale_ttl: int = 7200):
cached = r.get(key)
if cached is None:
return rebuild(key) # 冷启动,同步回源
data = json.loads(cached)
# 物理上不设 TTL,只判断逻辑过期时间
if data["expire_at"] > time.time():
return data["value"]
# 已逻辑过期:先返回旧数据,同时异步刷新
refresh_async(key) # 丢进消息队列或线程池
return data["value"]
这个方案的核心优势是永远不需要等待——所有请求立即拿到数据(可能稍旧),刷新在后台做。用户体验完全没有延迟抖动。
代价是会返回旧数据。所以它只适合"数据旧一点没关系"的场景:商品详情、文章内容、配置项都可以;账户余额、库存数量这种就不行。
4.3 该选哪个
| 场景 | 推荐 | 原因 |
|---|---|---|
| 强一致要求(余额、库存) | 互斥锁 | 宁可等,不能给旧值 |
| 可容忍短暂旧数据(内容页) | 逻辑过期 | 零延迟抖动,体验最好 |
| 回源成本极高(复杂聚合查询) | 逻辑过期 | 避免大量请求阻塞 |
五、雪崩:三个层次一起防
雪崩的根因是批量 key 同时失效,所以解法也在"打散失效时刻"上。
5.1 第一层:TTL 加随机抖动
这是最有效也最简单的一招:
import random
def set_with_jitter(key: str, value, base_ttl: int = 3600):
# 在基准 TTL 上浮动 ±10%,把过期时刻打散
ttl = int(base_ttl * random.uniform(0.9, 1.1))
r.setex(key, ttl, value)
我们把所有 setex 换成这个函数之后,原本"整点集体过期"的现象彻底消失。开销是几乎为零——一次随机数生成。
这一条是所有防护里性价比最高的,应该无条件加上。
5.2 第二层:多级缓存
即使 Redis 整体挂了,也不该让请求全部打到数据库。加一层进程内缓存(本地内存)做兜底:
from functools import lru_cache
class TwoLevelCache:
def __init__(self, redis_client, local_ttl=10):
self.local = {}
self.redis = redis_client
self.local_ttl = local_ttl
def get(self, key):
# 一级:本地缓存(毫秒级,但可能旧)
hit = self.local.get(key)
if hit and hit[1] > time.time():
return hit[0]
# 二级:Redis
cached = self.redis.get(key)
if cached is not None:
value = json.loads(cached)
self.local[key] = (value, time.time() + self.local_ttl)
return value
# 都没命中,回源
value = db.query(key)
self.redis.setex(key, 3600, json.dumps(value))
self.local[key] = (value, time.time() + self.local_ttl)
return value
本地缓存 TTL 要短(10 秒以内),因为多实例部署时各实例的本地缓存无法同步,TTL 太长会出现"同一份数据不同实例看到不同值"。10 秒的窗口,业务上基本感知不到。
5.3 第三层:限流与降级
最后一道防线:当数据库压力真的过大时,主动拒绝一部分请求比让所有请求都超时更明智。
from redis import Redis
def check_rate_limit(key: str, limit: int = 1000, window: int = 1) -> bool:
"""滑动窗口限流,Redis 侧原子操作"""
pipe = r.pipeline()
now = time.time()
pipe.zremrangebyscore(key, 0, now - window)
pipe.zadd(key, {str(now): now})
pipe.zcard(key)
pipe.expire(key, window)
_, _, count, _ = pipe.execute()
return count <= limit
超限时返回降级内容(比如"当前访问人数较多,请稍后重试"),而不是让请求排队等死。主动降级比被动超时的用户体验好得多——前者是明确告知,后者是页面转圈 30 秒然后报错。
六、还有两件必须做的事
6.1 缓存预热
服务启动后缓存是空的,第一波流量会全部打到数据库。这在大促或活动开始时特别危险。
def warm_up_hot_keys():
"""服务启动后预热 TOP 100 热点数据"""
hot_keys = db.query("""
SELECT key FROM access_log
WHERE created_at > NOW() - INTERVAL 1 DAY
GROUP BY key ORDER BY COUNT(*) DESC LIMIT 100
""")
for row in hot_keys:
get_hot_data(row["key"]) # 触发回源并填充缓存
print(f"warmed up {len(hot_keys)} keys")
更好的做法是定时预热:在流量高峰前 30 分钟跑一次,避免缓存自然过期后正好撞上高峰。
6.2 监控必须盯的四个指标
| 指标 | 正常值 | 异常含义 |
|---|---|---|
| 缓存命中率 | > 90% | 骤降说明穿透或大面积失效 |
| Redis 内存使用率 | < 70% | 接近 100% 会触发淘汰,命中率崩盘 |
| 慢查询数(> 10ms) | 接近 0 | 有大 key 或复杂命令 |
| 回源 QPS | 与命中率反比 | 突增就是数据库告警的前兆 |
第二条特别重要。Redis 内存满了会按淘汰策略删 key,如果策略是 allkeys-lru,它会开始删还在用的热数据,命中率迅速崩塌,接着就是雪崩。内存使用率到 80% 就该扩容了,不要等到 100%。
七、常见问题 Q&A
Q1:缓存和数据库的一致性怎么保证?
没有完美方案,只有权衡。最常用的是 Cache Aside:更新时先写库再删缓存(注意是删不是更新,避免并发写导致脏数据)。删除失败时可以加重试或走消息队列补偿。对一致性要求极高的场景,用「延迟双删」或订阅数据库 binlog 异步失效。
Q2:为什么是删缓存而不是更新缓存?
因为并发更新时,两次写库的顺序和两次更新缓存的顺序可能不一致,导致缓存里是旧值。删缓存则让下次读自然回源拿到最新值。另外删除操作更轻,写少读多的场景能省不少开销。
Q3:布隆过滤器误判了怎么办?
误判意味着"实际不存在但判断为存在",这些请求会继续穿透到下一层(缓存空值 + 数据库),并不会出错,只是多了一次无效查询。误判率设 0.1% 意味着每 1000 次无效查询有 1 次会走到数据库,相比之前 1000 次全部走库已经好了三个数量级。
Q4:Redis 集群模式下这些方案还能用吗?
大部分可以,但要注意:互斥锁和 pipeline 涉及多 key 操作时,必须保证这些 key 在同一个 slot(用 hash tag,如 {user}:1001 和 {user}:1001:lock 会被分到同一节点)。否则跨节点操作会失败。
Q5:本地缓存会不会导致数据不一致问题?
会,但可以控制。关键是把本地 TTL 设短(10 秒内),并且在数据变更时主动广播失效——可以通过 Redis 的 Pub/Sub 通知所有实例清掉对应本地缓存。我们的实践是:普通数据靠短 TTL 自然过期,关键配置靠广播即时失效。
Q6:并发量不大,需要做这些防护吗?
至少做两件:TTL 加随机抖动(零成本)和缓存空值(几行代码)。这两个不需要额外组件,也不需要复杂逻辑,但能挡掉最常见的两类故障。其余的按实际压力逐步加。
八、总结
三个问题看起来是三个技巧,实际上指向同一个原则:永远不要假设缓存一定有。
穿透是"缓存里根本没有这条数据",击穿是"这一条刚好过期了",雪崩是"这些条同时过期了"。所有防护措施,都是在回答同一个问题——当缓存失效时,怎么保护后面的数据库不被压垮。
这也解释了为什么三种问题经常同时发生。我们那次故障就是这样:爬虫触发了穿透,恰逢配置 key 过期引发击穿,而所有 key 相同 TTL 造成了雪崩。单点加一个防护是没用的,必须从"缓存失效"这个角度整体设计。
最后一句实践建议:先把 TTL 随机抖动加上。一行代码,零成本,能消掉雪崩这个最危险的情况。剩下的按实际流量逐步补,不要一次性堆一堆用不上的机制。
- 点赞
- 收藏
- 关注作者
评论(0)