缓存穿透、缓存雪崩和缓存击穿的简单了解

缓存穿透

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

攻击者可以开多个线程连续不断避开缓存去请求数据库,其实就是一直请求数据库中不存在的数据,这样Redis不会缓存,数据库就会被高量的请求一直攻击,最终就有可能数据库服务器崩掉。 

 常见的解决缓存穿透的方法:

  • 缓存空对象(更常见)

即请求数据库中不存在的数据时,给Redis设置缓存空对象,这样攻击再次发生时就能直接命中缓存了。

优点:实现简单,维护简单。

缺点:额外的内存消耗;可能造成短期的不一致。

  • 布隆过滤

在客户端请求和缓存之间再加一层布隆过滤器,客户端请求数据之前会先询问布隆过滤器数据是否存在,不存在则直接拒绝,存在则放行给Redis,缓存命中则返回,缓存未命中则查询数据库。

布隆过滤器不是直接重复存入数据,而是将数据映射为二进制标识存入过滤器中。

优点:内存占用较少,没有多余key。

缺点:实现复杂;存在误判可能。

解决缓存穿透的实现 

 解决缓存穿透前:

public Result queryById(Long id) {
        String key = "cache:shop:" + id;
        // 1.从Redis中查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            // 3.存在,直接返回
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }
        // 4.不存在,根据id查询数据库
        Shop shop = getById(id);
        // 5.不存在,返回错误
        if (shop == null) {
            return Result.fail("店铺不存在");
        }
        // 6.存在,写入Redis
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);

        return Result.ok(shop);
    }

 解决后:

public Result queryById(Long id) {
        String key = "cache:shop:" + id;
        // 1.从Redis中查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            // 3.存在,直接返回
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }
        //缓存命中空值
        if (shopJson != null) {
            //返回错误信息
            return Result.fail("店铺信息不存在!");
        }
        // 4.不存在,根据id查询数据库
        Shop shop = getById(id);
        // 5.不存在,返回错误
        if (shop == null) {
            //缓存空值
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            return Result.fail("店铺不存在");
        }
        // 6.存在,写入Redis
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);

        return Result.ok(shop);
    }

当然以上“缓存null值”和“布隆过滤”都是被动解决的方式,我们还可以通过增强id的复杂度,避免被猜测id规律、并在此基础上做好数据的基础格式校验,没有通过校验就拦截请求。

 缓存雪崩

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

 常见的应对缓存雪崩的的方法:

  • 给不同的Key的TTL(过期时间)添加随机值

针对的是同一时段大量的缓存key同时失效的情况。什么时候会出现同一时段大量的缓存key同时失效的情况?如缓存预热时存入了相同过期时间的大量的缓存key,时间一到就会发生这种情况。

  • 利用Redis集群提高服务的高可用性

在Redis集群中实现主从服务器,当主Redis服务器宕机,Redis哨兵机制可以从主从集群中迅速提挑选从服务器替代主服务器,并且从服务器上也同步了数据,这样就可以在很大程度上保证Redis的高可用性。

  • 给缓存业务添加降级限流策略

更极端的情况,如果整个Redis服务集群都同时宕机了,我们需要及时地做“服务降级”(如快速失败、拒绝服务),而不是继续将请求压到数据库中,这样就可以通过牺牲一定服务去保护数据库。

  • 给业务添加多级缓存

即除了Redis等缓存外,还可以在nginx和jvm中建立缓存。

缓存击穿

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

 常见的应对缓存击穿的方法:

  • 互斥锁

通过时序图,可以看出互斥锁最大的问题就是:大量线程等待。这样会使得性能下降。

  • 逻辑过期

逻辑过期并非真的过期,而实际上是永不过期。逻辑过期应对热点key的缓存问题时,是设置一个较长的且可以通过计算进行逻辑判断的过期时间,如果有某个线程查询缓存时发现逻辑时间已过期,那该线程会尝试获取互斥锁,如果获取成功就另开新线程进行缓存的更新操作,而该线程可以直接返回过期数据。如果获取互斥锁失败,就说明此时已经有线程在执行缓存的更新操作了,也可以直接返回过期数据。

两种方案的对比: 

  

利用互斥锁解决缓存击穿问题

