在构建高并发应用时,Redis 作为缓存层是提升性能的关键。然而,如果对缓存机制理解不深,很容易遇到缓存穿透、缓存击穿和缓存雪崩这三大“拦路虎”。本文基于黑马点评项目实战,深入剖析这三种问题的成因、解决方案,并分享在 MySQLPostgreSQL 等数据库优化中的最佳实践。无论你是初学者还是资深开发者,都能从中获得可落地的经验。

一、缓存穿透:当查询“不存在”时,如何保护数据库?

缓存穿透是指查询一个根本不存在的数据。由于缓存中也没有该数据,每次请求都会穿透缓存,直接打到数据库,导致数据库压力骤增。在 数据库优化 中,这是最常见的风险之一。

典型场景: 恶意攻击者故意请求不存在的ID(如负数ID),或者业务中频繁查询已删除的记录。

解决方案:

  • 缓存空对象: 当数据库查询结果为空时,也缓存一个空值(如“null”),并设置较短的过期时间(比如5分钟)。这样后续相同请求会直接命中缓存,避免穿透。
  • 布隆过滤器: 在缓存前加一层布隆过滤器,快速判断数据是否可能存在。如果过滤器认为不存在,直接返回,避免查询数据库。

⚠️ 注意事项: 缓存空对象会占用内存,需要配合过期策略;布隆过滤器存在误判率,需要根据业务调整参数。

在实战中,我们推荐优先使用缓存空对象,因为它实现简单,适合大多数场景。具体流程如下:

上图展示了查询商户缓存时的完整流程。当缓存未命中时,先查询数据库,如果数据库为空,则缓存空对象并返回;如果数据库有数据,则缓存数据并返回。

 个人主页:北极的代码(欢迎来访)
作者简介:java后端学习者
❄️个人专栏:苍穹外卖日记SSM框架深入JavaWeb
命运的结局尽可永在,不屈的挑战却不可须臾或缺!

二、缓存击穿:热点Key“突然死亡”的应对策略

缓存击穿是指一个热点Key在失效的瞬间,大量并发请求同时涌入,全部穿透到数据库,导致数据库负载飙升。这与 Redis 的过期策略密切相关。

修改ShopController中的业务逻辑,满足下面的需求:
根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间

根据id修改店铺时,先修改数据库,再删除缓存

在这里我们使用了事务

  • 因为店铺数据可能涉及多张表(shop、shop_config、shop_address、shop_audit_log 等)

  • 事务回滚保证这些修改要么全成功,要么全失败

  • 避免出现“店铺名称改了,但配置没改”的中间状态

典型场景: 某个商品详情页的缓存过期,瞬间有上千个用户同时访问该商品。

解决方案对比:

方案实现难度性能数据一致性适用场景
互斥锁⭐ 简单中(有等待)强一致绝大多数场景
逻辑过期⭐⭐ 中等高(无等待)最终一致可容忍短暂不一致
永不过期⭐⭐ 中等最终一致真正的热点数据
分级缓存⭐⭐⭐ 较复杂极高可能不一致超高并发(秒杀)

从对比表可以看出,不同的方案适用于不同的场景:

  • 互斥锁(Mutex Lock): 利用 Redis 的 SETNX 命令实现分布式锁,只允许一个线程去查询数据库并重建缓存,其他线程等待。实现简单,99%的场景都适用。
  • 逻辑过期: 在缓存中设置一个逻辑过期时间(而非物理过期),后台异步线程负责刷新缓存。适合对延迟极度敏感的业务。
  • 永不过期 + 手动刷新: 针对超热点数据(如微博热搜),设置缓存永不过期,通过定时任务或事件驱动手动刷新。
  • 分级缓存: 本地缓存(如 Caffeine) + Redis 的组合,减少对 Redis 的依赖。

黑马点评项目中,我们采用互斥锁方案解决缓存击穿。流程图如下:

问:什么是缓存击穿?怎么解决?

:缓存击穿是指热点 Key 在过期瞬间,大量并发请求同时打到数据库。我们项目采用互斥锁方案:

  1. 当缓存失效时,不立即查数据库,而是先获取分布式锁

  2. 只有拿到锁的线程才能查数据库,其他线程等待

  3. 拿到锁的线程查完数据库并写入缓存后,释放锁

  4. 其他线程拿到锁后发现缓存已有数据,直接返回

