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

文章目录

背景

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

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

1.缓存空对象

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

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

2.布隆过滤器

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

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

代码实现

前置

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

实体类

java 复制代码
@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;
}

常量类

java 复制代码
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:";
}

工具类

java 复制代码
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;
    }

}

结果返回类

java 复制代码
@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);
    }
}

控制层

java 复制代码
@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);
    }
}

缓存空对象

流程图为:

服务层代码:

java 复制代码
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 依赖的布隆过滤器

依赖为:

xml 复制代码
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>30.1.1-jre</version>
</dependency>

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

java 复制代码
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)

java 复制代码
@Configuration
public class BloomConfig {

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

}

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

java 复制代码
@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 中

java 复制代码
@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);
}
相关推荐
Oak Zhang15 分钟前
sharding-jdbc自定义分片算法,表对应关系存储在mysql中,缓存到redis或者本地
redis·mysql·缓存
2401_8827275727 分钟前
BY组态-低代码web可视化组件
前端·后端·物联网·低代码·数学建模·前端框架
心仪悦悦43 分钟前
Scala中的集合复习(1)
开发语言·后端·scala
门牙咬脆骨1 小时前
【Redis】redis缓存击穿,缓存雪崩,缓存穿透
数据库·redis·缓存
门牙咬脆骨1 小时前
【Redis】GEO数据结构
数据库·redis·缓存
阿龟在奔跑1 小时前
引用类型的局部变量线程安全问题分析——以多线程对方法局部变量List类型对象实例的add、remove操作为例
java·jvm·安全·list
飞滕人生TYF1 小时前
m个数 生成n个数的所有组合 详解
java·递归
代码小鑫1 小时前
A043-基于Spring Boot的秒杀系统设计与实现
java·开发语言·数据库·spring boot·后端·spring·毕业设计
真心喜欢你吖1 小时前
SpringBoot与MongoDB深度整合及应用案例
java·spring boot·后端·mongodb·spring
激流丶1 小时前
【Kafka 实战】Kafka 如何保证消息的顺序性?
java·后端·kafka