一. 為什么使用緩存
如圖1,為了快速應對早期的業務快速發展,我們架設一個超級簡單的Web服務,只有一臺應用服務器和DB,這種架構簡單,便于快速開發和部署。但隨著應用服務器的QPS不斷增長,水漲船高,DB的QPS也逐漸提升,對DB的響應時間也有很高要求,單DB已無法快速滿足業務發展。這時候可以考慮對DB進行分庫分表,或者讀寫分離等方式以滿足業務的發展。但是這些方式仍然有很多問題:
- 性能提升有限,很難達到數量級上的提升,至于原因,我將再寫一篇文章論述。如果業務發展迅速,訪問量將會指數上升,僅僅依賴DB對于響應時間的降低幫助不大。
- 成本高昂,為了應對N倍訪問量,需要增加N倍DB服務器,成本難以接受。
-
DB為了數據高可靠,犧牲性能,具體表現為數據是存儲到硬盤中,而不是內存,這樣即使DB重啟保證數據不會丟失。所以訪問DB常常要從硬盤讀取數據,從圖2可以看出硬盤的讀取時間遠遠大于內存的。
圖1 最簡單的Web架構
在大部分業務場景下,數據的讀取符合二八原則,即80%的訪問量是落到20%的熱點數據上,假設我們把這20%熱點數據通過內存保存起來,在訪問這20%數據時先訪問保存的內存地方,這就是緩存。這將極大提升數據讀取速度,最終降低請求的響應時間,提高QPS。為了解決上面提出的問題,引入緩存組件,將熱點數據緩存到內存中,架構圖如圖3所示。應用服務器讀取數據的順序為:
- 第一步,從緩存讀取數據,如果有數據,直接返回數據;
- 第二步,從DB讀取數據,保存到緩存,返回數據。
因此,Web架構引入緩存后:
- 提升數據讀取速度,降低請求響應時間,承受更多的請求量;
- 提升系統擴展能力,緩存的內存空間不足,可以橫向擴展緩存組件,提升系統承載能力;
-
降低存儲成本,Cache+DB承受更多請求量,大大減少原有需要承受這么多請求量的DB服務器。
圖3 引入緩存的簡單Web架構
二. 緩存分類
選擇緩存作為Web架構的數據讀取組件后,下一步根據業務場景選擇具體的緩存組件,從緩存數據分布的特點對比表如下。
- 本地緩存如果使用在數據變化率高的場景,即無明顯高頻次的數據讀取,緩存數據使用內存越多,挪用Web應用的內存資源就越多,反而降低應用的性能;
- 伸縮性差表現在無法橫向擴展本地緩存,并且如果要清除緩存內容,只能逐臺服務器清除,達不到分布式緩存的一次性清除緩存的效果。
使用場景 | 優點 | 缺點 | 代表例子 | |
---|---|---|---|---|
本地緩存 | 數據變化率較低 | 存儲任意數據,無網絡存取延遲,數據查詢更快 | 占用應用系統資源,容量和伸縮性差 | HashMap、GuavaCache、OSCache、JCache、Ehcache |
分布式緩存 | 數據變化率較高 | 容量和伸縮性好 | 網絡開銷大,響應時間長 | MC、Redis、Tair |
從數據存儲方式看,對比表如下。
- 內存模式要考慮重啟機器內存數據丟失,運維復雜度也很高,但是相對于磁盤持久化模式而言,少了維護數據持久化到硬盤的功能,所以運維復雜度相對較低。
特點 | 運維復雜度 | 代表例子 | |
---|---|---|---|
內存模式 | 所有數據內存Cache | 相對較低 | Memcached |
磁盤持久化模式 | 熱點數據內存Cache,非熱點數據磁盤存儲 | 高 | Redis、Tair |
三. Memcached特性
Memcached作為一款經典的緩存組件,具備極高的讀取性能,下面將從四個特性分析Memcached的高性能。
- 協議簡單
- Memcached支持文本和二進制協議;
- 文本協議調試簡單,內容可視化;
- 二進制性能高效,且相對文本協議安全性高。
- 基于libevent的事件處理
- 使用IO多路復用的IO模型,Linux系統下使用epoll處理數據讀寫,具備極高的IO性能。
- 內置內存存儲方式
- 所有數據存放在內存,相對于Linux提供的malloc/free產生的內存碎片,Memcached獨特的內存存儲方式可以避免內存碎片,提高內存利用率和性能。
- 因為數據存儲在內存中,所以重啟Memcached和操作系統,數據將全部丟失。
- Memcached互不通信的分布式
- Memcached實際上不是一個真正的分布式服務器,集群的各個Memcached服務器不互相通信以共享數據,分布式特性通過客戶端實現。實際上這也避免分布式集群特有的問題:腦裂。
Memcached協議和基于libevent的事件處理都不是Memcached特有的特性,所以本文重點分享Memcached內置內存存儲方式和不互相通信的分布式的特性。
四. Slab Allocation
傳統的內存分配是通過malloc/free實現,易產生內存碎片,加重操作系統內存管理器的負擔。Memcached使用Slab Allocation機制高效管理內存,Slab Allocation機制按照一個特定的增長比例,將分配的內存分割成特定長度的塊,完全解決內存碎片問題。
Slab Allocation將內存分割成不同Slab Class,把相同尺寸的塊(Chunk)組成組(Slab),如圖4所示。Slab Allocation重復使用已分配的內存塊,覆蓋原有內存塊的數據。
首先介紹Slab Allocation的概念:
- Page:操作系統的內存頁,分配給Slab的內存空間,默認1MB;
- Chunk:用于緩存記錄的內存空間,不同Slab的Chunk大小通過一個特定增長比例逐漸增大;
- Slab:特定大小的Chunk的組,同一個Page分割成相同大小的Chunk,組成一個Slab。
Memcached根據數據大小選擇最合適的Slab,如圖5所示,100 bytes items選擇112 bytes的Slab Chunk存儲數據,內存空間利用率最高,其中items包含緩存數據的key/value等,具體請查看拙作[Memcached] Slab Allocation的MC項占用空間分析及實踐。
圖5引出Slab Allocation的一個缺點:內存空間有浪費。112的Chunk存放100 bytes數據,有12 bytes空間浪費。通過下面公式可以計算圖5的期望內存利用率,對我們啟示作用請查看拙作[Memcached] 為什么MC達到90%的內存利用率時開始踢出數據?
(88+112)/2/112=89% 112 bytes的Chunk存放的期望大小是(88+112)/2,所以期望內存利用率是89%,其他同理。
(112+144)/2/144=89%
(144+184)/2/184=89%
...
不同Chunk size遞增通過Growth Factor控制,Memcached啟動可以指定Growth Factor,默認是1.25。圖6和圖7的Growth Factor分別是2和1.25,有趣的是兩者的起始Chunk size都是96B,原因請查看拙作[Memcached] 初始Chunk size計算。
如下,根據不同Growth Factor推出期望內存利用率,當真實內存利用率達到期望內存利用率,警惕Memcached踢出數據,例子[Memcached] 為什么MC達到90%的內存利用率時開始踢出數據?
Growth Factor=2 : (n+2*n)/2/2n=75%
Growth Factor=1.25 : (n+1.25*n)/2/1.25n=90%
Growth Factor=gf : (n+gf*n)/2/(gf*n)=(1+gf)/2gf
五. Memcached刪除機制
- 數據不會真正從Memcached消失
- Memcached不會主動釋放已分配的內存,記錄超時后,客戶端get該記錄,Memcached不會返回該記錄,并標志該記錄過期失效,此時內存空間可重復使用。
- Lazy Expiration
- Memcached內部不會監視記錄是否過期,而是客戶端get時查看記錄的時間戳,檢查記錄是否過期,如果過期,標志為過期,下次存放新記錄優先使用過期記錄占用的內存,這種技術就是Lazy Expiration,Memcached不會在過期監控上耗費CPU資源。
- LRU: Least Recently Used
- 從緩存中有效刪除數據原理。Memcached優先使用記錄已超時和未使用的內存空間,但是在追加新記錄時如果沒有記錄已超時和未使用的內存空間,此時使用Least Recently Used(LRU)機制分配空間。顧名思義,就是刪除”最近最少使用“的記錄的機制。當Memcached內存空間不足(無法從Slab獲取Chunk)時,就刪除”最近最少使用“的記錄,將其內存空間分配給新記錄存放。從緩存使用角度,該模型非常理想。如果想關閉LRU機制,啟動Memcached指定-M參數即可。
- Slab 鈣化
- 請參考拙作[Memcached] Slab鈣化問題
六. Memcached保存記錄過程
圖8為Memcached保存item流程圖,具體步驟為:
- 第一步,從LRU隊列尋找過期item,這里的LRU隊列是相同Slab一個隊列,而不是全局統一,過期item標記方法通過Lazy Expiration實現,如果有過期的item,使用新item替換過期item,結束;
- 第二步,如果沒有過期item,查看是否有合適空閑的Chunk,如果有,保存新item到空閑Chunk,結束;
- 第三步,如果沒有合適空閑的Chunk,嘗試初始化一個新同等Chunk size的Slab,檢查內存是否足夠,如果夠,分配內存創建Slab和Chunk,并使用Chunk存放新item,結束;
- 第四步,如果內存不夠,從LRU隊列淘汰最近最少使用的item,然后用這個Chunk存放新item,結束。注意這一步將導致非過期LRU數據丟失。
七. Memcached內存儲存學習啟示
- 盡量避免緩存大對象,大對象降低內存利用率和命中率
- 緩存/節點失效后大量請求涌向數據庫,容易造成雪崩
- 利用組件在預警時間之后失效時間之間訪問緩存,主動刷新緩存
- 關鍵業務數據不要放MC,LRU機制導致緩存數據刪除,影響業務
八. Memcached不互相通信的分布式特征
memcached盡管是“分布式”緩存服務器,但服務器端并沒有分布式功能,各個memcached不會互相通信以共享信息,由客戶端的實現訪問Memcached集群。如圖9,應用程序通過MC客戶端程序庫訪問MC集群,MC客戶端程序庫根據Hash算法從服務器列表選擇一臺MC服務器存放數據,各個MC之間不共享數據,也就沒有腦裂問題。
九. 一致性Hash算法
MC客戶端程序庫根據普通的Hash取余算法選擇MC服務器存放數據,如果移除或者新增MC服務器,MC客戶端程序庫要根據服務器列表總數重新取余,就會選擇一臺其他的MC服務器存儲數據,而該臺服務器沒有緩存上一臺服務器的數據,所以導致大量數據發起大量請求訪問DB獲取數據,容易造成雪崩問題。
為了解決Hash取余算法的固有缺點,MC引入一致性Hash算法,如果圖10所示,首先求出MC服務器(節點)的哈希值,將其分配到0~232的環上。然后用同樣的方法求出存放數據的哈希值,映射到環上,并從數據映射的位置開始順時針查找,將數據存放到最近的一個MC節點。如果超過232仍然找不到服務器,就會保存到第一臺MC節點上。獲取數據的查找方式也是如此。
從圖10的狀態中添加一臺MC服務器節點5,變成圖11,只有環上增加節點5到逆時針方向的第一臺節點(節點2)之間的鍵受影響,本應映射到節點4而映射到節點5。因此,一致性Hash算法最大程度抑制鍵的重新分布。
圖12的哈希表或許更加直觀,md5根據key值摘要一個128bit的哈希值(校驗和),一般表示為32位的16進制數,我們取哈希值的第一位范圍將key映射到不同節點,如圖12左側表格所示。當新增一個節點5,把原本映射到節點4的一半數據映射到節點5,其他三個節點不受影響。但是引出數據在MC節點上分布不均勻的問題,原本左側表格每個節點映射的數據量一樣,但是右側表格的節點4/5只有其他三個節點的一半數據量,導致節點4/5的帶寬和內存使用率一直不飽滿。
為此,引入虛擬節點,如圖13所示,左側表格中依md5值劃分為16個虛擬節點,每四個虛擬節點映射到一個物理節點。當增加物理節點5時,就從節點1/2/3各拿一個虛擬節點映射到物理節點5,這樣每個物理節點基本有3到4個虛擬節點的映射,緩存數據分布相對圖12右側表格均衡很多。
一致性Hash算法啟示
- 副本存儲到多個節點,避免單點故障或者數據失效大量請求涌向DB
- 節點故障后,有快速預熱新節點應急手段,或者使用冷節點備份
十. Memcached一些疑難問題
1. 為什么不能用緩存保存session?
當MC內存空間不足,并且沒有過期數據,MC用新數據覆蓋LRU的數據,導致部分session信息被清除,用戶重新登錄才能獲取到session,用戶體驗差。實際上,可以把session持久化到DB,MC緩存一份session,待MC查詢不到session信息,再到DB查詢,并更新MC,既能保證數據不丟失,也保證session查詢速度快。
2. 為什么同一個MC的數據,有的緩存(值較大的舊數據)要2天才被置換出,有的緩存(值較小的新數據)幾分鐘就被置換出?
值較大的數據占用Slab Class的大Chunk size,值較小的數據占用Slab Class的小Chunk size,根據Slab鈣化問題,當值較小的數據占用Slab Class空間不夠用時,并且沒有多余的內存和過期的數據,不會挪用值較大的數據占用Slab Class空間,只會復用原有值較小的數據占用Slab Class空間,根據LRU算法置換出同一種Slab的Chunk數據。
Slab鈣化問題請查看拙作[Memcached] Slab鈣化問題
3. Cache失效后的擁堵問題
通常我們會為兩種數據做Cache,一種是熱數據,也就是說短時間內有很多人訪問的數據;另一種是高成本的數據,也就說查詢很耗時的數據。當這些數據過期的瞬間,如果大量請求同時到達,那么它們會一起請求后端重建Cache,造成擁堵問題。
一般有如下幾種解決思路可供選擇:
首先,通過定時任務主動更新Cache;
其次,我們可以加分布式鎖,保證只有一個請求訪問數據庫更新緩存。
4. Multiget的無底洞問題
出于效率的考慮,很多Memcached應用都以Multiget操作為主,隨著訪問量的增加,系統負載捉襟見肘,遇到此類問題,直覺通常都是增加服務器來提升系統性能,但是在實際操作中卻發現問題并不簡單,新加的服務器好像被扔到了無底洞里一樣毫無效果。Multiget根據key請求多臺服務器,但這并不是問題的癥結,真正的原因在于客戶端在請求多臺服務器時是并行的還是串行的!問題是很多客戶端,在處理Multiget多服務器請求時,使用的是串行的方式!也就是說,先請求一臺服務器,然后等待響應結果,接著請求另一臺,結果導致客戶端操作時間累加,請求堆積,性能下降。
5. 緩存命中率下降,但是內存的利用率很高時,我們需要如何進行處理?
內存空間不足,導致緩存失效移除,命中率下降,既然內存利用率高,擴容MC服務器即可。
6. 緩存命中率下降,內存的利用率也在下降時,我們需要如何進行處理?
跟問題2類似,也是Slab鈣化問題。空間利用率高的Slab Class不會使用空間利用率低的其他的Slab Class,導致空間利用率高的Slab Class不斷因為LRU踢出數據,總體而言,緩存命中率下降,內存的利用率也會下降。
Slab鈣化降低內存使用率,如果發生Slab鈣化,有三種解決方案:
- 重啟Memcached實例,簡單粗暴,啟動后重新分配Slab class,但是如果是單點可能造成大量請求訪問數據庫,出現雪崩現象,沖跨數據庫。
- 隨機過期:過期淘汰策略也支持淘汰其他slab class的數據,twitter工程師采用隨機選擇一個Slab,釋放該Slab的所有緩存數據,然后重新建立一個合適的Slab。
- 通過slab_reassign、slab_authmove。
7. 通常情況下,緩存的粒度越小,命中率會越高。
舉個實際的例子說明:當緩存單個對象的時候(例如:單個用戶信息),只有當該對象對應的數據發生變化時,我們才需要更新緩存或者移除緩存。而當緩存一個集合的時候(例如:所有用戶數據),其中任何一個對象對應的數據發生變化時,都需要更新或移除緩存。
由于序列化和反序列化需要一定的資源開銷,當處于高并發高負載的情況下,對大對象數據的頻繁讀取有可能會使得服務器的CPU崩潰。
8. 緩存被“擊穿”問題
對于一些設置了過期時間的key,如果這些key可能會在某些時間點被超高并發地訪問,是一種非常“熱點”的數據。這個時候,需要考慮另外一個問題:緩存被“擊穿”的問題。
- 概念:緩存在某個時間點過期的時候,恰好在這個時間點對這個Key有大量的并發請求過來,這些請求發現緩存過期一般都會從后端DB加載數據并回設到緩存,這個時候大并發的請求可能會瞬間把后端DB壓垮。
- 如何解決:業界比較常用的做法,是使用mutex。簡單地來說,就是在緩存失效的時候(判斷拿出來的值為空),不是立即去load db,而是先使用緩存工具的某些帶成功操作返回值的操作(比如Redis的SETNX或者Memcache的ADD)去set一個mutex key,當操作返回成功時,再進行load db的操作并回設緩存;否則,就重試整個get緩存的方法。類似下面的代碼:
public String get(key) {
String value = redis.get(key);
if (value == null) { //代表緩存值過期
//設置3min的超時,防止del操作失敗的時候,下次緩存過期一直不能load db
if (redis.setnx(key_mutex, 1, 3 * 60) == 1) { //代表設置成功
value = db.get(key);
redis.set(key, value, expire_secs);
redis.del(key_mutex);
} else { //這個時候代表同時候的其他線程已經load db并回設到緩存了,這時候重試獲取緩存值即可
sleep(50);
get(key); //重試
}
} else {
return value;
}
}