同时使用 Double Check 防止重复查询。对于超热点数据(如首页爆款商品),我们采用永不过期+定时刷新的策略,彻底避免击穿。在高并发场景下,互斥锁会增加少量延迟(约 10-20ms),但能有效保护数据库。

代码实现的核心逻辑如下(注意 Double Check 的重要性):

public Shop queryWithMutexCache(Long id) {
    String key = RedisConstants.CACHE_SHOP_KEY+ id;
    //从Redis中查询商品缓存信息
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    //判断缓存是否存在
    if(StrUtil.isNotBlank(shopJson)){
        //存在,直接返回
        //将json转为对象
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return shop;
    }
    //判断命中的是否为空
    if (shopJson != null){
        return null;
    }
    //缓存击穿利用互斥锁解决
    //1.获取互斥锁
    String lockKey=RedisConstants.LOCK_SHOP_KEY+id;
    Shop shop = null;
    try {
        boolean isLock = tryLock(lockKey);
        //3.判断锁是否获取成功
        if (!isLock){
            //获取锁失败,休眠并重试
            Thread.sleep(50);
            return queryWithMutexCache(id);
        }
        //4.获取锁成功,DoubleCheck双检测
        String resultShopJson= stringRedisTemplate.opsForValue().get( key);
        //5.存在,返回数据
        if (StrUtil.isNotBlank(resultShopJson)){
            return JSONUtil.toBean(resultShopJson, Shop.class);
        }
        // 4.5 DoubleCheck命中空值
        if (resultShopJson != null) {
            return null;
        }
        //不存在,查询数据库
        shop = getById(id);
        if (shop== null){
            //将空值写入缓存(防止缓存穿透)
            stringRedisTemplate.opsForValue().set(key,"",RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);
            return null;}
        //写入缓存
        stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new RuntimeException("获取锁失败", e);
    } finally {
        //6.释放锁
        unlock(lockKey);
    }
    return shop;
}

为什么需要 Double Check?

text

时间有双重检查无双重检查
t=0ms3个线程发现缓存null3个线程发现缓存null
t=1ms线程A拿到锁线程A拿到锁
t=2ms线程A第2次检查→null线程A直接查DB(无第2次检查)
t=3ms线程A查DB线程A查DB
t=101ms线程B拿到锁线程B拿到锁
t=102ms线程B第2次检查→有数据✅线程B直接查DB❌
t=103ms线程B返回,不查DB线程B又查了一次DB
t=104ms线程C拿到锁线程C拿到锁
t=105ms线程C第2次检查→有数据✅线程C直接查DB❌

从表格可见,无双重检查时,3个线程会执行3次DB查询;而有双重检查时,只有1次DB查询。这能显著降低数据库压力,是数据库优化的关键技巧。

❄️ 三、缓存雪崩:大规模缓存失效的“雪崩效应”

缓存雪崩是指大量缓存在同一时间过期,或者 Redis 服务宕机,导致所有请求直接打到数据库,造成数据库瞬间崩溃。这与 MySQLPostgreSQL 等数据库的并发承载能力密切相关。

缓存穿透是指:查询一个根本不存在的数据,缓存层和数据库层都没有这个数据。

每次请求都会直接穿透缓存,打到数据库上。

请求流程

text

危害

  • 大量请求直接打到数据库

  • 数据库连接被打满,CPU飙升

  • 可能引发缓存穿透攻击(恶意请求大量不存在的ID)

方案1:缓存空对象(最常用)

核心思路:查询不到数据时,缓存一个  或特殊标记,设置较短的过期时间。

优点:简单、有效
缺点:占用少量内存(短TTL缓解)

方案2:布隆过滤器(最彻底)

核心思路:将所有存在的 ID 存入布隆过滤器,快速判断一个 ID 是否一定不存在

Maven 依赖

xml

实现代码

优点:内存占用极小(100万ID约1MB),绝对拦截不存在的key
缺点:有误判率(0.1%),需要定期重建

方案3:Redis 布隆过滤器模块(生产级推荐)

Guava 的布隆过滤器是内存级的,多实例部署时每个 JVM 都要加载一遍。推荐使用 RedisBloom 模块。

安装 RedisBloom

bash

Java 代码

方案4:请求限流(防御恶意攻击)

使用 Sentinel 或 Guava RateLimiter 进行限流。

Guava 限流
Sentinel 限流(阿里开原,更强大)

