文章來自微信公眾號:PHP自學中心
快取能夠有效地加速應用的讀寫速度,同時也可以降低後端負載,對日常應用的開發至關重要。
下面會介紹快取使用技巧和設計方案,包含如下內容:快取的收益和成本分析、快取更新策略的選擇和使用場景、快取粒度控制方法、穿透問題優化、無底洞問題優化、雪崩問題優化、熱點key重建優化。
1)快取的收益和成本分析
下圖左側為客戶端直接呼叫儲存層的架構,右側為比較典型的快取層+儲存層架構。
下面分析一下快取加入後帶來的收益和成本。
收益:
①加速讀寫:因為快取通常都是全記憶體的,而儲存層通常讀寫效能不夠強悍(例如MySQL),通過快取的使用可以有效地加速讀寫,優化使用者體驗。
②降低後端負載:幫助後端減少訪問量和複雜計算(例如很複雜的SQL語句),在很大程度降低了後端的負載。
成本:
①資料不一致性:快取層和儲存層的資料存在著一定時間視窗的不一致性,時間視窗跟更新策略有關。
②程式碼維護成本:加入快取後,需要同時處理快取層和儲存層的邏輯,增大了開發者維護程式碼的成本。
③運維成本:以Redis Cluster為例,加入後無形中增加了運維成本。
快取的使用場景基本包含如下兩種:
①開銷大的複雜計算:以MySQL為例子,一些複雜的操作或者計算(例如大量聯表操作、一些分組計算),如果不加快取,不但無法滿足高併發量,同時也會給MySQL帶來巨大的負擔。
②加速請求響應:即使查詢單條後端資料足夠快(例如select*from table where id=
),那麼依然可以使用快取,以Redis為例子,每秒可以完成數萬次讀寫,並且提供的批量操作可以優化整個IO鏈的響應時間。
2)快取更新策略
快取中的資料會和資料來源中的真實資料有一段時間視窗的不一致,需要利用某些策略進行更新,下面會介紹幾種主要的快取更新策略。
①LRU/LFU/FIFO演算法剔除:剔除演算法通常用於快取使用量超過了預設的最大值時候,如何對現有的資料進行剔除。例如Redis使用maxmemory-policy這個配置作為記憶體最大值後對於資料的剔除策略。
②超時剔除:通過給快取資料設定過期時間,讓其在過期時間後自動刪除,例如Redis提供的expire命令。如果業務可以容忍一段時間內,快取層資料和儲存層資料不一致,那麼可以為其設定過期時間。在資料過期後,再從真實資料來源獲取資料,重新放到快取並設定過期時間。例如一個視訊的描述資訊,可以容忍幾分鐘內資料不一致,但是涉及交易方面的業務,後果可想而知。
③主動更新:應用方對於資料的一致性要求高,需要在真實資料更新後,立即更新快取資料。例如可以利用訊息系統或者其他方式通知快取更新。
三種常見更新策略的對比:
有兩個建議:
①低一致性業務建議配置最大記憶體和淘汰策略的方式使用。
②高一致性業務可以結合使用超時剔除和主動更新,這樣即使主動更新出了問題,也能保證資料過期時間後刪除髒資料。
3)快取粒度控制
快取粒度問題是一個容易被忽視的問題,如果使用不當,可能會造成很多無用空間的浪費,網路頻寬的浪費,程式碼通用性較差等情況,需要綜合資料通用性、空間佔用比、程式碼維護性三點進行取捨。
快取比較常用的選型,快取層選用Redis,儲存層選用MySQL。
4)穿透優化
快取穿透是指查詢一個根本不存在的資料,快取層和儲存層都不會命中,通常出於容錯的考慮,如果從儲存層查不到資料則不寫入快取層。
通常可以在程式中分別統計總呼叫數、快取層命中數、儲存層命中數,如果發現大量儲存層空命中,可能就是出現了快取穿透問題。造成快取穿透的基本原因有兩個。第一,自身業務程式碼或者資料出現問題,第二,一些惡意攻擊、爬蟲等造成大量空命中。下面我們來看一下如何解決快取穿透問題。
①快取空物件:如圖下所示,當第2步儲存層不命中後,仍然將空物件保留到快取層中,之後再訪問這個資料將會從快取中獲取,這樣就保護了後端資料來源。
快取空物件會有兩個問題:第一,空值做了快取,意味著快取層中存了更多的鍵,需要更多的記憶體空間(如果是攻擊,問題更嚴重),比較有效的方法是針對這類資料設定一個較短的過期時間,讓其自動剔除。第二,快取層和儲存層的資料會有一段時間視窗的不一致,可能會對業務有一定影響。例如過期時間設定為5分鐘,如果此時儲存層新增了這個資料,那此段時間就會出現快取層和儲存層資料的不一致,此時可以利用訊息系統或者其他方式清除掉快取層中的空物件。
②布隆過濾器攔截
如下圖所示,在訪問快取層和儲存層之前,將存在的key用布隆過濾器提前儲存起來,做第一層攔截。例如:一個推薦系統有4億個使用者id,每個小時演算法工程師會根據每個使用者之前歷史行為計算出推薦資料放到儲存層中,但是最新的使用者由於沒有歷史行為,就會發生快取穿透的行為,為此可以將所有推薦資料的使用者做成布隆過濾器。如果布隆過濾器認為該使用者id不存在,那麼就不會訪問儲存層,在一定程度保護了儲存層。
快取空物件和布隆過濾器方案對比
另:布隆過濾器簡單說明:
如果想判斷一個元素是不是在一個集合裡,一般想到的是將集合中所有元素儲存起來,然後通過比較確定。連結串列、樹、雜湊表(又叫雜湊表,Hash table)等等資料結構都是這種思路。但是隨著集合中元素的增加,我們需要的儲存空間越來越大。同時檢索速度也越來越慢。
Bloom Filter 是一種空間效率很高的隨機資料結構,Bloom filter 可以看做是對 bit-map 的擴充套件, 它的原理是:
當一個元素被加入集合時,通過 K 個 Hash 函式將這個元素對映成一個位陣列(Bit array)中的 K 個點,把它們置為 1。檢索時,我們只要看看這些點是不是都是 1 就(大約)知道集合中有沒有它了:
如果這些點有任何一個 0,則被檢索元素一定不在;如果都是 1,則被檢索元素很可能在。
5)無底洞優化
為了滿足業務需要可能會新增大量新的快取節點,但是發現效能不但沒有好轉反而下降了。用一句通俗的話解釋就是,更多的節點不代表更高的效能,所謂“無底洞”就是說投入越多不一定產出越多。但是分散式又是不可以避免的,因為訪問量和資料量越來越大,一個節點根本抗不住,所以如何高效地在分散式快取中批量操作是一個難點。
無底洞問題分析:
①客戶端一次批量操作會涉及多次網路操作,也就意味著批量操作會隨著節點的增多,耗時會不斷增大。
②網路連線數變多,對節點的效能也有一定影響。
如何在分散式條件下優化批量操作?我們來看一下常見的IO優化思路:
命令本身的優化,例如優化SQL語句等。
減少網路通訊次數。
降低接入成本,例如客戶端使用長連/連線池、NIO等。
這裡我們假設命令、客戶端連線已經為最優,重點討論減少網路操作次數。下面我們將結合Redis Cluster的一些特性對四種分散式的批量操作方式進行說明。
①序列命令:由於n個key是比較均勻地分佈在Redis Cluster的各個節點上,因此無法使用mget命令一次性獲取,所以通常來講要獲取n個key的值,最簡單的方法就是逐次執行n個get命令,這種操作時間複雜度較高,它的操作時間=n次網路時間+n次命令時間,網路次數是n。很顯然這種方案不是最優的,但是實現起來比較簡單。
②序列IO:Redis Cluster使用CRC16演算法計算出雜湊值,再取對16383的餘數就可以算出slot值,同時Smart客戶端會儲存slot和節點的對應關係,有了這兩個資料就可以將屬於同一個節點的key進行歸檔,得到每個節點的key子列表,之後對每個節點執行mget或者Pipeline操作,它的操作時間=node次網路時間+n次命令時間,網路次數是node的個數,整個過程如下圖所示,很明顯這種方案比第一種要好很多,但是如果節點數太多,還是有一定的效能問題。
③並行IO:此方案是將方案2中的最後一步改為多執行緒執行,網路次數雖然還是節點個數,但由於使用多執行緒網路時間變為O(1)
,這種方案會增加程式設計的複雜度。
④hash_tag實現:Redis Cluster的hash_tag功能,它可以將多個key強制分配到一個節點上,它的操作時間=1次網路時間+n次命令時間。
四種批量操作解決方案對比
6)雪崩優化
快取雪崩:由於快取層承載著大量請求,有效地保護了儲存層,但是如果快取層由於某些原因不能提供服務,於是所有的請求都會達到儲存層,儲存層的呼叫量會暴增,造成儲存層也會級聯當機的情況。
預防和解決快取雪崩問題,可以從以下三個方面進行著手:
①保證快取層服務高可用性。如果快取層設計成高可用的,即使個別節點、個別機器、甚至是機房宕掉,依然可以提供服務,例如前面介紹過的Redis Sentinel和Redis Cluster都實現了高可用。
②依賴隔離元件為後端限流並降級。在實際專案中,我們需要對重要的資源(例如Redis、MySQL、HBase、外部介面)都進行隔離,讓每種資源都單獨執行在自己的執行緒池中,即使個別資源出現了問題,對其他服務沒有影響。但是執行緒池如何管理,比如如何關閉資源池、開啟資源池、資源池閥值管理,這些做起來還是相當複雜的。
③提前演練。在專案上線前,演練快取層宕掉後,應用以及後端的負載情況以及可能出現的問題,在此基礎上做一些預案設定。
7)熱點key重建優化
開發人員使用“快取+過期時間”的策略既可以加速資料讀寫,又保證資料的定期更新,這種模式基本能夠滿足絕大部分需求。但是有兩個問題如果同時出現,可能就會對應用造成致命的危害:
當前key是一個熱點key(例如一個熱門的娛樂新聞),併發量非常大。
重建快取不能在短時間完成,可能是一個複雜計算,例如複雜的SQL、多次IO、多個依賴等。在快取失效的瞬間,有大量執行緒來重建快取,造成後端負載加大,甚至可能會讓應用崩潰。
要解決這個問題也不是很複雜,但是不能為了解決這個問題給系統帶來更多的麻煩,所以需要制定如下目標:
減少重建快取的次數
資料儘可能一致。
較少的潛在危險
①互斥鎖:此方法只允許一個執行緒重建快取,其他執行緒等待重建快取的執行緒執行完,重新從快取獲取資料即可,整個過程如圖所示。
下面程式碼使用Redis的setnx命令實現上述功能:
1)從Redis獲取資料,如果值不為空,則直接返回值;否則執行下面的2.1)和2.2)步驟。
2.1)如果set(nx和ex)結果為true,說明此時沒有其他執行緒重建快取,那麼當前執行緒執行快取構建邏輯。
2.2)如果set(nx和ex)結果為false,說明此時已經有其他執行緒正在執行構建快取的工作,那麼當前執行緒將休息指定時間(例如這裡是50毫秒,取決於構建快取的速度)後,重新執行函式,直到獲取到資料。
②永遠不過期
“永遠不過期”包含兩層意思:
從快取層面來看,確實沒有設定過期時間,所以不會出現熱點key過期後產生的問題,也就是“物理”不過期。
從功能層面來看,為每個value設定一個邏輯過期時間,當發現超過邏輯過期時間後,會使用單獨的執行緒去構建快取。
從實戰看,此方法有效杜絕了熱點key產生的問題,但唯一不足的就是重構快取期間,會出現資料不一致的情況,這取決於應用方是否容忍這種不一致。
兩種熱點key的解決方法
本作品採用《CC 協議》,轉載必須註明作者和本文連結