【總結系列】網際網路服務端技術體系:高效能之快取面面觀
近水樓臺先得月。
綜述入口: 網際網路應用服務端的常用技術思想與機制綱要
實際應用中,一些資料在短期內會反覆多次訪問。比如迴圈訪問、熱點暢銷商品、爆熱優惠活動。在一次下單中,提交中的訂單基本資訊會被反覆訪問、剛建立的訂單很快會被查詢多次。
資料在短期內被反覆訪問的場景下,快取可用來提升查詢效能。快取是用一個小而快的儲存來存放一個大而慢的儲存的資料子集,在查詢時通過快取命中而提升效能。快取是最基本的計算思想之一。在計算機系統的各個層次結構上,快取無處不在。
- CPU 快取記憶體:位於 CPU 晶片上。L1,L2,L3 快取。 L1 - 4 個時鐘;L2 - 10 個時鐘;L3 - 50 個時鐘。
- 虛擬主存: 作為磁碟資料的快取。
- 磁碟快取: 難以裝進主存的大物件、網路內容的本地快取
- 網路快取: 瀏覽器快取、HTTP 代理快取、負載均衡快取、CDN。
本文總結網際網路技術體系中尤為重要的快取技術。
基本思想
- 快取是以空間換時間,提升查詢效能。快取遵循“近水樓臺先得月”法則:鄰近 CPU 優先,鄰近使用者優先(CDN)。
- 快取依據:訪問區域性性原理。時間區域性性 - 某個儲存器位置在短時間內被再次訪問;空間區域性性 - 若某個儲存器位置被訪問,則鄰近儲存器位置也很可能會被訪問。重複引用相同變數的程式具有良好的時間區域性性。步長為 1 的引用模式的程式具有良好的空間區域性性。一個典型例子是陣列求和。求和變數體現了時間區域性性,陣列訪問體現了空間區域性性。可以用快取命中率來衡量區域性性。
- 順序引用模式:順序地每隔 k 個元素地訪問一個連續向量中的每個元素,稱為步長為 k 的順序引用模式 。 k 越大,空間區域性性越差。步長為 1 的順序引用模式是區域性性原理的重要應用之一。高效訪問順序與儲存結構設計及儲存細節是緊密關聯的。陣列和列表是連續儲存結構,因此順序引用模式很吃香。
- 儲存器層次結構:對於每個 k, 位於第 k 層的更快更小的儲存裝置作為位於第 k+1 層的更慢更大的儲存裝置的快取。資料總是以塊為傳送單元,在第 k 層和第 k+1 層之間進行復制的。層次結構中,相鄰的兩層的塊大小是一樣的;不同層次的塊大小可以不同。越靠近慢而大的儲存層次,塊大小越大。
快取問題
快取問題主要包括快取結構設計、快取讀寫一致性、快取策略(熱身/替換/清理)、快取保護(擊穿/雪崩/穿透)。 一致性問題涉及準確性;快取策略涉及效能(快取命中率及主存佔用);而快取保護涉及穩定性(在大併發請求下且快取未能命中時保護原始資料來源不被壓倒)。
快取結構設計
快取資料結構主要包括記錄型和雜湊型。記錄型的快取,是一個連續儲存陣列,可簡化為多維陣列;雜湊型的快取,是基於雜湊表。 CPU 快取記憶體是基於記錄型的,因為硬體上不宜做複雜的運算;應用快取通常是基於雜湊型的,比如 Redis 快取。
CPU快取記憶體
CPU 快取記憶體可使用 (S, E, B, m) 來表示組織結構。m 位儲存器具有 2^m 個儲存器地址,其對應的快取記憶體組織劃分為 S = 2^s 個組,每組 E 個快取行,每個快取行包括一個有效位、t 個標記位、B = 2^b 個位元組,快取大小 C = S * E * B。 其中 s 是組索引,標識快取塊在哪個組裡;t = m-s-b 標識快取塊在快取組的哪個快取行裡;b 是位元組在快取行裡的偏移量。[s,t,b] 標識了快取位元組在快取結構裡的位置。發生快取替換時,替換的是某個組裡的某個快取行。
E = 1 時,DMC Directed-Map Cache ;1 < E < C/B 時,SAC Set Associative Cache ;E = C/B 時 Full Associative Cache FAC。 DMC 每組只有一個快取行,在組中查詢快取行沒有開銷,但容易發生組的衝突不命中; SAC 在組中查詢快取行有一定開銷,但可以減少組的衝突不命中概率; FAC 只有一個組,在定位組時無開銷,替換快取行時有更大的選擇,但在查詢快取行時開銷比較大。在硬體層,搜尋和匹配標記位是昂貴的操作,因此 FAC 一般應用在搜尋和匹配操作代價不高的地方,比如虛擬主存或應用快取。
快取記憶體定位字的步驟是:首先從 m 中拿到 s 位組索引,找到快取行所在的組;再根據 t 位標記位找到匹配的組內的快取行;最後,根據 b 位偏移量找到字在快取塊中的位置。如果有效位未置位,則可能是過期快取;如果 t 位標記位無法匹配所有的組,則是快取未命中。
CPU 寫主存時可採用兩種方式:直寫和回寫。直寫會在更新快取是直接寫入快取,而回寫在更新快取時只是標記快取塊的快取狀態,只有在替換快取塊時才會寫回主存。這就導致了 CPU 快取與主存的一致性問題。這個問題是通過 MESI 協議來解決的。
MESI協議
MESI 協議是 SMP 體系結構的 CPU 快取一致性協議,涉及讀寫時多個 CPU 快取記憶體如何與主存保持一致 。主要設計思想包括:快取條目狀態的狀態轉換自動機、寫緩衝器、匯流排事務定義及快取控制、操作非同步化佇列、操作屏障。
一致性概念
多處理器儲存系統是一致的,如果某個程式的任何執行結果都滿足下列條件:對於任何單元,有可能建立一個假想的操作序列(將所有程序的讀寫操作排成一個全序),此序列與執行結果一致,並且在此序列中:
- 任何特定程序發出的操作,所表現出的序和該程序向儲存系統發出他們的序相同;
- 每個讀操作返回的值是對相應單元按序列順序寫入的最後一個值。
一致性前提
- 系統總線上的所有事務對所有處理器的快取記憶體控制器可見,且以相同順序可見。
- 為響應儲存的所有必要事務都出現在總線上,且快取控制器採取適當的措施。
- 當快取記憶體監聽到與之相關的寫操作事務時,要麼使快取塊拷貝作廢,要麼更新它。處理器隨後的訪問,要麼快取不命中而載入新的值,要麼直接看到新的值。
CPU巨集觀結構
CPU 巨集觀結構主要包括:CPU Core, Store Buffer , CPU Cache , System BUS 。 CPU Cache 和 Store Buffer 是 CPU 專有的,System BUS 是共享的訊息通道。 CPU Cache 是一個快取條目的陣列(多維陣列),每個快取條目有 tag, data, flag 三個值,tag 表示主存地址,flag 表示快取條目的狀態。flag 定義瞭如下值:
- Modified(M):已修改狀態。某個處理器快取副本擁有已修改的值, 主存裡的是過期的;
- Exclusive(E):乾淨獨佔狀態。僅有該處理器快取副本與主存一致且主存狀態是最新的,獨佔控制權,快取能夠寫操作並轉移到 M 狀態,卻不產生匯流排事務;
- Shared(S):至少兩個處理器快取副本與主存一致,主存有最新的值,其他處理器可能有最新的或者過期的值;
- Invalid(I):初始狀態,快取無效狀態。
快取條目狀態簡稱為 CES。CES 的狀態轉換圖可以定位為一個有限狀態自動機。理解 CES 的有限狀態轉換機是關鍵。如下圖所示,A/B 表示當觀察 A 事件時,將產生一個 B 匯流排事務。Flush’ 表示清除相應的儲存塊,前提是使用了快取到快取的共享,且清除是由提供資料的快取。BusRd(S) 表示由共享訊號 S 生成的匯流排讀事務。快取控制器通過共享訊號 S 在地址階段確定是否有其它快取擁有同樣的快取拷貝。如果一個快取確定自己擁有同樣的儲存塊拷貝,就會發出 S 訊號。
MESI 協議定義了一些匯流排事務(匯流排讀事務、匯流排排它讀事務、匯流排寫事務、回寫事務)。結合 CES 狀態轉換圖、匯流排事務及 CPU 快取讀寫控制來實現一致性。
快取讀
讀是指拿到變數的最新值並讀取到 CPU 暫存器。假設處理器 P1 和 P2 均擁有變數 x 的副本。如果 P1 發現 x 的 CES 為 M/E/S,則直接獲取副本 x 的值。若 P1 發現變數 x 的 CES 為 I,則遵循如下步驟:
- STEP1 -- 傳送 BUS Read 事務;
- STEP2 -- P2 擁有變數 x 的最新副本( CES 為 M),嗅探到 x Read 事務,就會將 x 的最新副本寫入主存,構造 Read Response 傳送到 BUS 上,並將 CES 更新為 S ;如果有多個處理器快取都擁有變數 x 的最新副本,則通過某種策略來選擇從某個快取記憶體來提供新值還是直接由主存來提供新值。
- STEP3 -- P1 嗅探到到 x Read Response ,將 CES 更新為 S,寫入相應的快取塊。
注意:任何一個處理器在嗅探到快取塊的 BUS Read 事務,且相應快取塊為 M 狀態時,都會執行 STEP2 操作。
快取寫
寫是指將變數 x 的最新值寫到快取塊。對一個處於 E 或 I 狀態的快取塊的寫操作,將其置為 M 狀態之前,所有其他處理器快取拷貝都必須通過一個排它讀匯流排事務將自己的快取作廢。如果快取狀態是 M/E ,則不傳送匯流排事務;遵循如下步驟:
- STEP1:P1 傳送匯流排排它讀事務;
- STEP2:其他處理器嗅探到匯流排排它讀事務,更新 CES 為 I,再發送 Invalidate Acknowledge ;後續讀會產生一次快取不命中,從而通過一次匯流排讀事務讀取最新值。
- STEP3:P1 收到所有 Invalidate Acknowledge ,將 CES 更新為 E,獲得資料控制權。然後寫入快取行,將 CES 更新為 M。CPU 寫需要等待其他處理器都發送 Invalidate Acknowledge 訊息,此時會有寫等待問題。
快取替換
當一個快取塊被替換時:
- 如果快取塊處於 S 或 I, 則邏輯上直接更新為 I; 如果快取塊處於 M 狀態,則從 M 到 I 的狀態轉換會觸發一次回寫事務,將快取塊的狀態寫入主存。
寫等待問題
寫緩衝器(Store Buffer)、無效化佇列(Invalidate Queue)。CPU 會直接先寫 Store Buffer ,再同步快取。其他處理器則會將訊息存入 Invalidate Queue 就傳送 Invalidate Acknowledge ,非同步去更新 CES 。 寫緩衝器和無效化佇列將 CPU 快取副本更新變成非同步處理。讀則採用儲存轉發,先查詢寫緩衝器,再查詢快取記憶體。相當於寫緩衝器又加了一層快取。寫快取非同步化又會帶來一致性問題。
主存屏障
Store Barrier 和 Load Barrier 。Store Barrier 將 Store Buffer 的資料寫入快取; Load Barrier 根據 Invalidate Queue 的主存地址,將相應的 CES 更新為 I。
讀寫模式及一致性
常見的快取讀寫模式有 Cache Aside Pattern 和 Write Behind Caching Pattern 。
- Cache Aside Pattern:讀更寫刪。讀模式基本是固定的。讀取資料時,先讀快取,快取命中則直接返回(查詢效能提升體現在這裡),未命中再去讀 DB;寫入資料時,先更新 DB ,再刪除快取。可以採集 DB binlog 非同步刪除快取。如果是主從 DB,則必須採集最後一個從庫 binlog (最終一致性)。
- Write Behind Caching Pattern --- 寫入時只更新快取,非同步去更新 DB 。犧牲短暫的一致性來獲得高吞吐量。
可以採用 [ xC, xDb, yC, yDb ] 操作序列分析讀寫一致性問題,x,y 是讀、更新、刪除,C 表示快取,Db 表示資料庫。 在 Cache Aside Pattern 模式中要考慮兩個問題:
- 為什麼不先刪除快取,再更新 DB ? 如果先刪除快取再更新 DB,考慮這樣一個操作序列: 執行緒 A 刪除快取; 執行緒 B 讀,快取未命中,讀取 DB 老資料到快取裡; 執行緒 A 寫 DB , DB 是新資料。DB 與快取的資料不一致。
- 為什麼不直接更新快取到最新狀態,而是刪除快取? 兩個原因: 1. 如果先寫快取,再操作資料庫,且不加鎖的話,兩個寫執行緒過來,xyyx 寫模式(x 寫 C , y 寫 C,y 寫 Db,x 寫 Db)就會導致快取與 DB 裡的資料不一致; 2. 快取是查詢和計算得到,更新代價大。
快取熱身
空快取會直接導致不命中,從而影響第一次讀的效能。如果第一次訪問面臨大併發的情形,很容易導致大量併發請求直接打到 DB 上,使得 DB 壓力陡增。
快取熱身即是預先把一些資料載入到快取,提升第一次訪問的效能,同時防止第一次訪問面臨大併發時會將後臺打出問題。比如商家做活動前,把一些活動商品和活動資訊資料載入到快取(可以是 Redis 快取及本地快取);把一些極少變動的靜態資料載入到快取。
快取替換策略
快取總有未命中的情況:
- 空不命中:總是不會命中,亦稱冷快取。避免冷快取的方法是進行“快取熱身”。將 k+1 層的快取塊放到第 k 層的策略稱為放置策略。通常採用取模的方式: j = i Mod N ,即:將第 k+1 層的第 i 個塊對 N 取模後,放到第 k 層的第 i 個塊裡。
- 衝突不命中:比如按取模的放置策略,有可能在快取未滿的情況下,總是對第 k 層的同一個塊進行替換。比如 j mod 4 ,當 j=0,4,8,12 時,總是會放在到第 0 塊上。快取抖動是一種特殊的衝突不命中,指快取記憶體反覆載入或驅逐相同的快取記憶體塊/組/行。
- 容量不命中:快取容量滿了。
快取替換策略是指當快取未命中,且快取容量已滿時,判斷要替換哪個塊的快取資料。原則上,應該淘汰:1. 只訪問過一次的資料; 2. 相比其他資料更少訪問的; 3. 在一段時間內沒有再訪問的。
快取替換策略主要有 FIFO, LRU, LFU。
- FIFO : 最先進入快取的首先被淘汰。佇列實現。或者使用雙向連結串列,新進入元素新增到連結串列尾,丟棄連結串列頭的元素。FIFO適合丟棄那些只有一次訪問的資料。
- LRU :最近最少使用淘汰。使用連結串列實現,若快取命中,則將節點移至首部,淘汰尾部節點。 LRU 適合熱點資料訪問。LRU 無法識別哪些快取是最多被訪問的。偶發性、週期性的批量操作可能導致快取被大量替換,造成快取汙染,使得 LRU 的效率大幅下降。實際採用 LRU-K 演算法,將快取分為兩級,資料在較短時間被訪問 K 次以上,則進入二級快取。兩級都採用 LRU 策略。
- LFU : 最少次數使用淘汰。引用計數 + 優先順序佇列(堆)。
快取清理策略
當快取對應的原始資料更新後,快取裡的資料就與原始資料不一致了,即快取失效了。這時候需要及時清理快取,避免讀到過期資料。快取清理策略是指什麼時候清理過期或失效快取。
- TTL: 設定過期時間,可採用定期清理或惰性清理。
- 寫時失效: 寫失效、寫更新。寫失效 - 標記快取資料已過期,讀時清理或替換;寫更新 - 在更新資料時就替換快取項。
- 讀時失效:寫時只標註失效資訊,讀時判斷是否失效。如果有大量快取物件要更新,可以採用讀時失效將寫更新成本分攤到每一個讀上。快取物件時,同時儲存相應的版本號或時間戳。需要展示資料時,通過對比版本號來判斷是否快取已失效。
快取擊穿/雪崩/穿透
- 快取擊穿【重點】。 熱點問題。大併發集中對熱點 key 進行訪問,當這個 key 在失效的瞬間,持續的大併發就穿破快取,直接請求資料庫,就像在一個屏障上鑿開了一個洞。基本方案:多級快取(不同失效時間)+ 熱點雜湊 + 熱點識別、熔斷降級、互斥鎖、不過期+非同步更新。
- 快取雪崩。 大量 key 同時失效,導致大量請求打到 DB,造成巨大 DB 壓力和系統不穩定。基本方案:過期時間+隨機化。
- 快取穿透。大量不存在的 key 的非法訪問請求,同樣會使得大量請求打到 DB。使用布隆過濾器過濾大量非法請求。還有一種方法是空值快取,失效時間設定小一些,應對短時間內無效重複 key 的大量查詢。
- 快取命中統計、快取監控。
快取實現
以本地快取為例,來分析快取實現。本地快取通常在單機共享範圍內:某個程序內的被多次訪問的主存資料;單機範圍內的多程序共享的主存資料。要實現快取功能,通常需要考慮如下因素:
- 快取的規格指定,會影響快取的建立和效能。
- 快取的值的計算和遲載入。
- 快取策略的配置。
- 快取對併發的支援。
- 快取更新的通知與監聽。
- 快取的監控與統計。
Guava.Cache 是本地快取的一個實現。核心類是 CacheBuilderSpec (規格指定)、CacheBuilder (根據快取規格建立快取)、LocalCache (快取功能的核心實現類)。 LocalCache 的底層是一個雜湊表,支援併發訪問,實現了 ConcurrentMap 介面。實現要點如下:
- 快取資料的讀寫與 ConcurrentHashMap 類似。
- 有兩個用雙向連結串列實現的優先順序佇列: writeQueue 和 accessQueue ,用來控制快取何時過期。writeQueue 按寫時間排序,accessQueue 按訪問時間排序。在每次寫入或更新或清理操作的時候,會執行清理操作,根據這兩個佇列來判斷快取資料是否過期,如果過期則從快取資料雜湊表中移除。
高效應用快取
快取友好的程式碼
針對連續型儲存的快取記憶體,編寫對快取友好的程式碼。比如聚焦核心函式的迴圈;減少迴圈內部不命中的數量;對區域性變數的反覆引用;步長為 1 的順序引用模式;多重迴圈中的迴圈變數的次序。
換言之,每個迴圈都會在快取記憶體上產生很大的影響,進而影響程式執行效能。
快取與動態規劃
動態規劃法通常會複用到子問題的解,因此可以使用快取來儲存子問題的解。一個簡單的例子如下,計算階乘:
public class factorialCalc {
private static Log log = LogFactory.getLog(factorialCalc.class);
static Random random = new Random(System.currentTimeMillis());
public static void main(String[]args) {
for (int i=1; i < 10; i++) {
int num = random.nextInt(15);
String info = String.format("fac(%d)=%d", num, fac(num));
log.info(info);
String info2 = String.format("facWithCache(%d)=%d", num, facWithCache(num));
log.info(info2);
printCacheInfo(cache);
}
}
private static void printCacheInfo(Cache<Integer, Long> cache) {
log.info("cache contents: " + cache.asMap());
log.info("cache stat: " + cache.stats());
}
public static long fac(int n) {
if (n <= 1) return 1;
return n * fac(n-1);
}
private static Cache<Integer, Long> cache = CacheBuilder.newBuilder().recordStats().build();
public static long facWithCache(int n) {
if (n <= 1) {
cache.put(1, 1L);
return 1L;
}
Long facN_1 = cache.getIfPresent(n-1);
if (facN_1 == null) {
facN_1 = facWithCache(n-1);
}
long facN = n * facN_1;
cache.put(n, facN);
return facN;
}
}
分散式快取
一般採用 Redis 來做多機共享的分散式快取。一些有效做法和要避免的坑:
- 名稱空間隔離,部署隔離,避免業務相互影響和耦合。
- 通常採用批量獲取快取資料的方法提升查詢效能,避免不必要的網路傳輸時間開銷。
- 採用定期刪除+惰性刪除的策略,同時配合快取替換策略一起管理快取。
- 主存佔用和同步要特別注意,避免主存佔用大、同步慢影響了業務。
- 單個 key 的 value 不超過 10KB, list, set, map 等不超過 1000 個元素。
- 儘量使用 O(1) 的命令,避免使用遍歷性命令。
- 做好快取的監控,測量快取的命中率及效能提升情況。
- 快取主要用來提升效能,不要當做持久化儲存使用,避免資料丟失的風險。
- 避免浪費快取資源。主存快取是比較昂貴的資源。