12.缓存四件套

12.1 面试题

  • 缓存预热、雪崩、穿透、击穿分别是什么?你遇到过那几个情况?

  • 缓存预热你是怎么做的?

  • 如何避免或者减少缓存雪崩?

  • 穿透和击穿有什么区别?他两是一个意思还是截然不同?

  • 穿透和击穿你有什么解决方案?如何避免?

  • 假如出现了缓存不一致,你有哪些修补方案?


12.2 缓存预热(Cache Warming)

缓存预热(Cache Warming)是一种优化策略,指在系统启动、业务高峰期到来前或缓存失效后,主动将高频访问的数据预先加载到缓存中,避免用户首次请求时因缓存未命中(Cache Miss)而直接访问数据库或后端服务,从而提升系统响应速度和稳定性。

12.2.1 核心原理

  1. 冷启动问题:系统刚启动时缓存为空,大量请求直接穿透到数据库,可能导致瞬时高负载甚至宕机。

  2. 提前加载:通过预测或历史数据分析,提前将热点数据(如商品详情、配置信息等)放入缓存,模拟用户访问路径。

12.2.2 常见的预热方式

  1. 静态数据预热

    • 手动或脚本批量加载固定数据(如城市列表、商品分类)。

    • @PostConstruct 初始化白名单数据

  2. 动态预热

    • 基于历史访问日志,分析高频热点数据并提前加载。

    • 使用机器学习预测未来可能访问的数据。

  3. 模拟请求预热

    • 通过自动化工具(如 JMeter)模拟用户请求,触发缓存生成。
  4. 旁路预热

    • 在缓存系统旁部署预热服务,异步更新缓存数据。

12.2.3 典型应用场景

  • 电商大促前:提前加载促销商品、秒杀商品到缓存。

  • 服务重启后:避免因缓存失效导致数据库压力激增。

  • 定时任务:结合业务周期(如每天凌晨)刷新缓存。


12.3 缓存雪崩

12.3.1 场景

  • redis 主机挂了,redis 全盘崩溃,偏硬件运维(运维拜拜)

  • redis 中有大量 key 同时过期大面积失效,偏软件开发(我卷铺盖跑路)

12.3.2 预防+解决

  • redis 中 key 设置为永不过期 or 过期时间错开

  • redis 缓存集群实现高可用

    • 主从+哨兵

    • Redis Cluster

    • 开启 Redis 持久化机制 AOF、RDB,尽快恢复缓存集群

  • 多缓存结合预防雪崩

    • ehcache 本地缓存(用户端)+redis 缓存
  • 服务降级

    • Hystrix 或者阿里 sentinel 限流&降级
  • 人民币玩家

    • 阿里云-云数据库 redis 版

12.4 缓存穿透

12.4.1 是什么

  • 请求去查询一条记录,先查 redis 无,后查 mysql 无,都查询不到该条记录,但是请求每次都会打到数据库上面,导致后台数据库压力暴增,这种现象我们称为缓存穿透,这个 redis 变成了一个摆设

  • 简单来说:本来无一物,两库都没有。既不在 Redis 缓存库,也不在 mysql,数据库存在被多次暴击风险

12.4.2 解决

方案一:回写增强

如果发生了缓存穿透,我可以针对要查询的数据,在 Redis 里存一个和业务部门商量后确定的缺省值(比如:0,负数、defaultNull 等)

  • 比如,键 uid:abcdxxx,值为 defaultNull 作为案例的 key 和 value

  • 先去 redis 查键 uid:abcdxxx 没有,再去 mysql 查没有获得,这就发生了一次穿透现象

mysql 也查不到的话也让 redis 存入刚刚查不到的 key 并保护 mysql。

第一次来查询 uid:abcdxxx,redis 和 mysql 都没有,返回 null 给调用者,但是增强回写后第二次来查 uid:abcdxxx,此时 redis 就有值了。

可以直接从 Redis 中读取 default 缺省值返回给业务应用程序,避免了把大量请求发送给 mysql 处理,打爆 mysql。

但是,此方法架不住黑客的恶意攻击,有缺陷...,只能解决 key 相同的情况