xml

方案5:参数校验(基础防御)

三大原因:

  • 大量 Key 同时过期: 比如所有缓存都设置了相同的 TTL(如 1 小时),导致同一时刻集体失效。
  • Redis 实例宕机: 缓存层不可用,所有请求直接穿透到数据库。
  • 缓存服务重启: 重启后缓存为空,瞬间涌入大量请求。

我们主要修改的是将空值写入缓存中,在数据库中查不到商铺的时候直接将空值写入Redis,

然后结束,还有一步逻辑,就是在判断缓存命中的时候,即便缓存是空值,也会命中的,所以我们要添加一个逻辑,也就判断命中的时候是否是空值。也就是:当缓存中存的是空字符串  时,直接返回失败,不再查数据库

问题本质:空字符串 vs null 的区别

代码逻辑

  1. 先判断  → 处理有数据的命中

  2. 再判断  → 处理空字符串命中

  3. 最后才是  → 查数据库

这是一个标准的缓存穿透防护模式


四如果不处理空值,有什么后果?

后果1:数据库被打穿

数据库压力:假设QPS=1000,全部穿透到数据库,数据库瞬间崩溃。

后果2:缓存被无效数据占满

虽然写了空值缓存,但因为不判断空值,每次请求都会:

  1. 从缓存读到 

  2. 不认为命中

  3. 查数据库

  4. 再次写入 (覆盖已有的 )

浪费网络IO和CPU。

后果3:日志爆炸

每次穿透都会打印SQL日志、慢查询日志,磁盘很快写满。

缓存雪崩:

缓存雪崩是指:大量的缓存 key 在同一时间集中过期,导致大量请求直接打到数据库,造成数据库压力骤增甚至崩溃。

典型场景

与缓存穿透、缓存击穿的区别

解决方案:

  • 随机过期时间: 给每个 Key 的 TTL 增加一个随机偏移量(如 1 小时 ± 10 分钟),避免集体失效。
  • 多级缓存: 在 Redis 前加一层本地缓存(如 Caffeine),即使 Redis 宕机,本地缓存仍能提供服务。
  • 限流降级: 对数据库层做限流保护,超出阈值的请求直接返回错误或降级页面。
  • Redis 高可用: 部署 Redis 主从 + 哨兵模式或 Redis Cluster,确保缓存层的高可用。

最佳实践: 在项目初期就规划好缓存的过期策略,避免后期因缓存雪崩导致线上事故。同时,建议对数据库(如 MySQLPostgreSQL)做读写分离和连接池优化,提高抗压能力。

1. 大量key同时过期(最常见)

java

2. Redis 实例宕机

  • Redis 服务挂了

  • 所有请求直接打到数据库

3. 网络故障

  • 应用与 Redis 之间的网络断开

  • 所有缓存操作超时,请求穿透

三、解决方案(6种)

方案1:设置随机过期时间(最常用,推荐)

核心思路:给过期时间加上一个随机偏移量,避免同时过期。

更精细的随机策略

方案2:永不过期 + 异步更新(终极方案)

核心思路:缓存不设过期时间,由后台定时任务异步刷新。

优点:彻底避免雪崩
缺点:数据一致性稍差(有延迟)

方案3:使用 Redis 集群(解决实例宕机)

Redis 集群是 Redis 提供的分布式存储方案,用于解决单机 Redis 的三大瓶颈:

  • 容量瓶颈:单机内存有限(比如 64GB),存不下所有数据

  • 性能瓶颈:单机 QPS 有限(比如 10w/s),扛不住高并发

  • 可用性瓶颈:单机挂了,整个服务不可用

核心思想:将数据分片存储到多个 Redis 节点上,每个节点只存一部分数据。

Redis Cluster(官方集群)详解

4.1 架构图

text

4.2 核心概念

1. 槽位(Slot)
  • Redis Cluster 有 16384 个槽位(0 ~ 16383)

  • 每个 key 通过 CRC16 算法计算属于哪个槽:

  • 每个 Master 节点负责一部分槽位

java

2. 分片(Sharding)

text

3. 主从复制
  • 每个 Master 至少有 1 个 Slave(从节点)

  • Master 挂了,Slave 自动升级为 Master

  • 实现高可用

4.3 数据读写流程

text

如果连错节点

text

形象理解:

流程

  1. 所有写操作(set、del、incr)必须走 Master

  2. Master 将数据异步同步给所有 Slave

  3. 所有读操作(get、mget)可以走 Slave

说明:我们这里只是简单的理解,后面我们还会更深入的学习

三种主流部署模式对比

yaml

或者使用 Redis Cluster

java

方案4:多级缓存(本地缓存 + Redis)

核心思路:在应用内存中加一层 Caffeine 缓存,即使 Redis 挂了,本地缓存还能扛一阵。

xml

优点:Redis 挂了本地缓存还能用
缺点:占用 JVM 内存,多实例间数据不一致

方案5:请求限流 + 熔断降级

使用 Sentinel 或 Hystrix 保护数据库。

方案6:缓存预热(提前加载)

核心思路:系统启动时或高峰期前,提前把热点数据加载到缓存。

定时预热

四、实战总结与最佳实践

通过以上分析,我们可以总结出以下核心经验:

问题答案
为什么双重检查时缓存还是null?因为当前线程是第一个拿到锁的线程,还没有任何线程写入过缓存
什么时候会出现这种情况?缓存真正为空时(从未查询过 或 缓存已过期)
这种情况是好是坏?✅ 是好的!说明当前线程应该去查数据库
如果双重检查时不是null呢?说明其他线程已经查过了,当前线程直接返回,不查数据库

核心记忆: 缓存穿透查“不存在”,缓存击穿打“热点”,缓存雪崩是“集体失效”。

双重检查时缓存还是null → 说明我是第一个 → 我去查数据库

双重检查时缓存不是null → 说明别人查过了 → 我直接用

在实际项目中,建议结合 Redis 的监控工具(如 RedisInsight)和 数据库优化 工具(如 MySQL 的慢查询日志)持续观察缓存命中率和数据库负载,及时调整策略。

最后,推荐大家阅读《Redis 设计与实现》和《高性能 MySQL》这两本书,深入理解缓存与数据库的底层原理。

[AFFILIATE_SLOT_1]

如果你正在构建高并发系统,不妨试试将本文的互斥锁方案集成到你的项目中。同时,关注 MongoDBPostgreSQL 的缓存策略,它们与 Redis 的配合能进一步提升系统性能。

[AFFILIATE_SLOT_2]

结语:如果本文对你有帮助,请点赞、关注、收藏,你的支持就是我最大的鼓励!

 //不存在,查询数据库
        Shop shop = getById(id);
        if (shop== null){
        return Result.fail("商铺信息不存在");}
        //写入缓存
        stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
        return Result.ok(shop);
    @Transactional
    public Result update(Shop shop) {
        Long id = shop.getId();
        if (id==null){
            return Result.fail("店铺id不能为空");
        }
        //更新数据库
        updateById( shop);
        //删除缓存
        stringRedisTemplate.delete(RedisConstants.CACHE_SHOP_KEY+id);
        return Result.ok();
    }
}
时间线:
[开启事务] → [修改 DB 表1] → [修改 DB 表2] → [提交事务] → [删除 Redis 缓存]
                ↑                              ↑              ↑
           事务保证原子性                  提交成功才删缓存    可重试
                                           
如果 DB 修改失败 → 事务回滚 → 不删缓存(缓存数据依然正确)
如果 DB 成功但删缓存失败 → 记录日志 → 异步重试删除
场景:线程A和线程B同时发现缓存不存在

错误流程(没有Double Check):
t=0ms:   线程A: 获取锁成功 → 查数据库(耗时200ms)
t=1ms:   线程B: 获取锁失败 → 休眠50ms → 重试
t=51ms:  线程B: 重试时,缓存还是空的(线程A还没写完)
         线程B: 再次获取锁 → 又查了一次数据库 ❌

正确流程(有Double Check):
t=0ms:   线程A: 获取锁成功 → 查数据库(耗时200ms)
t=1ms:   线程B: 获取锁失败 → 休眠50ms → 重试
t=51ms:  线程B: 重试 → 获取锁成功
         线程B: Double Check → 发现缓存已有数据(线程A写入了)
         线程B: 直接返回,不查数据库 ✅
请求 → 查缓存(没有) → 查数据库(也没有) → 返回空
       ↑                              ↑
   缓存未命中                    每次都查DB
