緩存-基礎知識
熟悉計算機基礎的同學們都知道,服務的存儲大多是多層級的,呈現金字塔類型。通常來說本機存儲比通過網絡通信的外部存儲更快(現在也不一定了,因為網絡傳輸速度很快,至少可以比一些過時的本地存儲設備速度快)。常見的本機存儲和網絡設備有:
- 本機:從快到慢有CPU寄存器、CPU多級緩存、內存、磁盤
- 網絡設備:從快到慢有Redis這類的基于內存的緩存,MySQL這類持久化數據庫,RPC調用(如果假設RPC調用的下游也需要調用緩存和數據庫等組件)等
那么使用緩存有什么好處呢?通俗地講就是“快”,準確地說是讀取快,每秒承載的訪問次數多,這樣服務端每秒能處理的請求就更多,服務總體的吞吐量就大。具體來說,基于內存的存儲比基于磁盤的快,所以大家常常使用Redis作為MySQL的緩存;而本機的內存緩存又會比通過網絡調用Redis快,因此現在一些互聯網公司也在搭建本地緩存(Local Cache)來緩解Redis的壓力。
另外需要看到緩存的缺點:緩存增加了數據的副本份數,所以寫入的份數也增加了,寫入性能必然降低,一致性管理成本必然增加。所以緩存基本都是用于讀多寫少,且不要求強一致性的場景。
更新事件捕獲與更新緩存的策略
在常見的在線服務調用中,我們會構建緩存來避免對數據庫和下游的調用,來減少數據庫壓力和對下游服務的壓力。當相關數據改變時,可以使緩存失效或者更新緩存。那么這里引入了兩個問題,第一個是如何知道緩存該被更新了,第二個是怎么選擇刪除緩存和更新緩存的策略。
更新事件捕獲
通常有兩種方式來捕獲更新事件:
- 服務代碼主動更新:用戶操作涉及修改數據時,處理用戶請求的服務代碼會刷新緩存和數據庫里的數據
- 訂閱數據庫binlog延遲更新:數據庫配置binlog,并通過一些binlog同步工具將數據庫變更記錄發送到消息隊列。服務端訂閱消息隊列的指定話題獲取變更記錄,進而更新緩存數據
1#通常被用于更新自己團隊的數據,而2#通常用于更新下游服務,或者作為緩存更新失敗時的兜底邏輯。畢竟下游是數據的生產方,讓下游更新緩存的時候通知所有上游不太現實,這種基于訂閱模式的事件捕獲機制有很強的擴展性。
更新策略選擇
具體策略(刪除或更新)的選擇通常取決于數據是否在本次處理中已經計算好了。比方說收到一條變更消息,說用戶的年齡已經被更新了。與年齡相關的可能還有用戶對好友的可見性、用戶是否可以接收其它用戶的信息等內容,它們都可能隨著年齡的更改而同步變化,而這些信息目前還是未知的——除非再次請求下游。因此對于這種情況,我們會將相關的數據直接刪除。由于年齡的更新頻率非常低,所以我們可以認為刪除相關緩存的操作對其性能的總體影響很小。
但是如果需要更新的字段比較獨立,不會影響別的字段,比方說用戶昵稱或者頭像,那直接更新對應的數據即可
本地緩存(Local Cache)
說完了更新緩存的策略,我們再來看看最近業界比較流行的Local Cache的實現。這里的local,指的是服務器的單實例,也就是每個實例都會有自己的緩存副本。它的適用條件是讀請求次數遠遠大于寫,且數據總量較小。比如對于Redis來說,性能指標的表現就是內存利用率低,但是CPU占用率很高。這對于現在的微服務架構來說比較常見,每個服務需要管理的字段不多,但是請求量巨大,比如對于社交應用來說,用戶年齡相關的字段就符合這種訪問模式,每次請求基本都要讀取年齡字段,但是數據量卻很小。
讀寫過程
本地緩存的讀取策略很簡單,不過就是在Redis之前加了個讀取本地緩存的過程,如果緩存不存在就從更高層級緩存拉取。
那如何更新緩存呢?由于分布式系統的特性,每個服務器實例都有自己的內存,除非使用特別的路由策略,讓同一個用戶的請求只打到同一臺機器上,否則更新緩存是很頭疼的事情:用戶的緩存可能存在于不同機器上,如何讓多臺服務器的緩存同時失效?因此在分布式的世界中,使用Redis這樣的集中緩存比較常見,只要存儲是集中式的且從計算單元中分離出來,服務就可以做成無狀態的,減少數據狀態的管理成本。
回憶一下我們剛剛說過的捕獲更新和緩存更新方式,其實本地緩存的更新也不出其右:用binlog+消息隊列同步數據庫變更,所有啟用本地緩存的服務器實例,都需要監聽消息隊列里的變更消息,并刷新(或者刪除)本地緩存。唯一的不同是,如果需要刷新Redis緩存,那我們可以把刷新緩存的服務和處理用戶請求的服務獨立開來;但是本地緩存的緩存更新邏輯得和服務器邏輯都放在同一個實例中。這也很好理解,Redis是集中式的緩存,而本地緩存和服務器實例牢牢捆綁在一起。
本地緩存的優勢
之前說過,本地緩存適用于讀遠遠大于寫,且請求量大但是緩存數據總量較小的場景。為什么呢?因為假如CPU和內存利用率都很高,即使本地緩存降低了Redis的讀取次數,那也只能降低Redis的CPU占用率,而內存利用率還是很高。內存利用率和緩存數據總量有關,所以多一層緩存不會減少緩存的數據總量。根據木桶原理,即使CPU利用率降低了,由于內存的需求還在,也不能縮減實例的數量,所以并不能節約太多錢(可以降低一下CPU規格,但是一臺生產環境實例的基本價格在那,降低或提高規格帶來的費用變化并不大)。
那有人問了,本地緩存不是也提高了服務端的內存占用,這方面的費用不算了嗎?剛剛說過,生產環境里一臺實例的基礎費用不低,而且增加一些內存帶來的費用相比于減少Redis實例數量來說很小。而且本來本地緩存就適合數據總量小的場景,因此增加一些內存容量(比如4-8G)就能有客觀的命中率(比如50%+)。
我相信還有人會問,增加了更新鏈路不是也帶來了額外費用嗎?這就要再強調一下本地緩存的適用場景:讀遠遠大于寫,比如大了兩個數量級。這樣緩存的更新頻率足夠低,緩存更新鏈路所需的資源也足夠少。
緩存常見問題
緩存如何解決熱點問題
熱點問題是緩存遇到的常見問題之一,比如強如Redis也有其吞吐量上限,而Redis同一個key只能存儲在一個分片、一個進程里,可能還是無法應付一些熱點用戶的數據訪問(比如某個熱點問題,某個熱點主播)。《數據密集型系統》給出兩個比較籠統的方案:
- 給熱key實例更多資源:比如將其單獨存儲在一個實例中,或者給它分配更好的機器。但是這種方案很容易就會達到上限,因為不具備水平擴展能力。
- 拆key:比如某個用戶(id為1234)是熱點,那就將它的數據存在100個副本中,每個副本的key是id后面加上兩位數。這樣當看到1234這個id時,服務端會自動在后面加上兩位隨機數,生成形如123401,123402…123499這樣的id,再到Redis里訪問。這相當于把一個熱key的訪問壓力分散為原來的1/100。這種方式明顯是優化了讀請求。如果是寫請求比較重,那也可以做類似的事情,將寫分散到不同的實例上,但讀的時候就需要讀取所有的100個分片。
由于1#不具備水平擴展能力,所以一些業界流行的做法都采用了類似2#的方式,只不過實現起來各有特點,比方說在Redis之前加一層緩存代理,這層代理也是一個分布式服務,一旦有熱key存在,就會將熱key緩存到代理服務的本地緩存里,實現了類似于2#中拆key的效果(因為代理服務也是分布式的,多個實例都可以緩存熱key的內容,進而分散了Redis單實例的壓力)