点评项目-7-缓存击穿的两种解决方案、缓存工具类的编写

缓存击穿

在高并发访问的访问中,对于复杂业务 key 的缓存,可能会在缓存生效前打入大量的请求,导致大量的请求打到数据库

解决方案:

1.互斥锁,给缓存的构建过程加上一个锁,当拿到锁时才进行下一步,锁被占用则睡眠一段时间后再拿锁

2.逻辑过期,给缓存加上一个逻辑过期时间,但是在 redis 中过期的数据不会被真正删除,在查询时,如果 key 在逻辑上过期了,则开启一个锁,并把更新 key 的任务交给另一个线程,然后先直接返回旧数据;若某个遇到锁被占用无需等待,直接返回旧数据。

编写缓存工具类,实现代码的复用

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

    private StringRedisTemplate stringRedisTemplate;

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

    //存入缓存
    public void setForString(String key, Object value, long time, TimeUnit unit){
        String json = JSONUtil.toJsonStr(value);
        stringRedisTemplate.opsForValue().set(key,json,time,unit);
    }
    //删除缓存
    public void removeKey(String key){
        stringRedisTemplate.delete(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)));//设置过期时间
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData),time,unit);
    }
    //防止穿透的查询店铺信息功能缓存
    public <R,ID> R getById(String preKey, ID id, Class<R> type, Function<ID,R> getById,long time,TimeUnit unit) {
        String key = preKey + id;
        //先在 redis 中查询
        String json = stringRedisTemplate.opsForValue().get(key);
        if(StrUtil.isNotBlank(json)){
            //(2024-10-11这个逻辑出现了错误,json 并不是 null,可是封装过去后全是 null,原因:用成了 BeanUtil)
            return JSONUtil.toBean(json, type);
        }
        //判断是否穿透后查询到 ""
        if(json != null){
            return null;
        }
        //redis 中没查到,在数据库中查询
        R r = getById.apply(id);
        if(r == null){
            setForString(key,"",time,unit);//防击穿,防雪崩
            return null;
        }
        String s = JSONUtil.toJsonStr(r);
//      //(2024-10-11,这一句的逻辑出bug了,找到原因:传参 time 为 0,括号出现了位置错误)
        stringRedisTemplate.opsForValue().set(key,s,time,unit);
        return r;
    }

}

防穿透方法改造后的测试:

java 复制代码
    //使用工具类查询(解决缓存穿透版)
    @Override
    public Result getById(Long id){
        long ttl =  (long) (Math.random() * 100)+1;
        Shop shop = cacheClient.getById("shop:cache:", id, Shop.class, a -> shopMapper.selectById(a),ttl, TimeUnit.MINUTES);
        if(shop == null){
            return Result.fail("未查询到该店铺ToT");
        }
        return Result.ok(shop);
    }

基于互斥锁方式改造店铺查询业务:

对于锁的使用,我们可以利用 redis 中的 setnx 命令(只有 key 不存在是操作才成功)来充当锁,需要开启锁时写入一个 setnx ,释放锁时 del 这个 key

java 复制代码
//基于互斥锁的方式防止店铺信息查询击穿
    public <R,ID> R queryShopByLock(String preKey, ID id, Class<R> type, Function<ID,R> getById,long time,TimeUnit unit){
        String key = preKey + id;
        //先在 redis 中查询
        String json = stringRedisTemplate.opsForValue().get(key);
        if(StrUtil.isNotBlank(json)){
            //存在,直接返回
            return JSONUtil.toBean(json, type);
        }
        //判断是否穿透后查询到 ""
        if(json != null){
            return null;
        }
        //redis 中没查到,获取互斥锁
        String lockKey = "shop:lock:"+id;
        R r = null;
        try {
            boolean getLock = tryToGetLock(lockKey);
            if(!getLock){
                //获取锁失败,休眠后重试
                Thread.sleep(50);
                queryShopByLock(preKey,id,type,getById,time,unit);
            }
            //获取锁成功,完成缓存重建
            r = getById.apply(id);
            if(r == null){
                setForString(key,"",time,unit);
                return null;
            }
            String s = JSONUtil.toJsonStr(r);
            stringRedisTemplate.opsForValue().set(key,s,time,unit);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            cleanLock(lockKey);
        }
        return r;
    }

使用 jmeter 进行并发测试:

java 复制代码
    @Override
    public Result getById(Long id){
        long ttl =  (long) (Math.random() * 100)+1;
        Shop shop = cacheClient.queryShopByLock("cache:shop:", id, Shop.class, a -> shopMapper.selectById(a), ttl, TimeUnit.MINUTES);
        if(shop == null){
            return Result.fail("未查询到该店铺ToT");
        }
        return Result.ok(shop);
    }

所有请求都成功响应

基于逻辑过期方式改造店铺查询

