Redis 缓存三大坑:穿透、击穿、雪崩

2026-05-04 15:59 Redis 缓存三大坑:穿透、击穿、雪崩已关闭评论

Redis 缓存三大坑:穿透、击穿、雪崩 — 我踩过的坑和解决方案

结论先行:缓存穿透、击穿、雪崩是分布式系统中最常见的性能杀手,90% 的缓存问题都可以通过合理的数据校验、互斥锁和分布式锁来解决,但每种场景的应对策略有本质区别。

作为一个写了 5 年 Redis 的老手,我在这三个坑上栽过跟头。今天不扯理论,直接上实战,把我踩坑的过程和修复代码都贴出来。

1. 缓存穿透:空值也能把数据库打崩

踩坑实录

去年做电商商品详情页,用户搜索一个不存在的商品 ID(比如 -1999999),请求直接穿透 Redis 打到 MySQL。上线当天,DBA 就报警说数据库连接数暴涨。当时我还在想,怎么会有这么闲的用户,后来才知道,有些攻击者就是专门对着这些不存在的 ID 狂刷请求。

原因分析

缓存穿透是指查询一个根本不存在的数据,缓存和数据库都查不到,每次请求都直接访问数据库。如果恶意攻击者批量请求不存在的 key,数据库直接 GG。

解决方案

方案一:缓存空值(最简单有效)

def get_product(product_id):
    # 先从 Redis 查
    product = redis.get(f"product:{product_id}")
    if product:
        return product

    # 查数据库
    product = db.query("SELECT * FROM product WHERE id = %s", product_id)

    if product:
        redis.set(f"product:{product_id}", product, ex=3600)
    else:
        # 缓存空值,过期时间设短一点
        redis.set(f"product:{product_id}", "NULL", ex=60)  # 60秒过期

    return product

注意: 空值过期时间一定要短,否则数据库新增了数据,用户还是查不到。这个教训我也是后来才学到的,第一次设成了 1 小时,结果新增商品后用户等了整整一小时才看到。

方案二:布隆过滤器(防御型方案)

# 初始化布隆过滤器(使用 Redis 模块)
from redisbloom.client import Client

rb = Client()
rb.bfCreate("product_filter", 0.01, 1000000)  # 1% 误判率,100万容量

# 启动时加载所有合法 ID
for product_id in get_all_product_ids():
    rb.bfAdd("product_filter", product_id)

# 查询时先过滤
def safe_get_product(product_id):
    if not rb.bfExists("product_filter", product_id):
        return None  # 直接返回,不查数据库
    # 继续正常查询流程

这个方案我后来用在了用户 ID 校验上,效果非常好,把无效请求挡在了第一道防线之外。

2. 缓存击穿:热点 key 突然失效

踩坑实录

双十一秒杀活动,某个爆款商品的缓存突然过期,瞬间几万请求同时打到数据库。MySQL 连接池被打满,整个服务瘫痪了 3 分钟。那 3 分钟我度秒如年,看着监控面板上的红色报警,手都在抖。

原因分析

缓存击穿是指一个热点 key 在失效的瞬间,大量并发请求同时发现缓存为空,全部去数据库查询。区别在于:穿透是查不存在的数据,击穿是查存在但缓存刚好过期。

解决方案

方案一:互斥锁(我最常用的方案)

import redis
import time

def get_hot_product(product_id):
    cache_key = f"hot_product:{product_id}"
    lock_key = f"lock:{product_id}"

    # 尝试从缓存获取
    product = redis.get(cache_key)
    if product:
        return product

    # 尝试获取分布式锁
    if redis.setnx(lock_key, "1"):
        redis.expire(lock_key, 5)  # 锁过期时间
        try:
            # 双重检查,防止第一个线程已经重建缓存
            product = redis.get(cache_key)
            if product:
                return product

            # 查数据库
            product = db.query("SELECT * FROM product WHERE id = %s", product_id)
            redis.set(cache_key, product, ex=3600)
            return product
        finally:
            redis.delete(lock_key)
    else:
        # 没拿到锁的线程等待并重试
        time.sleep(0.1)
        return get_hot_product(product_id)

这个方案的关键在于双重检查,我第一次写的时候没加,结果第一个线程还没重建完缓存,第二个线程又去查数据库了,锁形同虚设。