null
java
@Service
public class ShopService {
    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    private ShopMapper shopMapper;
    private static final String CACHE_KEY_PREFIX = "shop:";
    private static final Long NORMAL_TTL = 3600L;  // 正常数据1小时
    private static final Long NULL_TTL = 60L;      // 空对象1分钟
    public Shop getShopById(Long id) {
        String cacheKey = CACHE_KEY_PREFIX + id;
        // 1. 查缓存
        String cachedJson = redisTemplate.opsForValue().get(cacheKey);
        if (cachedJson != null) {
            // 判断是否是空对象标记
            if ("NULL".equals(cachedJson)) {
                return null;
            }
            // 反序列化返回
            return JSON.parseObject(cachedJson, Shop.class);
        }
        // 2. 查数据库
        Shop shop = shopMapper.selectById(id);
        // 3. 缓存结果
        if (shop != null) {
            // 正常数据
            redisTemplate.opsForValue().set(
                cacheKey,
                JSON.toJSONString(shop),
                NORMAL_TTL,
                TimeUnit.SECONDS
            );
        } else {
            // 空对象缓存
            redisTemplate.opsForValue().set(
                cacheKey,
                "NULL",
                NULL_TTL,
                TimeUnit.SECONDS
            );
        }
        return shop;
    }
}

    com.google.guava
    guava
    33.0.0-jre
java
@Service
public class ShopService {
    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    private ShopMapper shopMapper;
    // 布隆过滤器:预计100万数据,误判率0.001
    private BloomFilter bloomFilter = BloomFilter.create(
        Funnels.longFunnel(),
        1_000_000,   // 预期插入数量
        0.001        // 误判率
    );
    @PostConstruct
    public void initBloomFilter() {
        // 启动时加载所有存在的shop_id到布隆过滤器
        List allIds = shopMapper.selectAllIds();
        for (Long id : allIds) {
            bloomFilter.put(id);
        }
        log.info("布隆过滤器初始化完成,加载了 {} 个ID", allIds.size());
    }
    public Shop getShopById(Long id) {
        // 1. 布隆过滤器快速判断
        if (!bloomFilter.mightContain(id)) {
            // 一定不存在,直接返回
            return null;
        }
        // 2. 查缓存
        String cacheKey = "shop:" + id;
        String cachedJson = redisTemplate.opsForValue().get(cacheKey);
        if (cachedJson != null) {
            return JSON.parseObject(cachedJson, Shop.class);
        }
        // 3. 查数据库(可能存在,也可能是误判)
        Shop shop = shopMapper.selectById(id);
        if (shop != null) {
            // 缓存正常数据
            redisTemplate.opsForValue().set(
                cacheKey,
                JSON.toJSONString(shop),
                3600,
                TimeUnit.SECONDS
            );
        }
        return shop;
    }
    // 新增店铺时,同步更新布隆过滤器
    public void addShop(Shop shop) {
        shopMapper.insert(shop);
        bloomFilter.put(shop.getId());
    }
}
# Docker 方式
docker run -p 6379:6379 redislabs/rebloom:latest
java
@Service
public class ShopService {
    @Autowired
    private RedisTemplate redisTemplate;
    @Autowired
    private ShopMapper shopMapper;
    private static final String BLOOM_KEY = "shop_bloom";
    @PostConstruct
    public void initBloom() {
        // 初始化布隆过滤器(不存在则创建)
        redisTemplate.execute((RedisCallback) connection -> {
            connection.executeCommand(
                "BF.RESERVE".getBytes(),
                BLOOM_KEY.getBytes(),
                "0.001".getBytes(),      // 误判率
                "1000000".getBytes()      // 容量
            );
            return true;
        });
        // 加载所有ID到布隆过滤器
        List allIds = shopMapper.selectAllIds();
        for (Long id : allIds) {
            redisTemplate.execute((RedisCallback) connection -> {
                connection.executeCommand(
                    "BF.ADD".getBytes(),
                    BLOOM_KEY.getBytes(),
                    id.toString().getBytes()
                );
                return true;
            });
        }
    }
    public Shop getShopById(Long id) {
        // 1. 布隆过滤器判断
        boolean exists = redisTemplate.execute((RedisCallback) connection -> {
            return connection.executeCommand(
                "BF.EXISTS".getBytes(),
                BLOOM_KEY.getBytes(),
                id.toString().getBytes()
            ) == 1;
        });
        if (!exists) {
            return null;  // 一定不存在
        }
        // 2. 查缓存 + 查数据库(同方案1)
        // ...
    }
}
java
@Service
public class ShopService {
    // 每秒最多10个请求(针对单个ID)
    private final LoadingCache limiters = Caffeine.newBuilder()
        .expireAfterWrite(1, TimeUnit.MINUTES)
        .build(id -> RateLimiter.create(10.0));  // 每秒10个令牌
    public Shop getShopById(Long id) {
        // 限流检查
        RateLimiter limiter = limiters.get(id);
        if (!limiter.tryAcquire()) {
            log.warn("ID {} 请求过于频繁,已被限流", id);
            return null;
        }
        // 正常查询逻辑...
    }
}

    com.alibaba.csp
    sentinel-core
    1.8.6
