【Redis学习 | 第5篇】Redis缓存 —— 缓存的概念 + 缓存穿透 + 缓存雪崩 + 缓存击穿

文章目录

  • 完成任务
    • [1. 什么是缓存](#1. 什么是缓存)
    • [2. 添加商户缓存](#2. 添加商户缓存)
    • [3. 缓存更新策略](#3. 缓存更新策略)
      • [3.1 主动更新](#3.1 主动更新)
    • [4. 缓存穿透](#4. 缓存穿透)
    • [5. 缓存雪崩](#5. 缓存雪崩)
    • [6. 缓存击穿](#6. 缓存击穿)
      • [6.1 使用互斥锁查询商铺信息](#6.1 使用互斥锁查询商铺信息)
      • [6.2 使用逻辑过期查询商铺信息](#6.2 使用逻辑过期查询商铺信息)
    • [7. 封装 Redis 工具类](#7. 封装 Redis 工具类)

完成任务

1. 什么是缓存

缓存:数据交换的缓冲区(Cache),是临时存储数据的地方,一般读写性能较高

比如说,CPU读取数据是内存从磁盘中读取,再到CPU,磁盘中读取数据速度非常慢,于是在 CPU 中设置一个缓冲区 ,将 CPU 常用的数据存储在该缓冲区中,需要使用这些数据时,直接从缓冲区中读取要比从磁盘中读取快得多!

  • 缓存的作用: 降低后端负载;提高读写效率,降低响应时间
  • 缓存的成本:数据一致性成本;代码维护成本;运维成本

2. 添加商户缓存

使用Redis,用户访问商铺信息的过程:

关于 Redis 的操作有:

  1. 先从 Redis 中查询数据,如果 Redis 中存在响应数据,则返回给客户端。
  2. 如果 Redis 中不存在响应数据,将从数据库中查询到的数据存储到 Redis,以便于下次访问时,直接从 Redis 中获取,效率会提高很多。

实现代码:

java 复制代码
	/**
     * 根据id查询店铺信息
     */
    @Override
    public Result queryById(Long id) {
        // 1. 查询 Redis 缓存
        String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
        // 2. 判断是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            // 3. 如果存在,从 Redis 缓存中获取返回
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }
        // 4. 不存在,根据 id 查询数据库
        Shop shop = getById(id);

        // 5. 如果 shop 不存在,返回错误
        if (shop == null) {
            return Result.fail("店铺不存在!");
        }
        // 6. 存在,将 shop 存入 Redis 缓存
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop));
        // 7. 返回
        return Result.ok(shop);
    }

对比一下,从 Redis 中获取数据和从数据库中获取数所需时间:

第一次访问,Redis 中没有存储对应数据,需要从数据库中获取,花费较多的时间。第二次访问,直接从 Redis 中获取数据,可以发现,需要的时间比较少。

查询商铺类型使用 Redis,因为商铺类型基本都是静态的,不会很大地变动,所以建议使用 Redis:

java 复制代码
	/**
     * 查询所有商铺类型
     * @return
     */
    @Override
    public Result queryTypeList() {
        // 1. 从 Redis 中获取缓存
        String shopTypesJson = stringRedisTemplate.opsForValue().get(CACHE_SHOPTYPE_KEY);
        // 2. 判断缓存是否存在
        if (shopTypesJson != null) {
            // 3. 缓存存在,直接返回
            List<ShopType> shopTypes = JSONUtil.toList(shopTypesJson, ShopType.class);
            return Result.ok(shopTypes);
        }
        // 4. 缓存不存在,查询数据库
        List<ShopType> shopTypes = this.query().orderByAsc("sort").list();
        // 5. 将查询结果缓存到 Redis
        stringRedisTemplate.opsForValue().set(CACHE_SHOPTYPE_KEY, JSONUtil.toJsonStr(shopTypes));

        return Result.ok(shopTypes);
    }

3. 缓存更新策略

(1)内存淘汰 :当 Redis 内存不足时,自动淘汰部分数据,下次查询时更新缓存。一致性差,无维护成本。

(2)超时剔除 :给缓存数据添加 TTL 时间,到期后自动删除缓存。下次查询时更新缓存。一致性一般,维护成本低。

(3)主动更新:编写业务逻辑,再修改数据库的同时,更新缓存。一致性好,维护成本高。

  • 低一致性需求(不经常修改数据):使用内存淘汰机制。
  • 高一致性需求(需要常常对数据进行修改):使用主动更新,并以超时剔除作为兜底。比如说:优惠券。

3.1 主动更新

在更新数据库的同时更新缓存。

  • 删除缓存 还是更新缓存?
    不采取更新缓存:每次更新数据库都要更新缓存,增加无效写 操作。比如,当更新了100次数据库,那就要更新100次缓存,而这期间并未对缓存的内容进行访问,此时就是有100次无效写的操作。
    采取删除缓存:更新数据库时让缓存失效,查询时再更新缓存。
  • 如何保证缓存和数据库操作的同时成功或失败?
    单体系统:将缓存与数据库操作放在一个事务中
    分布式系统:利用 TCC 等分布式事务方案
  • 先操作数据库,再删除缓存。这样发生线程安全的可能性更低。

更新店铺信息,同时更新数据库和缓存中的数据信息:

java 复制代码
	/**
     * 更新店铺信息
     */
    @Override
    public Result update(Shop shop) {
        Long id = shop.getId();
        if (null == id) {
            return Result.fail("店铺id不能为空!");
        }
        // 1. 更新数据库
        updateById(shop);
        // 2. 删除缓存
        stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
        return Result.ok();
    }

4. 缓存穿透

缓存穿透:客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。

如果客户端请求一个根本不存在的 id,数据库只能返回 null 给客户端,客户端收到 null,再次请求... ...

当一个人恶意地使用多个线程并发请求数据库中根本不存在的 id,这些请求都会到达数据库,很可能使数据库崩溃。

那应该怎么解决缓存穿透问题呢?

(1)缓存空对象 :实现简单,维护方便;但会有额外的内存消耗。

(2)布隆过滤 :内存占用较少,没有多余的 key;但实现复杂,存在误判 的可能。

那布隆过滤器怎么知道数据库是否存在当前访问的数据?

  • 可理解布隆过滤器中有一个byte 数组,里面存储二进制位 ,当要判断数据库中是否存在当前访问对象时,把数据库中的是数据基于某种哈希算法计算出哈希值,再将哈希值转换为二进制位保存在布隆过滤器中,以0和1的形式进行保存,判断数据是否存在时,就是判断对应的位置是0还是1,以此判断数据是否存在。
  • 判断存在是有误判的可能 的。也就是说,布隆过滤器判断不存在,那就一定是不存在;但如果判断是存在的,也有可能数据库中并不存在。

5. 缓存雪崩

缓存雪崩 :指同一时段大量的缓存 key 同时失效 或者 Redis 服务宕机,导致大量请求到达数据库,带来巨大压力。

解决方案:

(1)给不同的 key 的 TTL 添加随机值 ------> 大量缓存同时失效

(2)利用 Redis 集群提高服务的可用性 ------> Redis 服务宕机

(3)给缓存业务添加降级限流策略

(4)给业务添加多级缓存

6. 缓存击穿

缓存击穿 :也叫热点 key 问题 ,就是一个高并发访问 并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。

解决方案:

(1)互斥锁

(2)逻辑过期

优点 缺点
互斥锁 没有额外的内存消耗;保证一致性;实现简单 线程需要等待,性能受影响;可能有死锁的风险
逻辑过期 线程无需等待,性能较好 不保证一致性;有额外的内存消耗;实现复杂

6.1 使用互斥锁查询商铺信息

java 复制代码
public Shop queryWithMutex(Long id) {
        // 1. 查询 Redis 缓存
        String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
        // 2. 判断是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            // 3. 如果存在,从 Redis 缓存中获取返回
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return shop;
        }
        // 判断是否为空字符串
        if (shopJson.equals("")) {
            return null;
        }

        // 4. 实现缓存重建
        // 4.1 尝试获取锁
        Shop shop = null;
        try {
            boolean lock = tryLock(LOCK_SHOP_KEY + id);
            // 4.2 判断是否获取到锁
            if (!lock) {
                // 4.3 获取锁失败,休眠并重试
                Thread.sleep(50);
                return queryWithMutex(id);
            }
            // 4.4 获取锁成功,根据 id 查询数据库
            shop = getById(id);

            // 5. 如果 shop 不存在,返回错误
            if (shop == null) {
                // 将空字符串写入 Redis 缓存
                stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
                // 返回错误信息
                return null;
            }
            // 6. 存在,将 shop 存入 Redis 缓存
            stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            // 7. 释放锁
            unLock(LOCK_SHOP_KEY + id);
        }

        // 8. 返回
        return shop;
    }
    
	/**
     * 尝试获取锁
     */
    private boolean tryLock(String key) {
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }

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

6.2 使用逻辑过期查询商铺信息

java 复制代码
// 逻辑过期解决缓存击穿
    public Shop queryWithLogicalExpire(Long id) {
        // 1. 查询 Redis 缓存
        String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
        // 2. 判断是否存在
        if (StrUtil.isBlank(shopJson)) {
            // 3. 如果不存在,从 Redis 缓存中获取返回
            return null;
        }

        // 4. 存在,Json 反序列化为对象
        RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
        Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);

        // 5. 判断是否过期
        if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
            // 5.1 未过期,直接返回
            return shop;
        }
        // 5.2 过期,缓存重建
        // 6. 缓存重建
        // 6.1 获取互斥锁
        boolean flag = tryLock(LOCK_SHOP_KEY + id);
        // 6.2 判断是否获取到锁
        if (flag) {
            // 6.3 如果获取到锁,开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    // 缓存重建
                    saveShop2Redis(id, 30L);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    // 释放锁
                    unLock(LOCK_SHOP_KEY + id);
                }
            });

        }
        // 6.4 未获取到锁,返回过期的缓存数据
        return shop;
    }

	/**
     * 保存店铺信息到 Redis
     */
    public void saveShop2Redis(Long id, Long expireSeconds) {
        // 1. 查询数据库
        Shop shop = getById(id);
        // 2. 封装逻辑过期时间
        RedisData redisData = new RedisData();
        redisData.setData(shop);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
        // 3. 存入 Redis
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
    }

7. 封装 Redis 工具类

Redis 的缓存穿透和缓存击穿的解决方法还是比较复杂的,如果每次都重写这些方法,会浪费较多的时间,所以需要将对 Redis 的缓存穿透和缓存击穿的解决方法封装到一个工具类中

这段代码有比较高的复用性,我粘贴在这里,以便于以后使用:

java 复制代码
@Slf4j
@Component
public class CacheClient {

    private final StringRedisTemplate stringRedisTemplate;

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

    /**
     * 将任意 Java 对象序列化为 json 并存储在 String 类型的 key 中,并可以设置TTL过期时间
     */
    public void set(String key, Object value, Long time, TimeUnit unit) {
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
    }

    /**
     * 将任意 Java 对象序列化为 json 并存储在 String 类型的 key 中,并可以设置逻辑过期时间,用于处理缓存击穿问题
     */
    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));
    }

    /**
     * 根据指定的 key 查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
     */
    public <R, ID> R queryWithPassThrough(
            String predixKey, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
        String key = predixKey + id;
        // 1. 查询 Redis 缓存
        String json = stringRedisTemplate.opsForValue().get(key);
        // 2. 判断是否存在
        if (StrUtil.isNotBlank(json)) {
            // 3. 如果存在,从 Redis 缓存中获取返回
            return JSONUtil.toBean(json, type);
        }
        // 判断是否为空值
        if (json != null) {
            // 返回错误信息
            return null;
        }

        // 4. 不存在,根据 id 查询数据库
        R r = dbFallback.apply(id);

        // 5. 如果 shop 不存在,返回错误
        if (r == null) {
            // 将空字符串写入 Redis 缓存
            stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            // 返回错误信息
            return null;
        }
        // 6. 存在,将 shop 存入 Redis 缓存
        set(key, r, time, unit);
        // 7. 返回
        return r;
    }
    
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    /**
     * 根据指定的 key 查询缓存,并反序列化为指定类型,利用逻辑过期的方式解决缓存击穿问题
     */
    public <R, ID> R queryWithLogicalExpire(
            String prefixKey, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
        String key = prefixKey + id;
        // 1. 查询 Redis 缓存
        String json = stringRedisTemplate.opsForValue().get(key);
        // 2. 判断是否存在
        if (StrUtil.isBlank(json)) {
            // 3. 如果不存在,直接返回
            return null;
        }

        // 4. 存在,Json 反序列化为对象
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);

        // 5. 判断是否过期
        if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
            // 5.1 未过期,直接返回
            return r;
        }
        // 5.2 过期,缓存重建
        // 6. 缓存重建
        // 6.1 获取互斥锁
        String lockKey = LOGIN_CODE_KEY + id;
        boolean flag = tryLock(lockKey);
        // 6.2 判断是否获取到锁
        if (flag) {
            // 6.3 如果获取到锁,开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    // 查询数据库
                    R r1 = dbFallback.apply(id);
                    // 存储到 Redis
                    setWithLogicalExpire(key, r1, time, unit);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    // 释放锁
                    unLock(lockKey);
                }
            });

        }
        // 6.4 未获取到锁,返回过期的缓存数据
        return r;
    }

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

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


}
相关推荐
造梦师阿鹏几秒前
【SpringBoot】@Value 没有注入预期的值
java·spring boot·后端·spring
经常喝假酒的胡小臣5 分钟前
Django学习笔记之数据库(一)
数据库·学习·django
Elastic 中国社区官方博客11 分钟前
Elasticsarch:使用全文搜索在 ES|QL 中进行过滤 - 8.17
大数据·数据库·sql·elasticsearch·搜索引擎·全文检索
jimiStephen18 分钟前
Mybatis原理简介
java·mybatis
三水川30 分钟前
[人工智能自学] Python包学习-pandas
人工智能·python·学习
sealaugh3234 分钟前
aws(学习笔记第二十三课) step functions进行开发(lambda函数调用)
笔记·学习·aws
凉秋girl40 分钟前
Redis常见知识点
数据库·redis·缓存
qingy_20461 小时前
【JavaWeb】JavaWeb入门之Tomcat详解
java·tomcat
胡图蛋.1 小时前
什么是MVCC
java·服务器·数据库
大霸王龙1 小时前
MongoDB中的索引是提高查询效率的重要工具
数据库·mongodb