方案二:逻辑过期(适合极端高并发

def get_hot_product_v2(product_id):
    cache_key = f"hot_product:{product_id}"

    # 从缓存获取包含逻辑过期时间的数据
    cached_data = redis.get(cache_key)
    if cached_data:
        data, expire_time = cached_data
        if expire_time > time.time():
            return data  # 逻辑过期时间未到,直接返回
        else:
            # 逻辑过期,异步重建缓存
            async_rebuild_cache(product_id)
            return data  # 先返回旧数据,不阻塞用户

    return None

注意: 逻辑过期方案会返回旧数据,适用于对数据一致性要求不高的场景(比如商品详情页)。如果数据必须实时更新,还是老老实实用互斥锁。

3. 缓存雪崩:缓存集体失效

踩坑实录

某次上线时,我偷懒把所有缓存 key 的过期时间都设成了 1 小时。结果整点一到,几万个 key 同时过期,数据库直接被打挂。后来花了 2 小时恢复数据。那天晚上我写了个脚本,把所有 key 的过期时间都加上了随机值,然后盯着监控一直看到凌晨 3 点。

原因分析

缓存雪崩是指大量缓存 key 在同一时间过期,或者 Redis 实例宕机,导致所有请求直接打到数据库。

解决方案

方案一:过期时间加随机值(我的血泪教训)

def set_cache_with_jitter(key, value, base_expire=3600):
    # 在基础过期时间上加随机偏移(0-300秒)
    jitter = random.randint(0, 300)
    redis.set(key, value, ex=base_expire + jitter)

就这么一个简单的随机值,能避免 90% 的缓存雪崩问题。我现在写任何缓存代码,都会条件反射地加上这个函数。

方案二:多级缓存(高可用方案)

# 本地缓存(Caffeine) + Redis 缓存
from cachetools import TTLCache

local_cache = TTLCache(maxsize=1000, ttl=60)  # 本地缓存60秒

def get_product_multi_level(product_id):
    # 第一级:本地缓存
    product = local_cache.get(product_id)
    if product:
        return product

    # 第二级:Redis
    product = redis.get(f"product:{product_id}")
    if product:
        local_cache[product_id] = product  # 回填本地缓存
        return product

    # 第三级:数据库
    product = db.query("SELECT * FROM product WHERE id = %s", product_id)
    redis.set(f"product:{product_id}", product, ex=3600)
    local_cache[product_id] = product
    return product

多级缓存的好处是,即使 Redis 挂了,本地缓存还能撑一会儿,给运维留出恢复时间。

方案三:Redis 高可用

# 使用 Redis Sentinel 或 Cluster,避免单点故障
sentinel:
  master: mymaster
  nodes:
    - 192.168.1.1:26379
    - 192.168.1.2:26379
    - 192.168.1.3:26379

总结对比

问题 原因 核心解决思路 推荐方案
穿透 查询不存在的数据 缓存空值 / 布隆过滤器 空值缓存(简单)
击穿 热点 key 过期 互斥锁 / 逻辑过期 分布式锁(通用)
雪崩 大量 key 同时过期 过期时间随机化 / 多级缓存 随机过期时间(低成本)

延伸思考

  1. 缓存预热:上线前把热点数据提前加载到缓存,避免首次请求击穿。我一般会在项目启动脚本里加一个预热任务。
  2. 降级策略:当缓存和数据库都挂了,返回静态数据或错误提示,不要无限重试。有一次我忘了加降级,结果请求全部堆积,把整个服务拖垮了。
  3. 监控告警:对 Redis 命中率、数据库 QPS 设置阈值,及时发现问题。我现在用 Prometheus 加 Grafana 做监控,命中率低于 80% 就告警。

这三个坑我每个都踩过,最痛的还是缓存雪崩那次。现在写代码,过期时间必加随机值,热点 key 必加互斥锁。缓存不是银弹,但用好了是神器,用不好是毒药。

你可能感兴趣的文章

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

资源分享

Conversion to Dalvik format failed Conversion to Dalvik format
5维度专家评审 5维度专家评审
避孕常见的误区 避孕常见的误区
Claude Code 如何写 Skill 技能 Claude Code 如何写 Skill 技

评论已关闭!