基于Redis出现缓存击穿、缓存雪崩、缓存穿透的解决方案,基于Redis的分布式锁实现方法(超详细)

1. 缓存穿透

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

在这里插入图片描述

常见的解决方案:
  • 缓存空对象
    在这里插入图片描述
    优点: 实现简单,维护方便
    缺点: 1. 额外的内存消耗 2. 可能造成短期的不一致
  • 布隆过滤
    在这里插入图片描述
    优点: 内存占用较少,没有多余Key
    缺点: 1. 实现复杂 2. 存在误判可能
基于 缓存空对象 解决实例:
//获取Redis操作对象
@Autowired
private StringRedisTemplate stringRedisTemplate;

//解决 缓存穿透
public Shop queryWithPassThrough(Long id) {
    String key = CACHE_SHOP_KEY + id;
    //1. 从redis查询商铺缓存
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    //2. 判断是否为空-该方法判断shopJson是否有值
    if (StrUtil.isNotBlank(shopJson)) {
        //3. 存在,直接返回
        Shop shop = JSONUtil.toBean(shopJson,Shop.class);
        return shop;
    }
    //--------------------------------------------------------------------------------------
    //TODO 判断命中的是否为空值-这时候只有当 shopJson==null 值才会被定义为首次访问redis没有,需要到数据库进行访问
    if (shopJson != null) {
        // 返回空对象-json==""
        return null;
    }
    //--------------------------------------------------------------------------------------
    //4. 不存在,根据id查询数据库
    Shop shop = getById(id);
    //5. 数据库不存在,空字符串存入Redis,并返回空对象
    if (shop == null) {
        //TODO 将空值写入redis
        stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
        return null;
    }
    //6. 数据库存在,写入redis
    stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonPrettyStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
    //7. 返回
    return shop;
}
2. 缓存击穿

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

在这里插入图片描述

常见的解决方案:
  • 互斥锁
    当有多个并发同时访问的key失效时,给缓存业务重建这个过程上把锁,只允许一个线程建立缓存业务,其余线程等待Redis缓存的建立,当缓存建立成功后,直接从Redis缓存返回结果。
    在这里插入图片描述
    优点:没有额外的内存消耗;保证返回结果一致性;实现简单
    缺点:线程需要等待,性能受影响;可能有死锁风险

  • 逻辑过期
    缓存中key的生命周期是永久的,需要人为清理,但是内部对key设置了一个逻辑时长,当线程访问该key时会对逻辑时长进行判断,若判断为过期,该线程会新建一个线程拿到锁进入数据库更新缓存内容和逻辑时长,而并发的线程在尝试获取锁失败后,会返回Redis中的旧数据
    在这里插入图片描述
    优点:线程无需等待,性能较好
    缺点:不保证返回结果一致性;有额外内存消耗;实现复杂

    适用于新旧数据影响不大的情况,并且有个 前提:需要提前在Redis中缓存该key

解决实例:

不管是用 互斥锁 还是 逻辑过期 来解决缓存击穿,都需要获取锁和释放锁,因为判断是否对Redis的key进行操作,所以选用 基于Redis的分布式锁最为合适

