335
技術社區[雲棲]
互聯網應用的緩存實踐分享
簡介
在當今互聯網應用中,緩存作為一把尖刀利器對應用的性能起著舉足輕重的作用。緩存的使用可以說無處不在,從應用請求的訪問路徑來看,用戶user -> 瀏覽器緩存 -> 反向代理緩存-> WEB服務器緩存 -> 應用程序緩存 -> 數據庫緩存等,幾乎每條鏈路都充斥著緩存的使用,當然交換機,網絡適配器,硬盤上也有Cache 但這不是我們要討論的範圍。今天我們討論的“緩存”,自然就是“用空間換時間”的算法。緩存就是把一些數據暫時存放於某些地方,可能是內存,也有可能硬盤。總之,目的就是為了避免某些耗時的操作。我們常見的耗時的操作,比如數據庫的查詢、一些數據的計算結果,或者是為了減輕服務器的壓力。其實減輕壓力也是因查詢或計算,雖然短耗時,但操作很頻繁,累加起來也很長,造成嚴重排隊等情況,服務器抗不住。
緩存介質
雖然從硬件介質上來看,無非就是內存和硬盤兩種,但從技術上,可以分成內存、硬盤文件、數據庫。
- 內存 將緩存存儲於內存中是最快的選擇,無需額外的I/O開銷,但是內存的缺點是沒有持久化落地物理磁盤,一旦應用異常break down而重新啟動,數據很難或者無法複原。
- 硬盤 一般來說,很多緩存框架會結合使用內存和硬盤,在內存分配空間滿了或是在異常的情況下,可以被動或主動的將內存空間數據持久化到硬盤中,達到釋放空間或備份數據的目的。
- 數據庫 前麵有提到,增加緩存的策略的目的之一就是為了減少數據庫的I/O壓力。這裏所指的數據庫隻是簡單的key-value存儲結構的特殊NOSQL數據庫(如BerkeleyDB和Redis),響應速度和吞吐量都遠遠高於我們常用的關係型數據庫等。
緩存命中率
緩存命中率通常指的是緩存查詢命中總數與緩存查詢總數的比率,應用緩存命中率越高越好,這是衡量緩存使用是否良好的重要指標。
緩存回收策略
緩存的回收策略在分布式緩存中類型主要有如下幾種
1. 基於時間
TTL:存活期,一條緩存自創建時間起多久後失效
TTI:空閑期,一條緩存自最後讀取或更新起多久後失效
2. 基於空間
通常指的是設置存儲空間大小,比如TAIR申請時空間大小,超過這個閾值後,會按照一定的策略算法移除數據。
3. 基於容量
通常指的是設置緩存條目數量大小,超過這個閾值後,會按照一定的策略算法移除數據。
緩存係統的整體回收首先根據時間進行移除過期數據,如果超過空間或者容量設置的閾值,會根據相應的算法移除數據,移除數據的算法主要有LRU、FIFO、LFU,最常用的算法是LRU,分布式緩存memcached、redis以及tair都支持該算法,本地緩存guava cache、ehcache也同樣支持LRU算法。
數據淘汰算法簡要介紹:
FIFO(first in first out)
先進先出策略,最先進入緩存的數據在緩存空間不夠的情況下(超出最大元素限製)會被優先被清除掉,以騰出新的空間接受新的數據。策略算法主要比較緩存元素的創建時間。在數據實效性要求場景下可選擇該類策略,優先保障最新數據可用。LFU(less frequently used)
最少使用策略,無論是否過期,根據元素的被使用次數判斷,清除使用次數較少的元素釋放空間。策略算法主要比較元素的hitCount(命中次數)。在保證高頻數據有效性場景下,可選擇這類策略。LRU(least recently used)
最近最少使用策略,無論是否過期,根據元素最後一次被使用的時間戳,清除最遠使用時間戳的元素釋放空間。策略算法主要比較元素最近一次被get使用時間。在熱點數據場景下較適用,優先保證熱點數據的有效性。
緩存使用場景與分類
使用緩存的目的提高係統的整體性能,緩存的工作機製是先從緩存中讀取數據,如果沒有,則再從慢速設備上讀取實際數據並同步到緩存。那些經常查詢的數據、頻繁訪問的數據、熱點數據、IO瓶頸數據、計算昂貴的數據、符合五分鍾法則和局部性原理的數據都可以進行緩存。
在互聯網應用中常見的緩存的場景主要:
- 數據庫緩存: 隨著業務量的上升,數據庫存儲的數據量越來越大,並發請求逐漸增大,隨之而來的問題就是數據庫係統的負載升高,響應延遲下降,嚴重的時候,甚至有可能因此而導致服務中斷,這時啟用緩存利器可以提高係統性能。
- 臨時數據存儲: 應用程序需要維護大量臨時數據,例如計數器、分布式鎖、用戶session等,將臨時數據存儲在分布式緩存中,可以降低內存管理的開銷,改進應用程序工作負載。
在互聯網應用中,從應用與緩存耦合度角度緩存主要分為本地緩存、分布式緩存兩大類。
本地緩存:指的是在應用中的緩存組件,其最大的優點是應用和cache是在同一個進程內部,請求緩存非常快速,沒有過多的網絡開銷等,在單應用不需要集群支持或者集群情況下各節點無需互相通知的場景下使用本地緩存較合適;同時,它的缺點也是應為緩存跟應用程序耦合,多個應用程序無法直接的共享緩存,各應用或集群的各節點都需要維護自己的單獨緩存,對內存是一種浪費。
Guava Cache、Ehcache、MapDB都可以實現JAVA堆內存本地緩存,談堆內存其實JAVA還支持堆外內存,Ehcache 3.x、MapDB 3.x也同樣支持堆外內存,堆外內存意味著把內存對象分配在Java虛擬機的堆以外的內存,這些內存直接受操作係統管理(而不是虛擬機),Netty就是使用堆外內存來管理內存,建議慎用堆外內存,使用不當容易導致OOM,關於堆外內存與堆內存接下來不做重點介紹。分布式緩存:指的是與應用分離的緩存組件或服務,其最大的優點是自身就是一個獨立的應用,與本地應用隔離,多個應用可直接的共享緩存,像memcached、redis、tair都是分布式緩存。
緩存使用實踐
緩存的使用也是講究一定技巧性,如果使用不當會導致數據一致性問題、緩存被穿透導致應用雪崩等。
上麵講到局部性原理,簡單介紹下與緩存相關的局部性原理:
- 時間局部性(temporal locality):數據將被多次訪問
- 空間局部性(spatial locality):鄰近數據將被訪問
基於局部性原理,緩存在設計上需要考慮許多的因素:
- 緩存關聯性(cache associativity)
- 寫策略(writing policy)
- 替換策略(cache replacement)
- 緩存一致性(cache coherency)
- cache失效可能引發的dog-piling效應(cache stampede)
- .....
接下來將會根據以上幾點介紹緩存使用的一些實踐。
緩存與DB數據一致性
數據的更新與緩存同步,沒有高科技含量,但要做好並不容易,有些場景需要做到實時一致性,有些場景需要做到最終一致性。
如果要做到強一致性,可以采取以下方案:
數據庫更新後,刪除緩存。
這種方式的優點是實現簡單,缺點是刪除緩存後,如果有多個查詢請求並發過來,都發現緩存中沒數據,都會將請求落到數據庫上,導致數據庫壓力瞬間增加。數據庫更新後,更新緩存。
這是對刪除方式的改進,但也有缺點,寫入前要多一次查詢,在部分場景下是沒法使用的,比如分頁查詢場景,各種請求參數組合很多,應用無法知道有多少種key,自然無法主動寫入,隻能等緩存失效。
以上兩種實時同步緩存機製,先操作數據庫然後操作緩存,因異構數據存儲無法通過事務保證一致。當然緩存涉及到網絡IO開銷,如果連接分布式緩存超時也需要考慮,否則會出現事務超時,導致應用線程掛起。
如果要做到最終一致性,可以采取以下方案:
- MQ異步刷新、定時刷新 采用MQ異步消息機製刷新,如果更新失敗要有適當的補償機製。所有需要更新的對象存儲到一張定時任務表,定時任務掃描任務表異步更新。這兩種更新機製不能保證查詢緩存同DB的一致性,但是能夠保證最終一致性。
- 自動失效 合理設置緩存失效時間,需根據業務場景設置每個緩存的失效時間,一致性要求越高,自然失效時間也要越短。
緩存並發
緩存過期後將嚐試從後端數據庫獲取數據,這是一個看似合理的流程。但是,在高並發場景下,有可能多個請求並發的去從數據庫獲取數據,對後端數據庫造成極大的衝擊,甚至導致 “雪崩”現象。此外,當某個緩存key在被更新時,同時也可能被大量請求在獲取,這也會導致一致性的問題。那如何避免類似問題呢?我們會想到類似"鎖"的機製(可重入鎖),在緩存更新或者過期的情況下,先嚐試獲取到鎖,當更新或者從數據庫獲取完成後再釋放鎖,其他的請求隻需要犧牲一定的等待時間,即可直接從緩存中繼續獲取數據。
緩存被穿透
在高並發場景下,如果某一個key被高並發訪問,沒有被命中,出於對容錯性考慮,會嚐試去從後端數據庫中獲取,從而導致了大量請求達到數據庫,而當該key對應的數據本身就是空的情況下,這就導致數據庫中並發的去執行了很多不必要的查詢操作,從而導致巨大衝擊和壓力。
我們在應用中使用緩存的時候,很可能就是使用的如下代碼所表示的邏輯的方式。 先獲取緩存中的數據,如果為空則查詢數據庫或者其他方式獲取數據,然後再存入緩存,返回數據,如下偽代碼。
data=cache.get(key);
if(data=null || !isValid(data)){
sql="SELECT ......";
data=db.query(sql);
//data可能為null
if(data != null){
cache.set(key,data,expire);
}
}
return data;
相信大多數人會認為這段代碼沒有問題,很多人也是這麼去寫的。
問題:
當key的內容在數據庫也不存在時,那麼上麵代碼中的data始終為null,緩存中也始終沒有數據,如果這個key的請求突然變得很大(很多情況下都會發生,比如查詢請求不存在的數據),那麼將會有大量的請求繞過緩存,直接到了後端數據庫,對數據庫的IOQPS造成過大的衝擊,最後很可能導致係統崩潰。
解決方案:
1. 緩存空對象
解決這個問題的辦法就是當數據庫查詢到null時,我們也應該把null進行相應的緩存。
比如數據庫返回的是一個list,那麼我們可以存入一個空的list來處理cache.set(key,new List(),expire)。
同時,也需要保證緩存數據的時效性。這種方式實現起來成本較低,比較適合命中不高,但可能被頻繁更新的數據。
2. 單獨做過濾處理
對所有可能對應數據為空的key進行統一的存放,並在請求前做攔截,這樣避免請求穿透到後端數據庫。這種方式實現起來相對複雜,比較適合命中不高,但是更新不頻繁的數據。
比較常用的方案是通過Bloom Filter提前攔截,Bloom Filter是一個空間效率很高的隨機數據結構,它由一個位數組和一組hash映射函數組成。Bloom Filter可以用於檢索一個元素是否在一個集合中,它的優點是空間效率和查詢時間都遠遠超過一般的算法,缺點是有一定的誤識別率。因此Bloom Filter不適合那些“零錯誤”的應用場合。而在能容忍低錯誤率的應用場合下,Bloom Filter通過極少的錯誤換取了存儲空間的極大節省。
像Guava Cache、Google的bigtable都有類似bloomfilter實現,關於bloomfilter具體可以參考:https://www.javacodegeeks.com/2012/11/bloom-filter-implementation-in-java-on-github.html
以上兩種方案均可以保障的緩存被穿透問題,第一種方案更簡單,但需要額外占一些緩存空間。第二種方案複雜一些,但是占用緩存空間少。
熱點緩存
比如key XXX對應的數據訪問量特別大,但是XXX在緩存中是有失效時間的。一旦緩存失效,會有N多線程並發的去請求數據庫,然後更新緩存,這個時候會導致係統壓力過大。通常有這麼幾種解決方法:
1. 加鎖,同時隻允許一個線程去查詢數據庫並更新緩存
2. 緩存不加失效時間,但後台有個異步線程定期的去更新它
3. 引入類似於Hystrix的熔斷機製,隻允許一定量的請求去請求數據庫並更新緩存
比如緩存使用memcached、redis,可以考慮如上方案針對熱點數據。如果使用tair緩存的話,tair提供了hotkey的解決方案,主要原理開啟本地LocalCache功能,每次寫操作會自動強製刪除 Localcache 裏存在的 key,讀操作後會自動從 Localcache 裏讀取, Localcache 中不存在則從服務端獲取,成功後存儲到 Localcache 裏。在 Hotkey 防禦係統中,客戶端要開啟 hot-running 模式,該模式下隻能緩存帶熱點標記的 key,Localcache 中非熱點的 key 將逐步被淘汰。即一旦開啟客戶端的該模式,會強製改變 Localcache 的工作模式。
數據緩存模式通常有懶加載和預加載兩種模式,但是對於熱點緩存最好采取預加載模式,筆者曾經遇到應用係統剛發布完就直接掛掉的案例,一些熱點數據查詢在高並發場景下還沒有加載進來,請求流量就湧入進來。
緩存大對象
某些場景下我們想要把一些大對象緩存起來,因為產生一次大對象的代價很大,我們需要產生一次,盡可能的多次使用,從而提升QPS。
通常的解決方案是對數據進行壓縮,壓縮可以在客戶端進行壓縮,緩存服務端也可以壓縮,像memcached的話需要在客戶端進行壓縮,可以采用gzip壓縮算法進行壓縮,像tair的話如果value超過一定的閾值服務端會自動對value進行壓縮。
最後更新:2017-07-28 14:32:48