快取最佳化(快取穿透)
快取穿透
- 快取穿透是指查詢一個一定不存在的資料時,資料庫查詢不到資料,也不會寫入快取,這將導致這個不存在的資料每次請求都要到資料庫去查詢,可能導致資料庫崩潰。
- 這種情況大機率是遭到了攻擊。
- 常見的解決方案有:快取空資料,使用布隆過濾器等。
當前專案中存在的問題
-
當前專案中,使用者端會大量訪問的資料,即菜品資料和套餐資料,會被按照其分類id快取在redis中。當使用者查詢某個分類下的菜品時,首先會查詢redis中是否有這個分類id的資料,如果有就直接返回,否則就去查詢資料庫並將查詢到的資料返回,同時會將查詢到的資料存入redis快取。
-
假如查詢的資料在資料庫中不存在,那麼就會將空資料快取在redis中。
-
快取空資料的優點是實現簡單,但是它也有缺點:會消耗額外的記憶體。尤其是在當前專案中,快取在redis中的資料都沒有設定過期時間,因此快取的空資料只會越來越多,直到把redis的記憶體中間佔滿。
解決方案
使用redisson提供的布隆過濾器作為攔截器,拒絕掉不存在的資料的絕大多數查詢請求。
布隆過濾器
布隆過濾器主要用於檢索一個元素是否在一個集合中。如果布隆過濾器認為該元素不存在,那麼它就一定不存在。
底層資料結構
- 布隆過濾器的底層資料結構為bitmap+雜湊對映。
- bitmap其實就是一個陣列,它只儲存二進位制數0或1,也就是按位(bit)儲存。雜湊對映就是使用雜湊函式將原來的資料對映為雜湊值,一個資料只對應一個雜湊值,這樣就能夠判斷之後輸入的值是不是原來的資料了。但是雜湊對映可能會出現雜湊衝突,即多個資料對映為同一個雜湊值。
具體的演算法步驟為:
- 初始化一個較大的bitmap,每個索引的初始值為0,並指定數個雜湊函式(比如3個)。
- 儲存資料時,使用這些雜湊函式處理輸入的資料得到多個雜湊值,再使用這些雜湊值模上bitmap的大小,將餘數位置的值置為1。
- 查詢資料時,使用相同的雜湊函式處理輸入的資料得到多個雜湊值,模上bitmap的大小後判斷對應位置是否都為1,如果有一個不為1,布隆過濾器就認為這個數不存在。
- 由於雜湊對映本來就存在雜湊衝突,並且查詢時,計算得到的索引處的值雖然都是1,但卻是不同資料計算得到的。所以布隆過濾器存在誤判。陣列越大誤判率越小,陣列越小誤判率越大,我們可以透過調整陣列大小來控制誤判率。
優點
記憶體佔用較少,快取中沒有多餘的空資料鍵。
缺點
實現複雜,存在誤判,難以刪除。
程式碼開發
配置redisson
- 在pom.xml中引入redisson的依賴:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.33.0</version>
</dependency>
- 在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;
}
- 配置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);
}
}
配置布隆過濾器
- 在配置檔案application.yml中引入布隆過濾器相關配置:
spring
bloom-filter:
expected-insertions: 100
false-probability: 0.01
- 配置布隆過濾器,在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;
}
}
- 在CategoryMapper介面中建立getCategoryIdsByStatus方法:
@Select("select id from category where status = #{status}")
List<Integer> getCategoryIdsByStatus(Integer status);
配置布隆過濾器的攔截器
- 在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;
}
}
}
- 在MessageConstant類中增加一條資訊提示常量,用於攔截器丟擲:
public class MessageConstant {
...
public static final String CATEGORY_ID_NOT_FOUND = "分類不存在";
}
- 在sky-common模組下的com.sky.exception包下建立新的異常類CategoryIdNotFoundException類,用於攔截器丟擲:
public class CategoryIdNotFoundException extends BaseException {
public CategoryIdNotFoundException() {
}
public CategoryIdNotFoundException(String msg) {
super(msg);
}
}
註冊布隆過濾器的攔截器
- 在配置類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來更新布隆過濾器
當資料庫裡的分類資料發生改變時,布隆過濾器也要相應的更新,由於布隆過濾器難以刪除元素,所以更新步驟為:
- 將布隆過濾器裡所有鍵的過期時間都設定為現在。
- 清理布隆過濾器裡所有過期的鍵。
- 重新預熱布隆過濾器。
程式碼如下:
- 在com.sky.annotation包下自定義註解UpdateBloomFilter,用於標識某個方法執行完成後需要更新布隆過濾器:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface UpdateBloomFilter {}
- 在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);
}
}
- 在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快取更新之後: