Redis 缓存穿透解决方案

2026-05-04 16:37 Redis 缓存穿透解决方案已关闭评论

别再被缓存穿透搞崩了:我踩过的三个坑和一套通用解法

结论: 缓存穿透没有银弹,但布隆过滤器 + 空值缓存 + 参数校验三件套能挡住 99% 的穿透流量。

1. 第一次被搞崩:空值缓存的正确姿势

去年双十一大促,我的服务突然雪崩。查日志发现有个恶意用户用不存在的用户 ID 疯狂刷接口,每次请求都绕过 Redis 直接打到 MySQL,数据库连接池瞬间被打满。当时我整个人都慌了,赶紧翻代码找问题。

第一反应:加空值缓存。这个思路其实很直接——既然查不到数据,那就把这个“查不到”的结果也缓存起来,下次同样的请求就不用再去数据库了。

def get_user(user_id):
    # 先查缓存
    user = redis.get(f"user:{user_id}")
    if user:
        return user

    # 查数据库
    user = db.query("SELECT * FROM users WHERE id = %s", user_id)
    if user:
        redis.set(f"user:{user_id}", user, ex=3600)
        return user

    # 关键:空值也缓存,但过期时间要短
    redis.set(f"user:{user_id}", None, ex=60)  # 60秒过期
    return None

踩坑记录: 一开始我设了 5 分钟过期,结果正常用户注册后,5 分钟内一直拿不到数据。用户反馈说“我刚注册的账号怎么登录不上”,我赶紧查才发现是空值缓存捣的鬼。空值缓存过期时间必须短于业务容忍时间。 现在我一般设 30-60 秒,既挡住攻击,又不影响正常用户。

2. 布隆过滤器:大流量场景的杀手锏

空值缓存能挡住低频攻击,但面对每秒 10 万次的随机 ID 请求,内存直接炸了——每个空 key 都要占几十字节。那段时间我每天盯着监控面板,看着内存曲线像过山车一样往上冲,心里那个慌啊。

这时得上布隆过滤器。它就像一个超大的“可能包含”集合,用极小的内存代价判断一个元素是否可能存在。

from pybloom_live import BloomFilter

# 初始化:预估100万数据,误判率0.1%
bloom = BloomFilter(capacity=1000000, error_rate=0.001)

# 预热:启动时加载所有合法ID
def warm_up_bloom():
    ids = db.query("SELECT id FROM users")
    for id in ids:
        bloom.add(id)

def get_user_v2(user_id):
    # 第一道防线:布隆过滤器
    if user_id not in bloom:
        return None  # 直接返回,不查任何存储

    # 后续逻辑不变
    ...

实战经验: 布隆过滤器不能删除元素,所以 ID 删除后需要重建。我每周凌晨重建一次,配合业务低峰期。如果你问我为什么不用更动态的方案——因为简单可靠,而且凌晨重建对业务几乎没影响。

3. 参数校验:最容易被忽略的第一道门

很多团队只关注后端缓存策略,却忘了业务层校验。比如用户 ID 必须是正整数,UUID 必须符合格式。这其实是最容易想到、也最容易忽略的防线。



![三层过滤网拦截缓存穿透的架构图](imgs/三件套集成.png)

import re

def validate_user_id(user_id):
    # 严格校验格式
    if not isinstance(user_id, int) or user_id <= 0:
        return False
    # 或者 UUID 格式
    if not re.match(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', str(user_id)):
        return False
    return True

def get_user_v3(user_id):
    if not validate_user_id(user_id):
        return None  # 非法参数直接拒绝
    ...

血的教训: 有一次线上事故,攻击者传了 user_id=-1,数据库查询走了全表扫描。你猜怎么着?那张表有 500 万行数据,查询耗时直接飙到 10 秒,整个服务都卡住了。参数校验要放在最外层,甚至可以用 API 网关层拦截。 我现在所有接口都强制加参数校验,连开发环境都不放过。

4. 三件套集成:我的通用模板

把前面三个方案整合到一起,我封装了一个通用的缓存穿透防护类。它就像一个三层过滤网,层层拦截,确保只有合法请求才能到达数据库。

class CachePenetrationGuard:
    def __init__(self, redis_client, bloom_filter):
        self.redis = redis_client
        self.bloom = bloom_filter

    def get_data(self, key, query_func, bloom_check=True, 
                 empty_ttl=60, normal_ttl=3600):
        # 1. 参数校验(由调用方保证)

        # 2. 布隆过滤器拦截
        if bloom_check and key not in self.bloom:
            return None

        # 3. 查缓存(含空值)
        cached = self.redis.get(key)
        if cached is not None:
            return cached if cached != 'NULL' else None

        # 4. 查数据库
        data = query_func()
        if data:
            self.redis.setex(key, normal_ttl, data)
        else:
            # 空值缓存,短过期
            self.redis.setex(key, empty_ttl, 'NULL')
        return data

使用示例:

guard = CachePenetrationGuard(redis, bloom)

def get_user(user_id):
    return guard.get_data(
        key=f"user:{user_id}",
        query_func=lambda: db.query("SELECT * FROM users WHERE id=%s", user_id),
        bloom_check=True
    )

这个模板我用了大半年,在多个项目里验证过,效果相当稳定。

5. 性能对比(压测数据)

用 1000 个合法 ID + 10000 个非法随机 ID 压测:

方案 QPS 数据库查询次数 内存占用
无防护 1000 11000/s
空值缓存 5000 100/s 高(空 key 堆积)
布隆过滤器 8000 1000/s 低(10MB)
三件套 9000 10/s

三件套方案下,数据库查询几乎只来自合法请求的缓存未命中。 看到这个数据的时候,我长舒了一口气——终于不用担心数据库被打爆了。

延伸思考

  1. 布隆过滤器误判:0.1% 误判率意味着每 1000 个非法请求会有 1 个穿透到 DB,你能接受吗?如果不行,可以加二级缓存。我一般会再加一层本地缓存,把误判带来的影响降到最低。
  2. 热点 key 叠加:缓存穿透 + 缓存击穿同时发生时,布隆过滤器 + 互斥锁 + 本地缓存组合使用。这个组合我下次专门写一篇文章。
  3. 动态数据场景:用户 ID 频繁新增,布隆过滤器重建成本高,可以考虑 Redis 的 BF.RESERVE 命令实现动态扩容。不过对我来说,每周重建一次已经够用了。

最后一句:别想着一次性解决所有问题,先挡住 99% 的流量,剩下的 1% 用兜底策略处理。 技术方案永远是在成本和效果之间找平衡,别追求完美,追求够用。

你可能感兴趣的文章

来源:每日教程每日一例,深入学习实用技术教程,关注公众号TeachCourse
转载请注明出处: https://teachcourse.cn/4091.html ,谢谢支持!

资源分享

Python库JWT实现token校验的示例 Python库JWT实现token校验的
LinkedHashMap方法解析 LinkedHashMap方法解析
mysql数据库导出和导入 mysql数据库导出和导入
kotlin-coder.skill kotlin-coder.skill

评论已关闭!