前言
在網際網路高速發展的今天,快取技術被廣泛地應用。無論業內還是業外,只要是提到效能問題,大家都會脫口而出“用快取解決”。
這種說法帶有片面性,甚至是一知半解,但是作為專業人士的我們,需要對快取有更深、更廣的瞭解。
快取技術存在於應用場景的方方面面。從瀏覽器請求,到反向代理伺服器,從程式內快取到分散式快取。其中快取策略,演算法也是層出不窮,今天就帶大家走進快取。
正文
快取對於每個開發者來說是相當熟悉了,為了提高程式的效能我們會去加快取,但是在什麼地方加快取,如何加快取呢?
假設一個網站,需要提高效能,快取可以放在瀏覽器,可以放在反向代理伺服器,還可以放在應用程式程式內,同時可以放在分散式快取系統中。
從使用者請求資料到資料返回,資料經過了瀏覽器,CDN,代理伺服器,應用伺服器,以及資料庫各個環節。每個環節都可以運用快取技術。
從瀏覽器/客戶端開始請求資料,通過 HTTP 配合 CDN 獲取資料的變更情況,到達代理伺服器(Nginx)可以通過反向代理獲取靜態資源。
再往下來到應用伺服器可以通過程式內(堆內)快取,分散式快取等遞進的方式獲取資料。如果以上所有快取都沒有命中資料,才會回源到資料庫。
快取的請求順序是:使用者請求 → HTTP 快取 → CDN 快取 → 代理伺服器快取 → 程式內快取 → 分散式快取 → 資料庫。
看來在技術的架構每個環節都可以加入快取,看看每個環節是如何應用快取技術的。
1. HTTP快取
當使用者通過瀏覽器請求伺服器的時候,會發起 HTTP 請求,如果對每次 HTTP 請求進行快取,那麼可以減少應用伺服器的壓力。
當第一次請求的時候,瀏覽器本地快取庫沒有快取資料,會從伺服器取資料,並且放到瀏覽器的快取庫中,下次再進行請求的時候會根據快取的策略來讀取本地或者服務的資訊。
一般資訊的傳遞通過 HTTP 請求頭 Header 來傳遞。目前比較常見的快取方式有兩種,分別是:
-
強制快取
-
對比快取
1.1. 強制快取
當瀏覽器本地快取庫儲存了快取資訊,在快取資料未失效的情況下,可以直接使用快取資料。否則就需要重新獲取資料。
這種快取機制看上去比較直接,那麼如何判斷快取資料是否失效呢?這裡需要關注 HTTP Header 中的兩個欄位 Expires 和 Cache-Control。
Expires 為服務端返回的過期時間,客戶端第一次請求伺服器,伺服器會返回資源的過期時間。如果客戶端再次請求伺服器,會把請求時間與過期時間做比較。
如果請求時間小於過期時間,那麼說明快取沒有過期,則可以直接使用本地快取庫的資訊。
反之,說明資料已經過期,必須從伺服器重新獲取資訊,獲取完畢又會更新最新的過期時間。
這種方式在 HTTP 1.0 用的比較多,到了 HTTP 1.1 會使用 Cache-Control 替代。
Cache-Control 中有個 max-age 屬性,單位是秒,用來表示快取內容在客戶端的過期時間。
例如:max-age 是 60 秒,當前快取沒有資料,客戶端第一次請求完後,將資料放入本地快取。
那麼在 60 秒以內客戶端再傳送請求,都不會請求應用伺服器,而是從本地快取中直接返回資料。如果兩次請求相隔時間超過了 60 秒,那麼就需要通過伺服器獲取資料。
1.2. 對比快取
需要對比前後兩次的快取標誌來判斷是否使用快取。瀏覽器第一次請求時,伺服器會將快取標識與資料一起返回,瀏覽器將二者備份至本地快取庫中。瀏覽器再次請求時,將備份的快取標識傳送給伺服器。
伺服器根據快取標識進行判斷,如果判斷資料沒有發生變化,把判斷成功的 304 狀態碼發給瀏覽器。
這時瀏覽器就可以使用快取的資料來。伺服器返回的就只是 Header,不包含 Body。
下面介紹兩種標識規則:
1.2.1. Last-Modified/If-Modified-Since 規則
在客戶端第一次請求的時候,伺服器會返回資源最後的修改時間,記作 Last-Modified。客戶端將這個欄位連同資源快取起來。
Last-Modified 被儲存以後,在下次請求時會以 Last-Modified-Since 欄位被髮送。
當客戶端再次請求伺服器時,會把 Last-Modified 連同請求的資源一起發給伺服器,這時 Last-Modified 會被命名為 If-Modified-Since,存放的內容都是一樣的。
伺服器收到請求,會把 If-Modified-Since 欄位與伺服器上儲存的 Last-Modified 欄位作比較:
-
若伺服器上的 Last-Modified 最後修改時間大於請求的 If-Modified-Since,說明資源被改動過,就會把資源(包括 Header+Body)重新返回給瀏覽器,同時返回狀態碼 200。
-
若資源的最後修改時間小於或等於 If-Modified-Since,說明資源沒有改動過,只會返回 Header,並且返回狀態碼 304。瀏覽器接受到這個訊息就可以使用本地快取庫的資料。
注意:Last-Modified 和 If-Modified-Since 指的是同一個值,只是在客戶端和伺服器端的叫法不同。
1.2.2. ETag / If-None-Match 規則
客戶端第一次請求的時候,伺服器會給每個資源生成一個 ETag 標記。這個 ETag 是根據每個資源生成的唯一 Hash 串,資源如何發生變化 ETag 隨之更改,之後將這個 ETag 返回給客戶端,客戶端把請求的資源和 ETag 都快取到本地。
ETag 被儲存以後,在下次請求時會當作 If-None-Match 欄位被髮送出去。
在瀏覽器第二次請求伺服器相同資源時,會把資源對應的 ETag 一併傳送給伺服器。在請求時 ETag 轉化成 If-None-Match,但其內容不變。
伺服器收到請求後,會把 If-None-Match 與伺服器上資源的 ETag 進行比較:
-
如果不一致,說明資源被改動過,則返回資源(Header+Body),返回狀態碼 200。
-
如果一致,說明資源沒有被改過,則返回 Header,返回狀態碼 304。瀏覽器接受到這個訊息就可以使用本地快取庫的資料。
注意:ETag 和 If-None-Match 指的是同一個值,只是在客戶端和伺服器端的叫法不同。
2. CDN 快取
HTTP 快取主要是對靜態資料進行快取,把從伺服器拿到的資料快取到客戶端/瀏覽器。
如果在客戶端和伺服器之間再加上一層 CDN,可以讓 CDN 為應用伺服器提供快取,如果在 CDN 上快取,就不用再請求應用伺服器了。並且 HTTP 快取提到的兩種策略同樣可以在 CDN 伺服器執行。
CDN 的全稱是 Content Delivery Network,即內容分發網路。
讓我們來看看它是如何工作的吧:
-
客戶端傳送 URL 給 DNS 伺服器。
-
DNS 通過域名解析,把請求指向 CDN 網路中的 DNS 負載均衡器。
-
DNS 負載均衡器將最近 CDN 節點的 IP 告訴 DNS,DNS 告之客戶端最新 CDN 節點的 IP。
-
客戶端請求最近的 CDN 節點。
-
CDN 節點從應用伺服器獲取資源返回給客戶端,同時將靜態資訊快取。注意:客戶端下次互動的物件就是 CDN 快取了,CDN 可以和應用伺服器同步快取資訊。
CDN 接受客戶端的請求,它就是離客戶端最近的伺服器,它後面會連結多臺伺服器,起到了快取和負載均衡的作用。
3. 負載均衡快取
說完客戶端(HTTP)快取和 CDN 快取,我們離應用服務越來越近了,在到達應用服務之前,請求還要經過負載均衡器。
雖說它的主要工作是對應用伺服器進行負載均衡,但是它也可以作快取。可以把一些修改頻率不高的資料快取在這裡,例如:使用者資訊,配置資訊。通過服務定期重新整理這個快取就行了。
以 Nginx 為例,我們看看它是如何工作的:
-
使用者請求在達到應用伺服器之前,會先訪問 Nginx 負載均衡器,如果發現有快取資訊,直接返回給使用者。
-
如果沒有發現快取資訊,Nginx 回源到應用伺服器獲取資訊。
-
另外,有一個快取更新服務,定期把應用伺服器中相對穩定的資訊更新到 Nginx 本地快取中。
4. 程式內快取
通過了客戶端,CDN,負載均衡器,我們終於來到了應用伺服器。應用伺服器上部署著一個個應用,這些應用以程式的方式執行著,那麼在程式中的快取是怎樣的呢?
程式內快取又叫託管堆快取,以 Java 為例,這部分快取放在 JVM 的託管堆上面,同時會受到託管堆回收演算法的影響。
由於其執行在記憶體中,對資料的響應速度很快,通常我們會把熱點資料放在這裡。
在程式內快取沒有命中的時候,我們會去搜尋程式外的快取或者分散式快取。這種快取的好處是沒有序列化和反序列化,是最快的快取。缺點是快取的空間不能太大,對垃圾回收器的效能有影響。
目前比較流行的實現有 Ehcache、GuavaCache、Caffeine。這些架構可以很方便的把一些熱點資料放到程式內的快取中。
這裡我們需要關注幾個快取的回收策略,具體的實現架構的回收策略會有所不同,但大致的思路都是一致的:
-
FIFO(First In First Out):先進先出演算法,最先放入快取的資料最先被移除。
-
LRU(Least Recently Used):最近最少使用演算法,把最久沒有使用過的資料移除快取。
-
LFU(Least Frequently Used):最不常用演算法,在一段時間內使用頻率最小的資料被移除快取。
在分散式架構的今天,多應用中如果採用程式內快取會存在資料一致性的問題。
這裡推薦兩個方案:
-
訊息佇列修改方案
-
Timer 修改方案
4.1. 訊息佇列修改方案
應用在修改完自身快取資料和資料庫資料之後,給訊息佇列傳送資料變化通知,其他應用訂閱了訊息通知,在收到通知的時候修改快取資料。
4.2. Timer 修改方案
為了避免耦合,降低複雜性,對“實時一致性”不敏感的情況下。每個應用都會啟動一個 Timer,定時從資料庫拉取最新的資料,更新快取。
不過在有的應用更新資料庫後,其他節點通過 Timer 獲取資料之間,會讀到髒資料。這裡需要控制好 Timer 的頻率,以及應用與對實時性要求不高的場景。
程式內快取有哪些使用場景呢?
-
場景一:只讀資料,可以考慮在程式啟動時載入到記憶體。當然,把資料載入到類似 Redis 這樣的程式外快取服務也能解決這類問題。
-
場景二:高併發,可以考慮使用程式內快取,例如:秒殺。
5. 分散式快取
說完程式內快取,自然就過度到程式外快取了。與程式內快取不同,程式外快取在應用執行的程式之外,它擁有更大的快取容量,並且可以部署到不同的物理節點,通常會用分散式快取的方式實現。
分散式快取是與應用分離的快取服務,最大的特點是,自身是一個獨立的應用/服務,與本地應用隔離,多個應用可直接共享一個或者多個快取應用/服務。
既然是分散式快取,快取的資料會分佈到不同的快取節點上,每個快取節點快取的資料大小通常也是有限制的。
資料被快取到不同的節點,為了能方便的訪問這些節點,需要引入快取代理,類似 Twemproxy。他會幫助請求找到對應的快取節點。
同時如果快取節點增加了,這個代理也會只能識別並且把新的快取資料分片到新的節點,做橫向的擴充套件。
為了提高快取的可用性,會在原有的快取節點上加入 Master/Slave 的設計。當快取資料寫入 Master 節點的時候,會同時同步一份到 Slave 節點。
一旦 Master 節點失效,可以通過代理直接切換到 Slave 節點,這時 Slave 節點就變成了 Master 節點,保證快取的正常工作。
每個快取節點還會提供快取過期的機制,並且會把快取內容定期以快照的方式儲存到檔案上,方便快取崩潰之後啟動預熱載入。
5.1. 高效能
當快取做成分散式的時候,資料會根據一定的規律分配到每個快取應用/服務上。
如果我們把這些快取應用/服務叫做快取節點,每個節點一般都可以快取一定容量的資料,例如:Redis 一個節點可以快取 2G 的資料。
如果需要快取的資料量比較大就需要擴充套件多個快取節點來實現,這麼多的快取節點,客戶端的請求不知道訪問哪個節點怎麼辦?快取的資料又如何放到這些節點上?
快取代理服務已經幫我們解決這些問題了,例如:Twemproxy 不但可以幫助快取路由,同時可以管理快取節點。
這裡有介紹三種快取資料分片的演算法,有了這些演算法快取代理就可以方便的找到分片的資料了。
5.1.1. 雜湊演算法
Hash 表是最常見的資料結構,實現方式是,對資料記錄的關鍵值進行 Hash,然後再對需要分片的快取節點個數進行取模得到的餘數進行資料分配。
例如:有三條記錄資料分別是 R1,R2,R3。他們的 ID 分別是 01,02,03,假設對這三個記錄的 ID 作為關鍵值進行 Hash 演算法之後的結果依舊是 01,02,03。
我們想把這三條資料放到三個快取節點中,可以把這個結果分別對 3 這個數字取模得到餘數,這個餘數就是這三條記錄分別放置的快取節點。
Hash 演算法是某種程度上的平均放置,策略比較簡單,如果要增加快取節點,對已經存在的資料會有較大的變動。
5.1.2. 一致性雜湊演算法
一致性 Hash 是將資料按照特徵值對映到一個首尾相接的 Hash 環上,同時也將快取節點對映到這個環上。
如果要快取資料,通過資料的關鍵值(Key)在環上找到自己存放的位置。這些資料按照自身的 ID 取 Hash 之後得到的值按照順序在環上排列。
如果這個時候要插入一條新的資料其 ID 是 115,那麼就應該插入到如下圖的位置。
同理如果要增加一個快取節點 N4 150,也可以放到如下圖的位置。
這種演算法對於增加快取資料,和快取節點的開銷相對比較小。
5.1.3. Range Based 演算法
這種方式是按照關鍵值(例如 ID)將資料劃分成不同的區間,每個快取節點負責一個或者多個區間。跟一致性雜湊有點像。
例如:存在三個快取節點分別是 N1,N2,N3。他們用來存放資料的區間分別是,N1(0, 100], N2(100, 200], N3(300, 400]。
那麼資料根據自己 ID 作為關鍵字做 Hash 以後的結果就會分別對應放到這幾個區域裡面了。
5.2. 可用性
根據事物的兩面性,在分散式快取帶來高效能的同時,我們也需要重視它的可用性。那麼哪些潛在的風險是我們需要防範的呢?
5.2.1. 快取雪崩
當快取失效,快取過期被清除,快取更新的時候。請求是無法命中快取的,這個時候請求會直接回源到資料庫。
如果上述情況頻繁發生或者同時發生的時候,就會造成大面積的請求直接到資料庫,造成資料庫訪問瓶頸。我們稱這種情況為快取雪崩。
從如下兩方面來思考解決方案:
快取方面:
-
避免快取同時失效,不同的 key 設定不同的超時時間。
-
增加互斥鎖,對快取的更新操作進行加鎖保護,保證只有一個執行緒進行快取更新。快取一旦失效可以通過快取快照的方式迅速重建快取。對快取節點增加主備機制,當主快取失效以後切換到備用快取繼續工作。
設計方面,這裡給出了幾點建議供大家參考:
-
熔斷機制:某個快取節點不能工作的時候,需要通知快取代理不要把請求路由到該節點,減少使用者等待和請求時長。
-
限流機制:在接入層和代理層可以做限流,當快取服務無法支援高併發的時候,前端可以把無法響應的請求放入到佇列或者丟棄。
-
隔離機制:快取無法提供服務或者正在預熱重建的時候,把該請求放入佇列中,這樣該請求因為被隔離就不會被路由到其他的快取節點。
-
如此就不會因為這個節點的問題影響到其他節點。當快取重建以後,再從佇列中取出請求依次處理。
5.2.2. 快取穿透
快取一般是 Key,Value 方式存在,一個 Key 對應的 Value 不存在時,請求會回源到資料庫。
假如對應的 Value 一直不存在,則會頻繁的請求資料庫,對資料庫造成訪問壓力。如果有人利用這個漏洞攻擊,就麻煩了。
解決方法:如果一個 Key 對應的 Value 查詢返回為空,我們仍然把這個空結果快取起來,如果這個值沒有變化下次查詢就不會請求資料庫了。
將所有可能存在的資料雜湊到一個足夠大的 Bitmap 中,那麼不存在的資料會被這個 Bitmap 過濾器攔截掉,避免對資料庫的查詢壓力。
5.2.3. 快取擊穿
在資料請求的時候,某一個快取剛好失效或者正在寫入快取,同時這個快取資料可能會在這個時間點被超高併發請求,成為“熱點”資料。
這就是快取擊穿問題,這個和快取雪崩的區別在於,這裡是針對某一個快取,前者是針對多個快取。
解決方案:導致問題的原因是在同一時間讀/寫快取,所以只有保證同一時間只有一個執行緒寫,寫完成以後,其他的請求再使用快取就可以了。
比較常用的做法是使用 mutex(互斥鎖)。在快取失效的時候,不是立即寫入快取,而是先設定一個 mutex(互斥鎖)。當快取被寫入完成以後,再放開這個鎖讓請求進行訪問。
小結
總結一下,快取設計有五大策略,從使用者請求開始依次是:
-
HTTP 快取
-
CDN 快取
-
負載均衡快取
-
程式內快取
-
分散式快取
其中,前兩種快取靜態資料,後三種快取動態資料:
-
HTTP 快取包括強制快取和對比快取。
-
CDN 快取和 HTTP 快取是好搭檔。
-
負載均衡器快取相對穩定資源,需要服務協助工作。
-
程式內快取,效率高,但容量有限制,有兩個方案可以應對快取同步的問題。
-
分散式快取容量大,能力強,牢記三個效能演算法並且防範三個快取風險。
關注公眾號【零壹技術棧】,持續推送高質量的後端技術乾貨,包括虛擬機器基礎,多執行緒程式設計,高效能框架,非同步、快取和訊息中介軟體,分散式和微服務,架構學習和進階等學習資料和文章。