Redis - ⭐数据缓存 Cache

48 阅读9分钟

概述

  1. 目标:将 Redis 作为数据交换的缓冲区(临时储存数据的位置)

  2. 功能

    1. 降低负载:缓存可以大大降低用户访问并发量对后端带来的数据库读写压力
    2. 提高效率:提高读写效率,降低响应时间
  3. 成本

    1. 数据一致性成本
    2. 代码维护成本
    3. 运维成本
  4. 缓存分级

    1. 浏览器缓存:浏览器端的本地缓存
    2. 应用层换层:tomcat 本地缓存,redis 缓存
    3. 数据库缓存:数据库的 buffer pool 空间,增删改查数据优先加载到这个位置
    4. CPU缓存:L1,L2,L3级缓存
    5. 磁盘缓存

缓存的查询 & 更新

  1. 工作流程

    1. 客户端发起查询请求
    2. 查询缓存数据(避免直接查询数据库)
    3. 缓存中存在目标数据 → 直接返回目标数据
    4. 缓存中不存在目标数据 → 查询数据库,将目标数据写入缓存并设置失效时间
    5. 返回结果给客户端

一、缓存更新策略

  1. 策略

    1. ⭐主动更新:手动调用方法删除缓存,通常用于解决缓存与数据库的不一致问题
    2. 内存淘汰:内存超限时,淘汰掉一些不重要的数据
    3. 超时剔除:将超时的数据进行删除
  2. 工作流程

    1. 用户提交修改请求
    2. 更新数据库
    3. 删除缓存 (直接删除而不更新,等待用户下次查询时自动更新缓存)
  3. 删除缓存而非更新缓存的原因:避免写多读少降低效率,更新后用户第一次查询缓存未命中会查数据库并自动写入缓存

二、一致性问题

  1. 问题说明:数据库中数据发生变化,Cache 没有及时同步而导致用户使用 Cache 过时数据而无法到达数据库取用最新数据

  2. 解决方案:先更新数据库再删 Cache (因为更新数据库的速度很慢,这段时间如果用户查询则又会导致不一致问题)

image.png

三、代码实现(查询 & 更新)

  1. ShopController

    1. 注入 Service 依赖 (用于调用实现的方法)

      @Resource
      public IShopService shopService;
      
    2. 创建 queryById 方法

      @GetMapping("/{id}")
      public Result queryShopById(@PathVariable("id") Long id){
      	return shopService.queryId(id);
      }
      
  2. ShopServiceImpl

    1. 注入 StringRedisTemplate 依赖 (用于操作 Redis)

      @Resource
      private StringRedisTemplate stringRedisTemplate;
      
    2. 重写 queryById 方法

      @Override
      public Result queryById(Long id){
      	// 1. 查询redis中缓存的商铺信息
      	String shopKey = CACHE_SHOP_KEY + id;
      	String shopJson = stringRedisTemplate.opsForValue().get(shopKey);
      	
      	// 2. 如果目标商户在缓存中,则直接返回目标数据
      	if( StrUtil.isNotBlank(shopJson) ){
      		Shop shop = JSONUtil.toBean(shopJson, Shop.class);
      		return Result.ok(shop);
      	}
      	
      	// 3. 如果目标商户不在缓存中,则查询数据库
      	Shop shop = getById(id);
      	// 4. 如果数据库中也不存在商铺数据, 则返回错误信息
      	if( shop == null )
      		return Result.fail("店铺不存在!");
      		
      	// 5. 如果数据库中存在商铺数据,写入缓存并返回商铺信息
      	stringRedisTemplate.opsForValue.set(shopKey, JSONUtil.toJsonStr(shop), 30L, TimeUnit.MINUTES);
      	return Result.ok(shop);
      }
      
    3. 重写 update 方法

      @Override
      @Transactional
      public Result update(Shop shop){
      	Long id = shop.getId();
      	if(id == null)
      		return Result.fail("店铺id不能为空");
      	updateById(shop);
      	stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
      	return Result.ok();
      }
      

缓存穿透

  1. 问题说明:客户端请求的数据在缓存中和数据库中都不存在,这些请求都会穿透缓存,直接查询数据库

  2. 解决方案

    1. 缓存空对象
    2. 布隆过滤

一、缓存空对象

  1. 定义:即使数据库中不存在也将被查询的数据写入 redis,下次查询此数据时在 redis 中也能找到这个数据,避免直击数据库
  2. 优点:实现简单,维护方便
  3. 缺点:消耗额外内存,可能造成短期不一致

二、⭐布隆过滤

  1. 优点:内存占用极少,没有多余 key

  2. 缺点:实现复杂,存在误判可能

  3. 工作流程