这种方式一般是通过手动的批量添加需要频繁访问的 key,在不需要使用时再将其批量删除,使用场景有:临时活动相关的请求

这里通过测试方法批量添加 key

java 复制代码
    @Test
    void saveKeysWithLogicalExpire(){
        for(int i=0;i<10;i++){
            RedisData redisData = new RedisData();
            Shop shop = shopMapper.selectById(i + 1);
            redisData.setData(shop);
            redisData.setExpireTime(LocalDateTime.now());
            stringRedisTemplate.opsForValue().set("cache:shop:"+shop.getId(), JSONUtil.toJsonStr(redisData));
        }
    }
    //手动删除缓存
    @Test
    void delKeys(){
        for(int i=0;i<10;i++){
            stringRedisTemplate.delete("cache:shop:"+(i+1));
        }
    }

维护一个线程池,用于单独处理更新缓存的逻辑

java 复制代码
    //线程池,用于逻辑过期执行更新数据库
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

业务逻辑:

java 复制代码
    //基于逻辑过期的方式防止店铺信息查询击穿
    public <R,ID> R queryShopByLogicExpire(String preKey, ID id, Class<R> type , Function<ID,R> getById,long time,TimeUnit unit){
        String key = preKey + id;
        //先在 redis 中查询
        String json = stringRedisTemplate.opsForValue().get(key);
        if(StrUtil.isBlank(json)){
            //正常情况一定能查到,没查到,返回空对象
            return null;
        }
        //查到了,将 json 转换为可以使用的类
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        R r = JSONUtil.toBean((JSONObject) redisData.getData(),type);//将Object类型的json转换为指定 type 的类
        LocalDateTime expireTime = redisData.getExpireTime();
        //判断是否过期,没过期直接返回数据
        if(expireTime.isAfter(LocalDateTime.now())){
            //过期时间在当前时间之后,未过期
            return r;
        }
        //过期了,重建缓存
        String lockKey = "shop:lock:"+id;
        boolean getLock = tryToGetLock(lockKey);
        if(getLock){
            //成功获取锁,再次判断是否过期
            boolean isOverTime = judgeLogicalExpire(key);
            if(!isOverTime){
                //释放锁
                cleanLock(lockKey);
                return r;
            }
            //再次判断依然过期,唤醒处理数据库的线程
            CACHE_REBUILD_EXECUTOR.submit(()->{
                //需要交给线程执行的逻辑
                try {
                    R apply = getById.apply(id);
                    this.setWithLogicalExpire(key,apply,time,unit);
                } catch (Exception e) {
                    e.printStackTrace();
                }finally {
                    //释放锁
                    cleanLock(lockKey);
                }
            });
        }
        //返回已过期的数据
        return r;
    }

    //判断是否逻辑过期,true 表示过期
    public boolean judgeLogicalExpire(String key){
        String json = stringRedisTemplate.opsForValue().get(key);
        if(StrUtil.isBlank(json)){
            return true;
        }
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        LocalDateTime expireTime = redisData.getExpireTime();
        return !expireTime.isAfter(LocalDateTime.now());
    }

    //尝试获取锁
    private boolean tryToGetLock(String key){
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "aaa", 10, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }
    //释放锁
    private void cleanLock(String key){
        stringRedisTemplate.delete(key);
    }

使用 jmeter 进行并发测试:

java 复制代码
    @Override
    public Result getById(Long id){
        long ttl =  (long) (Math.random() * 100)+1;
        Shop shop = cacheClient.queryShopByLogicExpire("cache:shop:", id, Shop.class, a -> shopMapper.selectById(a), ttl, TimeUnit.MINUTES);
        if(shop == null){
            return Result.fail("未查询到该店铺ToT");
        }
        return Result.ok(shop);
    }

所有的请求都成功响应

相关推荐
段帅龙呀28 分钟前
Redis构建缓存服务器
服务器·redis·缓存
夜斗小神社16 小时前
【黑马点评】(二)缓存
缓存
Hello.Reader1 天前
Redis 延迟监控深度指南
数据库·redis·缓存
Hello.Reader1 天前
Redis 延迟排查与优化全攻略
数据库·redis·缓存
在肯德基吃麻辣烫2 天前
《Redis》缓存与分布式锁
redis·分布式·缓存
先睡2 天前
Redis的缓存击穿和缓存雪崩
redis·spring·缓存
CodeWithMe3 天前
【Note】《深入理解Linux内核》 Chapter 15 :深入理解 Linux 页缓存
linux·spring·缓存
大春儿的试验田3 天前
高并发收藏功能设计:Redis异步同步与定时补偿机制详解
java·数据库·redis·学习·缓存
likeGhee3 天前
python缓存装饰器实现方案
开发语言·python·缓存
C182981825753 天前
OOM电商系统订单缓存泄漏,这是泄漏还是溢出
java·spring·缓存