基于Redis的分布式锁实现方法:

  1. 版本1.0----利用Redis中字符串命令:SETNX (只有在 key 不存在时设置 key 的值)
    获取锁
    互斥:确保只能有一个线程获取锁
    非阻塞:尝试一次,成功返回 true,失败返回 false

    # 添加锁,NX是互斥,EX是设置超时时间
    SET lock thread1 NX EX 10
    

    释放锁
    手动释放
    超时释放:获取锁时添加一个超时时间

    # 释放锁,删除key
    DEL key
    
    //获取Redis对象
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    
    //TODO 利用radis中SETNX只能对Key添加一次value的特性 加锁
    private boolean tryLock(String key) {
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key,"lock",10,TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }
    
    //TODO 删除键值 释放锁
    private void unlock(String key) {
        stringRedisTemplate.delete(key);
    }
    
  2. 版本2.0----对于锁的值,存入线程唯一标识符
    上面的锁大致看着合理,但是也有一些漏洞,对该漏洞进行分析:
    在这里插入图片描述
    如图,当执行的业务时间大于超时释放锁的时间时,线程1 获取了锁执行业务,但是业务遭到阻塞,导致锁被提前超时释放,这时在高并发情况下 线程2 获取了锁,又会对数据库再次执行业务,这时若 线程1 业务执行完毕则会释放锁,这时若用第一种方法,则会把 线程2 的锁给释放掉,那么 线程3 又会乘虚而入,后面 线程2 又会把线程3 的锁给释放掉,线程4 又会进来,那么在这种情况下,数据库的访问压力依然很大,锁也没有发挥它的作用,虽然是一把锁,但是在缓存没建立起来之前,被线程反复拿了又释放掉。

    所以改进方法为,在线程拿到锁后,Redis中存入的值为线程唯一标识,释放锁时判断Redis中的值和该线程唯一标识是否相等

    代码实现:

    //获取Redis对象
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    
    //key前缀
    private static final String KEY_PREFIX = "lock:";
    //value前缀-随机数的目的在于防止多个Tomacat的情况下线程id相同
    //         final修饰的目的在于只初始化一次,防止获取的线程标识不唯一
    private static final String ID_PREFIX = UUID.randomUUID().toString().replace("-","") + "-";
    
    //获取锁
    public boolean tryLock(String key) {
        //获取线程标识
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        //获取锁 - SETNX
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX+key,threadId,10, TimeUnit.SECONDS);
            //自动拆箱,可能遇到空指针风险
    //        return success;
        return Boolean.TRUE.equals(success);
    }
    
    //释放锁
    public void unlock(String key) {
        //获取线程标识
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        //获取锁中的标识
        String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + key);
        //判断标识是否一致
        if (threadId.equals(id)) {
            // 释放锁
            stringRedisTemplate.delete(KEY_PREFIX+key);
        }
    }
    
  3. 版本3.0----Redis操作原子性,执行lua脚本
    对于版本2.0,改进了锁对线程标识识别的问题,但是在释放锁时,因为 要取出Redis中key锁的线程唯一标识,然后和该线程标识进行判断,最后 再删除key锁,对于Redis操作了两次,这其中,又会出现一些情况
    在这里插入图片描述
    当业务执行完毕,线程1 要释放锁时,常规顺序为 从Redis中key锁取出线程唯一标识进行判断,从Redis删除该key锁,但是若在这中间遇到了阻塞,提前超时释放锁,在高并发的情况下,那么 线程2 将会拿到锁执行业务,但是 线程1 因为已经判断完毕,所以直接释放了 线程2 的锁,那么 线程3 又会拿到锁执行业务。

    该锁不完善的地方在于,在释放锁时,对于Redis的两次操作,可能会被其它线程阻塞而不能连贯整体执行,所以解决办法为引用 lua 脚本,保证对Redis操作的原子性,即Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。

    代码实现:

    # 编写释放锁的lua脚本-文件名 unlock.lua
    
    	-- 比较线程标识与锁标识是否一致
    if(redis.call('get',KEYS[1]) == ARGV[1]) then
        -- 释放锁 del key
        return redis.call('del',KEYS[1])
    end
    return 0
    
    //获取Redis对象
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    
    //key前缀
    private static final String KEY_PREFIX = "lock:";
    //value前缀-随机数的目的在于防止多个Tomacat的情况下线程id相同
    //         final修饰的目的在于只初始化一次,防止获取的线程标识不唯一
    private static final String ID_PREFIX = UUID.randomUUID().toString().replace("-","") + "-";
    
    //获取lua脚本文件
    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class);
    }
    
    //获取锁
    public boolean tryLock(String key) {
        //获取线程标识
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        //获取锁 - SETNX
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX+key,threadId,10, TimeUnit.SECONDS);
            //自动拆箱,可能遇到空指针风险
    //        return success;
        return Boolean.TRUE.equals(success);
    }
    
    //释放锁
    public void unlock(String key) {
        //调用lua脚本
       //KEYS是集合,ARGV是任意个,取值下标从1开始
        stringRedisTemplate.execute(
               UNLOCK_SCRIPT, Collections.singletonList(KEY_PREFIX + key),
               ID_PREFIX + Thread.currentThread().getId());
    }
    

