圖文并茂 | 5W1H分析法幫你系統掌握緩存
來,先上文章的目錄,讓大家可以對 緩存 這塊知識先建立一個系統性的認知,然后我會按點逐個擊破,讀者們也可以按需閱讀哈!
文章目錄
一、什么是緩存(What)
維基百科對緩存的定義是:
In computing, a cache is a hardware or software component that stores data so that future requests for that data can be served faster; the data stored in a cache might be the result of an earlier computation or a copy of data stored elsewhere.
簡而言之,緩存就是存儲數據副本或計算結果的組件,以便后續可以更快地訪問。
有緩存 VS 無緩存
在計算中,緩存是一個高速數據存儲層,其中存儲了數據的子集,且通常是短暫性存儲,這樣日后再次請求該數據時,直接讀緩存會比重新計算結果或讀數據存儲更快。通過緩存,你可以高效地重用之前檢索或計算的數據。
二、為什么要使用緩存(Why)
從定義上可以看出所謂緩存其實是其他數據的副本,使用緩存是為了更快地檢索或計算數據。
1.硬件層面:如CPU中的高速緩存
CPU VS 內存
從上圖可以看出CPU(藍色)和內存(粉紅色)之間存在巨大的性能差距。
在CPU訪問數據和指令遵循計算機系統的局部性原理:
- 時間局部性:CPU 通常使用的許多數據會被多次使用。
- 空間局部性:CPU 使用的許多數據通常在物理上接近以前使用的數據。
使用高速緩存可以彌補CPU和內存之間的性能差異,減少 CPU 浪費計算時間等待內存數據。
2.軟件層面
- 為緩解 CPU 壓力而做緩存:比如把方法運行結果存儲起來、把原本要實時計算的內容提前算好、把一些公用的數據進行復用,這可以節省 CPU 算力,順帶提升響應性能。
- 為緩解 I/O 壓力而做緩存:比如把原本對網絡、磁盤等較慢介質的讀寫訪問變為對內存等較快介質的訪問,將原本對單點部件(如數據庫)的讀寫訪問變為到可擴縮組件(如緩存中間件)的訪問,順帶提升響應性能。
3.產品層面
是否可以解決用戶的痛點問題決定著用戶會不會一開始嘗試使用某款產品,是否有極致的用戶體驗影響用戶會不會持續使用某款產品。在業務中使用緩存的目的就是通過擴大系統吞吐量、減少時延和響應時間來優化用戶體驗,適當的性能優化可以提升體驗,增強用戶粘性。
在現有的互聯網應用中,緩存的使用是一種能夠提升服務快速響應的關鍵技術,也是產品經理無暇顧及的非功能需求,需要在設計技術方案時對業務場景,具有一定的前瞻性評估后,決定在技術架構中是否需要引入緩存解決這種這種非功能需求。
三、什么時候使用緩存(When)
緩存不是架構設計的必選項,也不是業務開發中的必要功能點,只有在業務出現性能瓶頸,進行優化性能的時候才需要考慮使用緩存來提升系統性能。并非所有的業務場景都適合用緩存,讀多寫少、不要求一致性、時效要求越低、訪問頻率越高、對最終一致性和數據丟失有一定程度的容忍的場景才適合使用緩存,緩存并不能解決所有的性能問題,倘若濫用緩存會帶來額外的維護成本,使得系統架構更復雜更難以維護。
雖然緩存適用于各種各樣的案例,但要充分利用緩存,需要進行一定的規劃。所以在決定是否緩存一段數據時,請考慮以下問題:
- 使用緩存值是否安全? 相同的數據在不同的上下文中可能有不同的一致性要求。例如電商系統中,在線結賬期間,必須知道商品的確切價格,因此不適合使用緩存,但在其他頁面上,價格晚幾分鐘更新不會給用戶帶來負面影響。
- 對于該數據而言,緩存是否高效? 某些應用程序會生成不適合緩存的訪問模式;例如,掃描頻繁變化的大型數據集的鍵空間。在這種情況下,保持緩存更新可能會抵消緩存帶來的所有優勢。
- 數據結構是否適合緩存?例如:以單條數據庫記錄形式緩存數據通常足以提供顯著的性能優勢。但有些時候,數據最好以多條記錄組合在一起的格式進行緩存。緩存以簡單的鍵值形式存儲,因此您可能還需要以多種不同格式緩存數據記錄,以便按記錄中的不同屬性進行訪問。
另外把緩存當做存儲來使用是一件極其致命的做法,這種錯誤的認識,將緩存引入系統的那一刻起就意味著已經讓系統走上了危險的局面,只有對緩存的使用邊界有深刻的理解,才能盡可能減少引入緩存帶來的副作用。
四、誰會使用緩存(Who)
其實緩存的思想隨處可見,日常生活中人們都會有意無意地用到。比如你會把常看的書放到書桌上,這樣你可以更快地拿到它們,而受限于桌面空間,不常看的書就要放在空間更大的書柜里了,等到要看的時候再從書柜里拿出來放到書桌上。這里的書桌其實就是一種緩存介質了。
對于程序員來說,緩存更是家常便飯了。有哪些程序員會用到緩存技術呢?
1.硬件開發工程師:比如CPU緩存、GPU緩存和數字信號處理器(DSP)緩存等。
2.軟件開發工程師:
- 客戶端開發:比如第4章節講到的頁面、瀏覽器、APP緩存技術都和客戶端息息相關。
- 后端開發:比如服務端本地緩存、redis和memcached充當數據庫緩存、靜態頁面緩存等。
- 分布式開發:現在分布式大行其道,分布式緩存必然是分布式開發中繞不開的一環,6.10節的分布式緩存也是我們要重點講解的。
- 操作系統開發:操作系統內核負責管理磁盤緩存,比較典型的就有主存中的頁面緩存技術。
五、哪些地方會使用緩存(Where)
1.緩存分類
按照不同的維度,可以對緩存分門別類,如下圖所示。
緩存分類
下面我著重按照 緩存所處鏈路節點的位置 來系統梳理出不同類型的緩存應用。
(1)客戶端緩存
HTTP 協議的無狀態性決定了它必須依靠客戶端緩存來解決網絡傳輸效率上的缺陷。由于每次請求都是獨立的,服務端不保存此前請求的狀態和資源,所以也不可避免地導致其攜帶有重復的數據,造成網絡性能降低。HTTP 協議對此問題的解決方案便是客戶端緩存。
常見的客戶端緩存有如下幾種:
1)頁面緩存
頁面緩存是指將靜態頁面獲取頁面中的部分元素緩存到本地,以便下次請求不需要重復資源文件,h5很好的支持的離線緩存的功能,具體實現可通過頁面指定manifest文件,當瀏覽器訪問一個帶有manifest屬性的文件時,會先從應用緩存中獲取加載頁面的資源文件,并通過檢查機制處理緩存更新的問題。
2)APP緩存
APP可以將內容緩存到內存或者本地數據庫中,例如在一些開源的圖片庫中都具備緩存的技術特性,當圖片等資源文件從遠程服務器獲取后會進行緩存,以便下一次不再進行重復請求,并可以減少用戶的流量費用。
客戶端緩存是前端性能優化的一個重要方向,畢竟客戶端是距離“用戶”最近的地方,是一個可以充分挖掘優化潛力的地方。
3)瀏覽器緩存
瀏覽器緩存通常會專門開辟內存空間以存儲資源副本,當用戶后退或者返回上一步操作時可以通過瀏覽器緩存快速的獲取數據,減少頁面加載時間和帶寬使用。在 HTTP 從 1.0 到 1.1,再到 2.0 版本的每次演進中,逐步形成了現在被稱為“狀態緩存”、“強制緩存”(許多資料中簡稱為“強緩存”)和“協商緩存”的 HTTP 緩存機制。在HTTP 1.1中通過引入e-tag標簽并結合expire、cache-control兩個特性能夠很好的支持瀏覽器緩存。
下面我們用思維導圖理解 強制緩存 和 協商緩存,請注意體會梳理思路!
瀏覽器緩存
對于Etag的補充說明:
HTTP 服務器可以根據自己的意愿來選擇如何生成這個標識,比如 Apache 服務器的 Etag 值默認是對文件的索引節點(INode),大小和最后修改時間進行哈希計算后得到的。
Etag 是 HTTP 中一致性最強的緩存機制,比如,Last-Modified 標注的最后修改只能精確到秒級,如果某些文件在 1 秒鐘以內,被修改多次的話,它將不能準確標注文件的修改時間;又或者如果某些文件會被定期生成,可能內容并沒有任何變化,但 Last-Modified 卻改變了,導致文件無法有效使用緩存,這些情況 Last-Modified 都有可能產生資源一致性問題,只能使用 Etag 解決。
Etag 卻又是 HTTP 中性能最差的緩存機制,體現在每次請求時,服務端都必須對資源進行哈希計算,這比起簡單獲取一下修改時間,開銷要大了很多。Etag 和 Last-Modified 是允許一起使用的,服務器會優先驗證 Etag,在 Etag 一致的情況下,再去對比 Last-Modified,這是為了防止有一些 HTTP 服務器未將文件修改日期納入哈希范圍內。
擴展知識:內容協商機制
到這里為止,HTTP 的協商緩存機制已經能很好地處理通過 URL 獲取單個資源的場景,為什么要強調“單個資源”呢?在 HTTP 協議的設計中,一個 URL 地址是有可能能夠提供多份不同版本的資源,比如,一段文字的不同語言版本,一個文件的不同編碼格式版本,一份數據的不同壓縮方式版本,等等。因此針對請求的緩存機制,也必須能夠提供對應的支持。為此,HTTP 協議設計了以 Accept(Accept、Accept-Language、Accept-Charset、Accept-Encoding)開頭的一套請求 Header 和對應的以 Content-(Content-Language、Content-Type、Content-Encoding)開頭的響應 Header,這些 Headers 被稱為 HTTP 的內容協商機制。與之對應的,對于一個 URL 能夠獲取多個資源的場景中,緩存也同樣也需要有明確的標識來獲知根據什么內容來對同一個 URL 返回給用戶正確的資源。這個就是 Vary Header 的作用,Vary 后面應該跟隨一組其他 Header 的名字,比如:
1HTTP/1.1 200 OK
2Vary: Accept, User-Agent
以上響應的含義是應該根據 MIME 類型和瀏覽器類型來緩存資源,獲取資源時也需要根據請求 Header 中對應的字段來篩選出適合的資源版本。
(2)網絡緩存
網絡緩存位于客戶端以及服務端中間,通過代理的方式解決數據請求的響應,降低數據請求的回源率?;卦绰视址譃橐韵聝煞N:
- 回源流量比:回源流量是代理服務器節點請求源服務器資源時產生流量?;卦戳髁勘?回源流量/(回源流量+用戶請求訪問的流量),比值越低,性能越好。
- 回源請求數比:指代理服務器節點對于沒有緩存、緩存過期(可緩存)和不可緩存的請求占全部請求記錄的比例。
網絡緩存常見的代理形式分為兩種:web代理緩存、邊緣緩存。
在介紹網絡緩存之前我們先了解下前置知識----兩種服務器代理方式:正向代理、反向代理。
正向代理其實就是:客戶端通過代理服務器與源服務器進行非直接連接??蛻舳丝梢愿兄酱矸掌鞯拇嬖冢瑢υ捶掌魍该?源服務器感知不到客戶端的存在),如下圖所示:
正向代理
通信時客戶端和代理服務器要設置好代理協議,比如Socks協議或者是HTTP協議(可以設置4.1節瀏覽器緩存中的HTTP Header)。
那正向代理有什么作用呢?
- 提高訪問速度:通常代理服務器都設置一個較大的緩沖區,當有外界的信息通過時,同時也將其保存到緩沖區中,當其他用戶再訪問相同的信息時, 則直接由緩沖區中取出信息,傳給用戶,以提高訪問速度。
- 控制對內部資源的訪問:如某大學FTP(前提是該代理地址在該資源的允許訪問范圍之內)使用教育網內地址段免費代理服務器,就可以用于對教育網開放的各類FTP下載上傳,以及各類資料查詢共享等服務。
- 過濾、調整內容:例如限制對特定計算機的訪問、壓縮請求包、改變請求包的語言格式等。
- 隱藏真實IP:通過代理服務器隱藏自己的IP,但更安全的方法是利用特定的工具創建代理鏈(如:Tor)。
- 突破網站的區域限制:通過代理服務器訪問一些被限制的網站。
反向代理就是:客戶端通過代理服務器與源服務器進行非直接連接??蛻舳酥粫弥聪虼淼腎P地址,而不知道在代理服務器后面的服務器集群的存在。如下圖所示:
反向代理
反向代理有什么作用呢?
- 對于靜態內容及短時間內有大量訪問請求的動態內容提供緩存服務。
- 對客戶端隱藏服務器(集群)的IP地址。
- 安全:作為應用層防火墻,為服務器提供基于Web的攻擊行為(例如DoS/DDoS)的防護,更容易排查惡意軟件等
- 為后端服務器(集群)統一提供加密和SSL加速(如SSL終端代理)。
- 負載均衡:若服務器集群中有機器負荷較高,反向代理通過URL重寫,把請求轉移到低負荷機器獲取與所需相同的資源。深入了解負載均衡強烈推薦看這篇干貨:??老生常談的負載均衡,你真的懂了嗎???
- 對一些內容進行壓縮,以節約帶寬或為帶寬不佳的網絡提供正常服務。
- 提供HTTP訪問認證。
- 介紹完2種服務器代理形式后,我們來說下兩種網絡緩存形式。
1)web代理緩存:web代理緩存通常是指正向代理,會將資源文件和熱點數據放在代理服務器上,當新的請求到來時,如果在代理服務器上能獲取數據,則不需要重復請求到應用服務器上。
2)邊緣緩存:和正向代理一樣,反向代理同樣可以用于緩存,例如nginx就提供了緩存的功能。進一步,如果這些反向代理服務器能夠做到和用戶請求來自同一個網絡,那么獲取資源的速度進一步提升,這類的反向代理服務器可以稱之為邊緣緩存。常見的邊緣緩存就是內容分發網絡(Content Delivery Network),簡稱CDN??梢詫D片等靜態資源文件放到CDN上。
那什么CDN呢?
如果把某個互聯網系統比喻為一家跨國企業,那內容分發網絡就是它遍布世界各地的分銷機構,如果現在有客戶要買一塊 CPU,那訂機票飛到美國加州英特爾總部肯定是不合適的,到本地電腦城找個裝機店鋪才是普遍的做法,在此場景中,內容分發網絡就相當于電腦城里的本地經銷商。
那CDN的主要工作過程有哪些呢?主要包括路由解析、內容分發、??負載均衡??(干貨文章,建議點擊閱讀)、CDN的利用場景。
路由解析
一次沒有內容分發網絡參與的DNS域名解析過程如下:
無論是使用瀏覽器抑或是在程序代碼中訪問某個網址域名,比如以www.wallbig.club.cn為例,如果沒有緩存的話,都會先經過 DNS 服務器的解析翻譯,找到域名對應的 IP 地址才能開始通信,這項操作是操作系統自動完成的,一般不需要用戶程序的介入。不過,DNS 服務器并不是一次性地將“www.wallbig.club.cn”直接解析成 IP 地址,需要經歷一個遞歸的過程。首先 DNS 會將域名還原為“www.wallbig.club.cn.”,注意最后多了一個點“.”,它是“.root”的含義。早期的域名必須帶有這個點才能被 DNS 正確解析,如今幾乎所有的操作系統、DNS 服務器都可以自動補上結尾的點號,然后開始如下解析步驟:
- 客戶端先檢查本地的 DNS 緩存,查看是否存在并且是存活著的該域名的地址記錄。DNS 是以存活時間(Time to Live,TTL)來衡量緩存的有效情況的,所以,如果某個域名改變了 IP 地址,DNS 服務器并沒有任何機制去通知緩存了該地址的機器去更新或者失效掉緩存,只能依靠 TTL 超期后的重新獲取來保證一致性。后續每一級 DNS 查詢的過程都會有類似的緩存查詢操作。
- 客戶端將地址發送給本機操作系統中配置的本地 DNS(Local DNS),這個本地 DNS 服務器可以由用戶手工設置,也可以在 DHCP 分配時或者在撥號時從 PPP 服務器中自動獲取到。
- 本地 DNS 收到查詢請求后,會按照“是否有www.wallbig.club.cn的權威服務器”→“是否有wallbig.club.cn的權威服務器”→“是否有club.cn的權威服務器”→“是否有cn的權威服務器”的順序,依次查詢自己的地址記錄,如果都沒有查詢到,就會一直找到最后點號代表的根域名服務器為止。這個步驟里涉及了兩個重要名詞:
權威域名服務器(Authoritative DNS):是指負責翻譯特定域名的 DNS 服務器,“權威”意味著這個域名應該翻譯出怎樣的結果是由它來決定的。DNS 翻譯域名時無需像查電話本一樣刻板地一對一翻譯,根據來訪機器、網絡鏈路、服務內容等各種信息,可以玩出很多花樣,權威 DNS 的也有很多靈活應用,后面也會講到。
根域名服務器(Root DNS)是指固定的、無需查詢的頂級域名(Top-Level Domain)服務器,可以默認為它們已內置在操作系統代碼之中。全世界一共有 13 組根域名服務器(注意并不是 13 臺,每一組根域名都通過任播的方式建立了一大群鏡像,根據維基百科的數據,迄今已經超過 1000 臺根域名服務器的鏡像了)。13 這個數字是由于 DNS 主要采用 UDP 傳輸協議(在需要穩定性保證的時候也可以采用 TCP)來進行數據交換,未分片的 UDP 數據包在 IPv4 下最大有效值為 512 字節,最多可以存放 13 組地址記錄,由此而來的限制。
現在假設本地 DNS 是全新的,上面不存在任何域名的權威服務器記錄,所以當 DNS 查詢請求按步驟 3 的順序一直查到根域名服務器之后,它將會得到“cn的權威服務器”的地址記錄,然后通過“cn的權威服務器”,得到“club.cn的權威服務器”的地址記錄,以此類推,最后找到能夠解釋www.wallbig.club.cn的權威服務器地址。
通過“www.wallbig.club.cn的權威服務器”,查詢www.wallbig.club.cn的地址記錄,地址記錄并不一定就是指 IP 地址,在 RFC 規范中有定義的地址記錄類型已經多達數十種,比如 IPv4 下的 IP 地址為 A 記錄,IPv6 下的 AAAA 記錄、主機別名 CNAME 記錄,等等。
下面畫個圖,方便大家理解這個解析過程:
沒有CDN參與的用戶訪問解析過程
前面提到過,每種記錄類型中還可以包括多條記錄,以一個域名下配置多條不同的 A 記錄為例,此時權威服務器可以根據自己的策略來進行選擇,典型的應用是智能線路:根據訪問者所處的不同地區(比如華北、華南、東北)、不同服務商(比如電信、聯通、移動)等因素來確定返回最合適的 A 記錄,將訪問者路由到最合適的數據中心,達到智能加速的目的。
那如果有CDN參與的話,路由解析的具體工作過程又是哪樣的呢?
- 架設好“wallbig.club”的服務器后,將服務器的 IP 地址在你的 CDN 服務商上注冊為“源站”,注冊后你會得到一個 CNAME,即本例中的“wallbig.club.cdn.dnsv1.com.”。
- 將得到的 CNAME 在你購買域名的 DNS 服務商上注冊為一條 CNAME 記錄。
- 當第一位用戶來訪你的站點時,將首先發生一次未命中緩存的 DNS 查詢,域名服務商解析出 CNAME 后,返回給本地 DNS,至此之后鏈路解析的主導權就開始由內容分發網絡的調度服務接管了。
- 本地 DNS 查詢 CNAME 時,由于能解析該 CNAME 的權威服務器只有 CDN 服務商所架設的權威 DNS,這個 DNS 服務將根據一定的均衡策略和參數,如拓撲結構、容量、時延等,在全國各地能提供服務的 CDN 緩存節點中挑選一個最適合的,將它的 IP 代替源站的 IP 地址,返回給本地 DNS。
- 瀏覽器從本地 DNS 拿到 IP 地址,將該 IP 當作源站服務器來進行訪問,此時該 IP 的 CDN 節點上可能有,也可能沒有緩存過源站的資源,這點將在下面的“內容分發”小節討論。
- 經過內容分發后的 CDN 節點,就有能力代替源站向用戶提供所請求的資源。
為了讀者更生動地理解以上步驟,我畫個時序圖,建議和上面的圖對比來看:
CDN路由解析過程
DNS 系統多級分流的設計使得 DNS 系統能夠經受住全球網絡流量不間斷的沖擊,但也并非全無缺點。典型的問題是響應速度,當極端情況(各級服務器均無緩存)下的域名解析可能導致每個域名都必須遞歸多次才能查詢到結果,顯著影響傳輸的響應速度。
專門有一種被稱為“DNS 預取”(DNS Prefetching)的前端優化手段用來避免這類問題:如果網站后續要使用來自于其他域的資源,那就在網頁加載時生成一個 link 請求,促使瀏覽器提前對該域名進行預解釋,比如下面代碼所示:
<link rel="dns-prefetch" href="http://domain.not-wallbig.club">
而另一種可能更嚴重的缺陷是 DNS 的分級查詢意味著每一級都有可能受到中間人攻擊的威脅,產生被劫持的風險。要攻陷位于遞歸鏈條頂層的(比如根域名服務器,cn 權威服務器)服務器和鏈路是非常困難的,它們都有很專業的安全防護措施。但很多位于遞歸鏈底層或者來自本地運營商的 Local DNS 服務器的安全防護則相對松懈,甚至不少地區的運營商自己就會主動進行劫持,專門返回一個錯的 IP,通過在這個 IP 上代理用戶請求,以便給特定類型的資源(主要是 HTML)注入廣告,以此牟利。
為此,最近幾年出現了另一種新的 DNS 工作模式:HTTPDNS(也稱為 DNS over HTTPS,DoH)。它將原本的 DNS 解析服務開放為一個基于 HTTPS 協議的查詢服務,替代基于 UDP 傳輸協議的 DNS 域名解析,通過程序代替操作系統直接從權威 DNS 或者可靠的 Local DNS 獲取解析數據,從而繞過傳統 Local DNS。這種做法的好處是完全免去了“中間商賺差價”的環節,不再懼怕底層的域名劫持,能夠有效避免 Local DNS 不可靠導致的域名生效緩慢、來源 IP 不準確、產生的智能線路切換錯誤等問題。
內容分發
在 DNS 服務器的協助下,無論是對用戶還是服務器,內容分發網絡都可以是完全透明的,在兩者都不知情的情況下,由 CDN 的緩存節點接管了用戶向服務器發出的資源請求。后面隨之而來的問題是緩存節點中必須有用戶想要請求的資源副本,才可能代替源站來響應用戶請求。這里面又包括了兩個子問題:“如何獲取源站資源”和“如何管理(更新)資源”。
CDN 獲取源站資源的過程被稱為“內容分發”,“內容分發網絡”的名字正是由此而來,可見這是 CDN 的核心價值。目前主要有以下兩種主流的內容分發方式:
- 主動分發(Push):分發由源站主動發起,將內容從源站或者其他資源庫推送到用戶邊緣的各個 CDN 緩存節點上。這個推送的操作沒有什么業界標準可循,可以采用任何傳輸方式(HTTP、FTP、P2P,等等)、任何推送策略(滿足特定條件、定時、人工,等等)、任何推送時間,只要與后面說的更新策略相匹配即可。由于主動分發通常需要源站、CDN 服務雙方提供程序 API 接口層面的配合,所以它對源站并不是透明的,只對用戶一側單向透明。主動分發一般用于網站要預載大量資源的場景。比如雙十一之前一段時間內,淘寶、京東等各個網絡商城就會開始把未來活動中所需用到的資源推送到 CDN 緩存節點中,特別常用的資源甚至會直接緩存到你的手機 APP 的存儲空間或者瀏覽器的localStorage上。
- 被動回源(Pull):被動回源由用戶訪問所觸發全自動、雙向透明的資源緩存過程。當某個資源首次被用戶請求的時候,CDN 緩存節點發現自己沒有該資源,就會實時從源站中獲取,這時資源的響應時間可粗略認為是資源從源站到 CDN 緩存節點的時間,再加上資源從 CDN 發送到用戶的時間之和。因此,被動回源的首次訪問通常是比較慢的(但由于 CDN 的網絡條件一般遠高于普通用戶,并不一定就會比用戶直接訪問源站更慢),不適合應用于數據量較大的資源。被動回源的優點是可以做到完全的雙向透明,不需要源站在程序上做任何的配合,使用起來非常方便。這種分發方式是小型站點使用 CDN 服務的主流選擇,如果不是自建 CDN,而是購買阿里云、騰訊云的 CDN 服務的站點,多數采用的就是這種方式。
對于“CDN 如何管理(更新)資源”這個問題,同樣沒有統一的標準可言,盡管在 HTTP 協議中,關于緩存的 Header 定義中確實是有對 CDN 這類共享緩存的一些指引性參數,比如“瀏覽器”小節HTTP header參數Cache-Control的 s-maxage,但是否要遵循,完全取決于 CDN 本身的實現策略。
現在,最常見的做法是超時被動失效與手工主動失效相結合。超時失效是指給予緩存資源一定的生存期,超過了生存期就在下次請求時重新被動回源一次。而手工失效是指 CDN 服務商一般會提供給程序調用來失效緩存的接口,在網站更新時,由持續集成的流水線自動調用該接口來實現緩存更新。
負載均衡
負載均衡就是以統一的接口對外提供服務,但構建和調度服務集群對用戶保持透明。深入了解負載均衡強烈推薦看這篇干貨:老生常談的負載均衡,你真的懂了嗎?
CDN的應用場景
- 加速靜態資源:這是 CDN 最普遍的應用場景。
- 安全防御:CDN 在廣義上可以視作網站的堡壘機,源站只對 CDN 提供服務,由 CDN 來對外界其他用戶服務,這樣惡意攻擊者就不容易直接威脅源站。CDN 對某些攻擊手段的防御,如對DDoS 攻擊的防御尤其有效。但需注意,將安全都寄托在 CDN 上本身是不安全的,一旦源站真實 IP 被泄漏,就會面臨很高的風險。
- 協議升級:不少 CDN 提供商都同時對接(代售 CA 的)SSL 證書服務,可以實現源站是 HTTP 協議的,而對外開放的網站是基于 HTTPS 的。同理,可以實現源站到 CDN 是 HTTP/1.x 協議,CDN 提供的外部服務是 HTTP/2 或 HTTP/3 協議、實現源站是基于 IPv4 網絡的,CDN 提供的外部服務支持 IPv6 網絡,等等。
- 狀態緩存:CDN 不僅可以緩存源站的資源,還可以緩存源站的狀態,比如源站的 301/302 轉向就可以緩存起來讓客戶端直接跳轉、還可以通過 CDN 開啟HSTS、可以通過 CDN 進行OCSP 裝訂加速 SSL 證書訪問等。有一些情況下甚至可以配置 CDN 對任意狀態碼(比如 404)進行一定時間的緩存,以減輕源站壓力,但這個操作應當慎重,在網站狀態發生改變時去及時刷新緩存。
- 修改資源:CDN 可以在返回資源給用戶的時候修改它的任何內容,以實現不同的目的。比如,可以對源站未壓縮的資源自動壓縮并修改 Content-Encoding,以節省用戶的網絡帶寬消耗、可以對源站未啟用客戶端緩存的內容加上緩存 Header,自動啟用客戶端緩存,可以修改CORS的相關 Header,將源站不支持跨域的資源提供跨域能力等。
- 訪問控制:CDN 可以實現 IP 黑/白名單功能,根據不同的來訪 IP 提供不同的響應結果,根據 IP 的訪問流量來實現 QoS 控制、根據 HTTP 的 Referer 來實現防盜鏈等。
- 注入功能:CDN 可以在不修改源站代碼的前提下,為源站注入各種功能。
六、緩存必知必會
1.緩存擊穿
緩存擊穿是一個失效的熱點Key被并發集中訪問,導致請求全部打在數據庫上。圖如下:
緩存擊穿
解決方案:
(1)熱點key緩存不失效:對熱點key可以設置永不過期。
(2)使用互斥鎖或堵塞隊列:這樣可以控制數據庫的線程訪問數,減小數據庫的壓力,但也會讓系統吞吐率有所下降。實現流程如下:
- 阻塞當前 Key 的請求
- 從后端存儲恢復數據
- 在Key 對應的Value 還未恢復的過程中, 如果有其他請求繼續獲取該 Key, 同樣阻塞該請求
- 當key 從后端恢復后,依次喚醒該 Key 對應阻塞的請求
(3)用堵塞隊列來實現的話可以參考Golang官方庫singleflight
熱點數據由代碼來手動管理,緩存擊穿是僅針對熱點數據被自動失效才引發的問題,對于這類數據,可以直接由開發者通過代碼來有計劃地完成更新、失效,避免由緩存策略來自動管理。
2.緩存雪崩
某個時刻熱點數據出現大規模的緩存失效,大量的請求全部打到數據庫,導致數據庫瞬時壓力過載從而拒絕服務甚至是宕機。
緩存雪崩
分析:造成緩存雪崩的關鍵在于在同一時間大規模的key失效。誘因可能是:
(1)大量熱點數據設置了相同或相近的過期時間。
(2)緩存組件不可用,比如redis宕機了。
解決方案:
(1)打散緩存失效時間:主要是通過對key的TTL增加隨機數去盡量規避,過期時間則需要根據業務場景去設置。
(2)使用多級緩存:這里有個github開源庫:hybridcache可以借鑒下
(3)兜底邏輯使用熔斷機制。防止過多請求同時打到DB。
在觸發熔斷機制后返回預先配置好的兜底數據,減少過多請求壓倒DB導致服務不可用。
熔斷檢測+數據兜底
(4)組件高可用:
- 對于redis這樣的緩存組件,可以搭建Redis集群(集群模式或哨兵模式),提高Redis的可用性,盡量規避單點故障導致緩存雪崩。
redis哨兵模式
Redis基于一個Master主節點多Slave從節點的模式和Redis持久化機制,將一份數據保持在多個實例中,從而實現增加副本冗余量,又使用哨兵機制實現主備切換, 在master故障時,自動檢測,將某個slave切換為master,最終實現Redis高可用
- 提高數據庫的容災能力,可以使用分庫分表,讀寫分離的策略。
3.緩存穿透
緩存穿透是指用戶查詢數據庫沒有的數據,緩存中自然也不會有。那先查緩存再查數據相當于進行了兩次無效操作。大量的無效請求將給數據庫帶來極大的訪問壓力,甚至導致其過載拒絕服務。下圖中紅色箭頭標出了每一次用戶請求到來都會經過的兩次無效查詢。
緩存穿透
分析:造成緩存穿透的原因可能是:
(1)空數據查詢(黑客攻擊),空數據查詢通常指攻擊者偽造大量不存在的數據進行訪問(比如不存在的商品信息、用戶信息)
(2)緩存污染(網絡爬蟲),緩存污染通常指在遍歷數據等情況下冷數據把熱數據驅逐出內存,導致緩存了大量冷數據而熱數據被驅逐。
解決方案:
(1)對于空數據查詢:
1)使用布隆過濾器高效判斷key是否存在,流程如如下:
布隆過濾器方案
雖然不能完全避免數據穿透的現象,但已經可以將99%的穿透查詢給屏蔽在Redis層了,極大的降低了底層數據庫的壓力,減少了資源浪費(布隆過濾器用bitmap實現)。如果想擁有更高的空間利用率和更小的誤判,替代的方案是使用布谷鳥過濾器。如果想了解這兩種過濾器的相關細節,強烈推薦看這篇文章:??作為一名后臺開發,你必須知道的兩種過濾器??
由于布隆過濾器存在“誤報”和“漏報”,而且實現也比較復雜,對于空數據查詢其實還有一種簡單粗暴的策略:
2)緩存空值,流程圖如下:
當碰到查詢結果為空的key時,放一個空值到緩存中,下次再訪問就知道此key是無效的,避免無效查詢數據庫。但這樣花費額外的空間來存儲空值。
(2)對于緩存污染:關鍵點是能識別出只訪問一次或者訪問次數很少的數據。然后使用淘汰策略去刪除冷數據,下面以redis為例:
緩存淘汰策略 | 解決緩存污染 |
noeviction | 不能 |
volatile-ttl | 能 |
volatile-random | 不能 |
volatile-lru | 不能 |
volatile-lfu | 能 |
allkeys-random | 不能 |
allkeys-lru | 不能 |
allkeys-lfu | 能 |
- noeviction策略:不會淘汰數據,解決不了。
- volatile-ttl策略:給數據設置合理的過期時間。當緩存寫滿時,會淘汰剩余存活時間最短的數據,避免滯留在緩存中從而造成污染。
- volatile-random策略:隨機選擇數據,無法把不再訪問的數據篩選出來,會造成緩存污染。
- volatile-lru策略:LRU策略只考慮數據的訪問時效,對只訪問一次的數據,不能很快篩選出來。
- volatile-lfu策略:LFU策略在LRU策略基礎上進行了優化,篩選數據時優先篩選并淘汰訪問次數少的數據。
- allkeys-random策略:隨機選擇數據,無法把不再訪問的數據篩選出來,會造成緩存污染。
- allkeys-lru策略:LRU策略只考慮數據的訪問時效,對只訪問一次的數據,不能很快篩選出來。
- allkeys-lfu策略:LFU策略在LRU策略基礎上進行了優化,篩選數據時優先篩選并淘汰訪問次數少的數據。
4.緩存預熱
緩存預熱是指系統上線后,提前將熱點數據加載到緩存系統。避免在用戶請求的時候,先查詢數據庫,然后再將數據緩存的問題,在線上高并發訪問時可以提高數據訪問速度和減小數據庫壓力。
解決方案:
(1)對于單點緩存
- 寫個緩存刷新頁面,上線時手工操作。
- 數據量不大時,可以在程序啟動時加載。
- 用定時器定時刷新緩存,或者模擬用戶觸發。
(2)對于分布式緩存系統,如Redis
- 寫程序或腳本往緩存中加載熱點數據。
- 使用緩存預熱框架。
5.緩存降級
緩存降級是指當訪問量劇增、服務出現問題(如響應時間慢或不響應)或非核心服務影響到核心流程的性能時,即使是有損部分其他服務,仍然需要保證主服務可用??梢詫⑵渌我盏臄祿M行緩存降級,從而提升主服務的穩定性。降級的目的是保證核心服務可用,即使是有損的。而且有些服務是無法降級的(如加入購物車、結算)。
以參考日志級別設置預案:
(1)一般:比如有些服務偶爾因為網絡抖動或者服務正在上線而超時,可以自動降級;
(2)警告:有些服務在一段時間內成功率有波動(如在95~100%之間),可以自動降級或人工降級, 并發送告警;
(3)錯誤:比如可用率低于90%,或者數據庫連接池被打爆了,或者訪問量突然猛增到系統能承受的最大閥值,此時可以根據情況自動降級或者人工降級;
(4)嚴重錯誤:比如因為特殊原因數據錯誤了,此時需要緊急人工降級。
服務降級的目的,是為了防止Redis服務故障,導致數據庫跟著一起發生雪崩問題。因此,對于不重要的緩存數據,可以采取服務降級策略,例如一個比較常見的做法就是,Redis出現問題,不去數據庫查詢,而是直接返回默認值給用戶。
6.緩存更新模式
(1)Cache-Aside模式
Cache-Aside是最常用的一種緩存更新模式,其邏輯如下:
- 失效:應用程序先從cache取數據,沒有得到,則從數據庫中取數據,成功后,放到緩存中。(圖1)
- 命中:應用程序從cache中取數據,取到后返回。(圖1)
- 更新:先把數據存到數據庫中,成功后,再讓緩存失效。(圖2)
Cache-Aside
注意,Cache-Aside更新操作是先更新數據庫,成功后,讓緩存失效。
如果是先刪除緩存,然后再更新數據庫,會有什么影響呢?
我們可以思考一下,假設有兩個并發操作,一個是更新操作,另一個是查詢操作,更新操作刪除緩存后,查詢操作沒有命中緩存,先把老數據讀出來后放到緩存中,然后更新操作更新了數據庫。于是,在緩存中的數據還是老的數據,導致緩存中的數據是臟的,而且還一直這樣臟下去了。
那么Cache-Aside是否會出現上面提到的問題呢?
我們可以腦補一下,假如有兩個并發操作:查詢和更新,首先,沒有了刪除cache數據的操作了,而是先更新了數據庫中的數據,此時,緩存依然有效,所以,并發的查詢操作拿的是沒有更新的數據,但是,更新操作馬上讓緩存的失效了,后續的查詢操作再把數據從數據庫中拉出來。就不會出現后續的查詢操作一直都在取老的數據的問題。
這是標準的design pattern,包括Facebook的論文《Scaling Memcache at Facebook》也使用了這個策略。為什么不是寫完數據庫后更新緩存?你可以看一下Quora上的這個問答《Why does Facebook use delete to remove the key-value pair in Memcached instead of updating the Memcached during write request to the backend?》,主要是怕兩個并發的寫操作導致臟數據。
那么,是不是Cache Aside這個就不會有并發問題了?不是的,比如,一個是讀操作,但是沒有命中緩存,然后就到數據庫中取數據,此時來了一個寫操作,寫完數據庫后,讓緩存失效,然后,之前的那個讀操作再把老的數據放進去,所以,會造成臟數據。
這種case理論上會出現,不過,實際上出現的概率可能非常低,因為這個條件需要發生在讀緩存時緩存失效,而且并發著有一個寫操作。而實際上數據庫的寫操作會比讀操作慢得多,而且還要鎖表,而讀操作必需在寫操作前進入數據庫操作,而又要晚于寫操作更新緩存,所有的這些條件都具備的概率基本并不大。
所以,這也就是Quora上的那個答案里說的,要么通過2PC或是Paxos協議保證一致性,要么就是拼命的降低并發時臟數據的概率,而Facebook使用了這個降低概率的玩法,因為2PC太慢,而Paxos太復雜。當然,最好還是為緩存設置上過期時間。
(2) Read/Write Through模式
在上面的Cache Aside中,應用代碼需要維護兩個數據存儲,一個是緩存(Cache),一個是數據庫(Repository)。所以,應用程序代碼比較復雜。而Read/Write Through是把更新數據庫(Repository)的操作由緩存服務自己代理,對于應用層來說,就簡單很多了。可以理解為,應用認為后端就是一個單一的存儲,而存儲自己維護自己的Cache。
1)Read-Through模式
Read-Through策略是當緩存失效時(過期或LRU換出),緩存服務自己從數據庫加載丟失的數據,填充緩存并將其返回給應用程序。
read through
Cache-Aside 和 Read-Through策略都是延遲加載數據,也就是說,只有在第一次讀取時才加載數據。
Read-Though和Cache-Aside的區別:
- Cache-Aside策略是應用程序負責從數據庫中獲取數據并填充到緩存。此邏輯在Read-Though中通常由庫或獨立緩存服務提供支持
- 與Cache-Aside 不同,Read-Through cache 中的數據模型不能與數據庫中的數據模型不同。
2)Write-Through模式
Write Through 套路和Read Through相仿,不過是在更新數據時發生。當有數據更新的時候,如果沒有命中緩存,直接更新數據庫,然后返回。如果命中了緩存,則更新緩存,然后再由Cache自己更新數據庫(這是一個同步操作)。
write through
就其本身而言,Write-Through似乎沒有太大作用,而且它會引入額外的寫入延遲,因為數據首先寫入緩存,然后寫入主數據庫。它一般和Read-Though配對使用,這樣可以獲得數據一致性保證而且免于應用程序和緩存失效、更新、集群管理等復雜的問題。
DynamoDB Accelerator (DAX) 是通讀/直寫緩存的一個很好的例子。它連接 DynamoDB 和你的應用程序。對 DynamoDB 的讀取和寫入可以通過 DAX 完成。
(3)Write Behind Caching模式
Write Behind也叫 Write Back。其實Linux文件系統的Page Cache算法用的也是這個,所以說底層的東西很多時候是相通。
Write Behind的思想是:在更新數據時,只更新緩存,不更新數據庫,而我們的緩存會異步地批量更新數據庫。
write behind caching
Write Behind的優勢有:
- 直接操作內存,數據的I/O操作速度極快。
- 更新數據庫時可以異步更新,可以合并對同一數據的多次操作,性能強悍。
事物總有兩面性,Write Behind的不足有:
- 數據不能保證強一直性,而且可能會丟失。比如在Linux/Unix系統下突然關機會導致數據丟失。
- 實現邏輯比較復雜。因為需要監視哪些數據發生被更新了,并且在適當的時機持久化這些數據。比如操作系統的Write Behind會在僅當這個cache需要失效的時候,才會被真正持久起來,比如,內存不夠了,或是進程退出了等情況,這又叫lazy write
沒有完美的方案,只有合適的方案。我們基本上不可能做出一個沒有缺陷的設計,軟件設計從來都是取舍Trade-Off。比如算法設計中的時間換空間、空間換時間,有時候軟件的強一致性和高性能不可兼得,高可用和高性能會有沖突等。
緩存更新這個章節,我們都沒有考慮緩存(Cache)和持久層(Repository)的整體事務的問題。比如,更新Cache成功,更新數據庫失敗了怎么嗎?對于事務、分布式事務這個龐大的話題,大家如果有興趣的話歡迎私信我,后續我再出幾篇文章來分析、總結和歸納下。
7.緩存數據庫更新一致性問題
(1)概念介紹
- 雙寫一致性:更新數據庫后保證redis緩存同步更新
(2)讀操作執行過程:
- 命中:讀操作先查詢cache,命中則直接返回緩存數據
- 失效:讀操作先查詢cache,cache失效或過期導致未命中,則從數據庫中讀,寫到緩存后返回
(3)寫操作執行過程:
1)寫操作更新緩存(更新緩存:先更新數據庫還是緩存?)
- 先更新數據庫后更新緩存
A線程更新數據庫后還沒更新緩存時,B線程覆蓋掉A的數據庫值,然后A后寫入緩存覆蓋掉B的緩存值,導致數據不一致
- 先更新緩存后更新數據庫
更新緩存成功,但是更新數據庫失敗導致數據不一致
2)寫操作刪除緩存(刪除緩存:先刪還是后刪緩存?)
- 先寫數據庫后刪緩存(facebook的緩存)
寫入數據庫未刪緩存之前的緩存命中會是舊值(數據不一致時間很短)
A線程的緩存未命中讀取數據庫舊值后B線程寫入數據庫并刪除緩存(這里沒有刪除掉緩存,因為緩存不存在,如果存在A線程不可能緩存未命中),之后A線程才把舊值寫入緩存導致數據不一致(概率小:讀操作必需在寫操作前進入數據庫操作,而又要晚于寫操作更新緩存,但是讀操作時間比寫操作時間快很多)
- 先刪緩存后寫數據庫
A線程刪掉緩存后未寫入數據庫之前,B線程讀取數據舊值,A線程寫入數據后,B更新緩存,導致數據不一致性
解決辦法:延時雙刪,延時雙刪策略會在寫庫前后刪除緩存中的數據,并且給緩存數據設置合理的過期時間,從而可以保證最終一致性。寫流程具體如下:
- 先刪除緩存,再寫數據庫
- 休眠一段時間(比如500ms)
- 再次刪除緩存
延時雙刪
這里為什么會休眠一段時間呢?這里主要是防止:在寫請求刪除緩存但還未成功寫入數據庫后,讀請求可能將舊值加載到緩存。
讀流程:先讀緩存,當緩存未命中,再從數據庫中讀取,然后再寫入緩存。
延時雙刪雖然解決了上述討論的緩存對數據庫不一致的問題,但這種策略存在以下問題:
- 休眠一段時間可能會對性能造成影響
- 在第二次緩存刪除失敗后,會導致數據不一致,需要業務方實現重試機制。
休眠時間需要業務方設置(難點在時間怎么設置),并且要大于一次讀操作時間,才能在舊值加載到就緩存之后二次順利刪掉緩存。
8.緩存過期
緩存過期是個很復雜的問題。沒有解決這一問題的“靈丹妙藥”,但有幾個簡單的策略可供參考:
- 始終對所有緩存鍵設置生存時間 (TTL),但Write-Through緩存更新策略(6.6節中有講到)除外??梢詫⑸鏁r間設置成很長的時間,例如數小時,甚至數天。這種方法能夠捕獲應用程序錯誤,例如在更新底層數據庫時,忘記更新或刪除給定的緩存鍵。最終,緩存鍵會自動過期并刷新。
- 對于頻繁更改的數據,只需設置較短的 TTL (幾秒鐘) 即可。例如評論、排行榜、活動流等,不要添加Write-Through緩存或復雜的過期邏輯。如果某條數據庫查詢在生產環境中被大量訪問,只需改動幾行代碼就能為此查詢添加 TTL 為 5 秒的緩存鍵。在你評估更優雅的解決方案時讓你的應用程序保持正常運行。
- Ruby on Rails 團隊研究出了一種更新的模式 - 俄羅斯套娃緩存。在這種模式下,嵌套記錄通過其自有緩存鍵進行管理,頂層資源就是這些緩存鍵的集合。假設您有一個包含用戶、文章和評論的新聞網頁。在這種方法中,他們中的每個都是自己的緩存鍵,頁面則分別查詢每個鍵。
- 若不確定緩存鍵是否會在數據庫更新時受到影響,只需刪除此緩存鍵。如果你使用的是延遲更新策略(Cache-Aside、Write-Through)會在需要時刷新此鍵。
有關緩存過期和俄羅斯套娃緩存的詳細介紹,請參閱 Basecamp Signal vs Noise 博客文章“俄羅斯套娃”緩存的性能影響。
還可以使用另一種模式以在下游服務不可用時一定程度提高服務的彈性,也就是使用兩個 TTL:一個軟 TTL 和一個硬 TTL??蛻舳藢L試根據軟 TTL 刷新緩存項,但如果下游服務不可用或因其他原因未響應請求,則將繼續使用現有的緩存數據,直至達到硬 TTL。
9.緩存淘汰
緩存回收策略可以分為:
- 基于時間:當某緩存超過生存時間時,則進行緩存回收?;蛘弋斈尘彺孀詈蟊辉L問后超過某時間仍然沒有被訪問,則進行緩存回收。
- 基于空間:當緩存超過某大小時,則進行緩存回收。
- 基于容量:當緩存超過某存儲條數時,則進行緩存回收。
緩存淘汰算法有:
- FIFO(First In First Out):優先淘汰最早進入被緩存的數據。FIFO 實現十分簡單,但一般來說它并不是優秀的淘汰策略,越是頻繁被用到的數據,往往會越早被存入緩存之中。如果采用這種淘汰策略,很可能會大幅降低緩存的命中率。
- LRU(Least Recent Used):優先淘汰最久未被使用訪問過的數據。LRU 通常會采用 HashMap加雙端鏈表的雙重結構來實現(如 LinkedHashMap),以 HashMap 來提供訪問接口,保證常量時間復雜度的讀取性能,以 LinkedList 的鏈表元素順序來表示數據的時間順序,每次緩存命中時把返回對象調整到 LinkedList 開頭,每次緩存淘汰時從鏈表末端開始清理數據。對大多數的緩存場景來說,LRU 都明顯要比 FIFO 策略合理,尤其適合用來處理短時間內頻繁訪問的熱點對象。但相反,它的問題是如果一些熱點數據在系統中經常被頻繁訪問,但最近一段時間因為某種原因未被訪問過,此時這些熱點數據依然要面臨淘汰的命運,LRU 依然可能錯誤淘汰價值更高的數據。
- LFU(Least Frequently Used):優先淘汰最不經常使用的數據。LFU 會給每個數據添加一個訪問計數器,每訪問一次就加 1,需要淘汰時就清理計數器數值最小的那批數據。LFU 可以解決上面 LRU 中熱點數據間隔一段時間不訪問就被淘汰的問題,但同時它又引入了兩個新的問題,首先是需要對每個緩存的數據專門去維護一個計數器,每次訪問都要更新,多線程并發更新要加鎖就會帶來高昂的開銷;另一個問題是不便于處理隨時間變化的熱度變化,譬如某個曾經頻繁訪問的數據現在不需要了,它也很難自動被清理出緩存。
緩存淘汰策略直接影響緩存的命中率,沒有一種策略是完美的、能夠滿足全部系統所需的。不過,隨著淘汰算法的發展,近年來的確出現了許多相對性能要更好的,也更為復雜的新算法。以 LFU 分支為例,針對它存在的兩個問題,近年來提出的 TinyLFU 和 W-TinyLFU 算法就往往會有更好的效果。
- TinyLFU(Tiny Least Frequently Used):TinyLFU 是 LFU 的改進版本。為了緩解 LFU 每次訪問都要修改計數器所帶來的性能負擔,TinyLFU 會首先采用 Sketch 對訪問數據進行分析,所謂 Sketch 是統計學上的概念,指用少量的樣本數據來估計全體數據的特征,這種做法顯然犧牲了一定程度的準確性,但是只要樣本數據與全體數據具有相同的概率分布,Sketch 得出的結論仍不失為一種高效與準確之間權衡的有效結論。借助Count–Min Sketch算法(可視為布隆過濾器的一種等價變種結構),TinyLFU 可以用相對小得多的記錄頻率和空間來近似地找出緩存中的低價值數據。為了解決 LFU 不便于處理隨時間變化的熱度變化問題,TinyLFU 采用了基于“滑動時間窗”的熱度衰減算法,簡單理解就是每隔一段時間,便會把計數器的數值減半,以此解決“舊熱點”數據難以清除的問題。
- W-TinyLFU(Windows-TinyLFU):W-TinyLFU 又是 TinyLFU 的改進版本。TinyLFU 在實現減少計數器維護頻率的同時,也帶來了無法很好地應對稀疏突發訪問的問題,所謂稀疏突發訪問是指有一些絕對頻率較小,但突發訪問頻率很高的數據,譬如某些運維性質的任務,也許一天、一周只會在特定時間運行一次,其余時間都不會用到,此時 TinyLFU 就很難讓這類元素通過 Sketch 的過濾,因為它們無法在運行期間積累到足夠高的頻率。應對短時間的突發訪問是 LRU 的強項,W-TinyLFU 就結合了 LRU 和 LFU 兩者的優點,從整體上看是它是 LFU 策略,從局部實現上看又是 LRU 策略。具體做法是將新記錄暫時放入一個名為 Window Cache 的前端 LRU 緩存里面,讓這些對象可以在 Window Cache 中累積熱度,如果能通過 TinyLFU 的過濾器,再進入名為 Main Cache 的主緩存中存儲,主緩存根據數據的訪問頻繁程度分為不同的段(LFU 策略,實際上 W-TinyLFU 只分了兩段),但單獨某一段局部來看又是基于 LRU 策略去實現的(稱為 Segmented LRU)。每當前一段緩存滿了之后,會將低價值數據淘汰到后一段中去存儲,直至最后一段也滿了之后,該數據就徹底清理出緩存。
另外還有兩種高級淘汰策略ARC(Adaptive Replacement Cache)、LIRS(Low Inter-Reference Recency Set),大家有興趣可以再深入閱讀,對其他緩存淘汰策略感興趣的讀者也可以參考維基百科中對Cache Replacement Policies的介紹。
10.分布式緩存
相比起緩存數據在進程內存中讀寫的速度,一旦涉及網絡訪問,由網絡傳輸、數據復制、序列化和反序列化等操作所導致的延遲要比內存訪問高得多,所以對分布式緩存來說,處理與網絡有相關的操作是對吞吐量影響更大的因素,往往也是比淘汰策略、擴展功能更重要的關注點,這決定了盡管也有 Ehcache、Infinispan 這類能同時支持分布式部署和進程內嵌部署的緩存方案,但通常進程內緩存和分布式緩存選型時會有完全不同的候選對象及考察點。我們決定使用哪種分布式緩存前,首先必須確認自己需求是什么?
- 從訪問的角度來說,如果是頻繁更新但甚少讀取的數據,通常是不會有人把它拿去做緩存的,因為這樣做沒有收益。對于甚少更新但頻繁讀取的數據,理論上更適合做復制式緩存;對于更新和讀取都較為頻繁的數據,理論上就更適合做集中式緩存。筆者簡要介紹這兩種分布式緩存形式的差別與代表性產品:
- 復制式緩存:復制式緩存可以看作是“能夠支持分布式的進程內緩存”,它的工作原理與 Session 復制類似。緩存中所有數據在分布式集群的每個節點里面都存在有一份副本,讀取數據時無須網絡訪問,直接從當前節點的進程內存中返回,理論上可以做到與進程內緩存一樣高的讀取性能;當數據發生變化時,就必須遵循復制協議,將變更同步到集群的每個節點中,復制性能隨著節點的增加呈現平方級下降,變更數據的代價十分高昂。
復制式緩存的代表是JBossCache,這是 JBoss 針對企業級集群設計的緩存方案,支持 JTA 事務,依靠 JGroup 進行集群節點間數據同步。以 JBossCache 為典型的復制式緩存曾有一段短暫的興盛期,但今天基本上已經很難再見到使用這種緩存形式的大型信息系統了,JBossCache 被淘汰的主要原因是寫入性能實在差到不堪入目的程度,它在小規模集群中同步數據尚算差強人意,但在大規模集群下,很容易就因網絡同步的速度跟不上寫入速度,進而導致在內存中累計大量待重發對象,最終引發 OutOfMemory 崩潰。如果對 JBossCache 沒有足夠了解的話,稍有不慎就要被埋進坑里。
為了緩解復制式同步的寫入效率問題,JBossCache 的繼任者Infinispan提供了另一種分布式同步模式(這種同步模式的名字就叫做“分布式”),允許用戶配置數據需要復制的副本數量,譬如集群中有八個節點,可以要求每個數據只保存四份副本,此時,緩存的總容量相當于是傳統復制模式的一倍,如果要訪問的數據在本地緩存中沒有存儲,Infinispan 完全有能力感知網絡的拓撲結構,知道應該到哪些節點中尋找數據。
- 集中式緩存:集中式緩存是目前分布式緩存的主流形式,集中式緩存的讀、寫都需要網絡訪問,其好處是不會隨著集群節點數量的增加而產生額外的負擔,其壞處自然是讀、寫都不再可能達到進程內緩存那樣的高性能。
集中式緩存還有一個必須提到的關鍵特點,它與使用緩存的應用分處在獨立的進程空間中,其好處是它能夠為異構語言提供服務,譬如用 C 語言編寫的Memcached完全可以毫無障礙地為 Java 語言編寫的應用提供緩存服務;但其壞處是如果要緩存對象等復雜類型的話,基本上就只能靠序列化來支撐具體語言的類型系統(支持 Hash 類型的緩存,可以部分模擬對象類型),不僅有序列化的成本,還很容易導致傳輸成本也顯著增加。舉個例子,假設某個有 100 個字段的大對象變更了其中 1 個字段的值,通常緩存也不得不把整個對象所有內容重新序列化傳輸出去才能實現更新,因此,一般集中式緩存更提倡直接緩存原始數據類型而不是對象。相比之下,JBossCache 通過它的字節碼自審(Introspection)功能和樹狀存儲結構(TreeCache),做到了自動跟蹤、處理對象的部分變動,用戶修改了對象中哪些字段的數據,緩存就只會同步對象中真正變更那部分數據。
如今Redis廣為流行,基本上已經打敗了 Memcached 及其他集中式緩存框架,成為集中式緩存的首選,甚至可以說成為了分布式緩存的實質上的首選,幾乎到了不必管讀取、寫入哪種操作更頻繁,都可以無腦上 Redis 的程度。也因如此,之前說到哪些數據適合用復制式緩存、哪些數據適合集中式緩存時,筆者都在開頭加了個拗口的“理論上”。盡管 Redis 最初設計的本意是 NoSQL 數據庫而不是專門用來做緩存的,可今天它確實已經成為許多分布式系統中無可或缺的基礎設施,廣泛用作緩存的實現方案。
- 從數據一致性角度說,緩存本身也有集群部署的需求,理論上你應該認真考慮一下是否能接受不同節點取到的緩存數據有可能存在差異。譬如剛剛放入緩存中的數據,另外一個節點馬上訪問發現未能讀到;剛剛更新緩存中的數據,另外一個節點訪問在短時間內讀取到的仍是舊的數據,等等。根據分布式緩存集群是否能保證數據一致性,可以將它分為 AP 和 CP 兩種類型。此處又一次出現了“理論上”,是因為我們實際開發中通常不太會把追求強一致性的數據使用緩存來處理,可以這樣做,但是沒必要(可類比 MESI 等緩存一致性協議)。譬如,Redis 集群就是典型的 AP 式,有著高性能高可用等特點,卻并不保證強一致性。而能夠保證強一致性的 ZooKeeper、Doozerd、Etcd 等分布式協調框架,通常不會有人將它們當為“緩存框架”來使用,這些分布式協調框架的吞吐量相對 Redis 來說是非常有限的。不過 ZooKeeper、Doozerd、Etcd 倒是常與 Redis 和其他分布式緩存搭配工作,用來實現其中的通知、協調、隊列、分布式鎖等功能。
分布式緩存與進程內緩存各有所長,也有各有局限,它們是互補而非競爭的關系,如有需要,完全可以同時把進程內緩存和分布式緩存互相搭配,構成透明多級緩存(Transparent Multilevel Cache,TMC),如圖 4-14 所示。先不考慮“透明”的話,多級緩存是很好理解的,使用進程內緩存做一級緩存,分布式緩存做二級緩存,如果能在一級緩存中查詢到結果就直接返回,否則便到二級緩存中去查詢,再將二級緩存中的結果回填到一級緩存,以后再訪問該數據就沒有網絡請求了。如果二級緩存也查詢不到,就發起對最終數據源的查詢,將結果回填到一、二級緩存中去。
多級緩存
盡管多級緩存結合了進程內緩存和分布式緩存的優點,但它的代碼侵入性較大,需要由開發者承擔多次查詢、多次回填的工作,也不便于管理,如超時、刷新等策略都要設置多遍,數據更新更是麻煩,很容易會出現各個節點的一級緩存、以及二級緩存里數據互相不一致的問題。必須“透明”地解決以上問題,多級緩存才具有實用的價值。一種常見的設計原則是變更以分布式緩存中的數據為準,訪問以進程內緩存的數據優先。大致做法是當數據發生變動時,在集群內發送推送通知(簡單點的話可采用 Redis 的 PUB/SUB,求嚴謹的話引入 ZooKeeper 或 Etcd 來處理),讓各個節點的一級緩存自動失效掉相應數據。當訪問緩存時,提供統一封裝好的一、二級緩存聯合查詢接口,接口外部是只查詢一次,接口內部自動實現優先查詢一級緩存,未獲取到數據再自動查詢二級緩存的邏輯。
附加解釋下CAP定理:CAP是分布式計算領域所公認的著名定理。其描述了一個分布式的系統中,涉及共享數據問題時,以下三個特性最多只能同時滿足其中兩個:
- 一致性(Consistency):代表數據在任何時刻、任何分布式節點中所看到的都是符合預期的。
- 可用性(Availability):代表系統不間斷地提供服務的能力,理解可用性要先理解與其密切相關兩個指標:可靠性(Reliability)和可維護性(Serviceability)??煽啃允褂闷骄鶡o故障時間(Mean Time Between Failure,MTBF)來度量;可維護性使用平均可修復時間(Mean Time To Repair,MTTR)來度量??捎眯院饬肯到y可以正常使用的時間與總時間之比,其表征為:A=MTBF/(MTBF+MTTR),即可用性是由可靠性和可維護性計算得出的比例值,譬如 99.9999%可用,即代表平均年故障修復時間為 32 秒。
- 分區容忍性(Partition Tolerance):代表分布式環境中部分節點因網絡原因而彼此失聯后,即與其他節點形成“網絡分區”時,系統仍能正確地提供服務的能力。
七、如何設計緩存(How)
在進行緩存結構設計的時候,需要考慮的點有很多:
1)對緩存帶來的價值持懷疑態度:從成本、延遲和可用性等方面來評估引入緩存的合理性,并仔細評估引入緩存帶來的額外風險和收益。
2)業務流量量級評估:對于低并發低流量的應用而言,引入緩存并不會帶來性能的顯著提升,反而會帶來應用的復雜度以及極高的運維成本。也不是任何數據都需要使用緩存,比如圖片視頻等文件使用分布式文件系統更合適而不是緩存。因此,在引入緩存前,需要對當前業務的流量進行評估,在高并發大流量的業務場景中引入緩存相對而言收益會更高;
3)緩存組件選型:緩存應用有很多如Redis、Memcached以及tair等等,針對每一種分布式緩存應用的優缺點以及適用范圍、內存效率、運維成本甚至團隊開發人員的知識結構都需要了解,才能做好技術選型;
4)緩存相關指標評估:在引入緩存前,需要著重評估value大小、峰值QPS、緩存內存空間、緩存命中率、過期時間、過期淘汰策略、讀寫更新策略、key值分布路由策略、數據一致性方案等多個因素,要做到心中有數;
5)緩存高可用架構:分布式緩存要高可用,比如緩存的集群設計、主從同步方案的設計等等,只有緩存足夠可靠,才能服務于業務系統,為業務帶來價值;
6)完善的監控平臺:當緩存投入生產環境后,需要有一套監控系統能夠顯式的觀測緩存系統的運行情況,才能更早的發現問題,同時對于預估不足的非預期熱點數據,也需要熱點發現系統去解決非預期的熱點數據緩存問題。
7)緩存最近訪問原則:將緩存數據放在離用戶最近的地方,無疑會極大的提升響應的速度,這也是多級緩存設計的核心思想。
8)考慮緩存數據的安全問題。包括加密、與外部緩存隊列通信時的傳輸安全性以及緩存投毒攻擊和側信道攻擊的影響。
引用
- coolshell:緩存更新的套路
- 亞馬遜緩存最佳實踐
- wikipedia:
- 鳳凰架構:透明多級分流系統
- FaceBook論文:《Scaling Memcache at Facebook》
- Golang官方庫:singleflight
- Github開源庫:hybridcache
- wikipedia:
- wikipedia:
- wikipedia: