缓存优化(缓存穿透)

缓存优化(缓存穿透)

缓存穿透

  • 缓存穿透是指查询一个一定不存在的数据时,数据库查询不到数据,也不会写入缓存,这将导致这个不存在的数据每次请求都要到数据库去查询,可能导致数据库崩溃。
  • 这种情况大概率是遭到了攻击。
  • 常见的解决方案有:缓存空数据,使用布隆过滤器等。

当前项目中存在的问题

  • 当前项目中,用户端会大量访问的数据,即菜品数据和套餐数据,会被按照其分类id缓存在redis中。当用户查询某个分类下的菜品时,首先会查询redis中是否有这个分类id的数据,如果有就直接返回,否则就去查询数据库并将查询到的数据返回,同时会将查询到的数据存入redis缓存。

  • 假如查询的数据在数据库中不存在,那么就会将空数据缓存在redis中。

  • 缓存空数据的优点是实现简单,但是它也有缺点:会消耗额外的内存。尤其是在当前项目中,缓存在redis中的数据都没有设置过期时间,因此缓存的空数据只会越来越多,直到把redis的内存中间占满。

解决方案

使用redisson提供的布隆过滤器作为拦截器,拒绝掉不存在的数据的绝大多数查询请求。

布隆过滤器

布隆过滤器主要用于检索一个元素是否在一个集合中。如果布隆过滤器认为该元素不存在,那么它就一定不存在。

底层数据结构

  • 布隆过滤器的底层数据结构为bitmap+哈希映射。
  • bitmap其实就是一个数组,它只存储二进制数0或1,也就是按位(bit)存储。哈希映射就是使用哈希函数将原来的数据映射为哈希值,一个数据只对应一个哈希值,这样就能够判断之后输入的值是不是原来的数据了。但是哈希映射可能会出现哈希冲突,即多个数据映射为同一个哈希值。

具体的算法步骤为:

  1. 初始化一个较大的bitmap,每个索引的初始值为0,并指定数个哈希函数(比如3个)。
  2. 存储数据时,使用这些哈希函数处理输入的数据得到多个哈希值,再使用这些哈希值模上bitmap的大小,将余数位置的值置为1。
  3. 查询数据时,使用相同的哈希函数处理输入的数据得到多个哈希值,模上bitmap的大小后判断对应位置是否都为1,如果有一个不为1,布隆过滤器就认为这个数不存在。
  • 由于哈希映射本来就存在哈希冲突,并且查询时,计算得到的索引处的值虽然都是1,但却是不同数据计算得到的。所以布隆过滤器存在误判。数组越大误判率越小,数组越小误判率越大,我们可以通过调整数组大小来控制误判率。

优点

内存占用较少,缓存中没有多余的空数据键。

缺点

实现复杂,存在误判,难以删除。

代码开发

配置redisson

  1. 在pom.xml中引入redisson的依赖:
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.33.0</version>
</dependency>
  1. 在sky-commom包下的com.sky.properties包下创建RedisProperties类,用于配置redisson:
@Component
@ConfigurationProperties(prefix = "spring.redis")
@Data
public class RedisProperties {

    /**
     * redis相关配置
     */
    private String host;
    private String port;
    private String password;
    private int database;

}
  1. 配置redisson,在com.sky.config包下创建RedissonConfiguration类并创建redissonClient的Bean对象:
@Configuration
@Slf4j
public class RedissionConfiguration {

    @Autowired
    private RedisProperties redisProperties;

    @Bean
    public RedissonClient redissonClient() {
        log.info("开始创建redisson客户端对象...");

        //拼接redis地址
        StringBuffer address = new StringBuffer("redis://");
        address.append(redisProperties.getHost()).append(":").append(redisProperties.getPort());

        //创建并配置redisson客户端对象
        Config config = new Config();
        config.setCodec(StringCodec.INSTANCE)
                .useSingleServer()
                .setAddress(address.toString())
                .setPassword(redisProperties.getPassword())
                .setDatabase(redisProperties.getDatabase());
        return Redisson.create(config);
    }
}

配置布隆过滤器

  1. 在配置文件application.yml中引入布隆过滤器相关配置:
spring
  bloom-filter:
    expected-insertions: 100
    false-probability: 0.01
  1. 配置布隆过滤器,在com.sky.config包下创建BloomFilterConfiguration类并创建bloomFilter的Bean对象:
@Configuration
@Slf4j
public class BloomFilterConfiguration {

    @Autowired
    private RedissonClient redissonClient;
    @Autowired
    private CategoryMapper categoryMapper;
    @Value("${spring.bloom-filter.expected-insertions}")
    private long expectedInsertions;
    @Value("${spring.bloom-filter.false-probability}")
    private double falseProbability;

    /**
     * 创建并预热布隆过滤器
     */
    @Bean("bloomFilter")
    public RBloomFilter<Integer> init() {
        log.info("开始创建布隆过滤器...");
        RBloomFilter<Integer> bloomFilter = redissonClient.getBloomFilter("BloomFilter", StringCodec.INSTANCE);
        bloomFilter.tryInit(expectedInsertions, falseProbability);

        log.info("开始预热布隆过滤器...");

        //查询所有的分类id
        List<Integer> CategoryIds = categoryMapper.getCategoryIdsByStatus(StatusConstant.ENABLE);

        //预热布隆过滤器
        bloomFilter.add(CategoryIds);

        return bloomFilter;
    }
}
  1. 在CategoryMapper接口中创建getCategoryIdsByStatus方法:
@Select("select id from category where status = #{status}")
List<Integer> getCategoryIdsByStatus(Integer status);

配置布隆过滤器的拦截器

  1. 在com.sky.interceptor包下创建BloomFilterInterceptor类并编写布隆过滤器拦截器的校验逻辑:
@Component
@Slf4j
public class BloomFilterInterceptor implements HandlerInterceptor {

    @Autowired
    private RBloomFilter bloomFilter;

    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        //判断当前拦截到的是Controller的方法还是其他资源
        if (!(handler instanceof HandlerMethod)) {
            //当前拦截到的不是动态方法,直接放行
            return true;
        }

        //1、从查询参数中获取分类id
        String categoryId = request.getQueryString().split("=")[1];

        //2、校验分类id
        try {
            log.info("布隆过滤器校验:{}", categoryId);
            if (bloomFilter.contains(Integer.valueOf(categoryId))) {
                //3、通过,放行
                log.info("布隆过滤器校验通过");
                return true;
            } else {
                //4、不通过,抛出分类不存在异常
                log.info("布隆过滤器校验不通过");
                throw new CategoryIdNotFoundException(MessageConstant.CATEGORY_ID_NOT_FOUND);
            }
        } catch (Exception ex) {
            //4、并响应404状态码
            response.setStatus(404);
            return false;
        }
    }
}
  1. 在MessageConstant类中增加一条信息提示常量,用于拦截器抛出:
public class MessageConstant {
	...
	public static final String CATEGORY_ID_NOT_FOUND = "分类不存在";
}
  1. 在sky-common模块下的com.sky.exception包下创建新的异常类CategoryIdNotFoundException类,用于拦截器抛出:
public class CategoryIdNotFoundException extends BaseException {

    public CategoryIdNotFoundException() {
    }

    public CategoryIdNotFoundException(String msg) {
        super(msg);
    }
}

注册布隆过滤器的拦截器

  1. 在配置类WebMvcConfiguration中注册BloomFilterInterception:
public class WebMvcConfiguration extends WebMvcConfigurationSupport {
    ...
    @Autowired
    private BloomFilterInterceptor bloomFilterInterceptor;
    
    protected void addInterceptors(InterceptorRegistry registry) {
        ...
        registry.addInterceptor(bloomFilterInterceptor)
                .addPathPatterns("/user/setmeal/list")
                .addPathPatterns("/user/dish/list");
    }
    ...
}

使用AOP来更新布隆过滤器

当数据库里的分类数据发生改变时,布隆过滤器也要相应的更新,由于布隆过滤器难以删除元素,所以更新步骤为:

  1. 将布隆过滤器里所有键的过期时间都设置为现在。
  2. 清理布隆过滤器里所有过期的键。
  3. 重新预热布隆过滤器。

代码如下:

  1. 在com.sky.annotation包下自定义注解UpdateBloomFilter,用于标识某个方法执行完成后需要更新布隆过滤器:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface UpdateBloomFilter {}
  1. 在com.sky.service.aspect包下创建切面类UpdateBloomFilterAspect,用于更新布隆过滤器:
@Aspect
@Component
@Slf4j
public class UpdateBloomFilterAspect {

    @Autowired
    private ApplicationContext applicationContext;
    @Value("${spring.bloom-filter.expected-insertions}")
    private long expectedInsertions;
    @Value("${spring.bloom-filter.false-probability}")
    private double falseProbability;
    @Autowired
    private CategoryMapper categoryMapper;

    /**
     * 切入点
     */
    @Pointcut("@annotation(com.sky.annotation.UpdateBloomFilter)")
    public void updateBloomFilterPointcut() {}

    /**
     * 后置通知,在通知中进行布隆过滤器的更新
     */
    @After("updateBloomFilterPointcut()")
    public void updateBloomFilter(JoinPoint joinPoint) {
        log.info("开始更新布隆过滤器");

        //获得布隆过滤器的Bean对象
        RBloomFilter<Integer> bloomFilter = (RBloomFilter<Integer>) applicationContext.getBean("bloomFilter");
        //清理布隆过滤器
        bloomFilter.expire(Instant.now());
        bloomFilter.clearExpire();
        //初始化布隆过滤器
        bloomFilter.tryInit(expectedInsertions, falseProbability);

        log.info("开始预热布隆过滤器...");

        //查询所有的分类id
        List<Integer> CategoryIds = categoryMapper.getCategoryIdsByStatus(StatusConstant.ENABLE);

        //预热布隆过滤器
        bloomFilter.add(CategoryIds);
    }
}
  1. 在CategoryController类中的新增分类、删除分类和启用禁用分类方法上加上注解@UpdateBloomFilter:
public class CategoryController {
    ...
    @UpdateBloomFilter
    public Result<String> save(@RequestBody CategoryDTO categoryDTO) {...}
    
    @UpdateBloomFilter
    public Result<String> deleteById(Long id) {...}
    
    @UpdateBloomFilter
    public Result<String> startOrStop(@PathVariable("status") Integer status, Long id) {...}
    ...
}

功能测试

布隆过滤器的拦截器功能验证

通过接口文档测试,并观察日志来进行验证:

  • 当前端查询数据库里存在的数据时:

  • 当前端查询数据库里不存在的数据时:

布隆过滤器的更新功能验证

通过接口文档测试或前后端联调测试,并观察日志和redis缓存进行验证:

  • 日志:

  • redis缓存更新之前:
  • redis缓存更新之后:
posted @   zgg1h  阅读(133)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)
点击右上角即可分享
微信分享提示