下面的逻辑中,最重要的部分就是“获取互斥锁”,不同于synchronized和lock,我们这里需要实现自定义锁,就需要用到redis的string数据类型的“setnx”,setnx为key赋值当且仅当key不存在,这样就可以实现互斥的效果,如果大量线程一起执行setnx操作,有且只能有一个线程能够设置成功。

获取锁:setnx key

释放锁:删除key

避免死锁:设置锁的有效期

 /**
     * 尝试获取锁
     * @param key 请求传入key
     * @return 是否成功
     */
    private boolean tryLock(String key) {
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }

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

/**
     * 互斥锁应对缓存穿透
     * @param id
     * @return
     */
    private Shop queryWithMutex(Long id) {
        String key = "cache:shop:" + id;
        // 1.从Redis中查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            // 3.存在,直接返回
            return JSONUtil.toBean(shopJson, Shop.class);
        }
        //缓存命中空值
        if (shopJson != null) {
            //返回错误信息
            return null;
        }
        // 4.实现缓存重建
        // 4.1.获取互斥锁
        String lockKey = "lock:shop:" + id;
        Shop shop = null;
        try {
            boolean isLock = tryLock(lockKey);
            // 4.2.判断获取锁是否成功
            if (!isLock){
                // 4.3.获取失败,则休眠一段时间后重试
                Thread.sleep(50);
                return queryWithMutex(id);
            }
            // 4.4.成功,则根据id查询数据库并重建缓存
            shop = getById(id);
            // 5.不存在,返回错误
            if (shop == null) {
                //缓存空值
                stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
                return null;
            }
            // 6.存在,写入Redis
            stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            // 7.释放互斥锁
            unLock(lockKey);
        }

        return shop;
    }

利用逻辑过期解决缓存击穿问题

涉及的实体类:

@Data
public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_shop")
public class Shop implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * 主键
     */
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    /**
     * 商铺名称
     */
    private String name;

    /**
     * 商铺类型的id
     */
    private Long typeId;

    /**
     * 商铺图片,多个图片以','隔开
     */
    private String images;

    /**
     * 商圈,例如陆家嘴
     */
    private String area;

    /**
     * 地址
     */
    private String address;

    /**
     * 经度
     */
    private Double x;

    /**
     * 维度
     */
    private Double y;

    /**
     * 均价,取整数
     */
    private Long avgPrice;

    /**
     * 销量
     */
    private Integer sold;

    /**
     * 评论数量
     */
    private Integer comments;

    /**
     * 评分,1~5分,乘10保存,避免小数
     */
    private Integer score;

    /**
     * 营业时间,例如 10:00-22:00
     */
    private String openHours;

    /**
     * 创建时间
     */
    private LocalDateTime createTime;

    /**
     * 更新时间
     */
    private LocalDateTime updateTime;


    @TableField(exist = false)
    private Double distance;
}

封装重建缓存的方法,方法内封装了逻辑过期时间 

/**
     * 封装重建缓存设置逻辑过期时间的方法
     * @param id
     * @param expireSeconds
     */
    private 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_PREFIX + id, JSONUtil.toJsonStr(redisData));
    }

声明线程池

/**
     * 声明线程池
     */
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
     * 逻辑过期应对缓存击穿
     * @param id
     * @return
     */
    public Shop queryWithLogicExpire(Long id) {
        String key = "cache:shop:" + id;
        // 1.从Redis中查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isBlank(shopJson)) {
            // 3.缓存未命中,直接返回
            return null;
        }
        //缓存命中,先取出value反序列化为对象
        RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
        JSONObject data = (JSONObject) redisData.getData();
        Shop shop = JSONUtil.toBean(data, Shop.class);
        LocalDateTime expireTime = redisData.getExpireTime();
        //判断是否过期
        if(expireTime.isAfter(LocalDateTime.now())) {
            // 未过期,返回店铺信息
            return shop;
        }
        // 已过期,需要重建缓存
        // 获取互斥锁
        String lockKey = LOCK_SHOP_KEY_PREFIX + id;
        boolean isLock = tryLock(lockKey);
        // 判断是否获取互斥锁成功
        if (isLock) {// 获取成功
            // 判断缓存是否过期
            String shopJson_ = stringRedisTemplate.opsForValue().get(key);
            if (StringUtils.isBlank(shopJson_)) {
                return null;
            }
            RedisData redisData_ = JSONUtil.toBean(shopJson_, RedisData.class);
            Shop shop_ = JSONUtil.toBean((JSONObject) redisData_.getData(), Shop.class);
            LocalDateTime exireTime_ = redisData_.getExpireTime();
            if (exireTime_.isAfter(LocalDateTime.now())){ //如果在获取锁的时候发现数据更新了则直接返回
                return shop_;
            }

            // 上面都不满足,则开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    // 重建缓存
                    this.saveShop2Redis(id, 20L);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    unLock(lockKey);
                }
            });
        }

        return shop;
    }

