從SpringBoot構建十萬博文聊聊快取穿透

  • 2019 年 10 月 3 日
  • 筆記

前言

在部落格系統中,為了提升響應速度,加入了 Redis 快取,把文章主鍵 ID 作為 key 值去快取查詢,如果不存在對應的 value,就去資料庫中查找 。這個時候,如果請求的並發量很大,就會對後端的資料庫服務造成很大的壓力。

造成原因

  • 業務自身程式碼或數據出現問題
  • 惡意攻擊、爬蟲造成大量空的命中,會對資料庫造成很大壓力

部落格架構

案例分析

由於文章的地址是這樣子的:

https://blog.52itstyle.top/49.html

大家很容易猜出,是不是還有 50、51、52 甚至是十萬+?如果是正兒八經的爬蟲,可能會讀取你的總頁數。但是有些不正經的爬蟲或者人,還真以為你有十萬+博文,然後就寫了這麼一個腳本。

for num in range(1,1000000):     //爬死你,開100個執行緒

解決方案

設置布隆過濾器,預先將所有文章的主鍵 ID 哈希到一個足夠大的 BitMap 中,每次請求都會經過 BitMap 的攔截,如果 Key 不存在,直接返回異常。這樣就避免了對 Redis 快取以及底層資料庫的查詢壓力。

這裡我們使用Google開源的第三方工具類來實現:

<dependency>        <groupId>com.google.guava</groupId>        <artifactId>guava</artifactId>        <version>25.1-jre</version>  </dependency>

編寫布隆過濾器:

/**   * 布隆快取過濾器   */  @Component  public class BloomCacheFilter {        public static BloomFilter<Integer> bloomFilter = null;        @Autowired      private DynamicQuery dynamicQuery;      /**       * 初始化       */      @PostConstruct      public void init(){          String nativeSql = "SELECT id FROM blog";          List<Object> list = dynamicQuery.query(nativeSql,new Object[]{});          bloomFilter = BloomFilter.create(Funnels.integerFunnel(), list.size());          list.forEach(blog ->bloomFilter.put(Integer.parseInt(blog.toString())));      }      /**       * 判斷key是否存在       * @param key       * @return       */      public static boolean mightContain(long key){          return bloomFilter.mightContain((int)key);      }  }  

然後,每一次查詢之前做一次 Key 值校驗:

/**   * 博文   */  @RequestMapping("{id}.shtml")  public String page(@PathVariable("id") Long id, ModelMap model) {       if(BloomCacheFilter.mightContain(id)){           Blog blog = blogService.getById(id);           model.addAttribute("blog",blog);           return  "article";       }else{           return  "error";       }  }

效率

那麼,在數據量很大的情況下,效率如何呢?我們來做個實驗,以 100W 為基數。

 public static void main(String[] args) {          int capacity = 1000000;          int key = 6666;          BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), capacity);          for (int i = 0; i < capacity; i++) {              bloomFilter.put(i);          }          /**返回電腦最精確的時間,單位納妙 */          long start = System.nanoTime();          if (bloomFilter.mightContain(key)) {              System.out.println("成功過濾到" + key);          }          long end = System.nanoTime();          System.out.println("布隆過濾器消耗時間:" + (end - start));  }

布隆過濾器消耗時間:281299,約等於 0.28 毫秒,匹配速度是不是很快?

錯判率

萬事萬物都有所均衡,既然效率如此之高,肯定其它方面定有所犧牲,通過測試我們發現,過濾器有 3% 的錯判率,也就是說,本來沒有的文章,有可能通過校驗被訪問到,然後報錯!

 public static void main(String[] args) {          int capacity = 1000000;          BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), capacity);          for (int i = 0; i < capacity; i++) {              bloomFilter.put(i);          }          int sum = 0;          for (int i = capacity + 20000; i < capacity + 30000; i++) {              if (bloomFilter.mightContain(i)) {                  sum ++;              }          }          //0.03          DecimalFormat df=new DecimalFormat("0.00");//設置保留位數          System.out.println("錯判率為:" + df.format((float)sum/10000));  }

通過源碼閱讀,發現 3% 的錯判率是系統寫死的。

public static <T> BloomFilter<T> create(Funnel<? super T> funnel, long expectedInsertions) {          return create(funnel, expectedInsertions, 0.03D);  }

當然我們也可以通過傳參,降低錯判率。測試了一下,查詢速度稍微有一丟丟降低,但也只是零點幾毫秒級的而已。

BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), capacity,0.01);

那麼如何做到零錯判率呢?答案是不可能的,布隆過濾器,錯判率必須大於零。為了保證文章 100% 的訪問率,正常情況下,我們可以關閉布隆校驗,只有才突發情況下開啟。比如,可以通過阿里的動態參數配置 Nacos 實現。

@NacosValue(value = "${bloomCache:false}", autoRefreshed = true)  private boolean bloomCache;  //省略部分程式碼  if(bloomCache||BloomCacheFilter.mightContain(id)){       Blog blog = blogService.getById(id);       model.addAttribute("blog",blog);       return  "article";  }else{       return  "error";  }

小結

快取穿透大多數情況下都是惡意攻擊導致的空命中率。雖然十萬部落格還沒有被百度收錄,每天也就寥寥的幾十個IP,但是夢想還是有的,萬一實現了呢?所以,還是要做好準備的!

源碼

https://gitee.com/52itstyle/spring-boot-blog