缓存穿透/雪崩/击穿

文章目录


一、缓存穿透

缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,导致每一次请求都会直接打在数据库上,缓存形同虚设。如果这类请求量很大,数据库将承受巨大压力,甚至可能被压垮。例如使用不存在的ID(如负数、超大数字)大量请求,频繁访问数据库以恶意攻击。

解决办法主要包括缓存空对象和布隆过滤器

1.缓存空对象

当数据库中查不到该数据时,也向缓存中写入一条记录,但值设为一个特殊标记(如空字符串 ""),并设一个较短的过期时间。后续相同请求命中这个"空值缓存",直接返回,不再查询数据库。

  • 优点:实现简单,维护方便
  • 缺点:额外的内存消耗与短期不一致问题
    • 额外的内存消耗:缓存中增加了大量空值 key,虽然单个很小,但大量累积也会占用内存
    • 可能造成短期的不一致:若数据库后来插入该数据,而缓存空值尚未过期,会短暂返回"无数据",直到过期或主动删除。

2.布隆过滤

布隆过滤器是一种空间效率很高的概率型数据结构,用于判断一个元素"必定不存在"或"可能存在"。在查询缓存之前,先用布隆过滤器判断 key 是否可能存在于数据库中:若布隆过滤器判定不存在,则直接返回,不再查数据库;若判定可能存在,才继续查询缓存和数据库。

  • 优点:内存占用较少,没有多余key
    • 内存占用极低,不存在额外的 key 写入 Redis
    • 可以过滤掉绝大部分非法 key 的请求
  • 缺点:实现复杂;存在误判可能
    • 实现较复杂(需维护布隆过滤器的位图、哈希函数)。
    • 存在误判:布隆过滤器有可能把不存在的 key 误判为"可能存在",但不会把存在的 key 漏掉。误判率可通过增大位图容量来降低。

3.其他辅助措施

  • 增强 ID 的复杂度,避免被猜测 ID 规律(如使用 UUID 或雪花 ID)。
  • 做好数据的基础格式校验(如 ID 必须为正整数、长度限定等),拦截明显非法的请求。
  • 加强用户权限校验,确保用户只能访问其有权查看的数据。
  • 对热点参数做限流,防止单一参数恶意高并发导致穿透。

二、缓存雪崩

缓存雪崩是指在同一时间段内,大量缓存的 key 同时失效,或者 Redis 服务不可用,导致所有请求直接落到数据库上,数据库瞬间压力剧增,可能引发宕机。

解决方法:

  • 给不同的 Key 的 TTL 添加随机值:例如原本统一设置 30 分钟过期,改为 30 ± 5 分钟,避免大量 key 集中过期。
  • 利用 Redis 集群提高服务的可用性:采用主从 + 哨兵模式,或 Redis Cluster,避免单机故障导致全部缓存不可用。
  • 给缓存业务添加降级限流策略:当缓存不可用时,直接限流或返回默认值,保护数据库不被压垮。
  • 给业务添加多级缓存:如 Nginx 本地缓存 → Redis → JVM 本地缓存 → 数据库,层层拦截,降低数据库压力。

三、缓存击穿

缓存击穿也叫热点 Key 问题:一个被高并发访问且缓存重建业务较复杂的 key 突然失效,大量请求会直接击穿到数据库,给数据库带来巨大冲击

两个关键特征:

  • 热点 key 过期:缓存中没有该数据。
  • 高并发访问,重建时间较长:业务逻辑复杂,数据库查询耗时。

如下例所示,线程高并发访问热点key,但是缓存中没有,并发访问数据库,带来压力

解决方案主要有两种:互斥锁和逻辑过期。

1.互斥锁

当热点 key 失效时,多个线程同时发现缓存不存在,此时通过分布式锁保证只有一个线程去执行数据库查询和缓存重建,其他线程等待或快速失败。拿到锁的线程完成重建后释放锁,其他线程从新缓存中读取。

让访问数据库的线程串行化执行,拿不到锁就等待

  • 问题:互斥等待,性能下降。在高并发下,大量线程被阻塞等待锁,虽然保护了数据库,但降低了系统吞吐量。

2.逻辑过期

缓存数据中额外存储一个逻辑过期时间,实际存入 Redis 时不设置 TTL(或设置较长 TTL)。当检测到数据逻辑过期时,返回旧数据(保证可用性),同时异步起一个线程去数据库查询并重建缓存,更新逻辑过期时间。这样请求永远不会因为缓存失效而直接击穿到数据库。

  • 优点:高可用,用户请求几乎无等待,直接拿到旧数据
  • 缺点:短期内返回的是旧数据,不能保证强一致性;实现复杂,需额外维护逻辑过期时间字段

3.方案对比

互斥锁保证一致性,性能差,无额外内存开销,可能死锁,实现简单
逻辑过期不保证一致性,性能优,有额外内存开销,无死锁风险,实现复杂

对比维度 互斥锁 逻辑过期
一致性 强一致,拿到的一定是数据库最新数据 最终一致,短期可能返回旧数据
可用性/性能 串行执行,线程等待,性能较差 异步重建,几乎无等待,性能好
额外开销 仅分布式锁,无额外内存 需维护逻辑过期字段,稍增内存
死锁风险 若锁未释放可能死锁(需设过期时间) 无死锁风险
实现复杂度 较简单 较复杂,需线程池+异步重建
适用场景 对一致性要求高、重建较快 对可用性要求高、重建较慢、可接受旧数据

四、设计锁的方案

使用 Redis 的 SETNX 实现互斥锁,并给锁设置一个超时时间,防止服务宕机导致死锁。

SETNX:当且仅当 key 不存在时,才设置 key 并返回成功;若 key 已存在,则不做任何操作并返回失败。多个并发请求同时执行 SETNX,只有一个能成功,其余都会失败,这就天然形成了一个分布式互斥锁------成功的那个线程获得锁,去执行数据库查询和缓存重建;失败的线程则等待或重试。

1.基于互斥锁

使用 setnx 作为互斥锁,首次到达的线程设置 setnx lock 1,后续线程设置 setnx 失败,直到delete lock。

java 复制代码
    /*    *//**
     * 获取锁
     *
     * @param key 关键
     * @return boolean
     *//*
    private boolean tryLock(String key) {
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }

    *//**
     * 释放锁
     *
     * @param key 关键
     *//*
    private void unLock(String key) {
        stringRedisTemplate.delete(key);
    }

互斥锁查询商铺示例

java 复制代码
private Shop queryWithMutex(Long id) {
    String shopKey = CACHE_SHOP_KEY + id;
    //从redis中查询
    String shopJson = stringRedisTemplate.opsForValue().get(shopKey);
    //判断是否存在
    if (StringUtils.isNotEmpty(shopJson)) {
        //存在直接返回
        return JSONUtil.toBean(shopJson, Shop.class);
    }
    //判断控值
    if ("".equals(shopJson)) {
        return null;
    }
    //实现缓存重建
    //获取互斥锁
    String lockKey = LOCK_SHOP_KEY + id;
    Shop shop = null;
    try {
        boolean isLock = tryLock(lockKey);
        //是否获取成功
        if (!isLock) {
            //获取失败 休眠并且重试
            Thread.sleep(50);
            return queryWithMutex(id);
        }
        //成功 通过id查询数据库
        shop = getById(id);
        //模拟重建延时
        Thread.sleep(200);
        if (shop == null) {
            //redis写入空值
            stringRedisTemplate.opsForValue().set(shopKey, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            //数据库不存在 返回错误
            return null;
        }
        //数据库存在 写入redis
        stringRedisTemplate.opsForValue().set(shopKey, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    } finally {
        //释放互斥锁
        unLock(lockKey);
    }
    //返回
    return shop;
}

2.基于逻辑过期

需要先准备一个数据包装类 RedisData

java 复制代码
@Data
public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}

热点数据预先存入Redis(设置逻辑过期时间)

java 复制代码
/**
* 存入redis 携带逻辑过期时间
*/
public void saveShopToRedis(Long id, Long expireSeconds) throws InterruptedException {
    //查询店铺数据
    Shop shop = getById(id);
    Thread.sleep(200);
    //封装逻辑过期
    RedisDate redisDate = new RedisDate();
    redisDate.setData(shop);
    redisDate.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
    //写入redis
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisDate));
}

逻辑过期查询商铺方法

java 复制代码
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

/**
 * 逻辑过期解决缓存击穿
 *
 * @param id id
 * @return {@link Shop}
 */
private Shop queryWithLogicalExpire(Long id) {
    String shopKey = CACHE_SHOP_KEY + id;
    //从redis中查询
    String shopJson = stringRedisTemplate.opsForValue().get(shopKey);
    //判断是否存在
    if (StringUtils.isEmpty(shopJson)) {
        //不存在返回空
        return null;
    }
    //命中 反序列化
    RedisDate redisDate = JSONUtil.toBean(shopJson, RedisDate.class);
    JSONObject jsonObject = (JSONObject) redisDate.getData();
    Shop shop = BeanUtil.toBean(jsonObject, Shop.class);
    LocalDateTime expireTime = redisDate.getExpireTime();
    //判断是否过期
    if (expireTime.isAfter(LocalDateTime.now())) {
        //未过期 直接返回
        return shop;
    }
    //已过期
    //获取互斥锁
    String lockKey = LOCK_SHOP_KEY + id;
    boolean flag = tryLock(lockKey);
    //是否获取锁成功
    if (flag) {
        //成功 异步重建
        CACHE_REBUILD_EXECUTOR.submit(() -> {
            try {
                this.saveShopToRedis(id, 20L);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            } finally {
                //释放锁
                unLock(lockKey);
            }
        });
    }
    //返回过期商铺信息
    return shop;
}

五、工具类封装

将缓存查询和重建逻辑封装为通用工具类 CacheClient,支持缓存穿透和逻辑过期两种模式,便于复用。