解决缓存击穿----互斥锁

//获取Redis对象
@Autowired
private StringRedisTemplate stringRedisTemplate;

public Shop queryWithMutex(Long id) {
   String key = CACHE_SHOP_KEY + id;
    //1. 从redis查询商铺缓存
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    //2. 判断是否存在字符不为空
    if (StrUtil.isNotBlank(shopJson)) {
        //3. 存在,直接返回
        Shop shop = JSONUtil.toBean(shopJson,Shop.class);
        return shop;
    }
    //TODO 判断命中的是否为空值
    if (shopJson != null) {
        // 返回空对象
        return null;
    }

    //TODO 4.实现缓存重建
    //锁名
    String lockKey = LOCK_SHOP_KEY + id;
    Shop shop = null;
    try {
    	//TODO 4.1 获取互斥锁
        boolean isLock = tryLock(lockKey);
        //TODO 4.2 判断是否获取成功
        if (!isLock) {
            //TODO 4.3 失败,则休眠并重试
            Thread.sleep(50);
            //利用递归,再从头开始执行,等待缓存的建立
            return queryWithMutex(id);
        }
        //4.4. 拿锁成功,根据id查询数据库
        shop = getById(id);
        
        //5. 数据库不存在,空值存入Redis并返回空对象
        if (shop == null) {
            //TODO 将空值写入redis
            stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
            return null;
        }
        //6. 数据库存在,写入redis
        stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonPrettyStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        //TODO 7. 释放互斥锁
        unlock(lockKey);
    }
    //8. 返回
    return shop;
}

该种方法是 Redis没有该缓存,就去数据库找,然后一个线程拿锁建立缓存,其余线程等待缓存的建立,但若数据库也没有该值,就 需要防止缓存穿透,所以实现方式为 互斥锁 + 存空值

解决缓存击穿----逻辑过期

首先封装一个逻辑过期对象-RedisData

@Data
public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}

再添加一个存储逻辑过期对象到Redis中的方法

 //TODO 存储逻辑过期缓存方法-id锁标识,expireSeconds有效时间
public void saveShopRedis(Long id,Long expireSeconds) {
    // 1. 根据id查询数据库
    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));
}

逻辑过期方法

//线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
@Autowired
private StringRedisTemplate stringRedisTemplate;

public Shop queryWithLogicalExpire(Long id) {
    String key = CACHE_SHOP_KEY + id;
    //1. 从redis查询商铺缓存
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    //2. 判断是否存命中-Redis中是否存在该对象
    if (StrUtil.isBlank(shopJson)) {
        //3. 直接返回
        return null;
    }
    // 4. 命中,需要先把json反序列化对象
    RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
//        JSONObject data = (JSONObject) redisData.getData();
//        Shop shop = JSONUtil.toBean(data,Shop.class);
    Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(),Shop.class);
    LocalDateTime expireTime = redisData.getExpireTime();
    // 5. 判断是否过期
    if (expireTime.isAfter(LocalDateTime.now())) {
        // 5.1 未过期,直接返回店铺信息
        return shop;
    }
    // 5.2 已过期,需要缓存重建
    // 6. 缓存重建
    // 锁名
    String lockKey = LOCK_SHOP_KEY + id;
    // 6.1 获取互斥锁
    boolean isLock = tryLock(lockKey);
    // 6.2 判断是否获取成功
    if (isLock) {
        // 6.3 成功,开启独立线程,实现缓存重建
        CACHE_REBUILD_EXECUTOR.submit(() -> {
            try {
                //重建缓存
                this.saveShopRedis(id,20L);
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                //释放锁
                unlock(lockKey);
            }
        });
    }
    // 6.4 返回过期的商铺信息
    return shop;
}

