【原創】分散式之一行程式碼解決快取擊穿問題

孤獨煙發表於2022-04-19

引言

今天,重新回顧一下快取擊穿這個問題!
之所以寫這個文章呢,因為目前網上流傳的文章落地性太差(什麼布隆過濾器啊,布穀過濾器啊,嗯,你們懂的),其實這類方案並不適合在專案中直接落地。

那麼,我們在專案中落地程式碼的時候,其實只需要一個註解就能解決這些問題,並不需要搞的那麼複雜。

本文有一個前提,讀者必須是java棧,且是用Springboot構建自己的專案,如果是go技術棧或者python技術棧的,可能介紹的思路僅供大家參考!

正文

目前缺陷

首先,為什麼說目前網上流傳的方案,落地性差呢,因為都缺乏一個可以和SpringBoot結合起來的真實場景,基本上都脫離了SpringBoot,只站在Java這個層級去分析。那問題就來了,現在還有隻用SpringMvc,卻不用SpringBoot的公司麼?因此,本文嘗試將該方案和SpringBoot結合起來,講一個確實可行,可以落地的方案!

當然,我們先來說說目前在網上流傳的幾套方案,到底不靠譜在哪裡!

(1)布隆過濾器

關於布隆過濾器,我就不介紹太多,這裡就理解為是一個過濾器,用於快速檢索一個元素是否在一個集合中;那麼當一個請求來的時候,快速判斷這個請求的key是否在指定集合中!如果在,說明有效,則放行。如果不在,則無效攔截。
至於實現,各大部落格也說了用了google提供的

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>19.0</version>
</dependency>

這個包裡有現成寫好的java類給你使用了,當然demo程式碼我就不貼了,一抓一大把!
當然,似乎看上去完美無暇!一切都是那麼的合適!

然而到這裡,我就真的問一句,你們真的用了這個方案了?

我如果猜的沒錯,應該沒幾個人遇到過快取擊穿問題~

更何況,證明這個說法的正確性~

該方案最大的一個問題是布隆過濾器不支援反向刪除操作,例如你的專案裡活躍的key的數量只有1000w個,但是全部key數量有5000w個,那這5000w個key會全部存在布隆過濾器裡!

直到某一天,你會發現這個過濾器太擁擠了,誤判率太高,不得不進行重建!

so,你們覺得這個做法真的靠譜?

那麼布隆過濾器這個說法出自哪裡呢?
(大家一定很好奇對不對!)

當然是xx機構~~此處保護自己的狗頭~~記住,他們為了割韭菜,一定會選擇一些看起來極為高階,但是落地巨不靠譜的方案(這也是區分一個機構到底是割韭菜還是真正有水平的標杆,小白不懂,很容易被坑)~~看到這裡,真是慚愧,我的第一篇文章也是寫這個方案了,但是在落地過程中,發現了不對勁(此處省略一萬字的檢討文,煙哥垃圾~~)。

(2)布穀過濾器

那麼,為了解決布隆過濾器查詢效能弱、空間利用效率低、不支援反向操作等問題,又有一篇文章誕生了,主張用布穀過濾器來解決快取擊穿問題!

但是,神奇的事情來了,基本上所有的文章都在說布穀過濾器多麼多麼牛逼,卻沒有任何落地的方案~

記住,我們平時寫程式碼,一定是怎麼方便怎麼來!再記住,面試是一回事,程式碼落地是另一回事~

那,真正簡便的方案是什麼樣的呢?來,我們一步步來~

真正方案

假設,你此刻用的是springboot-2.x的版本,你為了能夠連線redis,你在pom檔案里加入如下依賴

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

然後呢,我們修改application.yml

spring:
  datasource:
    ...
  redis:
    database: ...
    host: ...
    port: ...
(省事,不全貼了)

ok,說到這裡,就不得不說一下spring-cache了,Spring3.1之後,引入了註解快取技術,其本質上不是一個具體的快取實現方案,而是一個對快取使用的抽象,通過在既有程式碼中新增少量自定義的各種annotation,即能夠達到使用快取物件和快取方法的返回物件的效果。Spring的快取技術具備相當的靈活性,不僅能夠使用SpEL(Spring Expression Language)來定義快取的key和各種condition,還提供開箱即用的快取臨時儲存方案,也支援和主流的專業快取整合。

例如:我們在程式碼中經常有這麼一段邏輯,在目標方法執行前,會根據key先去快取中查詢看是否有資料,有就直接返回快取中的key對應的value值,不再執行目標方法;沒有則執行目標方法,去資料庫查詢出對應的value,並以鍵值對的形式存入快取。

如果我們不使用例如spring-cache的註解框架,你的程式碼中會充斥著大量冗餘程式碼,而用了該框架後,以@Cacheable註解為例, 該註解在方法上,表示該方法的返回結果是可以快取的。

也就是說,該方法的返回結果會放在快取中,以便於以後使用相同的引數呼叫該方法時,會返回快取中的值,而不會實際執行該方法。