java
@Service
public class ShopService {
    @PostConstruct
    public void init() {
        // 配置限流规则
        List rules = new ArrayList<>();
        FlowRule rule = new FlowRule();
        rule.setResource("getShopById");
        rule.setGrade(RuleConstant.FLOW_GRADE_QPS);
        rule.setCount(100);  // 每秒100 QPS
        rules.add(rule);
        FlowRuleManager.loadRules(rules);
    }
    public Shop getShopById(Long id) {
        try {
            // 使用 Sentinel 保护
            Entry entry = SphU.entry("getShopById");
            try {
                return doGetShop(id);
            } finally {
                entry.exit();
            }
        } catch (BlockException e) {
            log.warn("被限流了");
            return null;
        }
    }
    private Shop doGetShop(Long id) {
        // 正常查询逻辑...
    }
}
java
public Shop getShopById(Long id) {
    // 1. 基础校验
    if (id == null || id <= 0) {
        return null;
    }
    // 2. ID范围校验(如果是自增ID)
    Long maxId = getMaxShopId();  // 缓存最大ID
    if (id > maxId) {
        return null;
    }
    // 3. 格式校验(如果是雪花算法ID)
    if (String.valueOf(id).length() != 19) {
        return null;
    }
    // 正常查询...
}
""
    public Result queryById(Long id) {
        String key = RedisConstants.CACHE_SHOP_KEY+ id;
        //从Redis中查询商品缓存信息
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        //判断缓存是否存在
        if(StrUtil.isNotBlank(shopJson)){
            //存在,直接返回
            //将json转为对象
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        }
        //判断命中的是否为空
        if (shopJson != null){
            return Result.fail("商铺信息不存在");
        }
        //不存在,查询数据库
        Shop shop = getById(id);
        if (shop== null){
            //将空值写入缓存
            stringRedisTemplate.opsForValue().set(key,"",RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);
        return Result.fail("商铺信息不存在");}
        //写入缓存
        stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
        return Result.ok(shop);
    }
