1. 程式人生 > 遊戲 >《對馬島之鬼:導演剪輯版》玩家能擼貓擼鹿擼猴子

《對馬島之鬼:導演剪輯版》玩家能擼貓擼鹿擼猴子

一、為什麼使用快取

  如圖1,為了快速應對早期的業務快速發展,我們架設一個超級簡單的Web服務,只有一臺應用伺服器和DB,這種架構簡單,便於快速開發和部署。但隨著應用伺服器的QPS不斷增長,水漲船高,DB的QPS也逐漸提升,對DB的響應時間也有很高要求,單DB已無法快速滿足業務發展。這時候可以考慮對DB進行分庫分表,或者讀寫分離等方式以滿足業務的發展。但是這些方式仍然有很多問題:

  • 效能提升有限,很難達到數量級上的提升,至於原因,我將再寫一篇文章論述。如果業務發展迅速,訪問量將會指數上升,僅僅依賴DB對於響應時間的降低幫助不大。
  • 成本高昂,為了應對N倍訪問量,需要增加N倍DB伺服器,成本難以接受。
  • DB為了資料高可靠,犧牲效能,具體表現為資料是儲存到硬碟中,而不是記憶體,這樣即使DB重啟保證資料不會丟失。所以訪問DB常常要從硬碟讀取資料,從圖2可以看出硬碟的讀取時間遠遠大於記憶體的

圖1 最簡單的Web架構

圖2 伺服器不同級別的訪問時間

  在大部分業務場景下,資料的讀取符合二八原則,即80%的訪問量是落到20%的熱點資料上,假設我們把這20%熱點資料通過記憶體儲存起來,在訪問這20%資料時先訪問儲存的記憶體地方,這就是快取。這將極大提升資料讀取速度,最終降低請求的響應時間,提高QPS。為了解決上面提出的問題,引入快取元件,將熱點資料快取到記憶體中,架構圖如圖3所示。應用伺服器讀取資料的順序為:

  • 第一步,從快取讀取資料,如果有資料,直接返回資料;
  • 第二步,從DB讀取資料,儲存到快取,返回資料。

因此,Web架構引入快取後:

  • 提升資料讀取速度,降低請求響應時間,承受更多的請求量;
  • 提升系統擴充套件能力,快取的記憶體空間不足,可以橫向擴充套件快取元件,提升系統承載能力;
  • 降低儲存成本,Cache+DB承受更多請求量,大大減少原有需要承受這麼多請求量的DB伺服器。

圖3 引入快取的簡單Web架構

二. 快取分類

選擇快取作為Web架構的資料讀取元件後,下一步根據業務場景選擇具體的快取元件,從快取資料分佈的特點對比表如下。

  • 本地快取如果使用在資料變化率高的場景,即無明顯高頻次的資料讀取,快取資料使用記憶體越多,挪用Web應用的記憶體資源就越多,反而降低應用的效能
  • 伸縮性差表現在無法橫向擴充套件本地快取,並且如果要清除快取內容,只能逐臺伺服器清除,達不到分散式快取的一次性清除快取的效果。

從資料儲存方式看,對比表如下。

  • 記憶體模式要考慮重啟機器記憶體資料丟失,運維複雜度也很高,但是相對於磁碟持久化模式而言,少了維護資料持久化到硬碟的功能,所以運維複雜度相對較低。

三、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重複使用已分配的記憶體塊,覆蓋原有記憶體塊的資料。

圖4 Slab Class

首先介紹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

  圖5引出Slab Allocation的一個缺點:記憶體空間有浪費。112的Chunk存放100 bytes資料,有12 bytes空間浪費。通過下面公式可以計算圖5的期望記憶體利用率,對我們啟示作用請檢視[Memcached] 為什麼MC達到90%的記憶體利用率時開始踢出資料?
1 (88+112)/2/112=89%   112 bytes的Chunk存放的期望大小是(88+112)/2,所以期望記憶體利用率是89%,其他同理。
2 (112+144)/2/144=89%
3 (144+184)/2/184=89%
4 ...
  不同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%的記憶體利用率時開始踢出資料?
1 Growth Factor=2 : (n+2*n)/2/2n=75%
2 Growth Factor=1.25 : (n+1.25*n)/2/1.25n=90%
3 Growth Factor=gf : (n+gf*n)/2/(gf*n)=(1+gf)/2gf
圖6 Growth Factor=2的Slab Alloaction

圖7 Growth Factor=1.25的Slab Alloaction

五、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儲存記錄過程

圖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資料丟失。

圖8 Memcached儲存記錄過程

七、Memcached記憶體儲存學習啟示

  • 儘量避免快取大物件,大物件降低記憶體利用率和命中率
  • 快取/節點失效後大量請求湧向資料庫,容易造成雪崩
  • 利用元件在預警時間之後失效時間之間訪問快取,主動重新整理快取
  • 關鍵業務資料不要放MC,LRU機制導致快取資料刪除,影響業務

八、Memcached不互相通訊的分散式特徵

  memcached儘管是“分散式”快取伺服器,但伺服器端並沒有分散式功能,各個memcached不會互相通訊以共享資訊,由客戶端的實現訪問Memcached叢集。如圖9,應用程式通過MC客戶端程式庫訪問MC叢集,MC客戶端程式庫根據Hash演算法從伺服器列表選擇一臺MC伺服器存放資料,各個MC之間不共享資料,也就沒有腦裂問題。