黑客或者恶意攻击
  • 黑客会对你的系统进行攻击,拿一个不存在的 id 去查询数据,会产生大量的请求到数据库去查询。可能会导致你的数据库由于压力过大而岩掉

  • key 相同打你系统

    • 第一次打到 mysgl,空对象缓存后第二次就返回 defaultNull 缺省值,避免 mysgl 被攻击,不用再到数据库中去走一圈了
  • key 不同打你系统

    • 由于存在空对象缓存和缓存回写(看自己业务不限死),redis 中的无关紧要的 key 也会越写越多(记得设置 redis 过期时间)
方案二:Guava

Google 布隆过滤器 Guava(瓜哇)解决缓存穿透

Guava 中布隆过滤器的实现算是比较权威的所以实际项目中我们可以直接使用 Guava 布隆过滤器

源码

案例:白名单过滤器
  • 架构

    让布隆过滤器作白名单使用:白名单里面有的才让通过,没有直接返回。但是存在误判,由于误判率很小,1%的打到 mysql,可以接受
    使用注意:所有 key 都需要往 redis 和 bloomfilter 里面放入

  • 误判问题,但是概率小可以接受,不能从布隆过滤器删除

  • 全部合法的 key 都需要放入 Guava 版布隆过滤器+redis 里面,不然数据就是返回 null

  • Guava 数据是存在 JVM 中的,与 redis 完全解耦了

实战 Coding
  • case1:新建测试案例,hello 入门

    python 复制代码
      @Test
        public void testGuavaWithBloomFilter()
        {
    // 创建布隆过滤器对象
            BloomFilter<Integer> filter = BloomFilter.create(Funnels.integerFunnel(), 100);
    // 判断指定元素是否存在
            System.out.println(filter.mightContain(1));
            System.out.println(filter.mightContain(2));
    // 将元素添加进布隆过滤器
            filter.put(1);
            filter.put(2);
            System.out.println(filter.mightContain(1));
            System.out.println(filter.mightContain(2));
        }
     
  • case2:

    GuavaBloomFilterController

    java 复制代码
     package com.atguigu.redis7.controller;
    
    import com.atguigu.redis7.service.GuavaBloomFilterService;
    import io.swagger.annotations.Api;
    import io.swagger.annotations.ApiOperation;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.web.bind.annotation.PathVariable;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RequestMethod;
    import org.springframework.web.bind.annotation.RestController;
    
    import javax.annotation.Resource;
    
    /**
     * @auther zzyy
     * @create 2022-12-30 16:50
     */
    @Api(tags = "google工具Guava处理布隆过滤器")
    @RestController
    @Slf4j
    public class GuavaBloomFilterController
    {
        @Resource
        private GuavaBloomFilterService guavaBloomFilterService;
    
        @ApiOperation("guava布隆过滤器插入100万样本数据并额外10W测试是否存在")
        @RequestMapping(value = "/guavafilter",method = RequestMethod.GET)
        public void guavaBloomFilter()
        {
            guavaBloomFilterService.guavaBloomFilter();
        }
    }

    GuavaBloomFilterService

    java 复制代码
    package com.atguigu.redis7.service;
    
    import com.google.common.hash.BloomFilter;
    import com.google.common.hash.Funnels;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.stereotype.Service;
    
    import java.util.ArrayList;
    import java.util.List;
    
    /**
     * @auther zzyy
     * @create 2022-12-30 16:50
     */
    @Service
    @Slf4j
    public class GuavaBloomFilterService{
        public static final int _1W = 10000;
        //布隆过滤器里预计要插入多少数据
        public static int size = 100 * _1W;
        //误判率,它越小误判的个数也就越少(思考,是不是可以设置的无限小,没有误判岂不更好)
        //fpp the desired false positive probability
        public static double fpp = 0.03;
        // 构建布隆过滤器
        private static BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), size,fpp);
        public void guavaBloomFilter(){
            //1 先往布隆过滤器里面插入100万的样本数据
            for (int i = 1; i <=size; i++) {
                bloomFilter.put(i);
            }
            //故意取10万个不在过滤器里的值,看看有多少个会被认为在过滤器里
            List<Integer> list = new ArrayList<>(10 * _1W);
            for (int i = size+1; i <= size + (10 *_1W); i++) {
                if (bloomFilter.mightContain(i)) {
                    log.info("被误判了:{}",i);
                    list.add(i);
                }
            }
            log.info("误判的总数量::{}",list.size());
        }
    }

