非功能性約束之效能(1)-效能銀彈:快取
在《什麼是架構屬性》一文中提到提高「效能」的主要方式是優化,而優化的其中一個主要手段就是新增快取!
在軟體工程裡有這麼一句話:「沒有銀彈」!就是說由於軟體工程的複雜性,沒有任何一種技術或方法能解決所有問題!軟體工程是複雜的,沒有銀彈!但是,軟體工程中的某一個問題,是有銀彈的!
在《架構風格:萬金油CS與分層》一文中提到過,「 電腦科學領域的任何問題都可以通過增加一個間接的中間層來解決」!而「快取層」可以說是新增得最多的層!主要目的就是為了提高效能!所以,快取可以說是「效能銀彈」!
本文將探討如下內容:
- 快取的作用
- 快取的種類
- 快取演算法
- 分散式快取
- 快取的使用
- 網路中的快取
- 應用快取
- 資料庫快取
- 計算機中的快取
從程式碼說起
fn longRunningOperations(){
... // 很耗時
}
let result = longRunningOperations();
// do other thing
我們來看上面這段虛擬碼,longRunningOperations是個很耗時的方法(呼叫一次要幾十秒甚至幾分鐘),比如:
- 複雜的業務邏輯計算
- 複雜的資料查詢
- 耗時的網路操作等
對於這個方法,如果每次都去呼叫一次的話,會非常的影響效能,使用者體驗也非常的不好。
那我們該如何處理呢?
一般有幾種優化方案:
- 優化業務程式碼,比如:更快的資料結構和演算法,更快的IO模型,建立資料庫索引等
- 簡化業務邏輯,導致耗時的原因可能是業務過於複雜,可以通過簡化業務邏輯的方式來減少耗時
- 將操作的結果儲存起來。例如:對於某些統計類的結果,可以先用日終定時的去執行,將結果儲存到統計結果表中,查詢時,直接從結果中查詢即可;對於某些臨時操作,可以將結果儲存在記憶體中,再次呼叫時,直接從記憶體中獲取即可。
本文主要聊聊第三種方案:使用「快取」!
主動快取與被動快取
一般我們使用快取來儲存一些內容,這些內容有如下一些特點(符合一條或多條):
- 使用較頻繁
- 變更不頻繁
- 獲取較耗時
- 多系統訪問
比如,
- 字典資料:系統很多地方都會使用字典資料,而字典資料配置完成後一般不會修改,雖然從資料庫中直接獲取字典資料不是很耗時,但是多了查詢和網路傳輸,效能上還是不如直接從快取裡面取快速
- 秒殺商品資訊:在秒殺時訪問量很大,從快取(靜態檔案、CDN等)獲取要比從資料庫查詢要快得多
- 其它訪問頻次較多的資訊:此處的其它資訊是因為其快取的處理方式與上面的字典處理有差異,下面詳細說明。
對於字典資料來說,一般我們的做法是在系統啟動時,將字典資料直接載入到快取中,此類快取資料一般沒有過期時間;當修改字典時,會同時更新快取中的內容。此類快取稱為「主動快取」,因為其快取資料是由使用者的主動修改來觸發更新的。
而對於某些資訊來說,因為資訊量太大,不能一次性全部載入到快取中,且也不是太清楚哪些資料訪問頻次高、哪些資料訪問頻次低。對於這樣的資料,一般的做法是:
- 先到快取中查詢是否有訪問的資料,如果有則直接返回給使用者
- 如果沒有,則去溯源查詢
- 找到後將其新增到快取中
- 最後返回給使用者
此類快取稱為「被動快取」!其快取的資料的過期由系統來控制。那系統如何控制呢?這就涉及到快取置換演算法!
快取置換演算法
上面說了,對於被動快取來說,由於資訊量太大,資料不能一次全部載入到快取中,當快取滿了以後,需要新增資料時,就需要確定哪些資料要從快取裡清除,給新資料騰出空間。
用於判斷哪些資料優先從快取中剔除的演算法稱為「快取(頁面)置換演算法」!
Wiki中列出瞭如下置換演算法:
- RR(Random replacement)
- FIFO(First in first out)
- LIFO(Last in first out)
- MRU(Most recently used)
- LFU(Least-frequently used)
- LRU(Least recently used)
- TLRU(Time aware least recently used)
- PLRU(Pseudo-LRU)
- LRU-K
- SLRU(Segmented LRU)
- MQ(Multi queue)
- LFRU(Least frequent recently used)
- LFUDA(LFU with dynamic aging)
- LIRS(Low inter-reference recency set)
- ARC(Adaptive replacement cache)
- CAR(Clock with adaptive replacement)
- Pannier(Container-based caching algorithm for compound objects)
一般情況下我們不會自己去實現個快取,市面上有不少開源的快取中介軟體,比如:redis,memcached。這裡只簡單的梳理幾個常用的置換演算法。
FIFO
FIFO應該算是最簡單的置換演算法了:
- 它使用一個佇列來維護資料
- 資料按照載入到快取的順序進行排列,先載入的資料在佇列頭部,後加載的資料在佇列尾部
- 當快取滿了以後,從佇列頭部清除資料,給需要載入的資料騰出空間
- 新資料加到佇列的尾部
FIFO的實現很簡單,但是其效能並不總是很好。舉個簡單的例子,假設一個系統需要10個快取資料,恰巧此時5個數據在佇列頭部,另外5個數據不在快取中,又恰巧此時佇列又滿了。按照FIFO演算法,5條不在記憶體中的資料被載入到了快取中,而之前的5條資料被清除了。這就需要再次將被清除的5條資料載入到快取中。這就影響了效能。
這個問題可能會隨著所分配的快取大小的增加而增加,原本我們使用快取是為了提高效能的,現在可能會影響效能,這種現象稱為「Belady現象」!
LIFO和FIFO很類似,這裡就不贅述了。
LRU
目前比較常用的置換演算法稱為LRU置換演算法:優先替換掉「最近最少使用」的資料
- 每個資料都被關聯了該資料上次使用的時間
- 當需要置換資料的時候,LRU選擇最長時間沒有使用的資料
LRU的變體有很多,例如:
- TLRU(Time aware least recently used):大部分快取資料是有過期時間的。PLRU從最少使用和過期時間兩個維度來置換資料
- LRU-K:多維護一個佇列,用於記錄所有快取資料被訪問的次數。當資料的訪問次數達到K次的時候,才將資料放入快取。當需要淘汰資料時,LRU-K會淘汰第K次訪問時間距當前時間最大的資料。
- SLRU(Segmented LRU)2Queue?:一個FIFO佇列,一個LRU佇列。當資料第一次訪問時,將資料快取在FIFO佇列裡面,當資料第二次被訪問時,則將資料從FIFO佇列移到LRU佇列裡面,兩個佇列各自按照自己的方法淘汰資料。
- PLRU(Pseudo-LRU):LRU需要維護資料訪問時間,佔用了額外的空間,對於空間很小的裝置來說,此演算法太過浪費空間了。PLRU每個快取資料只需要1bit來儲存資料資訊,可以達到LRU的效果。具體流程見下圖:
還有和LRU類似的MRU,LFU這裡不在贅述!
快取叢集
為了提高快取的可用性,一般我們至少會對快取做個主備,即一個主快取,一個從快取。
- 快取的寫入只可以寫到主快取
- 主快取同步資料到從快取中
- 可以從主快取讀取資料。也可以從從快取讀取資料(不必須)
- 當主快取掛掉了,從快取升級為主快取
再安全一點的做法就是做快取叢集:
- 多臺機器快取了相同的資料,其中一臺為主快取
- 快取的寫入只可以寫到主快取
- 主快取同步資料到其它快取
- 可以從主快取讀取資料。也可以從其它快取中讀取資料(不必須)
- 當主快取掛掉了,會從其它快取服務中選擇一個作為新的主快取
分散式快取
無論是單機快取,主從備份還是快取叢集,都沒法解決快取大小限制的問題。因為一般快取會使用記憶體,而一臺機器的記憶體大小是有限的。當需要快取的資料遠遠超過一臺機器的記憶體大小的時候,就需要將快取的資料分佈到多臺機器上。每臺機器只快取一部分資料,這就是分散式快取。
分散式快取可以解決一臺機器快取資料有限的問題,但是也引入了新的問題:
- 哪些資料該快取在哪臺伺服器上
- 如何保證每臺伺服器快取的資料量基本相同
一般做法是對key進行hash,然後對伺服器數量進行取餘,來確定資料在哪臺伺服器上。這解決了「哪些資料該快取在哪臺伺服器上」的問題,但是卻無法保證「每臺伺服器快取的資料量基本相同」,因為可能多個key的hash取餘後都落到了同一個伺服器上,這就可能導致其中一臺伺服器快取的數量很多,其它伺服器快取的資料量很少。快取資料量多的伺服器可能會記憶體不夠用,觸發資料置換,進而導致效能下降。
可以使用一致性hash環來保證伺服器快取的資料量基本相同,大致邏輯如下:
- 將0~2^32個點均勻分配到一個圓上
- 每個點對應一臺快取伺服器
- 快取伺服器數量是遠小於2^32個的,所以多個節點對應一臺快取伺服器,多出來的節點稱為虛擬節點
- 確保快取伺服器的分佈均勻
- 同樣是對key進行hash
- 對2^32進行求餘
- 結果對應到hash環上
- 如果正好落到節點上,則資料就快取到對應的快取伺服器上
- 否則就存到落點前面的那個節點所對應的快取伺服器上
無處不在的快取
上面聊的主要是應用快取,實際上,快取無處不在。
下面通過我們訪問網站的流程,來簡單梳理一下,整個過程中,哪些地方可能會用到快取。
網路快取
當我們在瀏覽器中輸入URL,按下回車後。
首先,需要查詢域名所對應的IP!這裡就有各種快取!
- 瀏覽器快取:瀏覽器會快取DNS記錄一段時間,首先會從瀏覽器快取裡去找對應的IP。
- 系統快取:如果在瀏覽器快取裡沒有找到需要的記錄,就會到系統快取中查詢記錄
- 路由器快取:如果系統快取中也沒找到,就會到路由器快取中查詢記錄
- ISP DNS 快取:如果還是找不到,就到ISP快取DNS的伺服器裡查詢。在這一般都能找到相應的快取記錄。
- 遞迴搜尋:如果上面的快取都找不到,就需要從根域名伺服器開始遞迴查找了
找到IP後,還不一定要發請求,因為你訪問的資源可能之前已經訪問過,已經被快取到了瀏覽器快取中。此時,瀏覽器直接返回快取,而不會發送請求。
如果沒有快取,則傳送請求獲取資源。
後面可能會達到CDN。CDN是一種邊緣快取。在使用者訪問網站時,利用GSLB(Global Server Load Balance,全域性負載均衡)技術將使用者的訪問指向距離最近的工作正常的快取伺服器上,由快取伺服器直接響應使用者請求。如果CDN中找不到需要的資源,則請求可能就到了反向代理。
某些反向代理能夠做到和使用者來自同一個網路,那麼使用者訪問反向代理伺服器的時候,就會得到很高質量的響應速度,這樣的反向代理快取一般稱為邊緣快取,而CDN在邊緣快取的基礎上,使用了GSLB
一般反向代理有兩個功能:
- 隱藏源伺服器,防止伺服器惡意攻擊。客戶端感知不到代理伺服器和源伺服器的區別
- 快取,將原始伺服器資料進行快取,減少源伺服器的訪問壓力
如果反向代理中也找不到需要的資源,請求才到達源伺服器來獲取資源。
服務端與資料庫快取
一般情況下,Server接收到請求後,會根據請求,組裝出響應,進行返回。這個過程可能需要查詢資料庫、進行業務邏輯計算、頁面渲染等操作。這裡的每一步都可以引入快取。
對於資料庫查詢來說,目前一般的持久化框架都會提供查詢快取。即對於相同的sql,第二次查詢開始,可以不用再查詢資料庫,直接從快取中獲取第一次查詢所返回的資料。節省了呼叫資料庫查詢的時間消耗。對於某些訪問量很大的資料,也可以將其快取到快取中介軟體中。後續直接從快取中介軟體中獲取。
而資料庫本身也有快取!
- 客戶端傳送一條查詢給服務端
- 服務端檢查查詢快取,如果命中快取,則立刻返回快取中的結果。如果沒找到,則
- 進行sql解析、預處理、再由優化器生成對應的執行計劃
- 根據執行計劃,呼叫儲存引擎的API執行查詢
- 將結果返回給客戶端
mysql的查詢快取可能會降低效率。首先,寫快取是獨佔模式寫入。其次,假設一個查詢結果被快取了,當涉及到的其中一張表資料更新,該快取都會被置為無效。對於頻繁修改的資料,使用快取就會降低效率。
對於業務邏輯計算來說,如果某些業務邏輯很複雜,那麼可以針對結果進行快取。可以將結果快取到資料庫或快取中介軟體中。對於相同的引數的請求,第二次請求時,就不必進行計算,直接從快取中返回結果即可。
對於頁面渲染來說,某些訪問量很大的頁面,且資料基本不變的情況下,可以對頁面進行靜態化。即生成靜態的頁面,不必每次訪問的時候都動態生成頁面進行返回,而是預先生成好頁面,將其存到磁碟上,當訪問該頁面的時候,直接從磁盤獲取頁面進行返回即可。或者直接將頁面內容快取到快取中介軟體中,進一步提高效能。
另外,對於需要登入的Server來說,使用者資訊其實也是快取下來的。不論是存到伺服器Session中,還是存到了快取中介軟體中。否則,每次使用者訪問Server都需要到資料庫獲取使用者資訊,會影響Server端效能!
計算機快取
最後,執行系統的計算機本身也有很多的快取!
我們都知道,一般計算機由CPU、記憶體、主機板、硬碟、顯示卡、顯示器、滑鼠、鍵盤、網絡卡等組成!其中儲存類裝置包括了:雲端儲存(例如:百度雲盤,NAS等)、本地硬碟、記憶體、CPU中的快取記憶體(我們常說的一級快取、二級快取和三級快取)以及CPU暫存器。它們的速度各異,差異達數個量級。下圖顯示了各個裝置的訪問速率。
- CPU暫存器最快,達到1ns,但只能儲存幾百個位元組,造價也最貴
- 快取記憶體次之,也達到了10ns,可儲存幾十兆,造價次之。其中L1,L2,L3速度越來越慢。
- 然後是記憶體,為100ns,可達GB級別,造價比快取便宜(不過這兩年的記憶體價格貴得離譜)
- 硬碟訪問速率為10ms級別,可達TB級別,造價可以說是白菜價了
- 而云儲存則達到了秒級,基本可以無限擴充套件,只要錢夠
我們都知道CPU的快取記憶體是「快取」,實際上上面的裝置,上層裝置都可以說是下層裝置的「快取」!
在《深入理解計算機》一書中,簡單的介紹了計算機執行C語言的hello world程式時的計算機流程。
- 通過滑鼠、鍵盤輸入執行命令'./hello'
- 輸入的內容從鍵盤通過匯流排,進入暫存器,在進入記憶體
- 當按下回車後
- 通過DMA技術,將目標檔案,從硬碟中直接讀取到記憶體中
- 最後執行程式
- 將hello world拷貝到暫存器
- 再從暫存器拷貝到顯示器顯示
可以看到,絕大部分的操作,都是資料的拷貝!最終被CPU執行,為了資料能更快的到達CPU,就有了一層一層的「快取」!
- CPU暫存器裡的資料是直接給CPU使用的,相當於是L1的快取
- L1又是L2的快取,L2又是L3的快取
- L3是記憶體的快取
- 記憶體又是硬碟的快取。例如:一般硬碟中的資料,都需要先載入到記憶體中才能被CPU使用。另外硬碟的“HMB記憶體緩衝技術”,可以借用記憶體作為硬碟的快取。
- 硬碟本身也是有快取的,這是為了減少IO操作,批量的進行讀寫。
- 硬碟也可以是雲端儲存的快取。例如在網路不太好的情況下,我們可以把電影先下載下來再看,這樣就不會有卡頓的情況
總結
效能是架構設計時需要著重考慮的一個非功能性約束,而引入快取是提高系統性能的一個簡單且直接的方法。
本文從一個簡單的虛擬碼開始,簡單闡述了,快取的作用,涉及的技術以及目前快取的使用場景,以期能對架構設計提供一些參考。
參考資料
- 《深入理解計算機系統》
- 《圖解TCP/IP》
- 《高效能MySQL》
- 《作業系統概念》
- What really happens when you navigate to a URLCache replacement policies