华为云 DCS for Redis:缓存穿透、击穿、雪崩的真实解法

举报
辣椒酱来了 发表于 2026/11/04 15:50:34 2026/11/04
【摘要】 一、故障复盘凌晨 2:17,数据库连接数打满,接口全面超时。监控显示 QPS 从平时的 800 涨到 12000,但缓存命中率从 95% 掉到了 3%。日志里翻出真相:一个爬虫脚本在遍历用户主页,ID 从 1 递增到 100 万,其中绝大部分是不存在的 ID。每个请求都穿透缓存打到数据库,全表扫描查一个不存在的记录。这个故障里其实同时发生了三种问题:穿透:查不存在的数据,缓存永远不命中,每...

一、故障复盘

凌晨 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 随机抖动加上。一行代码,零成本,能消掉雪崩这个最危险的情况。剩下的按实际流量逐步补,不要一次性堆一堆用不上的机制。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。