那麼,你的程式碼只需要這麼寫

@Override
@Cacheable("menu")
public Menu findById(String id) {
    Menu menu = this.getById(id);
    if (menu != null){
        System.out.println("menu.name = " + menu.getName());
    }
    return menu;
}

在這個例子中,findById 方法與一個名為 menu 的快取關聯起來了。呼叫該方法時,會檢查 menu 快取,如果快取中有結果,就不會去執行方法了。

ok,說到這裡,其實都是大家懂得東西!!接下來開始我們的主題:如何解決快取擊穿問題!順便講講穿透和雪崩問題!

來來來,我們回憶一下快取擊穿,穿透以及快取雪崩的概念!

快取穿透

在高併發下,查詢一個不存在的值時,快取不會被命中,導致大量請求直接落到資料庫上,如活動系統裡面查詢一個不存在的活動。
多嘴一句:快取穿透是指,請求的是快取和資料庫中都沒有的資料!

對於快取穿透問題,有一個很簡單的解決方案,就是快取NULL值~從快取取不到的資料,在資料庫中也沒有取到,直接返回空值。

那麼spring-cache中,有一個配置是這樣的

spring.cache.redis.cache-null-values=true

帶上該配置後,就可以快取null值了,值得一提的是,這個快取時間要設的少一點,例如15秒就夠,如果設定過長,會導致正常的快取也無法使用。

快取擊穿

在高併發下,對一個特定的值進行查詢,但是這個時候快取正好過期了,快取沒有命中,導致大量請求直接落到資料庫上,如活動系統裡面查詢活動資訊,但是在活動進行過程中活動快取突然過期了。
多嘴一句:快取擊穿是指,請求的是快取沒有,而資料庫中有的資料!

記住,解決擊穿的最簡單的方法,只有一個,就是限流!至於怎麼限,其實可以各顯神通!例如其他文章提到的布隆過濾器,布穀過濾器等,不過是限流方式之一而已!甚至,你用一些其他的限流元件也是可以的!

這裡就要說spring-cahce的另一個配置了!

在快取過期之後,如果多個執行緒同時請求對某個資料的訪問,會同時去到資料庫,導致資料庫瞬間負荷增高。Spring4.3為@Cacheable註解提供了一個新的引數“sync”(boolean型別,預設為false),當設定它為true時,只有一個執行緒的請求會去到資料庫,其他執行緒都會等待直到快取可用。這個設定可以減少對資料庫的瞬間併發訪問。

看到這裡!!這不就是一個限流方案麼?

所以解決方法就是,加一個屬性sync=true,就行。程式碼就像下面這樣

@Cacheable(cacheNames="menu", sync="true")

用了該屬性後,可以指示底層將快取鎖住,使只有一個執行緒可以進入計算,而其他執行緒堵塞,直到返回結果更新到快取中。

當然,看到這裡,一定會有人和我抬槓!他的問題是這樣的!

你這個只是針對單機的限流,並不是整體叢集的限流!也就是說,假設你的叢集搭建了3000個pod,最差的情況下就是,3000個pod上,每個pod都會發起一個請求去資料庫查詢,照樣還是會導致資料庫連線數不夠用,等等資源問題!

對於這個問題我只能說!少年,但凡你的公司產品達到這種流量規模,此刻你就不會在看我的文章!你此刻關心的問題是:

(1)哎,買深圳灣一號還是深圳灣公館呢,糾結!
(2)昨天美股又跌了,又損失了兩套房
(3)昨天提前撤單了,又少掙了幾萬
....(省略一萬字)

當然,如果你非要解決,也有辦法。spring的aop有套路的,比如@Transactional的Advice是TransactionInterceptor,那麼cache也對應對一個CacheInterceptor,我們只要去改CacheInterceptor,這個切面就能解決。在裡頭做一個分散式鎖!虛擬碼如下

flag := 取分散式鎖
if flag {
    走資料庫查詢,並快取結果
}{
    睡眠一段時間,再次嘗試獲取key的值
}

但是,我還是要多嘴提一句,真沒必要~~
記住一句話,立足實際出發~但凡你的業務到了那種級別,是可以做到區域部署的,完全可以規避開這類問題。

快取雪崩

在高併發下,大量的快取key在同一時間失效,導致大量的請求落到資料庫上,如活動系統裡面同時進行著非常多的活動,但是在某個時間點所有的活動快取全部過期。

那麼針對該問題,最簡單的解決方法就是,過期時間加隨機值!

但是很麻煩的是,我們在使用@Cacheable註解的時候,原生功能沒法直接設定隨機過期時間的。

這個老實說,真沒啥好方法,只能自己繼承RedisCache,對其增強,改寫其中的put方法,帶上隨機時間!

(本文不贅述,自己可以去查閱相關部落格,我真的不喜歡寫文章貼大量程式碼,可讀性太差了,知道這麼個思路就行,出門搜尋一下,一堆答案!)

文末

自此,快取擊穿,穿透,雪崩問題都得到圓滿解決~~

相關文章