取样本 100W 数据,查查不在 100W 范围内,其它 10W 数据是否存在


12.4.3 布隆过滤器说明

12.4.4 自我总结

没有 100%的过滤,实战可以使用 Guava 过滤 99%,那 1%使用返回缺省值(defaultNull)方法(设置较短过期时间)

12.5 黑名单(抖音防止推荐重复视频)

以下是针对防止用户刷到重复视频的不同实现方案,按照技术特点和应用场景分类说明:


方案一:实时行为追踪方案(强一致性)

实现核心:实时记录+即时过滤

java 复制代码
// 使用Redis Bitmap存储用户观看记录(适合海量用户场景)
public class BitmapHistoryService {
    private static final int MAX_VIDEO_ID = 1_0000_0000; // 视频ID最大值
    public void markViewed(String userId, long videoId) {
        Redis.setbit(userId, videoId, true); // 每个用户一个bitmap
    }
  
    public boolean isViewed(String userId, long videoId) {
        return Redis.getbit(userId, videoId);
    }
}

// 推荐服务使用时快速过滤
candidates.stream()
         .filter(v -> !bitmapHistory.isViewed(userId, v.getId()))
         .collect(Collectors.toList());

优点:精确去重、查询效率 O(1)

缺点:存储成本高(1 亿视频需要 12MB/用户)、冷启动问题


方案二:滑动时间窗方案(平衡型)

实现核心:仅保留近期记录

java 复制代码
// 使用Redis ZSET实现滑动窗口(保留最近N个)
public class SlidingWindowService {
    private static final int WINDOW_SIZE = 500;
  
    public void addView(String userId, String videoId) {
        Redis.zadd(userKey(userId), System.currentTimeMillis(), videoId);
        Redis.zremrangeByRank(userKey(userId), 0, -WINDOW_SIZE-1);
    }
  
    public Set<String> getRecentViews(String userId) {
        return Redis.zrevrange(userKey(userId), 0, WINDOW_SIZE-1);
    }
}

优点:内存可控、符合短视频消费特点

缺点:可能漏掉早期重复内容


方案三:概率型过滤方案(高吞吐量)

实现核心:布隆过滤器+LRU 缓存

java 复制代码
// 分层过滤架构
public class ProbabilisticFilter {
    private BloomFilter<String> bloomFilter = BloomFilter.create(0.01);
    private LRUCache<String, Boolean> lruCache = new LRUCache<>(1000);

    public boolean mightContain(String videoId) {
        if (lruCache.contains(videoId)) return true;
        return bloomFilter.mightContain(videoId);
    }

    public void markViewed(String videoId) {
        lruCache.put(videoId, true);
        bloomFilter.put(videoId);
    }
}

优点:内存占用极小(1 亿视频仅需 114MB,误判率 1%)

缺点:存在误判可能、无法删除记录


方案四:客户端协同方案(降级方案)

实现核心:客户端存储+服务端校验

javascript 复制代码
// 客户端本地存储最近100条观看记录
localStorage.setItem('viewHistory', 
  JSON.stringify([...prevHistory, newVideoId].slice(-100)));

// 服务端二次验证
List<Video> filterDuplicates(List<Video> candidates, 
                           Set<String> clientHistory) {
    return candidates.stream()
        .filter(v -> !clientHistory.contains(v.id))
        .collect(Collectors.toList());
}

优点:减轻服务端压力

缺点:数据易被篡改、多设备同步困难


方案五:特征空间去重方案(内容相似过滤)

实现核心:视频特征向量+相似度计算

python 复制代码
# 使用Faiss进行向量相似检测(需GPU加速)
index = faiss.IndexHNSWFlat(512, 32)  # 512维特征
user_history = get_user_vectors(user_id)
index.add(user_history)