圖9 應用程式通過MC客戶端程式庫訪問MC叢集

九、一致性Hash演算法

  MC客戶端程式庫根據普通的Hash取餘演算法選擇MC伺服器存放資料,如果移除或者新增MC伺服器,MC客戶端程式庫要根據伺服器列表總數重新取餘,就會選擇一臺其他的MC伺服器儲存資料,而該臺伺服器沒有快取上一臺伺服器的資料,所以導致大量資料發起大量請求訪問DB獲取資料,容易造成雪崩問題。
  為了解決Hash取餘演算法的固有缺點,MC引入一致性Hash演算法,如果圖10所示,首先求出MC伺服器(節點)的雜湊值,將其分配到0~2^32的環上。然後用同樣的方法求出存放資料的雜湊值,對映到環上,並從資料對映的位置開始順時針查詢,將資料存放到最近的一個MC節點。如果超過2^32仍然找不到伺服器,就會儲存到第一臺MC節點上。獲取資料的查詢方式也是如

圖10 一致性Hash基本原理   從圖10的狀態中新增一臺MC伺服器節點5,變成圖11,只有環上增加節點5到逆時針方向的第一臺節點(節點2)之間的鍵受影響,本應對映到節點4而對映到節點5。因此,一致性Hash演算法最大程度抑制鍵的重新分佈

圖11 一致性Hash:新增伺服器

  圖12的雜湊表或許更加直觀,md5根據key值摘要一個128bit的雜湊值(校驗和),一般表示為32位的16進位制數,我們取雜湊值的第一位範圍將key對映到不同節點,如圖12左側表格所示。當新增一個節點5,把原本對映到節點4的一半資料對映到節點5,其他三個節點不受影響。但是引出資料在MC節點上分佈不均勻的問題,原本左側表格每個節點對映的資料量一樣,但是右側表格的節點4/5只有其他三個節點的一半資料量,導致節點4/5的頻寬和記憶體使用率一直不飽滿。

圖13 一致性Hash:引入虛擬節點   為此,引入虛擬節點,如圖13所示,左側表格中依md5值劃分為16個虛擬節點,每四個虛擬節點對映到一個物理節點。當增加物理節點5時,就從節點1/2/3各拿一個虛擬節點對映到物理節點5,這樣每個物理節點基本有3到4個虛擬節點的對映,快取資料分佈相對圖12右側表格均衡很多

圖13 一致性Hash:引入虛擬節點

一致性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資料。

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鈣化,有三種解決方案:

  1. 重啟Memcached例項,簡單粗暴,啟動後重新分配Slab class,但是如果是單點可能造成大量請求訪問資料庫,出現雪崩現象,衝跨資料庫。
  2. 隨機過期:過期淘汰策略也支援淘汰其他slab class的資料,twitter工程師採用隨機選擇一個Slab,釋放該Slab的所有快取資料,然後重新建立一個合適的Slab。
  3. 通過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快取的方法。類似下面的程式碼:
     1 static Lock reenLock = new ReentrantLock();
     2  
     3     public List<String> getData04() throws InterruptedException {
     4         List<String> result = new ArrayList<String>();
     5         // 從快取讀取資料
     6         result = getDataFromCache();
     7         if (result.isEmpty()) {
     8             if (reenLock.tryLock()) {
     9                 try {
    10                     System.out.println("我拿到鎖了,從DB獲取資料庫後寫入快取");
    11                     // 從資料庫查詢資料
    12                     result = getDataFromDB();
    13                     // 將查詢到的資料寫入快取
    14                     setDataToCache(result);
    15                 } finally {
    16                     reenLock.unlock();// 釋放鎖
    17                 }
    18  
    19             } else {
    20                 result = getDataFromCache();// 先查一下快取
    21                 if (result.isEmpty()) {
    22                     System.out.println("我沒拿到鎖,快取也沒資料,先小憩一下");
    23                     Thread.sleep(100);// 小憩一會兒
    24                     return getData04();// 重試
    25                 }
    26             }
    27         }
    28         return result;
    29     }

    還可以使用分級快取;採用 L1 (一級快取)和 L2(二級快取) 快取方式,L1 快取失效時間短,L2 快取失效時間長。 請求優先從 L1 快取獲取資料,如果 L1快取未命中則加鎖,只有 1 個執行緒獲取到鎖,這個執行緒再從資料庫中讀取資料並將資料再更新到到 L1 快取和 L2 快取中,而其他執行緒依舊從 L2 快取獲取資料並返回。這種方式,主要是通過避免快取同時失效並結合鎖機制實現。所以,當資料更新時,只能淘汰 L1 快取,不能同時將 L1 和 L2 中的快取同時淘汰。L2 快取中可能會存在髒資料,需要業務能夠容忍這種短時間的不一致。而且,這種方案可能會造成額外的快取空間浪費。

參考文章 https://blog.csdn.net/sanyaoxu_2/article/details/79472465 https://www.jianshu.com/p/049717570769