缓存值%%PROTECTED_INLINE_CODE_45%%%%PROTECTED_INLINE_CODE_46%%应该怎么处理
%%PROTECTED_INLINE_CODE_47%%%%PROTECTED_INLINE_CODE_48%%%%PROTECTED_INLINE_CODE_49%%命中,返回数据
%%PROTECTED_INLINE_CODE_50%% (空字符串)%%PROTECTED_INLINE_CODE_51%%%%PROTECTED_INLINE_CODE_52%%命中空值,直接返回失败
%%PROTECTED_INLINE_CODE_53%% (不存在)%%PROTECTED_INLINE_CODE_54%%%%PROTECTED_INLINE_CODE_55%%未命中,查数据库
isNotBlankshopJson != nullnull
java
// 恶意攻击:请求10万个不存在的ID
for (int i = 1000000; i < 1100000; i++) {
    queryById(i);
}
// 第一次请求:查DB → 缓存空值(2分钟过期)
// 第二次请求(2分钟内):如果不处理空值 → 又查DB → 又缓存空值
// 结果:数据库被反复查询,缓存形同虚设
""""""
java
// 场景1:批量设置缓存,过期时间都一样
for (int i = 1; i <= 10000; i++) {
    stringRedisTemplate.opsForValue().set(
        "shop:" + i,
        shopJson,
        3600,  // 都是1小时后过期
        TimeUnit.SECONDS
    );
}
// 1小时后,这10000个key同时过期
// 下一秒的请求全部穿透到数据库 
概念原因特点影响范围
缓存穿透查询不存在的数据缓存和DB都没有单个key
缓存击穿热点key过期缓存没有,DB有单个热点key
缓存雪崩大量key同时过期缓存没有,DB有大量key
// 错误示例:所有key都是同一个过期时间
redis.set("product:1", data, 3600);
redis.set("product:2", data, 3600);
redis.set("product:3", data, 3600);
// ... 10000个
java
@Service
public class ShopService {
    private static final long BASE_TTL = 3600;  // 基础1小时
    private static final Random RANDOM = new Random();
    public void saveShopToCache(Shop shop) {
        String key = "shop:" + shop.getId();
        // 1小时 + 随机0~300秒,避免同时过期
        long randomOffset = RANDOM.nextInt(300);  // 0-300秒
        long ttl = BASE_TTL + randomOffset;
        stringRedisTemplate.opsForValue().set(
            key,
            JSONUtil.toJsonStr(shop),
            ttl,
            TimeUnit.SECONDS
        );
    }
}
java
// 方案A:固定范围随机
long ttl = 3600 + ThreadLocalRandom.current().nextInt(600);  // 1小时 ± 5分钟
// 方案B:按业务类型分组随机
long ttl = 3600 + (id % 300);  // 根据ID取模,分散过期时间
// 方案C:按时间槽分散
int hour = LocalDateTime.now().getHour();
long ttl = 3600 + (hour * 60);  // 不同时段不同TTL
java
@Service
public class ShopService {
    // 缓存永不过期(或者设置很长的TTL,比如7天)
    public void saveShopToCache(Shop shop) {
        String key = "shop:" + shop.getId();
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
        // 不设置过期时间
    }
    // 后台定时任务:每小时刷新一次热点数据
    @Scheduled(cron = "0 0 * * * ?")  // 每小时执行
    public void refreshHotShopCache() {
        // 获取所有热点店铺ID
        List hotShopIds = getHotShopIds();
        for (Long id : hotShopIds) {
            Shop shop = getById(id);
            if (shop != null) {
                stringRedisTemplate.opsForValue().set(
                    "shop:" + id,
                    JSONUtil.toJsonStr(shop)
                );
            }
        }
        log.info("刷新了 {} 个热点店铺缓存", hotShopIds.size());
    }
}
┌─────────────┐
                    │   客户端     │
                    └──────┬──────┘
                           │
              ┌────────────┼────────────┐
              │            │            │
         ┌────▼────┐  ┌────▼────┐  ┌────▼────┐
         │ Node 1  │  │ Node 2  │  │ Node 3  │
         │ 槽0-5460│  │槽5461-  │  │槽10923- │
         │ Master  │  │10922    │  │16383    │
         └────┬────┘  └────┬────┘  └────┬────┘
              │            │            │
         ┌────▼────┐  ┌────▼────┐  ┌────▼────┐
         │ Node 4  │  │ Node 5  │  │ Node 6  │
         │  Slave  │  │  Slave  │  │  Slave  │
         └─────────┘  └─────────┘  └─────────┘
slot = CRC16(key) % 16384
// 计算 key 属于哪个槽
int slot = CRC16.getCRC16("user:1001") % 16384;
// 假设 slot = 12345,就去负责 12345 槽位的节点读取
节点1(Master):负责槽位 0-5460      → 约 1/3 的数据
节点2(Master):负责槽位 5461-10922  → 约 1/3 的数据  
节点3(Master):负责槽位 10923-16383 → 约 1/3 的数据
客户端:set user:1001 "张三"

步骤1:计算槽位
CRC16("user:1001") % 16384 = 12345

步骤2:查询槽位映射(客户端缓存了)
槽位 12345 在节点2上

步骤3:直接连接节点2执行写入

步骤4:节点2写入成功后,异步同步给 Slave
客户端连了节点1,执行 set user:1001 "张三"
节点1计算槽位 = 12345,发现自己不负责
节点1返回:MOVED 12345 192.168.1.2:6379
客户端收到 MOVED,更新本地映射,重试连节点2
写操作                    读操作
          │                         │
          ▼                         ▼
    ┌─────────┐                 ┌─────────┐
    │ Master  │  ──同步──→      │ Slave 1 │
    │ (主节点) │                 │ (从节点) │
    └─────────┘                 └─────────┘
          │                           │
          │  ──同步──→                 │
          ▼                           ▼
    ┌─────────┐                 ┌─────────┐
    │ Slave 2 │                 │  Client │
    │ (从节点) │                 │  读请求  │
    └─────────┘                 └─────────┘
