Lecture07-Redis-缓存实战与三大问题

文章目录

    [toc]

    单词词性音标含义
    penetratev./ˈpenɪtreɪt/穿透(缓存穿透:查询一个不存在的数据,打到数据库)
    breakdownn./ˈbreɪkdaʊn/击穿(缓存击穿:某个热点 key 过期瞬间高并发打爆数据库)
    avalanchen./ˈævəlɑːnʃ/雪崩(缓存雪崩:大量 key 同时过期或 Redis 宕机)
    cacheableadj./ˈkæʃəbl/可缓存的(@Cacheable 注解,方法结果自动缓存)
    evictv./ɪˈvɪkt/驱逐(@CacheEvict 注解,删除缓存)
    bloomn./bluːm/布隆(布隆过滤器,判断 key 是否存在,防穿透)
    hot keyn./hɒt kiː/热点键(被高并发访问的 key)
    consistentadj./kənˈsɪstənt/一致的(缓存与数据库的数据一致性)

    Lecture07-Redis-缓存实战与三大问题

    本讲是 Redis 在真实业务里最核心的一课:手动缓存、注解缓存、以及面试/生产必考的缓存穿透、击穿、雪崩。

    1、缓存的基本模式

    提问:为什么需要缓存机制?

    回答:关系型数据库在硬盘存储数据,硬盘天然比内存速度更慢,查询关系型数据库耗时更长。所以从总体架构设计方面来说,大型项目一定会做缓存。做了缓存之后,大部分查询请求都是从缓存中拿取数据,这样还有一个好处是减轻关系型数据库的负担,让关系型数据库更多的负责写操作,读操作大部分由缓存承担。


    1、读操作:Cache Aside(先查缓存)

    ① 查 Redis
        ├── 命中 → 直接返回(不查询数据库)
        └── 未命中 → ② 查数据库 → ③ 回填 Redis(带过期)→ 返回
    
    public Goods getGoods(Long id) {
        // 1. 先查缓存
        String key = "goods:" + id;
        String json = stringRedis.opsForValue().get(key);
        if (json != null) {
            return JSON.parseObject(json, Goods.class);   // 命中
        }
        // 2. 未命中查数据库
        Goods goods = goodsMapper.selectById(id);
        if (goods != null) {
            // 3. 回填缓存(带过期时间)
            stringRedis.opsForValue().set(key, JSON.toJSONString(goods), 30, TimeUnit.MINUTES);
        }
        return goods;
    }
    

    2、写操作:先更库、再删缓存

    为什么是"删缓存"而不是"更新缓存"?因为更新缓存容易和并发读产生不一致(两个写同时改、乱序)。删掉缓存,下次读会重新从库回填,最终一致。

    public void updateGoods(Goods goods) {
        // 1. 先更新数据库
        goodsMapper.updateById(goods);
        // 2. 再删除缓存,让下次读重新回填
        stringRedis.delete("goods:" + goods.getId());
    }
    

    2、注解缓存(@Cacheable)

    Spring 提供声明式缓存,配一个 CacheManager 指向 Redis 即可。

    1、启用缓存 + 配置 Redis 缓存管理器

    spring:
      cache:
        type: redis
        redis:
          time-to-live: 30m        # 默认缓存 30 分钟
          cache-null-values: false # 不缓存空值
    

    2、使用注解

    @Cacheable(value = "goods", key = "#id")
    public Goods getGoods(Long id) {        // 命中缓存直接返回,未命中才执行方法并自动缓存
        return goodsMapper.selectById(id);
    }
    
    @CacheEvict(value = "goods", key = "#goods.id")
    public void updateGoods(Goods goods) {  // 执行后删除缓存
        goodsMapper.updateById(goods);
    }
    

    注解缓存适合简单场景;复杂缓存(手动控制过期、防穿透、分布式锁)用手动缓存更灵活。


    3、缓存穿透(Cache Penetration)

    1、现象

    客户端持续查询数据库中不存在的数据,缓存永远不命中,每次都打到数据库——恶意攻击/异常请求可把数据库打崩。

    请求 不存在ID → 缓存没有 → 数据库没有 → 每次都查库  ← 穿透!
    

    cache-problems


    2、解决方案

    方案思路缺点
    缓存空值查库为 null 也写缓存(value=null,过期短,如 5 分钟)大量空值占用内存
    布隆过滤器请求前先判断 key 是否存在,不存在直接拒绝,不查库需维护、有小误差
    参数校验非法/越界 ID 提前拦截只能挡"明显非法"
    限流 / 黑名单对异常来源限流治标

    推荐组合:参数校验 + 布隆过滤器 + 缓存空值兜底。


    3、缓存空值代码

    public Goods getGoods(Long id) {
        String key = "goods:" + id;
        String json = stringRedis.opsForValue().get(key);
        if (json != null) {
            if ("NULL".equals(json)) return null;      // 命中的空值标记
            return JSON.parseObject(json, Goods.class);
        }
        Goods goods = goodsMapper.selectById(id);
        if (goods == null) {
            // 缓存空值,防止穿透(过期时间设短些)
            stringRedis.opsForValue().set(key, "NULL", 5, TimeUnit.MINUTES);
        } else {
            stringRedis.opsForValue().set(key, JSON.toJSONString(goods), 30, TimeUnit.MINUTES);
        }
        return goods;
    }
    

    4、缓存击穿(Cache Breakdown)

    1、现象

    某个热点 key(如秒杀商品、热搜)正好过期,一瞬间大量并发请求同时查库。

    热点key过期瞬间 → 大量请求同时打数据库  ← 击穿!
    

    2、解决方案

    方案思路
    互斥锁查库前先抢锁,只有抢到锁的线程查库回填,其余等待后走缓存
    逻辑过期缓存不设物理过期,value 里带过期时间,过期后异步刷新(缓存永在)

    互斥锁代码(基于 SETNX,第 8 讲完整讲锁)

    public Goods getGoods(Long id) {
        String key = "goods:" + id;
        String json = stringRedis.opsForValue().get(key);
        if (json != null) return JSON.parseObject(json, Goods.class);
    
        String lockKey = "lock:goods:" + id;
        // 抢锁(带过期,防止锁不释放)
        Boolean ok = stringRedis.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
        if (Boolean.TRUE.equals(ok)) {
            try {
                // 双重检查:可能别人已回填
                json = stringRedis.opsForValue().get(key);
                if (json != null) return JSON.parseObject(json, Goods.class);
                Goods goods = goodsMapper.selectById(id);
                stringRedis.opsForValue().set(key, JSON.toJSONString(goods), 30, TimeUnit.MINUTES);
                return goods;
            } finally {
                stringRedis.delete(lockKey);      // 释放锁
            }
        }
        // 没抢到锁:睡一下再查缓存(简单降级)
        Thread.sleep(50);
        return getGoods(id);                      // 递归重试(生产一般用循环限次)
    }
    

    5、缓存雪崩(Cache Avalanche)

    1、现象

    • 大量 key 在同一时刻过期(如统一设置 30 分钟,同时写入的缓存同时失效)→ 大量请求同时打库。
    • 或 Redis 整体宕机 → 所有请求直接压向数据库。
    大量key同时过期 或 Redis宕机 → 海量请求打爆数据库  ← 雪崩!
    

    2、解决方案

    层面方案
    过期时间设置过期时间时加随机值(30min + random(0~5min)),错开失效时刻
    Redis 高可用主从 + 哨兵 / 集群,避免单点宕机(第 10、11 讲)
    多级缓存本地缓存(Caffeine)兜底,Redis 挂了还有本地内存
    服务降级缓存不可用时返回旧数据/兜底数据,而不是打爆数据库
    提前预热高峰期前预热热点 key

    过期时间加随机值

    // 缓存写入时给过期时间加随机扰动
    int expire = 30 * 60 + ThreadLocalRandom.current().nextInt(5 * 60);
    stringRedis.opsForValue().set(key, json, expire, TimeUnit.SECONDS);
    

    6、缓存与数据库一致性(够用版)

    策略做法一致性
    先更库、再删缓存(推荐)写库成功→删缓存,读时重新回填最终一致,简单
    延迟双删删缓存→等一小段时间→再删一次缓存更强一致(能接受短暂不一致)
    分布式事务 / 监听 binlog更复杂,本课不做展开强一致

    生产最常用:先更新数据库,再删除缓存。若想进一步降低"删除缓存失败"的风险,可配合延迟双删或对删除做重试。


    7、课堂练习

    1. 写一个 getGoods 手动缓存方法:命中返回、未命中查库回填、缓存空值防穿透。
    2. 在热点 key 场景下,用 setIfAbsent 实现互斥锁防击穿(参考上文代码)。
    3. 给缓存过期时间加随机扰动,模拟"错开过期"。
    4. (思考)为什么写操作要"删缓存"而不是"更新缓存"?用并发场景推理说明。

    8、本节验证

    • 掌握 Cache Aside 读/写模式(先查缓存、先更库再删缓存)✅
    • 会用 @Cacheable / @CacheEvict 注解缓存 ✅
    • 区分缓存穿透/击穿/雪崩三种问题及其解决方案 ✅
    • 会写缓存空值、布隆过滤器、互斥锁、过期时间加随机值等应对代码 ✅
    end