更新時間:2022-10-14 來源:黑馬程序員 瀏覽量:
前言
提到緩存,想必每一位軟件工程師都不陌生,它是目前架構設計中提高性能最直接的方式。
緩存技術存在于應用場景的方方面面。從網站提高性能的角度分析,緩存可以放在瀏覽器,可以放在反向代理服務器,還可以放在應用程序進程內,同時可以放在分布式緩存系統中。
從用戶請求數據到數據返回,數據經過了瀏覽器,CDN,Nginx代理緩存,應用服務器,以及數據庫各個環節。每個環節都可以運用緩存技術。
緩存的請求順序是:用戶請求 → HTTP 緩存 → CDN 緩存 → Nginx代理緩存 → 進程內緩存 → 分布式緩存。
在技術的架構每個環節都可以加入緩存,我們看看每個環節是如何應用緩存技術的。
2. HTTP緩存
通常 HTTP 緩存策略分為兩種:
- 強緩存
- 協商緩存。
從字面意思我們可以很直觀的看到它們的差別:
- 強緩存即強制直接使用緩存。
- 協商緩存就得和服務器協商確認下這個緩存能不能用。
強緩存
強緩存不會向服務器發送請求,直接從緩存中讀取資源,在 chrome 控制臺的 network 選項中可以看到該請求返回 200 的狀態碼,并且`size`顯示`from disk cache`或`from memory cache`;
協商緩存
協商緩存會先向服務器發送一個請求,服務器會根據這個請求的 request header 的一些參數來判斷是否命中協商緩存,如果命中,則返回 304 狀態碼并帶上新的 response header 通知瀏覽器從緩存中讀取資源。
2.1 HTTP 緩存控制
在 HTTP 中,我們可以通過設置響應頭以及請求頭來控制緩存策略。
強緩存可以通過設置`Expires`和`Cache-Control` 兩種響應頭實現。如果同時存在,`Cache-Control`優先級高于`Expires`。
Expires
Expires 響應頭,它是 HTTP/1.0 的產物。代表該資源的過期時間,其值為一個絕對時間。它告訴瀏覽器在過期時間之前可以直接從瀏覽器緩存中存取數據。由于是個絕對時間,客戶端與服務端的時間時差或誤差等因素可能造成客戶端與服務端的時間不一致,將導致緩存命中的誤差。如果在`Cache-Control`響應頭設置了 `max-age` 或者 `s-max-age` 指令,那么 `Expires` 會被忽略。
Expires: Wed, 21 Oct 2015 07:28:00 GMT
Cache-Control
`Cache-Control` 出現于 HTTP/1.1。可以通過指定多個指令來實現緩存機制。主要用表示資源緩存的最大有效時間。即在該時間端內,客戶端不需要向服務器發送請求。優先級高于 Expires。其過期時間指令的值是相對時間,它解決了絕對時間的帶來的問題。
Cache-Control: max-age=315360000
`Cache-Control` 有很多屬性,不同的屬性代表的意義也不同。
可緩存性
- `public` 表明響應可以被任何對象(包括:發送請求的客戶端,代理服務器,等等)緩存。
- `private` 表明響應只能被單個用戶緩存,不能作為共享緩存(即代理服務器不能緩存它)
- `no-cache` 不使用強緩存,需要與服務器驗協商緩存驗證。
- `no-store` 緩存不應存儲有關客戶端請求或服務器響應的任何內容,即不使用任何緩存。
過期
- `max-age=` 緩存存儲的最大周期,超過這個周期被認為過期。
- `s-maxage=` 設置共享緩存。會覆蓋`max-age`和`expires`,私有緩存會忽略它
- `max-stale[=]` 客戶端愿意接收一個已經過期的資源,可以設置一個可選的秒數,表示響應不能已經過時超過該給定的時間。
- `min-fresh=` 客戶端希望在指定的時間內獲取最新的響應
重新驗證和重新加載
- `must-revalidate` 如頁面過期,則去服務器進行獲取。
- `proxy-revalidate` 與`must-revalidate` 作用相同,但是用于共享緩存。
其他
- `only-if-cached` 不進行網絡請求,完全只使用緩存。
- `no-transform` 不得對資源進行轉換和轉變。例如,不得對圖像格式進行轉換。
協商緩存可以通過 `Last-Modified`/`If-Modified-Since`和`ETag`/`If-None-Match`這兩對 Header 來控制。
2.2 Last-Modified、If-Modified-Since
`Last-Modified`與`If-Modified-Since` 的值都是 GMT 格式的時間字符串,代表的是文件的最后修改時間。
1.在服務器在響應請求時,會通過`Last-Modified`告訴瀏覽器資源的最后修改時間。
2. 瀏覽器再次請求服務器的時候,請求頭會包含`Last-Modified`字段,后面跟著在緩存中獲得的最后修改時間。
3. 服務端收到此請求頭發現有`if-Modified-Since`,則與被請求資源的最后修改時間進行對比,如果一致則返回 304 和響應報文頭,瀏覽器只需要從緩存中獲取信息即可。如果已經修改,那么開始傳輸響應一個整體,服務器返回:200 OK
<img src="images/image-20220718222822409.png" alt="image-20220718222822409" style="zoom:80%;" />
但是在服務器上經常會出現這種情況,一個資源被修改了,但其實際內容根本沒發生改變,會因為`Last-Modified`時間匹配不上而返回了整個實體給客戶端(即使客戶端緩存里有個一模一樣的資源)。為了解決這個問題,HTTP/1.1 推出了`Etag`。Etag 優先級高與`Last-Modified`。
2.3 Etag、If-None-Match
`Etag`都是服務器為每份資源生成的唯一標識,就像一個指紋,資源變化都會導致 ETag 變化,跟最后修改時間沒有關系,`ETag`可以保證每一個資源是唯一的。
在瀏覽器發起請求,瀏覽器的請求報文頭會包含 `If-None-Match` 字段,其值為上次返回的`Etag`發送給服務器,服務器接收到次報文后發現 `If-None-Match` 則與被請求資源的唯一標識進行對比。如果相同說明資源沒有修改,則響應返 304,瀏覽器直接從緩存中獲取數據信息。如果不同則說明資源被改動過,則響應整個資源內容,返回狀態碼 200。
3. CDN緩存
CDN:Content Delivery Network,即內容分發網絡,它是構建在現有網絡基礎上的虛擬智能網絡,依靠部署在各地的邊緣服務器,通過中心平臺的負載均衡、調度及內容分發等功能模塊,使用戶在請求所需訪問的內容時能夠就近獲取,以此來降低網絡擁塞,提高資源對用戶的響應速度。
本地存儲和瀏覽器緩存帶來的性能提升主要針對的是瀏覽器端已經緩存了所需的資源,當發生二次請求相同資源時便能夠進行快速響應,避免重新發起請求或重新下載全部響應資源。
這些方法對于首次資源請求的性能提升是無能為力的,若想提升首次請求資源的響應速度,除了資源壓縮、圖片優化等方式,還可借助CDN技術。
3.1 使用CDN網絡資源獲取過程
如果使用了CDN網絡,則資源獲取的大致過程是這樣的。
1.由于DNS服務器將對CDN的域名解析權交給了CNAME指向的專用DNS服務器,所以對用戶輸入域名的解析最終是在CDN專用的DNS服務器上完成的。
2. 解析出的結果IP地址并非確定的CDN緩存服務器地址,而是CDN的負載均衡器的地址。
3. 瀏覽器會重新向該負載均衡器發起請求,經過對用戶IP地址的距離、所請求資源內容的位置及各個服務器復雜狀況的綜合計算,返回給用戶確定的緩存服務器IP地址。
4. 對目標緩存服務器請求所需資源的過程。
這個過程也可能會發生所需資源未找到的情況,那么此時便會依次向其上一級緩存服務器繼續請求查詢,直至追溯到網站的根服務器并將資源拉取到本地。
3.2 CDN網絡的核心功能包括兩點:
緩存與回源
緩存指的是將所需的靜態資源文件復制一份到CDN緩存服務器上;
回源指的是如果未在CDN緩存服務器上查找到目標資源,或CDN緩存服務器上的緩存資源已經過期,則重新追溯到網站根服務器獲取相關資源的過程。
4. Nginx代理緩存
用戶請求在達到應用服務器之前,會先訪問 Nginx 負載均衡器,如果發現有緩存信息,直接返回給用戶。
如果沒有發現緩存信息,Nginx 回源到應用服務器獲取信息。
另外,有一個緩存更新服務,定期把應用服務器中相對穩定的信息更新到 Nginx 本地緩存中。
Nginx設置緩存有兩種方式:
- proxy_cache_path和proxy_cache
- Cache-Control和Pragma
對于站點中不經常修改的靜態內容(如圖片,JS,CSS),可以在服務器中設置expires過期時間,控制瀏覽器緩存,達到有效減小帶寬流量,降低服務器壓力的目的。
<img src="images/image-20220718224542963.png" alt="image-20220718224542963" style="zoom: 67%;" />
第一步:客戶端第一次向Nginx請求數據A;
第二步:當Nginx發現緩存中沒有數據A時,會向服務端請求數據A;
第三步:服務端接收到Nginx發來的請求,則返回數據A到Nginx,并且緩存在Nginx;
第四步:Nginx返回數據A給客戶端應用;
第五步:客戶端第二次向Nginx請求數據A;
第六步:當Nginx發現緩存中存在數據A時,則不會請求服務端;
第七步:Nginx把緩存中的數據A返回給客戶端應用。
默認情況下,NGINX尊重Cache-Control源服務器的標頭。它不緩存響應Cache-Control設置為Private,No-Cache或No-Store或Set-Cookie在響應頭。NGINX只緩存GET和HEAD客戶端請求。
如下配置可覆蓋這些默認值:
- proxy_buffering默認為on,若proxy_buffering設置為off,則NGINX不會緩存響應。
- proxy_ignore_headers可以配置忽略Cache-Control:
location /images/ { proxy_cache my_cache; proxy_ignore_headers Cache-Control; proxy_cache_valid any 30m; # ... }
5. 進程緩存
通過了客戶端,CDN,Nginx代理緩存,我們終于來到了應用服務器。應用服務器上部署著一個個應用,這些應用以進程的方式運行著,那么在進程中的緩存是怎樣的呢?
進程內緩存又叫托管堆緩存,以 Java 為例,這部分緩存放在 JVM 的托管堆上面,同時會受到托管堆回收算法的影響。
由于其運行在內存中,對數據的響應速度很快,通常我們會把熱點數據放在這里。
在進程內緩存沒有命中的時候,我們會去搜索進程外的緩存或者分布式緩存。這種緩存的好處是沒有序列化和反序列化,是最快的緩存。缺點是緩存的空間不能太大,對垃圾回收器的性能有影響。
目前比較流行的實現有 Ehcache、GuavaCache、Caffeine。這些架構可以很方便的把一些熱點數據放到進程內的緩存中。
這里我們需要關注幾個緩存的回收策略,具體的實現架構的回收策略會有所不同,但大致的思路都是一致的:
- FIFO(First In First Out):先進先出算法,最先放入緩存的數據最先被移除。
- LRU(Least Recently Used):最近最少使用算法,把最久沒有使用過的數據移除緩存。
- LFU(Least Frequently Used):最不常用算法,在一段時間內使用頻率最小的數據被移除緩存。
在分布式架構的今天,多應用中如果采用進程內緩存會存在數據一致性的問題。
這里推薦兩個方案:
- 消息隊列方案
應用在修改完自身緩存數據和數據庫數據之后,給消息隊列發送數據變化通知,其他應用訂閱了消息通知,在收到通知的時候修改緩存數據。
- 定時任務修改方案
為了避免耦合,降低復雜性,對“實時一致性”不敏感的情況下。每個應用都會啟動一個定時任務,定時從數據庫拉取最新的數據,更新緩存。
進程內緩存有哪些使用場景呢?
- 場景一:只讀數據,可以考慮在進程啟動時加載到內存。當然,把數據加載到類似 Redis 這樣的進程外緩存服務也能解決這類問題。
- 場景二:高并發,可以考慮使用進程內緩存,例如:秒殺。
6. 分布式緩存
說完進程內緩存,自然就過度到進程外緩存了。
與進程內緩存不同,進程外緩存在應用運行的進程之外,它擁有更大的緩存容量,并且可以部署到不同的物理節點,通常會用分布式緩存的方式實現。
分布式緩存是與應用分離的緩存服務,最大的特點是,自身是一個獨立的應用/服務,與本地應用隔離,多個應用可直接共享一個或者多個緩存應用/服務。
為了提高緩存的可用性,會在原有的緩存節點上加入 Master/Slave 的設計。當緩存數據寫入 Master 節點的時候,會同時同步一份到 Slave 節點。
一旦 Master 節點失效,可以通過代理直接切換到 Slave 節點,這時 Slave 節點就變成了 Master 節點,保證緩存的正常工作。
每個緩存節點還會提供緩存過期的機制,并且會把緩存內容定期以快照的方式保存到文件上,方便緩存崩潰之后啟動預熱加載。
6.1 緩存雪崩
當緩存失效,緩存過期被清除,緩存更新的時候。請求是無法命中緩存的,這個時候請求會直接回源到數據庫。
如果上述情況頻繁發生或者同時發生的時候,就會造成大面積的請求直接到數據庫,造成數據庫訪問瓶頸。我們稱這種情況為緩存雪崩。
從如下兩方面來思考解決方案:
緩存方面:
- 避免緩存同時失效,不同的 key 設置不同的超時時間。
- 增加互斥鎖,對緩存的更新操作進行加鎖保護,保證只有一個線程進行緩存更新。緩存一旦失效可以通過緩存快照的方式迅速重建緩存。對緩存節點增加主備機制,當主緩存失效以后切換到備用緩存繼續工作。
設計方面,這里給出了幾點建議供大家參考:
- 熔斷機制:某個緩存節點不能工作的時候,需要通知緩存代理不要把請求路由到該節點,減少用戶等待和請求時長。
- 限流機制:在接入層和代理層可以做限流,當緩存服務無法支持高并發的時候,前端可以把無法響應的請求放入到隊列或者丟棄。
- 隔離機制:緩存無法提供服務或者正在預熱重建的時候,把該請求放入隊列中,這樣該請求因為被隔離就不會被路由到其他的緩存節點。
- 如此就不會因為這個節點的問題影響到其他節點。當緩存重建以后,再從隊列中取出請求依次處理。
62. 緩存穿透
緩存一般是 Key,Value 方式存在,一個 Key 對應的 Value 不存在時,請求會回源到數據庫。
假如對應的 Value 一直不存在,則會頻繁的請求數據庫,對數據庫造成訪問壓力。如果有人利用這個漏洞攻擊,就麻煩了。
解決方法:如果一個 Key 對應的 Value 查詢返回為空,我們仍然把這個空結果緩存起來,如果這個值沒有變化下次查詢就不會請求數據庫了。
將所有可能存在的數據哈希到一個足夠大的 Bitmap 中,那么不存在的數據會被這個 Bitmap 過濾器攔截掉,避免對數據庫的查詢壓力。
6.3 緩存擊穿
在數據請求的時候,某一個緩存剛好失效或者正在寫入緩存,同時這個緩存數據可能會在這個時間點被超高并發請求,成為“熱點”數據。
這就是緩存擊穿問題,這個和緩存雪崩的區別在于,這里是針對某一個緩存,前者是針對多個緩存。
解決方案:導致問題的原因是在同一時間讀/寫緩存,所以只有保證同一時間只有一個線程寫,寫完成以后,其他的請求再使用緩存就可以了。
比較常用的做法是使用 mutex(互斥鎖)。在緩存失效的時候,不是立即寫入緩存,而是先設置一個 mutex(互斥鎖)。當緩存被寫入完成以后,再放開這個鎖讓請求進行訪問。