作用说明类比
读写分离Master 写,Slave 读,分担压力老板签字,助理复印
数据备份Slave 实时同步 Master 数据实时云备份
高可用Master 挂了,Slave 自动升级为 Master老板休假,助理顶班
模式数据分片高可用水平扩展复杂度适用场景
主从复制❌ 不分片✅ 读写分离❌ 只能扩读⭐ 简单读多写少
哨兵模式❌ 不分片✅ 自动故障转移❌ 只能扩读⭐⭐ 中等需要自动切换
Redis Cluster✅ 分片✅ 自动故障转移✅ 可扩写⭐⭐⭐ 复杂海量数据、高并发
# 主从复制 + 哨兵模式
spring:
  redis:
    sentinel:
      master: mymaster
      nodes: 
        - 192.168.1.10:26379
        - 192.168.1.11:26379
        - 192.168.1.12:26379
@Configuration
public class RedisConfig {
    
    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisClusterConfiguration clusterConfig = new RedisClusterConfiguration()
            .clusterNode("192.168.1.10", 6379)
            .clusterNode("192.168.1.11", 6379)
            .clusterNode("192.168.1.12", 6379);
        
        return new LettuceConnectionFactory(clusterConfig);
    }
}

    com.github.ben-manes.caffeine
    caffeine
    3.1.8
java
@Service
public class ShopService {
    @Autowired
    private StringRedisTemplate redisTemplate;
    // 本地缓存:最大10000条,5分钟后过期
    private final Cache localCache = Caffeine.newBuilder()
        .maximumSize(10000)
        .expireAfterWrite(5, TimeUnit.MINUTES)
        .build();
    public Shop getShopById(Long id) {
        // 1. 查本地缓存
        Shop shop = localCache.getIfPresent(id);
        if (shop != null) {
            return shop;
        }
        // 2. 查Redis
        String key = "shop:" + id;
        String shopJson = redisTemplate.opsForValue().get(key);
        if (StrUtil.isNotBlank(shopJson)) {
            shop = JSONUtil.toBean(shopJson, Shop.class);
            localCache.put(id, shop);  // 写入本地缓存
            return shop;
        }
        // 3. 查数据库
        shop = getById(id);
        if (shop != null) {
            // 写入Redis
            redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), 3600, TimeUnit.SECONDS);
            localCache.put(id, shop);
        }
        return shop;
    }
}
java
@Service
@Slf4j
public class ShopService {
    @Autowired
    private StringRedisTemplate redisTemplate;
    public Shop getShopById(Long id) {
        try {
            // 限流:每秒最多100个请求
            Entry entry = SphU.entry("getShop");
            try {
                return doGetShop(id);
            } finally {
                entry.exit();
            }
        } catch (BlockException e) {
            // 被限流,返回降级数据
            log.warn("请求被限流,id: {}", id);
            return getFallbackShop(id);
        }
    }
    private Shop doGetShop(Long id) {
        // 正常查询逻辑...
        String key = "shop:" + id;
        String shopJson = redisTemplate.opsForValue().get(key);
        if (StrUtil.isNotBlank(shopJson)) {
            return JSONUtil.toBean(shopJson, Shop.class);
        }
        return getById(id);
    }
    // 降级方案:返回默认数据
    private Shop getFallbackShop(Long id) {
        Shop fallback = new Shop();
        fallback.setId(id);
        fallback.setName("系统繁忙,请稍后重试");
        return fallback;
    }
}
java
@Component
public class CachePreheatRunner implements CommandLineRunner {
    @Autowired
    private ShopService shopService;
    @Override
    public void run(String... args) throws Exception {
        // 系统启动时,加载热门店铺到缓存
        log.info("开始缓存预热...");
        List hotShopIds = getHotShopIds();  // 比如销量前1000的店铺
        for (Long id : hotShopIds) {
            shopService.getShopById(id);  // 触发缓存加载
        }
        log.info("缓存预热完成,共加载 {} 个店铺", hotShopIds.size());
    }
    private List getHotShopIds() {
        // 可以从数据库查询热销店铺ID
        return shopMapper.selectHotShopIds(1000);
    }
}
java
@Component
public class CacheScheduler {
    // 每天凌晨2点预热,早上高峰期缓存都在
    @Scheduled(cron = "0 0 2 * * ?")
    public void preheatBeforePeak() {
        // 预热逻辑...
    }
}