# 过滤相似内容
D, I = index.search(candidate_vectors, 1)
return [candidates[i] for i in np.where(D > threshold)[0]]

优点:防止相似内容重复

缺点:计算成本高、需要特征工程


方案六:混合分级方案(工业级实践)

典型架构

  1. 第一层:客户端本地记录(最近 50 条)

  2. 第二层:Redis Bloom 模块(全量记录,0.1%误判率)

  1. 第三层:HBase 精准查询(长周期去重)

  2. 第四层:推荐算法多样性控制(内容类型/作者过滤)

java 复制代码
// 分级检查实现
public boolean isDuplicate(String userId, String videoId) {
    // 1. 检查客户端上报记录
    if (clientReportedHistory.contains(videoId)) return true;
  
    // 2. 布隆过滤器快速判断
    if (!redisBloom.mightContain(userId, videoId)) return false;
  
    // 3. HBase精准验证
    return hBaseClient.checkViewed(userId, videoId);
}

方案对比表

方案 精确度 内存消耗 计算成本 实施难度 适用场景
实时 Bitmap 100% 极高 中小型系统
滑动窗口 95% 通用场景
布隆过滤器 99% 极低 最低 超大规模系统
客户端协同 80% 降级方案
特征空间去重 动态 极高 内容安全场景
混合分级方案 99.9% 极高 头部短视频平台

选择建议

  1. 初创公司:滑动窗口方案(Redis ZSET)

  2. 快速扩张期:布隆过滤器+客户端协同

  1. 头部平台:混合分级方案+特征空间过滤

  2. 特定内容场景:特征空间去重优先

实际工程中,抖音这类头部平台通常会采用客户端本地记录(最近 200 条)+ 服务端布隆过滤器(7 天记录)+ HBase 长期存储(精准去重)的三级架构,同时推荐系统本身会加入多样性控制算法,从多个维度共同解决重复问题。


12.6 缓存击穿

12.6.1 是什么

  • 大量的请求同时查询一个 key 时,此时这个 key 正好失效了,就会导致大量的请求都打到数据库上面去

  • 简单说就是热点 key 突然失效了,暴打 mysql

  • 备注:穿透和击穿,截然不同

12.6.2 危害

  • 会造成某一时刻数据库请求量增大,压力剧增

  • 一般技术部门需要知道**热点 key 是那些个,**做到心里有数防止击穿

12.6.3 解决

  • 热点 key 失效

    • 时间到了自然清楚但还被访问到

    • delete 掉的 key,刚巧又被访问

  • 方案一:逻辑过期

    • 差异失效时间,对于访问频繁的热点 key,干脆就不设置过期时间
  • 方案二:双检加锁

    • 互斥更新,采用双检加锁策略

    多个线程同时去查询数据库的这条数据,那么我们可以在第一个查询数据的请求上使用一个 互斥锁来锁住它。

    其他的线程走到这一步拿不到锁就等着,等第一个线程查询到了数据,然后做缓存。后面的线程进来发现已经有缓存了,就直接走缓存。

12.7 案例

天猫聚划算功能实现+防止缓存击穿

问题:热点 key 突然失效导致了缓存击穿

12.7.1 分析

选择 list

12.7.2 正常业务

JHSTaskService

java 复制代码
@Service
@Slf4j
public class JHSTaskService
{
    public  static final String JHS_KEY="jhs";
    public  static final String JHS_KEY_A="jhs:a";
    public  static final String JHS_KEY_B="jhs:b";

    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 偷个懒不加mybatis了,模拟从数据库读取100件特价商品,用于加载到聚划算的页面中
     * @return
     */
    private List<Product> getProductsFromMysql() {
        List<Product> list=new ArrayList<>();
        for (int i = 1; i <=20; i++) {
            Random rand = new Random();
            int id= rand.nextInt(10000);
            Product obj=new Product((long) id,"product"+i,i,"detail");
            list.add(obj);
        }
        return list;
    }

