• 解决Redis缓存穿透(缓存空对象、布隆过滤器)


    背景

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

    常见的解决方案有两种,分别是缓存空对象布隆过滤器

    1.缓存空对象

    image-20241025163728328

    优点:实现简单,维护方便

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

    2.布隆过滤器

    image-20241025163737389

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

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

    代码实现

    前置

    这里以根据 id 查询商品店铺为案例

    实体类

    @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;
    }
    

    常量类

    public class RedisConstants {
        public static final Long CACHE_NULL_TTL = 2L;
    
        public static final Long CACHE_SHOP_TTL = 30L;
        public static final String CACHE_SHOP_KEY = "cache:shop:";
    }
    

    工具类

    public class ObjectMapUtils {
    
        // 将对象转为 Map
        public static Map<String, String> obj2Map(Object obj) throws IllegalAccessException {
            Map<String, String> result = new HashMap<>();
            Class<?> clazz = obj.getClass();
            Field[] fields = clazz.getDeclaredFields();
            for (Field field : fields) {
                // 如果为 static 且 final 则跳过
                if (Modifier.isStatic(field.getModifiers()) && Modifier.isFinal(field.getModifiers())) {
                    continue;
                }
                field.setAccessible(true); // 设置为可访问私有字段
                Object fieldValue = field.get(obj);
                if (fieldValue != null) {
                    result.put(field.getName(), field.get(obj).toString());
                }
            }
            return result;
        }
    
        // 将 Map 转为对象
        public static Object map2Obj(Map<Object, Object> map, Class<?> clazz) throws Exception {
            Object obj = clazz.getDeclaredConstructor().newInstance();
            for (Map.Entry<Object, Object> entry : map.entrySet()) {
                Object fieldName = entry.getKey();
                Object fieldValue = entry.getValue();
                Field field = clazz.getDeclaredField(fieldName.toString());
                field.setAccessible(true); // 设置为可访问私有字段
                String fieldValueStr = fieldValue.toString();
                // 根据字段类型进行转换
                if (field.getType().equals(int.class) || field.getType().equals(Integer.class)) {
                    field.set(obj, Integer.parseInt(fieldValueStr));
                } else if (field.getType().equals(boolean.class) || field.getType().equals(Boolean.class)) {
                    field.set(obj, Boolean.parseBoolean(fieldValueStr));
                } else if (field.getType().equals(double.class) || field.getType().equals(Double.class)) {
                    field.set(obj, Double.parseDouble(fieldValueStr));
                } else if (field.getType().equals(long.class) || field.getType().equals(Long.class)) {
                    field.set(obj, Long.parseLong(fieldValueStr));
                } else if (field.getType().equals(String.class)) {
                    field.set(obj, fieldValueStr);
                } else if(field.getType().equals(LocalDateTime.class)) {
                    field.set(obj, LocalDateTime.parse(fieldValueStr));
                }
    
            }
            return obj;
        }
    
    }
    

    结果返回类

    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public class Result {
        private Boolean success;
        private String errorMsg;
        private Object data;
        private Long total;
    
        public static Result ok(){
            return new Result(true, null, null, null);
        }
        public static Result ok(Object data){
            return new Result(true, null, data, null);
        }
        public static Result ok(List<?> data, Long total){
            return new Result(true, null, data, total);
        }
        public static Result fail(String errorMsg){
            return new Result(false, errorMsg, null, null);
        }
    }
    

    控制层

    @RestController
    @RequestMapping("/shop")
    public class ShopController {
    
        @Resource
        public IShopService shopService;
    
        /**
         * 根据id查询商铺信息
         * @param id 商铺id
         * @return 商铺详情数据
         */
        @GetMapping("/{id}")
        public Result queryShopById(@PathVariable("id") Long id) {
            return shopService.queryShopById(id);
        }
        
        /**
         * 新增商铺信息
         * @param shop 商铺数据
         * @return 商铺id
         */
        @PostMapping
        public Result saveShop(@RequestBody Shop shop) {
            return shopService.saveShop(shop);
        }
    
        /**
         * 更新商铺信息
         * @param shop 商铺数据
         * @return 无
         */
        @PutMapping
        public Result updateShop(@RequestBody Shop shop) {
            return shopService.updateShop(shop);
        }
    }
    

    缓存空对象

    流程图为:

    image-20241025165838030

    服务层代码:

    public Result queryShopById(Long id) {
        // 从 redis 查询
        String shopKey = RedisConstants.CACHE_SHOP_KEY + id;
        Map<Object, Object> entries = redisTemplate.opsForHash().entries(shopKey);
        // 缓存命中
        if(!entries.isEmpty()) {
            try {
                // 如果是空对象,表示一定不存在数据库中,直接返回(解决缓存穿透)
                if(entries.containsKey("")) {
                    return Result.fail("店铺不存在");
                }
                // 刷新有效期
                redisTemplate.expire(shopKey, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
                Shop shop = (Shop) ObjectMapUtils.map2Obj(entries, Shop.class);
                return Result.ok(shop);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }
        // 查询数据库
        Shop shop = this.getById(id);
        if(shop == null) {
            // 存入空值
            redisTemplate.opsForHash().put(shopKey, "", "");
            redisTemplate.expire(shopKey, RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
            // 不存在,直接返回
            return Result.fail("店铺不存在");
        }
        // 存在,写入 redis
        try {
            redisTemplate.opsForHash().putAll(shopKey, ObjectMapUtils.obj2Map(shop));
            redisTemplate.expire(shopKey, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
        } catch (IllegalAccessException e) {
            throw new RuntimeException(e);
        }
        return Result.ok(shop);
    }
    

    布隆过滤器

    这里选择使用布隆过滤器存储存在于数据库中的 id,原因在于,如果存储了不存在于数据库中的 id,首先由于 id 的取值范围很大,那么不存在的 id 有很多,因此更占用空间;其次,由于布隆过滤器有一定的误判率,那么可能导致少数原本存在于数据库中的 id 被判为了不存在,然后直接返回了,此时就会出现根本性的正确性错误。相反,如果存储的是数据库中存在的 id,那么即使少数不存在的 id 被判为了存在,由于数据库中确实没有对应的 id,那么也会返回空,最终结果还是正确的

    这里使用 guava 依赖的布隆过滤器

    依赖为:

    <dependency>
        <groupId>com.google.guavagroupId>
        <artifactId>guavaartifactId>
        <version>30.1.1-jreversion>
    dependency>
    

    封装了布隆过滤器的类(注意初始化时要把数据库中已有的 id 加入布隆过滤器):

    public class ShopBloomFilter {
    
        private BloomFilter<Long> bloomFilter;
    
        public ShopBloomFilter(ShopMapper shopMapper) {
            // 初始化布隆过滤器,设计预计元素数量为100_0000L,误差率为1%
            bloomFilter = BloomFilter.create(Funnels.longFunnel(), 100_0000, 0.01);
            // 将数据库中已有的店铺 id 加入布隆过滤器
            List<Shop> shops = shopMapper.selectList(null);
            for (Shop shop : shops) {
                bloomFilter.put(shop.getId());
            }
        }
    
        public void add(long id) {
            bloomFilter.put(id);
        }
    
        public boolean mightContain(long id){
            return bloomFilter.mightContain(id);
        }
    
    }
    

    对应的配置类(将其设置为 bean)

    @Configuration
    public class BloomConfig {
    
        @Bean
        public ShopBloomFilter shopBloomFilter(ShopMapper shopMapper) {
            return new ShopBloomFilter(shopMapper);
        }
    
    }
    

    首先要修改查询方法,在根据 id 查询时,如果对应 id 不在布隆过滤器中,则直接返回。然后还要修改保存方法,在保存的时候还需要将对应的 id 加入布隆过滤器中

    @Override
    public Result queryShopById(Long id) {
        // 如果不在布隆过滤器中,直接返回
        if(!shopBloomFilter.mightContain(id)) {
            return Result.fail("店铺不存在");
        }
        // 从 redis 查询
        String shopKey = RedisConstants.CACHE_SHOP_KEY + id;
        Map<Object, Object> entries = redisTemplate.opsForHash().entries(shopKey);
        // 缓存命中
        if(!entries.isEmpty()) {
            try {
                // 刷新有效期
                redisTemplate.expire(shopKey, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
                Shop shop = (Shop) ObjectMapUtils.map2Obj(entries, Shop.class);
                return Result.ok(shop);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }
        // 查询数据库
        Shop shop = this.getById(id);
        if(shop == null) {
            // 不存在,直接返回
            return Result.fail("店铺不存在");
        }
        // 存在,写入 redis
        try {
            redisTemplate.opsForHash().putAll(shopKey, ObjectMapUtils.obj2Map(shop));
            redisTemplate.expire(shopKey, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
        } catch (IllegalAccessException e) {
            throw new RuntimeException(e);
        }
        return Result.ok(shop);
    }
    
    @Override
    public Result saveShop(Shop shop) {
        // 写入数据库
        this.save(shop);
        // 将 id 写入布隆过滤器
        shopBloomFilter.add(shop.getId());
        // 返回店铺 id
        return Result.ok(shop.getId());
    }
    

    结合两种方法

    由于布隆过滤器有一定的误判率,所以这里可以进一步优化,如果出现误判情况,即原本不存在于数据库中的 id 被判为了存在,就用缓存空对象的方式将其缓存到 redis 中

    @Override
    public Result queryShopById(Long id) {
        // 如果不在布隆过滤器中,直接返回
        if(!shopBloomFilter.mightContain(id)) {
            return Result.fail("店铺不存在");
        }
        // 从 redis 查询
        String shopKey = RedisConstants.CACHE_SHOP_KEY + id;
        Map<Object, Object> entries = redisTemplate.opsForHash().entries(shopKey);
        // 缓存命中
        if(!entries.isEmpty()) {
            try {
                // 如果是空对象,表示一定不存在数据库中,直接返回(解决缓存穿透)
                if(entries.containsKey("")) {
                    return Result.fail("店铺不存在");
                }
                // 刷新有效期
                redisTemplate.expire(shopKey, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
                Shop shop = (Shop) ObjectMapUtils.map2Obj(entries, Shop.class);
                return Result.ok(shop);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }
        // 查询数据库
        Shop shop = this.getById(id);
        if(shop == null) {
            // 存入空值
            redisTemplate.opsForHash().put(shopKey, "", "");
            redisTemplate.expire(shopKey, RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
            // 不存在,直接返回
            return Result.fail("店铺不存在");
        }
        // 存在,写入 redis
        try {
            redisTemplate.opsForHash().putAll(shopKey, ObjectMapUtils.obj2Map(shop));
            redisTemplate.expire(shopKey, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
        } catch (IllegalAccessException e) {
            throw new RuntimeException(e);
        }
        return Result.ok(shop);
    }
    
  • 相关阅读:
    【Rust日报】2022-08-29 RLS 谢幕
    立体库核心干货|智能自动化立体库高承载高强耐受力高效率专用托盘
    C语言数组清零----使用memset函数
    P2P实现远程控制
    郁锦香、凯里亚德亮相“2022锦江行”,如何走出一条酒店破题之路
    华为复合vlan(mux vlan)
    调用API post请求
    我是如何写作的?
    了解舵机以及MG996R的控制方法
    关于javaFx tableView组件绑定Map数据
  • 原文地址:https://blog.csdn.net/Vendetta_A_A/article/details/143245005