java 复制代码
//缓存穿透
Shop shop = cacheClient.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
//逻辑过期解决缓存击穿
Shop shop = cacheClient.queryWithLogicalExpire(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
  • set(key, value, time, unit):将对象序列化为 JSON 存入 Redis,设置实际过期时间。
  • setWithLogicalExpire(key, value, time, unit):存储对象时携带逻辑过期时间,不依赖 Redis 自身的 TTL。
  • queryWithPassThrough(...):解决缓存穿透,查询缓存 → 查询数据库,数据库不存在时缓存空值。
  • queryWithLogicalExpire(...):解决缓存击穿,发现逻辑过期时返回旧数据并异步重建。
java 复制代码
@Slf4j
@Component
public class CacheClient {

    private final StringRedisTemplate stringRedisTemplate;

    @Autowired
    public CacheClient(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    /**
     * 将任意对象序列化成json存入redis
     *
     * @param key   关键
     * @param value 价值
     * @param time  时间
     * @param unit  单位
     */
    public void set(String key, Object value, Long time, TimeUnit unit) {
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
    }

    /**
     * 将任意对象序列化成json存入redis 并且携带逻辑过期时间
     *
     * @param key   关键
     * @param value 价值
     * @param time  时间
     * @param unit  单位
     */
    public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
        //封装逻辑过期时间
        RedisData redisData = new RedisData();
        redisData.setData(value);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
        //存入redis
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
    }

    /**
     * 设置空值解决缓存穿透
     *
     * @param keyPrefix  关键前缀
     * @param id         id
     * @param type       类型
     * @param dbFallback db回退
     * @param time       时间
     * @param unit       单位
     * @return {@link R}
     */
    public <R, ID> R queryWithPassThrough(
            String keyPrefix
            , ID id
            , Class<R> type
            , Function<ID, R> dbFallback
            , Long time
            , TimeUnit unit) {
        String key = keyPrefix + id;
        //从redis中查询
        String json = stringRedisTemplate.opsForValue().get(key);
        //判断是否存在
        if (StringUtils.isNotEmpty(json)) {
            //存在直接返回
            return JSONUtil.toBean(json, type);
        }
        //判断空值
        if ("".equals(json)) {
            return null;
        }
        //不存在 查询数据库
        R r = dbFallback.apply(id);
        if (r == null) {
            //redis写入空值
            this.set(key, "", CACHE_NULL_TTL, TimeUnit.SECONDS);
            //数据库不存在 返回错误
            return null;
        }
        //数据库存在 写入redis
        this.set(key, r, time, unit);
        //返回
        return r;
    }

    /**
     * 逻辑过期解决缓存击穿
     *
     * @param id id
     * @return {@link Shop}
     */
    public <R, ID> R queryWithLogicalExpire(String keyPrefix
            , ID id
            , Class<R> type
            , Function<ID, R> dbFallback
            , Long time
            , TimeUnit unit) {
        String key = keyPrefix + id;
        //从redis中查询
        String json = stringRedisTemplate.opsForValue().get(key);
        //判断是否存在
        if (StringUtils.isEmpty(json)) {
            //不存在返回空
            return null;
        }
        //命中 反序列化
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        JSONObject jsonObject = (JSONObject) redisData.getData();
        R r = BeanUtil.toBean(jsonObject, type);
        LocalDateTime expireTime = redisData.getExpireTime();
        //判断是否过期
        if (expireTime.isAfter(LocalDateTime.now())) {
            //未过期 直接返回
            return r;
        }
        //已过期
        //获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        boolean flag = tryLock(lockKey);
        //是否获取锁成功
        if (flag) {
            //成功 异步重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    //查询数据库
                    R newR = dbFallback.apply(id);
                    //写入redis
                    this.setWithLogicalExpire(key,newR,time,unit);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    //释放锁
                    unLock(lockKey);
                }
            });
        }
        //返回过期商铺信息
        return r;
    }

    /**
     * 简易线程池
     */
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    /**
     * 获取锁
     *
     * @param key 关键
     * @return boolean
     */
    private boolean tryLock(String key) {
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }

    /**
     * 释放锁
     *
     * @param key 关键
     */
    private void unLock(String key) {
        stringRedisTemplate.delete(key);
    }
}
相关推荐
许彰午1 小时前
CacheSQL(四):CacheSQLClient——用一张路由表实现水平扩展
java·数据库·缓存·系统架构·政务
许彰午1 小时前
CacheSQL(三):双 HTTP 引擎与 SQL 查询——接口抽象的价值
java·数据库·sql·缓存
lKWO OMET1 小时前
mysql之字符串函数
android·数据库·mysql
Flying pigs~~11 小时前
RAG智慧问答项目
数据库·人工智能·缓存·微调·知识库·rag
misL NITL11 小时前
mysql之如何获知版本
数据库·mysql
许彰午11 小时前
CacheSQL(二):主从复制——OpLog 环形缓冲区与故障自动恢复
java·数据库·缓存
2401_8323655212 小时前
JavaScript中rest参数(...args)取代arguments的优势
jvm·数据库·python
2301_7796224113 小时前
Go语言怎么用信号量控制并发_Go语言semaphore信号量教程【入门】
jvm·数据库·python
2301_7662834413 小时前
c++如何将控制台输出保存到文件_cout重定向到txt【详解】
jvm·数据库·python