封装工具类解决以上问题

@Component
public class CacheClient {
    private final StringRedisTemplate stringRedisTemplate;

    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

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

    /**
     * 写入redis
     * @param key
     * @param value
     * @param time
     * @param unit
     */
    public void set(String key, Object value, Long time, TimeUnit unit) {
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
    }

    /**
     * 设置逻辑过期时间
     * @param key 键
     * @param value 值
     * @param time 逻辑过期时间
     * @param unit 时间单位
     */
    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));
    }


    public <R, ID> R queryWithPassThrouth(
            String keyPrefix, ID id, Class<R> type, Function<ID, R> dbCallBack, Long time, TimeUnit unit) {
        String key = keyPrefix + id;
        // 1.从Redis中查询商铺缓存
        String json = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isNotBlank(json)) {
            // 3.存在,直接返回
            return JSONUtil.toBean(json, type);
        }
        //缓存命中空值
        if (json != null) {
            //返回错误信息
            return null;
        }
        // 4.不存在,根据id查询数据库
        R r = dbCallBack.apply(id);
        // 5.不存在,返回错误
        if (r == null) {
            //缓存空值
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            return null;
        }
        // 6.存在,写入Redis
        this.set(key, r, time, unit);

        return r;
    }

    /**
     * 逻辑过期应对缓存击穿
     * @param id
     * @return
     */
    public <R, ID> R queryWithLogicExpire(
            String keyPrefix, ID id, Class<R> type, Function<ID, R> dbCallBack, Long expireSeconds) {
        String key = keyPrefix + id;
        // 1.从Redis中查询商铺缓存
        String json = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isBlank(json)) {
            // 3.缓存未命中,直接返回
            return null;
        }
        //缓存命中,先取出value反序列化为对象
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
        LocalDateTime expireTime = redisData.getExpireTime();
        //判断是否过期
        if(expireTime.isAfter(LocalDateTime.now())) {
            // 未过期,返回店铺信息
            return r;
        }
        // 已过期,需要重建缓存
        // 获取互斥锁
        String lockKey = LOCK_SHOP_KEY_PREFIX + id;
        boolean isLock = tryLock(lockKey);
        // 判断是否获取互斥锁成功
        if (isLock) {// 获取成功
            // 判断缓存是否过期
            String json_ = stringRedisTemplate.opsForValue().get(key);
            if (StringUtils.isBlank(json_)) {
                return null;
            }
            RedisData redisData_ = JSONUtil.toBean(json_, RedisData.class);
            R r_ = JSONUtil.toBean((JSONObject) redisData_.getData(), type);
            LocalDateTime exireTime_ = redisData_.getExpireTime();
            if (exireTime_.isAfter(LocalDateTime.now())){ //如果在获取锁的时候发现数据更新了则直接返回
                return r_;
            }

            // 上面都不满足,则开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    // 重建缓存
                    this.saveShop2Redis(keyPrefix, id, expireSeconds, dbCallBack);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    unLock(lockKey);
                }
            });
        }

        return r;
    }

    /**
     * 尝试获取锁
     * @param key 请求传入key
     * @return 是否成功
     */
    private boolean tryLock(String key) {
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }

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

    /**
     * 封装重建缓存设置逻辑过期时间的方法
     * @param id
     * @param expireSeconds
     */
    public <R, IDTYPE> void saveShop2Redis(String keyPrefix, IDTYPE id, Long expireSeconds, Function<IDTYPE, R> dbCallback) {
        // 1.查询店铺数据
        R r = dbCallback.apply(id);
        // 2.封装逻辑过期时间
        try {
            Thread.sleep(200);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        RedisData redisData = new RedisData();
        redisData.setData(r);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
        // 3.写入Redis
        stringRedisTemplate.opsForValue().set(keyPrefix + id, JSONUtil.toJsonStr(redisData));
    }

}

 

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值