该方法有个前提,需要提前建立缓存,用于取出对象判断是否逻辑过期,不存在key失效问题,所以对于redis没有的key可以直接返回空,不需要另外解决缓存穿透

3. 缓存雪崩

缓存雪崩 是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
在这里插入图片描述

解决方案:
  1. 给不同的key的TTL添加随机值
  2. 利用 Redis 集群提高服务的可用性
  3. 给缓存业务添加降级限流策略
  4. 给业务添加多级缓存
4.自定义工具类整合

逻辑过期对象

@Data
public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}

释放锁的lua脚本----文件名 unlock.lua

-- 比较线程标识与锁标识是否一致
if(redis.call('get',KEYS[1]) == ARGV[1]) then
    -- 释放锁 del key
    return redis.call('del',KEYS[1])
end
return 0

工具类

@Component
@Slf4j
public class CacheClient {

    private final StringRedisTemplate stringRedisTemplate;

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

    // 存入Redis普通对象 方法
    public void set(String key, Object value, Long time,TimeUnit unit) {
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value),time,unit);
    }

    // 存入Redis逻辑过期对象 方法
    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));
    }

    // 泛型方法

    /**
     * 解决 缓存穿透
     * @param keyPrefix key的前缀,使相同类型的值放在一个类别中
     * @param id key的后缀,在相同类型中区分,避免值被覆盖
     * @param type R建模的类的类型,例如:String.class的类型是Class<String>,用于Bean的转换
     * @param dbFallback 接受 参数ID 并产生 结果R 的函数,用于接收数据库调用的函数
     * @param time 时间,用于设置失效时间
     * @param unit 时间单位
     * @param <R> 返回类型
     * @param <ID> id的类型
     * @return
     */
    public <R,ID> R queryWithPassThrough(
            String keyPrefix , ID id , Class<R> type , Function<ID,R> dbFallback,Long time,TimeUnit unit) {

        String key = keyPrefix + id;
        //1. 从redis查询相关key的缓存
        String json = stringRedisTemplate.opsForValue().get(key);
        //2. 判断是否存在对象
        if (StrUtil.isNotBlank(json)) {
            //3. 存在,直接返回
            return JSONUtil.toBean(json,type);
        }
        //TODO 判断命中的是否为空值
        if (json != null) {
            // 返回空对象-json==""
            return null;
        }
        //4. 不存在,根据id查询数据库
        R r = dbFallback.apply(id);
        //5. 不存在,返回错误
        if (r == null) {
            //TODO 将空值写入redis
            stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
            return null;
        }
        //6. 存在,写入redis
        this.set(key,r,time,unit);
        //7. 返回
        return r;
    }

    //线程池
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    //key前缀
    private static final String KEY_PREFIX = "lock:";
    //value前缀-随机数的目的在于防止多个Tomacat的情况下线程id相同
//         final修饰的目的在于只初始化一次,防止获取的线程标识不唯一
    private static final String ID_PREFIX = UUID.randomUUID().toString().replace("-","") + "-";

    //获取lua脚本文件
    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class);
    }

    //获取锁
    public boolean tryLock(String key) {
        //获取线程标识
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        //获取锁 - SETNX
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX+key,threadId,10, TimeUnit.SECONDS);
        //自动拆箱,可能遇到空指针风险