    @PostConstruct
    public void initJHS(){
        log.info("启动定时器淘宝聚划算功能模拟.........."+ DateUtil.now());
        new Thread(() -> {
            //模拟定时器一个后台任务,定时把数据库的特价商品,刷新到redis中
            while (true){
                //模拟从数据库读取100件特价商品,用于加载到聚划算的页面中
                List<Product> list=this.getProductsFromMysql();
                //采用redis list数据结构的lpush来实现存储
                this.redisTemplate.delete(JHS_KEY);
                //lpush命令
                this.redisTemplate.opsForList().leftPushAll(JHS_KEY,list);
                //间隔一分钟 执行一遍,模拟聚划算每3天刷新一批次参加活动
                try { TimeUnit.MINUTES.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }

                log.info("runJhs定时刷新..............");
            }
        },"t1").start();
    }
}
 

JHSProductController

java 复制代码
@RestController
@Slf4j
@Api(tags = "聚划算商品列表接口")
public class JHSProductController
{
    public  static final String JHS_KEY="jhs";
    public  static final String JHS_KEY_A="jhs:a";
    public  static final String JHS_KEY_B="jhs:b";

    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 分页查询:在高并发的情况下,只能走redis查询,走db的话必定会把db打垮
     * @param page
     * @param size
     * @return
     */
    @RequestMapping(value = "/pruduct/find",method = RequestMethod.GET)
    @ApiOperation("按照分页和每页显示容量,点击查看")
    public List<Product> find(int page, int size) {
        List<Product> list=null;

        long start = (page - 1) * size;
        long end = start + size - 1;

        try {
            //采用redis list数据结构的lrange命令实现分页查询
            list = this.redisTemplate.opsForList().range(JHS_KEY, start, end);
            if (CollectionUtils.isEmpty(list)) {
                //TODO 走DB查询
            }
            log.info("查询结果:{}", list);
        } catch (Exception ex) {
            //这里的异常,一般是redis瘫痪 ,或 redis网络timeout
            log.error("exception:", ex);
            //TODO 走DB查询
        }

        return list;
    }
}

12.7.3 BUG 和隐患

  • 热点 key 突然失效导致可怕的缓存击穿

    • delete 命令执行的一瞬间有空隙,其他请求线程继续找 redis 为 null

    • 打到 mysql,暴击 9999

12.7.4 进一步升级巩固

  • 互斥更新,采用双检加锁策略

  • 差异失效时间

JHSTaskService

java 复制代码
@Service
@Slf4j
public class JHSTaskService
{
    public  static final String JHS_KEY="jhs";
    public  static final String JHS_KEY_A="jhs:a";
    public  static final String JHS_KEY_B="jhs:b";

    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 偷个懒不加mybatis了,模拟从数据库读取100件特价商品,用于加载到聚划算的页面中
     * @return
     */
    private List<Product> getProductsFromMysql() {
        List<Product> list=new ArrayList<>();
        for (int i = 1; i <=20; i++) {
            Random rand = new Random();
            int id= rand.nextInt(10000);
            Product obj=new Product((long) id,"product"+i,i,"detail");
            list.add(obj);
        }
        return list;
    }

    //@PostConstruct
    public void initJHS(){
        log.info("启动定时器淘宝聚划算功能模拟.........."+ DateUtil.now());
        new Thread(() -> {
            //模拟定时器,定时把数据库的特价商品,刷新到redis中
            while (true){
                //模拟从数据库读取100件特价商品,用于加载到聚划算的页面中
                List<Product> list=this.getProductsFromMysql();
                //采用redis list数据结构的lpush来实现存储
                this.redisTemplate.delete(JHS_KEY);
                //lpush命令
                this.redisTemplate.opsForList().leftPushAll(JHS_KEY,list);
                //间隔一分钟 执行一遍
                try { TimeUnit.MINUTES.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }

                log.info("runJhs定时刷新..............");
            }
        },"t1").start();
    }

