解决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);
}
相关推荐
zhuyasen9 分钟前
Go 分布式任务和定时任务太难?sasynq 让异步任务从未如此简单
后端·go
东林牧之33 分钟前
Django+celery异步:拿来即用,可移植性高
后端·python·django
YuTaoShao1 小时前
【LeetCode 热题 100】131. 分割回文串——回溯
java·算法·leetcode·深度优先
源码_V_saaskw1 小时前
JAVA图文短视频交友+自营商城系统源码支持小程序+Android+IOS+H5
java·微信小程序·小程序·uni-app·音视频·交友
超浪的晨1 小时前
Java UDP 通信详解:从基础到实战,彻底掌握无连接网络编程
java·开发语言·后端·学习·个人开发
AntBlack2 小时前
从小不学好 ,影刀 + ddddocr 实现图片验证码认证自动化
后端·python·计算机视觉
Pomelo_刘金2 小时前
Clean Architecture 整洁架构:借一只闹钟讲明白「整洁架构」的来龙去脉
后端·架构·rust
双力臂4042 小时前
Spring Boot 单元测试进阶:JUnit5 + Mock测试与切片测试实战及覆盖率报告生成
java·spring boot·后端·单元测试
心之语歌2 小时前
Spring AI MCP 客户端
人工智能·spring·github
Edingbrugh.南空2 小时前
Aerospike与Redis深度对比:从架构到性能的全方位解析
java·开发语言·spring