參考:
https://www.cnblogs.com/weilingfeng/p/11570428.html
https://blog.csdn.net/belalds/article/details/80101666
https://blog.csdn.net/g6u8w7p06dco99fq3/article/details/97621982
https://blog.csdn.net/zjttlance/article/details/80234341
https://blog.csdn.net/qq_39399966/article/details/103136008
多級緩存的分層架構
前言
在互聯網高速發展的今天,緩存技術被廣泛地應用。無論業內還是業外,只要是提到性能問題,大家都會脫口而出“用緩存解決”。
這種說法帶有片面性,甚至是一知半解,但是作為專業人士的我們,需要對緩存有更深、更廣的了解。
緩存技術存在於應用場景的方方面面。從瀏覽器請求,到反向代理服務器,從進程內緩存到分布式緩存。其中緩存策略,算法也是層出不窮,今天就帶大家走進緩存。
正文
緩存對於每個開發者來說是相當熟悉了,為了提高程序的性能我們會去加緩存,但是在什么地方加緩存,如何加緩存呢?
假設一個網站,需要提高性能,緩存可以放在瀏覽器,可以放在反向代理服務器,還可以放在應用程序進程內,同時可以放在分布式緩存系統中。

從用戶請求數據到數據返回,數據經過了瀏覽器,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 緩存是好搭檔。
-
負載均衡器緩存相對穩定資源,需要服務協助工作。
-
進程內緩存,效率高,但容量有限制,有兩個方案可以應對緩存同步的問題。
-
分布式緩存容量大,能力強,牢記三個性能算法並且防范三個緩存風險。
大流量下多級緩存設計
什么是多級緩存
所謂多級緩存,即在整個系統架構的不同系統層級進行數據緩存,以提升訪問效率,這也是應用最廣的方案之一。我們應用的整體架構如圖1所示:
圖1 多級緩存方案
整體流程如上圖所示:
1)首先接入Nginx將請求負載均衡到應用Nginx,此處常用的負載均衡算法是輪詢或者一致性哈希,輪詢可以使服務器的請求更加均衡,而一致性哈希可以提升應用Nginx的緩存命中率,相對於輪詢,一致性哈希會存在單機熱點問題,一種解決辦法是熱點直接推送到接入層Nginx,一種辦法是設置一個閥值,當超過閥值,改為輪詢算法。
2)接着應用Nginx讀取本地緩存(本地緩存可以使用Lua Shared Dict、Nginx Proxy Cache(磁盤/內存)、Local Redis實現),如果本地緩存命中則直接返回,使用應用Nginx本地緩存可以提升整體的吞吐量,降低后端的壓力,尤其應對熱點問題非常有效。
3)如果Nginx本地緩存沒命中,則會讀取相應的分布式緩存(如Redis緩存,另外可以考慮使用主從架構來提升性能和吞吐量),如果分布式緩存命中則直接返回相應數據(並回寫到Nginx本地緩存)。
4)如果分布式緩存也沒有命中,則會回源到Tomcat集群,在回源到Tomcat集群時也可以使用輪詢和一致性哈希作為負載均衡算法。
5)在Tomcat應用中,首先讀取本地堆緩存,如果有則直接返回(並會寫到主Redis集群),為什么要加一層本地堆緩存將在緩存崩潰與快速修復部分細聊。
6)作為可選部分,如果步驟4沒有命中可以再嘗試一次讀主Redis集群操作。目的是防止當從有問題時的流量沖擊。
7)如果所有緩存都沒有命中只能查詢DB或相關服務獲取相關數據並返回。
8)步驟7返回的數據異步寫到主Redis集群,此處可能多個Tomcat實例同時寫主Redis集群,可能造成數據錯亂,如何解決該問題將在更新緩存與原子性部分細聊。
應用整體分了三部分緩存:應用Nginx本地緩存、分布式緩存、Tomcat堆緩存,每一層緩存都用來解決相關的問題,如應用Nginx本地緩存用來解決熱點緩存問題,分布式緩存用來減少訪問回源率、Tomcat堆緩存用於防止相關緩存失效/崩潰之后的沖擊。
雖然就是加緩存,但是怎么加,怎么用細想下來還是有很多問題需要權衡和考量的,接下來部分我們就詳細來討論一些緩存相關的問題。
如何緩存數據
接下來部將從緩存過期、維度化緩存、增量緩存、大Value緩存、熱點緩存幾個方面來詳細介紹如何緩存數據。
過期與不過期
對於緩存的數據我們可以考慮不過期緩存和帶過期時間緩存,什么場景應該選擇哪種模式需要根據業務和數據量等因素來決定。
不過期緩存場景一般思路如圖2所示:
圖2不過期緩存方案
使用Cache-Aside模式,首先寫數據庫,如果成功,則寫緩存。這種場景下存在事務成功、緩存寫失敗但無法回滾事務的情況。另外,不要把寫緩存放在事務中,尤其寫分布式緩存,因為網絡抖動可能導致寫緩存響應時間很慢,引起數據庫事務阻塞。如果對緩存數據一致性要求不是那么高,數據量也不是很大,則可以考慮定期全量同步緩存。
也有提到如下思路:先刪緩存,然后執行數據庫事務;不過這種操作對於如商品這種查詢非常頻繁的業務不適用,因為在你刪緩存的同時,已經有另一個系統來讀緩存了,此時事務還沒有提交。當然對於如用戶維度的業務是可以考慮的。
不過為了更好地解決以上多個事務的問題,可以考慮使用訂閱數據庫日志的架構,如使用canal訂閱mysql的binlog實現緩存同步。
對於長尾訪問的數據、大多數數據訪問頻率都很高的場景、緩存空間足夠都可以考慮不過期緩存,比如用戶、分類、商品、價格、訂單等,當緩存滿了可以考慮LRU機制驅逐老的緩存數據。
1. 過期緩存機制
即采用懶加載,一般用於緩存別的系統的數據(無法訂閱變更消息、或者成本很高)、緩存空間有限、低頻熱點緩存等場景;常見步驟是:首先讀取緩存如果不命中則查詢數據,然后異步寫入緩存並過期緩存,設置過期時間,下次讀取將命中緩存。熱點數據經常使用即在應用系統上緩存比較短的時間。這種緩存可能存在一段時間的數據不一致情況,需要根據場景來決定如何設置過期時間。如庫存數據可以在前端應用上緩存幾秒鍾,短時間的不一致時可以忍受的。
2. 維度化緩存與增量緩存
對於電商系統,一個商品可能拆成如基礎屬性、圖片列表、上下架、規格參數、商品介紹等;如果商品變更了要把這些數據都更新一遍那么整個更新成本很高:接口調用量和帶寬;因此最好將數據進行維度化並增量更新(只更新變的部分)。尤其如上下架這種只是一個狀態變更,但是每天頻繁調用的,維度化后能減少服務很大的壓力。
圖3 維度化緩存方案
按照不同維度接收MQ進行更新。
3. 大Value 緩存
要警惕緩存中的大Value,尤其是使用Redis時。遇到這種情況時可以考慮使用多線程實現的緩存,如Memcached,來緩存大Value;或者對Value進行壓縮;或者將Value拆分為多個小Value,客戶端再進行查詢、聚合。
4. 熱點緩存
對於那些訪問非常頻繁的熱點緩存,如果每次都去遠程緩存系統中獲取,可能會因為訪問量太大導致遠程緩存系統請求過多、負載過高或者帶寬過高等問題,最終可能導致緩存響應慢,使客戶端請求超時。一種解決方案是通過掛更多的從緩存,客戶端通過負載均衡機制讀取從緩存系統數據。不過也可以在客戶端所在的應用/ 代理層本地存儲一份,從而避免訪問遠程緩存,即使像庫存這種數據,在有些應用系統中也可以進行幾秒鍾的本地緩存,從而降低遠程系統的壓力。
千萬級並發!如何設計一個多級緩存系統?
首先我們需要明白,什么是一個多級緩存系統,它有什么用。所謂多級緩存系統,就是指在一個系統 的不同的架構層級進行數據緩存,以提升訪問效率。
我們都知道,一個緩存系統,它面臨着許多問題,比如緩存擊穿,緩存穿透,緩存雪崩,緩存熱點等等問題,那么,對於一個多級緩存系統,它有什么問題呢?
緩存熱點:多級緩存系統大多應用在高並發場景下,所以我們需要解決熱點Key問題,如何探測熱點key?
數據一致性:各層緩存之間的數據一致性問題,如應用層緩存和分布式緩存之前的數據一致性問題。
緩存過期:緩存數據可以分為兩大類,過期緩存和不過期緩存?如何設計,如何設計過期緩存?
在這之前,我們先看看一個簡單的多級緩存系統的架構圖:
整個多級緩存系統被分為三層,應用層nginx緩存,分布式redis緩存集群,tomcat堆內緩存。整個架構流程如下:
當接收到一個請求時,首先會分發到nginx集群中,這里可以采用nginx的負載均衡算法分發給某一台機器,使用輪詢可以降低負載,或者采用一致性hash算法來提升緩存命中率。
當nginx層沒有緩存數據時,會繼續向下請求,在分布式緩存集群中查找數據,如果緩存命中,直接返回(並且寫入nginx應用緩存中),如果未命中,則回源到tomcat集群中查詢堆內緩存。
在分布式緩存中查詢不到數據,將會去tomcat集群中查詢堆內緩存,查詢成功直接返回(並寫入分redis主集群中),查詢失敗請求數據庫;堆內緩存。
如果以上緩存中都沒有命中,則直接請求數據庫,返回結果,同步數據到分布式緩存中。
在簡單了解了多級緩存的基本架構之后,我們就該思考如何解決上面提到的一系列問題。
緩存熱點
緩存熱點,是一個很常見的問題,比如“某某明星宣布結婚”等等,都可能產生大量請求訪問的問題,一個最麻煩也是最容易讓人忽視的事情就是如何探測到熱點key,在緩存系統中,除了一些常用的熱點key外,在某些特殊場合下也會出現大量的熱點key,我們該如何發現呢?有以下策略:
數據調研。可以分析歷史數據以及針對不同的場合去預測出熱點key,這種方式雖然不能百分百使得緩存命中,但是卻是一種最簡單和節省成本的方案。
實時計算。可以使用現有的實時計算框架,比如storm、spark streaming、flink等框架統計一個時間段內的請求量,從而判斷熱點key。或者也可以自己實現定時任務去統計請求量。
這里我們着重討論一下第二種解決方案,對於熱點key問題,當緩存系統中沒有發現緩存時,需要去數據庫中讀取數據,當大量請求來的時候,一個請求獲取鎖去請求數據庫,其他阻塞,接着全部去訪問緩存,這樣可能因為一台服務器撐不住從而宕機,比如正常一台服務器並發量為5w左右,產生熱點key的時候達到了10w甚至20w,這樣服務器肯定會崩。所以我們在發現熱點key之后還需要做到如何自動負載均衡。
結合以上問題我們重新設計架構,如下圖所示:
我們將整個應用架構分為應用層,分布式緩存、系統層以及數據層。
在應用層,我們采用nginx集群,並且對接實時計算鏈路,通過flume監控nginx日志,將數據傳輸到kafka集群中,然后flink集群消費數據進行統計,如果統計 結果為熱點key,則將數據寫入zookeeper的節點中,而應用系統通過監控znode節點,讀取熱點key數據,去數據庫中加載數據到緩存中並且做到負載均衡。
實際上,對於應用系統中的每一台服務器,還需要一層防護機制,限流熔斷,這樣做的目的是為了防止單台機器請求量過高,使得服務器負載過高,不至於服務器宕機或者大量請求訪問數據庫。簡單思路就是為每一台服務器設計一個閥值,當請求量大於該值就直接返回用戶空白頁面或者提示用戶幾秒后刷新重新訪問。
數據一致性
數據一致性問題主要體現在緩存更新的時候,如何更新緩存,保證數據庫與緩存以及各層緩存層之間的一致性。
對於緩存更新問題,先寫緩存還是先寫數據庫,這里省略若干字。之前的文章介紹過,有興趣的讀者可以翻閱。
在單層緩存系統中,我們可以先刪除緩存然后更新數據庫的方案來解決其數據一致性問題,那么對於多級緩存呢?如果使用這種方案,我們需要考慮,如果先刪除緩存,那么需要逐層去做刪除操作,那么這一系列操作對系統帶來的耗時也是和可觀的。
如果我們使用分布式事務機制,就需要考慮該不該將寫緩存放入事務當中,因為我們更新分布式緩存,需要走網絡通信,大量的請求將導致網路抖動甚至阻塞,增加了系統的延遲,導致系統短時間內不可用。如果我們不將寫緩存這一操作放入事務當中,那么可能引起短時間內數據不一致。這也就是分布式系統的CAP理論,我們不能同時達到高可用和一致性。那么該如何抉擇呢?
這里我們選擇保證系統的可用性,就一個秒殺系統來講,短暫的不一致性問題對用戶的體驗影響並不大(當然,這里不涉及支付系統),而可用性對用戶來說卻很重要,一個活動可能在很短的時間內結束,而用戶需要在這段時間內搶到自己心儀的商品,所以可用性更重要一些(這里需要根據具體場景進行權衡)。
在保證了系統的可用性的基礎上,我們該如何實現呢?如果實時性要求不是很高,我們可以采用全量+增量同步的方式進行。首先,我們可以按照預計的熱點key對系統進行緩存預熱,全量同步數據到緩存系統。接着,在需要更新緩存的時候,我們可以采用增量同步的方式更新緩存。比如我們可以使用阿里Canal框架同步binlog的方式進行數據的同步。
緩存過期
緩存系統中的所有數據,根據數據的使用頻率以及場景,我們可以分為過期key以及不過期key,那么對齊過期緩存我們該如何淘汰呢?下面有常用的幾種方案:
FIFO:使用FIFO算法來淘汰過期緩存。
LFU:使用LFU算法來淘汰過期緩存。
LRU:使用LRU算法來淘汰過期緩存。
以上幾種方案是在緩存達到最大緩存大小的時候的淘汰策略,如果沒有達到最大緩存大小,我們有下面幾種方式:
定時刪除策略:設置一個定時任務,在規定時間內檢查並且刪除過期key。
定期刪除策略:這種策略需要設置刪除的周期以及時長,如何設置,需要根據具體場合來計算。
惰性刪除策略:在使用時檢查是否過期,如果過期直接去更新緩存,否則直接返回。
最全面的緩存架構設計
1:緩存技術和框架的重要性
互聯網的一些高並發,高性能的項目和系統中,緩存技術是起着功不可沒的作用。緩存不僅僅是key-value的簡單存取,它在具體的業務場景中,還是很復雜的,需要很強的架構設計能力。我曾經就遇到過因為緩存架構設計不到位,導致了系統崩潰的案例。
2:緩存的技術方案分類
1)是做實時性比較高的那塊數據,比如說庫存,銷量之類的這種數據,我們采取的實時的緩存+數據庫雙寫的技術方案,雙寫一致性保障的方案。
2)是做實時性要求不高的數據,比如說商品的基本信息,等等,我們采取的是三級緩存架構的技術方案,就是說由一個專門的數據生產的服務,去獲取整個商品詳情頁需要的各種數據,經過處理后,將數據放入各級緩存中。
3:高並發以及高可用的復雜系統中的緩存架構都有哪些東西
1)在大型的緩存架構中,redis是最最基礎的一層。高並發,緩存架構中除了redis,還有其他的組成部分,但是redis至關重要。
-
如果你的數據量不大(10G以內),單master就可以。redis持久化+備份方案+容災方案+replication(主從+讀寫分離)+sentinal(哨兵集群,3個節點,高可用性)
-
如果你的數據量很大(1T+),采用redis cluster。多master分布式存儲數據,水平擴容,自動進行master -> slave的主備切換。
2)最經典的緩存+數據庫讀寫的模式,cache aside pattern。讀的時候,先讀緩存,緩存沒有的話,那么就讀數據庫。更新緩存分以下兩種方式:
-
數據發生變化時,先更新緩存,然后再更新數據庫。這種適用於緩存的值相對簡單,和數據庫的值一一對應,這樣更新比較快。
-
數據發生變化時,先刪除緩存,然后再更新數據庫,讀數據的時候再設置緩存。這種適用於緩存的值比較復雜的場景。比如可能更新了某個表的一個字段,然后其對應的緩存,是需要查詢另外兩個表的數據,並進行運算,才能計算出緩存最新的值的。這樣更新緩存的代價是很高的。如果你頻繁修改一個緩存涉及的多個表,那么這個緩存會被頻繁的更新,頻繁的更新緩存代價很高。而且這個緩存的值如果不是被頻繁訪問,就得不償失了。
大部分情況下,建議適用刪除更新的方式。其實刪除緩存,而不是更新緩存,就是一個lazy計算的思想,不要每次都重新做復雜的計算,不管它會不會用到,而是讓它到需要被使用的時候再重新計算。
舉個例子,一個緩存涉及的表的字段,在1分鍾內就修改了20次,或者是100次,那么緩存跟新20次,100次; 但是這個緩存在1分鍾內就被讀取了1次,有大量的冷數據。28黃金法則,20%的數據,占用了80%的訪問量。實際上,如果你只是刪除緩存的話,那么1分鍾內,這個緩存不過就重新計算一次而已,開銷大幅度降低。每次數據過來,就只是刪除緩存,然后修改數據庫,如果這個緩存,在1分鍾內只是被訪問了1次,那么只有那1次,緩存是要被重新計算的。
3)數據庫與緩存雙寫不一致問題的解決方案
問題:並發請求的時候,數據發生了變更,先刪除了緩存,然后要去修改數據庫,此時還沒修改。另一個請求過來,去讀緩存,發現緩存空了,去查詢數據庫,查到了修改前的舊數據,放到了緩存中。
方案:數據庫與緩存更新與讀取操作進行異步串行化。(引入隊列)
更新數據的時候,將相應操作發送到一個jvm內部的隊列中。讀取數據的時候,如果發現數據不在緩存中,那么將重新讀取數據的操作也發送到同一個jvm內部的隊列中。隊列消費者串行拿到對應的操作,然后一條一條的執行。這樣的話,一個數據變更的操作,先執行刪除緩存,然后再去更新數據庫,但是還沒完成更新。此時如果一個讀請求過來,讀到了空的緩存,那么可以先將緩存更新的請求發送到隊列中,此時會在隊列中積壓,然后同步等待緩存更新完成。
這里有兩個可以優化的點:
-
一個隊列中,其實多個讀緩存,更新緩存的請求串在一起是沒意義的,而且如果讀同一緩存的大量請求到來時,會依次進入隊列等待,這樣會導致隊列最后一個的請求響應時間超時。因此可以做過濾,如果發現隊列中已經有一個讀緩存,更新緩存的請求了,那么就不用再放個新請求操作進去了,直接等待前面的更新操作請求完成即可。如果請求還在等待時間范圍內,不斷輪詢發現可以取到值了,那么就直接返回; 如果請求等待的時間超過一定時長,那么這一次直接從數據庫中讀取當前的舊值。
-
如果請求量特別大的時候,可以用多個隊列,每個隊列對應一個線程。每個請求來時可以根據請求的標識id進行hash路由進入到不同的隊列。
最后,一定要做根據實際業務系統的運行情況,去進行一些壓力測試,和模擬線上環境,去看看最繁忙的時候,內存隊列可能會擠壓多少更新操作,可能會導致最后一個更新操作對應的讀請求,會hang多少時間,如果讀請求在200ms返回,如果你計算過后,哪怕是最繁忙的時候,積壓10個更新操作,最多等待200ms,那還可以的。如果一個內存隊列可能積壓的更新操作特別多,那么你就要加機器,讓每個機器上部署的服務實例處理更少的數據,那么每個內存隊列中積壓的更新操作就會越少。其實根據之前的項目經驗,一般來說數據的寫頻率是很低的,因此實際上正常來說,在隊列中積壓的更新操作應該是很少的。
舉個例子:一秒就100個寫操作。單台機器,20個內存隊列,每個內存隊列,可能就積壓5個寫操作,每個寫操作性能測試后,一般在20ms左右就完成,那么針對每個內存隊列中的數據的讀請求,也就最多hang一會兒,200ms以內肯定能返回了。如果把寫QPS擴大10倍,但是經過剛才的測算,就知道,單機支撐寫QPS幾百沒問題,那么就擴容機器,擴容10倍的機器,10台機器,每個機器20個隊列,200個隊列。大部分的情況下,應該是這樣的,大量的讀請求過來,都是直接走緩存取到數據的,少量情況下,可能遇到讀跟數據更新沖突的情況,如上所述,那么此時更新操作如果先入隊列,之后可能會瞬間來了對這個數據大量的讀請求,但是因為做了去重的優化,所以也就一個更新緩存的操作跟在它后面。
4)大型緩存全量更新問題的解決方案
問題:緩存數據很大時,可能導致redis的吞吐量就會急劇下降,網絡耗費的資源大。如果不維度化,就導致多個維度的數據混合在一個緩存value中。而且不同維度的數據,可能更新的頻率都大不一樣。拿商品詳情頁來說,如果現在只是將1000個商品的分類批量調整了一下,但是如果商品分類的數據和商品本身的數據混雜在一起。那么可能導致需要將包括商品在內的大緩存value取出來,進行更新,再寫回去,就會很坑爹,耗費大量的資源,redis壓力也很大
方案:緩存維度化。舉個例子:商品詳情頁分三個維度:商品維度,商品分類維度,商品店鋪維度。將每個維度的數據都存一份,比如說商品維度的數據存一份,商品分類的數據存一份,商品店鋪的數據存一份。那么在不同的維度數據更新的時候,只要去更新對應的維度就可以了。大大減輕了redis的壓力。
5)通過多級緩存,達到高並發極致,同時給緩存架構最后的安全保護層。具體可以參照上一篇文章【億級流量的商品詳情頁架構分析】。
6)分布式並發緩存重建的沖突問題的解決方案
問題:假如數據在所有的緩存中都不存在了(LRU算法弄掉了),就需要重新查詢數據寫入緩存。對於分布式的重建緩存,在不同的機器上,不同的服務實例中,去做上面的事情,就會出現多個機器分布式重建去讀取相同的數據,然后寫入緩存中。
方案:分布式鎖:如果你有多個機器在訪問同一個共享資源,那么這個時候,如果你需要加個鎖,讓多個分布式的機器在訪問共享資源的時候串行起來。分布式鎖當然有很多種不同的實現方案,redis分布式鎖,zookeeper分布式鎖。
zookeeper分布式鎖的解決並發沖突的方案
-
(1)變更緩存重建以及空緩存請求重建,更新redis之前,都需要先獲取對應商品id的分布式鎖
-
(2)拿到分布式鎖之后,需要根據時間版本去比較一下,如果自己的版本新於redis中的版本,那么就更新,否則就不更新
-
(3)如果拿不到分布式鎖,那么就等待,不斷輪詢等待,直到自己獲取到分布式的鎖
7)緩存冷啟動的問題的解決方案
問題:新系統第一次上線,此時在緩存里可能是沒有數據的。或者redis緩存全盤崩潰了,數據也丟了。導致所有請求打到了mysql。導致mysql直接掛掉。
方案:緩存預熱。
-
提前給redis中灌入部分數據,再提供服務
-
肯定不可能將所有數據都寫入redis,因為數據量太大了,第一耗費的時間太長了,第二根本redis容納不下所有的數據,需要根據當天的具體訪問情況,實時統計出訪問頻率較高的熱數據,然后將訪問頻率較高的熱數據寫入redis中,肯定是熱數據也比較多,我們也得多個服務並行讀取數據去寫,並行的分布式的緩存預熱。
8)恐怖的緩存雪崩問題的解決方案
問題:緩存服務大量的資源全部耗費在訪問redis和源服務無果,最后自己被拖死,無法提供服務。
方案:相對來說,考慮的比較完善的一套方案,分為事前,事中,事后三個層次去思考怎么來應對緩存雪崩的場景。
-
事前:高可用架構。主從架構,操作主節點,讀寫,數據同步到從節點,一旦主節點掛掉,從節點跟上。
-
事中:多級緩存。redis cluster已經徹底崩潰了,緩存服務實例的ehcache的緩存還能起到作用。
-
事后:redis數據可以恢復,做了備份,redis數據備份和恢復,redis重新啟動起來。
9)緩存穿透問題的解決方案
問題:緩存中沒有這樣的數據,數據庫中也沒有這樣的數據。由於緩存是不命中時被動寫的,並且出於容錯考慮,如果從存儲層查不到數據則不寫入緩存,這將導致這個不存在的數據每次請求都要到存儲層去查詢,失去了緩存的意義。在流量大時,可能DB就掛掉了,要是有人利用不存在的key頻繁攻擊我們的應用,這就是漏洞。
方案:有很多種方法可以有效地解決緩存穿透問題,最常見的則是采用布隆過濾器,將所有可能存在的數據哈希到一個足夠大的bitmap中,一個一定不存在的數據會被 這個bitmap攔截掉,從而避免了對底層存儲系統的查詢壓力。另外也有一個更為簡單粗暴的方法(我們采用的就是這種),如果一個查詢返回的數據為空(不管是數 據不存在,還是系統故障),我們仍然把這個空結果進行緩存,但它的過期時間會很短,最長不超過五分鍾。
- 獲取鎖,直到成功或超時。如果超時,則拋出異常,返回。如果成功,繼續向下執行。
- 再去緩存中。如果存在值,則直接返回;如果不存在,則繼續往下執行如果成功獲取到鎖的話,就可以保證只有一個請求去數據源更新數據,並更新到緩存中了。
- 查詢 DB ,並更新到緩存中,返回值。