    @PostConstruct
    public void initJHSAB(){
        log.info("启动AB定时器计划任务淘宝聚划算功能模拟.........."+DateUtil.now());
        new Thread(() -> {
            //模拟定时器,定时把数据库的特价商品,刷新到redis中
            while (true){
                //模拟从数据库读取100件特价商品,用于加载到聚划算的页面中
                List<Product> list=this.getProductsFromMysql();
                //先更新B缓存
                this.redisTemplate.delete(JHS_KEY_B);
                this.redisTemplate.opsForList().leftPushAll(JHS_KEY_B,list);
                this.redisTemplate.expire(JHS_KEY_B,20L,TimeUnit.DAYS);
                //再更新A缓存
                this.redisTemplate.delete(JHS_KEY_A);
                this.redisTemplate.opsForList().leftPushAll(JHS_KEY_A,list);
                this.redisTemplate.expire(JHS_KEY_A,15L,TimeUnit.DAYS);
                //间隔一分钟 执行一遍
                try { TimeUnit.MINUTES.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }

                log.info("runJhs定时刷新双缓存AB两层..............");
            }
        },"t1").start();
    }
}

JHSProductController

java 复制代码
@RestController
@Slf4j
@Api(tags = "聚划算商品列表接口")
public class JHSProductController
{
    public  static final String JHS_KEY="jhs";
    public  static final String JHS_KEY_A="jhs:a";
    public  static final String JHS_KEY_B="jhs:b";

    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 分页查询:在高并发的情况下,只能走redis查询,走db的话必定会把db打垮
     * @param page
     * @param size
     * @return
     */
    @RequestMapping(value = "/pruduct/find",method = RequestMethod.GET)
    @ApiOperation("按照分页和每页显示容量,点击查看")
    public List<Product> find(int page, int size) {
        List<Product> list=null;

        long start = (page - 1) * size;
        long end = start + size - 1;

        try {
            //采用redis list数据结构的lrange命令实现分页查询
            list = this.redisTemplate.opsForList().range(JHS_KEY, start, end);
            if (CollectionUtils.isEmpty(list)) {
                //TODO 走DB查询
            }
            log.info("查询结果:{}", list);
        } catch (Exception ex) {
            //这里的异常,一般是redis瘫痪 ,或 redis网络timeout
            log.error("exception:", ex);
            //TODO 走DB查询
        }

        return list;
    }

    @RequestMapping(value = "/pruduct/findab",method = RequestMethod.GET)
    @ApiOperation("防止热点key突然失效,AB双缓存架构")
    public List<Product> findAB(int page, int size) {
        List<Product> list=null;
        long start = (page - 1) * size;
        long end = start + size - 1;
        try {
            //采用redis list数据结构的lrange命令实现分页查询
            list = this.redisTemplate.opsForList().range(JHS_KEY_A, start, end);
            if (CollectionUtils.isEmpty(list)) {
                log.info("=========A缓存已经失效了,记得人工修补,B缓存自动延续5天");
                //用户先查询缓存A(上面的代码),如果缓存A查询不到(例如,更新缓存的时候删除了),再查询缓存B
                this.redisTemplate.opsForList().range(JHS_KEY_B, start, end);
                //TODO 走DB查询
            }
            log.info("查询结果:{}", list);
        } catch (Exception ex) {
            //这里的异常,一般是redis瘫痪 ,或 redis网络timeout
            log.error("exception:", ex);
            //TODO 走DB查询
        }
        return list;
    }
}

12.8 总结

相关推荐
没有bug.的程序员2 小时前
JAVA面试宝典 -《缓存架构:穿透 / 雪崩 / 击穿解决方案》
java·缓存·面试
YuforiaCode2 小时前
使用DataGrip连接安装在Linux上的Redis
数据库·redis·缓存
伊织code5 小时前
Streamlit 官翻 2 - 开发指南 Develop Concepts
缓存·框架·应用·streamlit·状态·表单·执行
知北游天7 小时前
Linux:多线程---深入生产消费模型&&环形队列生产消费模型
linux·缓存
cui_hao_nan8 小时前
Redis总结
redis·缓存
程序员水自流10 小时前
Redis主从复制数据同步实现原理详细介绍
服务器·redis·缓存
胆大的11 小时前
“Operation not permitted”通常是因为容器缺少必要的权限导致 Redis 无法启动后台任务
redis·缓存
smileNicky12 小时前
接口幂等性设计:用Redis避免接口重复请求
数据库·redis·缓存
没有口袋啦1 天前
Redis 介绍和部署
数据库·redis·缓存