快取最佳化(快取穿透)

zgg1h發表於2024-07-28

快取最佳化(快取穿透)

快取穿透

  • 快取穿透是指查詢一個一定不存在的資料時,資料庫查詢不到資料,也不會寫入快取,這將導致這個不存在的資料每次請求都要到資料庫去查詢,可能導致資料庫崩潰。
  • 這種情況大機率是遭到了攻擊。
  • 常見的解決方案有:快取空資料,使用布隆過濾器等。

當前專案中存在的問題

  • 當前專案中,使用者端會大量訪問的資料,即菜品資料和套餐資料,會被按照其分類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快取更新之後:
快取最佳化(快取穿透)

相關文章