image.png

三、其他方法

  1. 增强id的复杂度,避免被猜测 id 规律
  2. 做好数据的基础格式校验
  3. 加强用户权限校验
  4. 做好热点参数的限流

缓存雪崩

  1. 问题说明:在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求直达数据库造成的查询压力

  2. 解决方案

    1. 给不同的 Key 的 TTL 添加随机值
    2. 利用 Redis 集群提高服务的可用性
    3. 给缓存业务添加降级限流策略
    4. 给业务添加多级缓存

缓存击穿

  1. 定义

    1. 缓存击穿:热点Key问题,一个被高并发访问并且缓存重建业务较复杂的 key 突然失效,大量访问该数据的直达数据
    2. 缓存重构:通过一些方法重构缓存且避免大量请求直达数据库
  2. 解决方案

    1. 互斥锁
    2. 逻辑过期

一、互斥锁

  1. 工作流程

    1. 线程1查询缓存,命中则直接返回结果,否则继续执行
    2. 线程1获得锁资源,执行业务逻辑
    3. 线程2查询此数据,等待锁资源
    4. 线程1释放资源锁,其他线程继续执行逻辑并能够顺利从缓存中拿到数据
  2. 优点:实现简单,无内存消耗

  3. 缺点:可能死锁,影响性能 (只能串行执行)

  4. 实现

    1. 逻辑锁:用redis的setnx方法,插入成功表示获取锁,插入失败表示等待锁资源

      private boolean tryLock(String key){
      	Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
      	return BooleanUtil.isTrue(flag);
      }
      
      private void unlock(String key){
      	stringRedisTemplate.delete(key);
      }
      
    2. 缓存重构

      	
      ... ...
      String lockKey = "lock:shop:" + id;        // 获取逻辑锁的key值
      Shop shop = null;
      try{
      	boolean isLock = tryLock(lockKey);       // 如果资源锁被占用则等待一段时间后重新调用
      	if(!isLock){                             // 如果锁已经占用则等待后重新尝试
      		Thread.sleep(50);
      		return queryWithMutex(id);
      	}
      	shop = getById(id);                      // 获取锁后查询数据库
      ... ...
      	
      

二、逻辑过期

  1. 工作流程

    1. 缓存预热
    2. 查询缓存(调用方向缓存系统索要数据)
    3. 未命中 → 返回 null(调用方收到 null 时,自行决定是否查询数据库或执行降级逻辑)
    4. 命中且未过期 → 返回结果(调用方拿到正确结果)
    5. 命中且过期 → 获取锁资源(调用方拿到过期结果)
    6. 获取锁资源失败 → 返回过期数据 (高并发情况下避免多个线程同时修改导致缓存击穿)
    7. 获取锁资源成功 → 开启一个新线程,返回过期数据
    8. 新线程异步构建缓存:新线程查询数据库,重建缓存数据和逻辑过期时间,释放锁资源
  2. 优点:异步的构建缓存,不需要等待,性能好

  3. 缺点:在于在构建完缓存之前,返回的都是脏数据,并且实现很麻烦

  4. 实现

    1. 创建实体类 (开闭原则,避免代码侵入)

      @Data
      public class RedisData {
      	private LocalDateTime expireTime;
      	private Object data;
      }
      
    2. 新增方法(ShopServiceImpl 中)

      public void saveShop2Redis(Long id, Long expireSeconds) {
      	Shop shop = getById(id);                                // 查询店铺信息
      	RedisData redisData = new RedisData();                  // 给店铺信息封装失效时间
      	redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
      	redisData.setData(shop);
      	// 写入Redis
      	stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
      }
      
    3. 缓存预热 (测试类中调用 saveShop2Redis 方法)

    4. 正式代码 (ShopServiceImpl 中)

      public Shop queryWithLogicalExpire( Long id ){
      	String key = CACHE_SHOP_KEY + id;
      	String json = stringRedisTemplate.opsForValue().get(key);
      	if(StrUtil.isBlank(json))
      		return null;
      	
      	RedisData redisShop = JSONUtil.toBean(json, RedisData.class);
      	Shop shop = redisShop.getShop();
      	LocalDateTime expireTime = redisShop.getExpire();
      	if(expireTime.isAfter(LocalDateTime.now())
      		return shop;
      	// 重建缓存过程
      	String lockKey = LOCK_SHOP_KEY + id;        // 获取互斥锁
      	boolean isLock = tryLock(lockKey);
      	if(isLock){                                 // 如果获取成功,则更新缓存信息
      		CACHE_REBUILD_EXECUTOR.submit( ()->{
      			try{
      				this.saveShop2Redis(id, 20L);
      			}catch (Exception e){
      				throw new RuntimeException(e);
      			}finally{
      				unlock(lockKey);
      			}
      		});
      	}
      	return shop;
      }
      

image.png


封装 Redis 工具类

  1. 定义:基于 StringRedisTemplate 的操作,封装一个缓存工具类

  2. 前置知识

    1. 泛型
    2. 函数式编程
    3. 线程池
  3. 代码逻辑

    @Slf4j
    @Component
    public class CacheClient {
    
        private final StringRedisTemplate stringRedisTemplate;
        
    
    		// 调用者要将自己的stringRedisTemplate注入工具类中,以便工具类对调用者的缓存进行操作
        public CacheClient(StringRedisTemplate stringRedisTemplate) {
            this.stringRedisTemplate = stringRedisTemplate;
        }
        
    
        // 一. 将key-value写入Redis并设置过期时间
        public void set(String key, Object value, Long time, TimeUnit unit){
            stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
        }
        
        
        // 二. 将key-value写入Redis并设置逻辑过期时间
        public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit){
            // 创建RedisData类,写入数据并设置逻辑过期时间
            RedisData redisData = new RedisData();
            redisData.setData(value);
            redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
            // 将RedisData序列化后作为value写入Redis
            stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
        }
        
    
        // 三. 查询缓存,用缓存空值解决缓存穿透问题
        public <R, ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
            String key = keyPrefix + id;
            String valueJson = stringRedisTemplate.opsForValue().get(key);
            // 缓存中存在则直接返回结果 (空字符串属于Blank,不会进入if)
            if(StrUtil.isNotBlank(valueJson)) {
                return JSONUtil.toBean(valueJson, type);
            }
            // 拦截空字符串,防止缓存穿透(因为此时不为null就一定是 "", 否则已经被返回了)
            if(valueJson != null){
                return null;
            }
            // 让调用者决定查找逻辑 (函数式编程)
            R r = dbFallback.apply(id);
            // 查找目标不存在 -> 写入空值并返回空值
            if ( r == null) {
                stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
                return null;
            }
            // 查找目标存在 -> 写入缓存并返回查找结果
            stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(r), time, unit);
            return r;
        }
        
    		// 创建一个线程池,用于在方法中开启新线程
        private final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
        
        // 查询缓存,用逻辑过期解决热点Key问题
        public <R, ID> R queryWithLogicalExpire(String keyPrefix, ID id, Class<R> typeR,
                                                String lockPrefix, Function<ID, R> dbFallback,
                                                Long time, TimeUnit unit) {
            String key = keyPrefix + id;
            // 查询缓存中是否存在目标结果
            String valueJson = stringRedisTemplate.opsForValue().get(key);
            // 查找到空值 -> 返回null (热点key预热过,如果查到空值则说明没有数据)
            if(StrUtil.isBlank(valueJson)) {
                return null;
            }
            
            // Json -> RedisData -> Bean
            RedisData redisData = JSONUtil.toBean(valueJson, RedisData.class);
            R r = JSONUtil.toBean((JSONObject) redisData.getData(), typeR);
            LocalDateTime expireTime = redisData.getExpireTime();
    
            // 结果未过期 -> 返回结果
            if(expireTime.isAfter(LocalDateTime.now())){
                return r;
            }
            // 结果已过期 -> 新线程重建缓存,返回过期数据
            String lockKey = lockPrefix + id;
            boolean isLock = tryLock(lockKey);
    
            // 成功获取锁, 开启新线程重建缓存
            if(isLock){
                CACHE_REBUILD_EXECUTOR.submit(() -> {
                    // 重建缓存
                    R rFallback = dbFallback.apply(id);
                    this.setWithLogicalExpire(key, rFallback, time, unit);
                    // 释放锁
                    unlock(lockKey);
                });
            }
    
            // 返回过期商品信息
            return r;
        }
        
        
    		// 热点key上锁(如果没有锁,则上锁10秒)
        private boolean tryLock(String key){
            Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
            return BooleanUtil.isTrue(flag);
        }
        
    		
    		// 热点key释放锁
        private void unlock(String key){
            stringRedisTemplate.delete(key);
        }
    
    }
    

单节点 Redis 问题

  1. 数据丢失问题:通过 Redis 数据持久化解决
  2. 并发能力问题:通过搭建主从集群、实现读写分离实现
  3. 故障恢复问题:通过 Redis 哨兵,实现健康检测和自动回复
  4. 存储能力问题:搭建分片集群,利用插槽机制实现动态扩容