//        return success;
        return Boolean.TRUE.equals(success);
    }

    //释放锁
    public void unlock(String key) {
        //调用lua脚本
        //KEYS是集合,ARGV是任意个,取值下标从1开始
        stringRedisTemplate.execute(
                UNLOCK_SCRIPT, Collections.singletonList(KEY_PREFIX + key),
                ID_PREFIX + Thread.currentThread().getId());
    }

    /*
     * 解决 缓存击穿+缓存穿透 方法1-互斥锁
     * 无缓存也可以执行
     * 参数同上
     * */
    public <R,ID> R queryWithMutex(
            String keyPrefix,ID id,Class<R> type,Function<ID,R> dbFallback,Long time,TimeUnit unit) {

        String key = keyPrefix + id;
        //1. 从redis查询缓存
        String json = stringRedisTemplate.opsForValue().get(key);
        //2. 判断是否存在对象
        if (StrUtil.isNotBlank(json)) {
            //3. 存在,直接返回
            R r = JSONUtil.toBean(json,type);
            return r;
        }
        //TODO 判断命中的是否为空值
        if (json != null) {
            // 返回空对象-json==""
            return null;
        }

        //TODO 4.实现缓存重建
        // 锁名
        String lockKey = LOCK_SHOP_KEY + id;
        R r = null;
        try {
            //TODO 4.1 获取互斥锁
            boolean isLock = tryLock(lockKey);
            //TODO 4.2 判断是否获取成功
            if (!isLock) {
                //TODO 4.3 失败,则休眠并重试
                Thread.sleep(50);
                return queryWithMutex(keyPrefix,id,type,dbFallback,time,unit);
            }
            //4.4. 成功,根据id查询数据库
            r = dbFallback.apply(id);
            //5. 数据库不存在,空值写入并返回空对象
            if (r == null) {
                //TODO 将空值写入redis
                stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
                return null;
            }
            //6. 数据库存在,写入redis
            stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonPrettyStr(r),time, unit);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            //TODO 7. 释放互斥锁
            unlock(lockKey);
        }
        //8. 返回
        return r;
    }

    /*
     * 解决 缓存击穿+缓存穿透 方法2-逻辑过期
     * 前提:需要有缓存才行!
     * 参数同上
     * */
    public <R,ID> R queryWithLogicalExpire(
            String keyPrefix,ID id,Class<R> type,Function<ID,R> dbFallback,Long time,TimeUnit unit) {

        String key = keyPrefix + id;
        //1. 从redis查询缓存
        String json = stringRedisTemplate.opsForValue().get(key);
        //2. 判断Redis缓存中是否存在该key对象,不存在直接返回空对象
        if (StrUtil.isBlank(json)) {
            //3. 直接返回
            return null;
        }
        // 4. 存在,需要先把json反序列化对象
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        R r = JSONUtil.toBean((JSONObject) redisData.getData(),type);
        LocalDateTime expireTime = redisData.getExpireTime();
        // 5. 判断是否过期
        if (expireTime.isAfter(LocalDateTime.now())) {
            // 5.1 未过期,直接返回店铺信息
            return r;
        }
        // 5.2 已过期,需要缓存重建
        // 6. 缓存重建
        // 6.1 获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(lockKey);
        // 6.2 判断是否获取成功
        if (isLock) {
            // 6.3 成功,开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    //查询数据库
                    R apply = dbFallback.apply(id);
                    //重建缓存
                    this.setWithLogicalExpire(key,apply,time,unit);
                } catch (Exception e) {
                    e.printStackTrace();
                } finally {
                    //释放锁
                    unlock(lockKey);
                }
            });
        }
        // 6.4 获取失败,返回过期的商铺信息
        return r;
    }
}

引用该工具类示例:

@Autowired
private CacheClient cacheClient;
...
Shop shop = cacheClient.
       queryWithMutex(CACHE_SHOP_KEY,id,Shop.class,this::getById,CACHE_SHOP_TTL,TimeUnit.MINUTES);
...
  • 4
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值