今天我們來聊一聊在大型分布式系統中,緩存應該怎么玩,從畢業到現在也有三年多了,大大小小的系統也經歷了幾十個,今天就從各個角度來討論一下,我們的不同的緩存應該怎么玩,才能用的高效。
我們團隊現在做的是直播類的產品,就拿抖音來說,比如說要開發一個榜一大哥、榜二大哥等各類大哥的排行榜單,要怎么開發,對于抖音這個dau十億級別的產品,緩存的設計肯定是家常便飯。
對于一個百萬、千萬級別的接口調用,若是沒有緩存的設計,直接打到數據持久層,那將是毀滅性的災難。之前我就經歷過,一個接口一天幾百萬次的調用,因為緩存的設計不嚴謹,緩存失效后,瞬間直接打在數據庫層,幸好有告警,及時修復,差點就領了p0的故障。
大體來說緩存分為客戶端緩存 和服務端緩存 ,客戶端緩存我們比較常見的就是瀏覽器緩存,也就是通過http進行控制的緩存。
客戶端緩存
基于請求-應答模式下,在大多數場景下客戶端都是通過https協議,請求后臺獲取數據,若是高頻的接口一天幾百萬次的調用,即使短時間的客戶端緩存也會帶來高效的收益。
因為客戶端到服務端要經過漫長的網絡鏈路,多變的網絡環境,數據包可能小的幾十K到大的數據包幾十M,這樣就能夠省去復雜多變的網絡請求的時間。
客戶端緩存減少了客戶端到服務端之間的通信次數以及成本,只要緩存可用,就能夠及時響應數據。
客戶端緩存常見的也就是瀏覽器緩存,簡而言之也就是http緩存,不知道大家在實際開發過程中有沒有用過這段代碼:
ResponseEntity.ok().cacheControl(CacheControl.maxAge(3,TimeUnit.SECONDS)).body()
看他的包,他就是屬于springframework 框架下http包下的一個工具類。
importorg.springframework.http.CacheControl; importorg.springframework.http.ResponseEntity;
在瀏覽器緩存中,http協議header有這么個key-value字段進行控制,叫做Cache-Control:max-age=30 ,max-age標志該資源在客戶端緩存多少秒。
假如max-age=0,表示不緩存數據,除了max-age可以控制數據的緩存狀態,還有以下三個屬性來控制緩存狀態no_store、no_cache、must-revalidate 。
no_store表示不緩存數據,每次都去服務器獲取 。
no_cache看起來也是不緩存的意思,但是它表示的意思是可以緩存的,只不過在使用緩存之前,都要去服務器驗證數據是否有效,是否過期,是否是最新版本 。
must-revalidate和no_cache有點類似,就是緩存不過期的話可以繼續使用,過期了就需要去服務器驗證一下 。
除了Cache-Control可以使用客戶端緩存,在http里面還有一個條件請求的header更加智能的使用客戶端緩存。
條件請求是基于響應報文返回的“Last-modified ”和“ETag ”實現的。Last-modified資源最后的一次修改時間,ETag則表示資源的唯一標識,你可以理解為只要資源修改后都不一樣了 。
再次請求的時候在請求頭里面就會帶上"If-None-Match:ETag返回的值 ",去驗證資源是否有效。
假如有效的話,就會返回"304 Not Modified ",表示緩存資源有效還可以繼續使用。
但是這種方式我較少使用,基本上使用Cache-Control就夠了,控制好實效的時間,一般的場景都是允許短暫的不一致。
除了客戶端能夠發送Cache-Control之外,客戶端也能夠發送Cache-Control兩者進行協商使用客戶端緩存的方案。
像我在瀏覽器訪問一個連接,在輸入框敲一下回車,Request的Headers里面就有Cache-Control:max-age = 0,表示不使用緩存,直接去后臺獲取數據。
所以Cache-Control來控制客戶端緩存也不太好控制,要兩者協商好,但是Cache-Control有一個好處就是可以控制CDN緩存。
服務端緩存
CDN緩存
上面聊到Cache-Control來控制客戶端緩存,它也同時影響CDN緩存,告訴CDN客戶緩存這個接口的數據。
CDN服務一般是由第三方提供的內容分發網絡服務,主要是用于緩存靜態的數據,比如:圖片、音頻、視頻,這些數據,都是不不變的,那么命中率就很高。
不用回源獲取數據,效率高,畢竟使用CDN的費用高,一般小公司也不會用,可能大公司采用。
CDN廠商花費大價錢在全國各地建立CDN的服務站點,用于用戶的就近訪問,減少響應時間。
所以這個對于應用層的來說是0開發的,一般只要在你的服務治理平臺針對某一個接口配置一下就好了。
除了緩存靜態數據,想一些動態數據,但是不會經常變的數據CDN也是可以緩存的,只不過可能緩存的時間設置的比較短,那么在高并發場井下取得的效益也是比較大的。
好了,關于CDN的也沒啥好說的,我還沒接觸過CDN開發,但是項目中使用到了,就是簡單而配置一下而已,等我接觸到開發,再和你們詳聊,CDN其實就是代理源站服務器緩存數據而已。
Redis緩存
在服務端緩存中Redis緩存可能是我們最常見的緩存,可以說Redis已經是各大公司常用的緩存中間件選型之首,也不為過。
我們項目中也是在使用Redis,只不過在Redis的層面上進行封裝,包括Redis哨兵、Redis分片 ,都是基于自己的業務情況下進行二次開發,然后供自己的業務使用。
在Redis的基礎數據類型中,有五大類型供大家選擇,包括String、List、Set、SortedSet、Hash 。這五種數據類型在百分之九十五的場景下都能夠解決,并且在這五種基本數據類型的底層運用了高效與省空間的數據結構,所以Redis的高性能之一也是因為有這些數據結構作為支撐。
圖片來源于Redis核心技術與實戰
比如:要實現一個排行榜單,凸顯直播間榜一大哥以及上榜大哥財大氣粗的實力。
那么這個明顯是按照某個字段進行排序,比如刷的抖音幣進行排序,那個在redis中List和Sorted Set都是可以實現有序的緩存。
List是按照寫入List順序進行存儲,而Sorted Set是按照某一個字段的權重來排序,并且可以查詢權重范圍內的數據。
對于我們的場景List可能不太適合,因為是對數據每次都是新產生的,并且按照時間來順序來寫入,List集合就比較適合。
我們的場景是某個在榜大哥不斷的刷禮物,就需要重新對他進行排序,并不是按照每次新增寫入緩存的順序取數,那么按照大哥刷的抖音幣的多少就可以當做權重來排序,很好的服務我們的場景,按照時間來排序的場景Sorted Set都可以來做。
還有一些聚合統計的場景,比如要統計兩個key數據集的交集、并集、差集可以使用set集合來做。
假如某一天你的老板讓你開發一個統計每天新增的用戶數據功能,其實那么也就兩個集合差集,一個set集合用戶保存所有用戶的id,一個set用戶保存當天用戶的id集合,然后當天用戶集合與所有用戶集合的差集就是新增的用戶集合。可以使用set集合中的SDIFFSTORE 命令進行實現。
還有一些二值統計的場景,也就是基于redis的Bitmap來統計,他并不記錄數據的本身,只能判斷是否存在,有沒有,Bitmap保存的是bit 位,所以億級別數量的存儲只要M級別的存儲單位就可以了,所以Bitmap非常的節省空間。
Bitmap中提供SETBIT設置bit位,以及GETBIT獲取某個bit位的值,還可以使用BITCOUNT統計bit位位1的值,比如可以統計某個月簽到場景。
Redis高性能的緩存給我們系統帶來了極大的性能提升,但是同時也會有一些類的問題,比如數據一致性的問題、緩存的三大問題(擊穿、穿透、雪崩)、與Redis網絡通信接口超時、Redis里面緩存的數據變多,操作時間復雜度大的導致Redis變慢 。
數據的一致性
數據一致性問題指的是緩存與數據庫的一致性問題,只要使用緩存就會有一致性問題,現在市場上都不會要求強一致性,都是追求最終一致性 。
緩存按照是否可寫分為讀寫緩存與只讀緩存 ,大部分是只讀緩存,現在我們來討論一下只讀緩存一致性問題。
只讀緩存的一致性問題包括以下以下兩種場景:
先更新數據庫,然后刪除緩存。
先刪除緩存,然后更新數據庫。
但是這兩種場景在高并發場景下都會有問題,先來看看第一種場景:先更新數據庫,然后刪除緩存。
這種場景也會有一致性問題,當我們更新了數據庫后,然后刪除緩存,刪除緩存失敗了,此時請求讀取的緩存還是舊的值。
這種情況下的解決方案就是重試 ,可以在應用層重試,也可以放入消息隊列里面重試,當重試次數達到最大的限制,就需要發送告警進行人工排查了。
或者設置比較短的緩存失效時間,短暫的不一致性,也是可以接受的。
第二種場景:先刪除緩存,然后再更新數據庫。在高并發場景下也有可能數據不一致。
假如線程A刪除了緩存,但是還沒有更新數據庫,然后線程B讀取緩存發現緩存缺失,然后從數據庫里面讀取舊值,并且緩存到Redis中,后面的請求就會從Redis中讀取舊值。
這種場景市面上推薦使用延遲雙刪的方案進行解決,就是在請求A刪除緩存后,更新數據庫,然后等一段時間刪除緩存,請求A的sleep的時間大于請求B的讀取數據寫入緩存的時間。
但是這種一般等的時間不調好估計,而且在高并發場景下,讓線程去等無疑是降低性能,這個通常是不允許的。
所以一般建議采用第一種方案,先更新數據庫,然后刪除緩存的方式。
我們項目中也會用到讀寫緩存,之前遇到一個需求就是,在直播過程中,主播的公屏的流水,要顯示用戶中獎的橫幅,也就是“恭喜某某在某某直播間抽中了XXX禮物”。
禮物的抽獎流水之前就已經發送消息隊列了,所以只要監聽對應抽獎流水topic就行了,然后將中獎的流水按照排序規則放入Redis中。然后客戶端從Redis中讀取,其實很簡單,流水也不需要存庫,只要展示就行了。
所以Redis的應用場景還是很多的,幾乎可以覆蓋開發中的95%以上的需求
緩存擊穿、穿透、雪崩
使用分布式緩存還會涉及到緩存的三大問題,也就是緩存擊穿、緩存穿透、緩存雪崩 。
緩存穿透 的解決方案有兩種:
緩存空對象:代碼維護較簡單,但是效果不好。
布隆過濾器:代碼維護復雜,效果很好。
緩存空對象是指當一個請求過來緩存中和數據庫中都不存在該請求的數據,第一次請求就會跳過緩存進行數據庫的訪問,并且訪問數據庫后返回為空,此時也將該空對象進行緩存。
若是再次進行訪問該空對象的時候,就會直接擊中緩存,而不是再次數據庫,緩存空對象實現的原理圖如下:
緩存空對象的實現代碼如下:
publicclassUserServiceImpl{ @Autowired UserDAOuserDAO; @Autowired RedisCacheredisCache; publicUserfindUser(Integerid){ Objectobject=redisCache.get(Integer.toString(id)); //緩存中存在,直接返回 if(object!=null){ //檢驗該對象是否為緩存空對象,是則直接返回null if(objectinstanceofNullValueResultDO){ returnnull; } return(User)object; }else{ //緩存中不存在,查詢數據庫 Useruser=userDAO.getUser(id); //存入緩存 if(user!=null){ redisCache.put(Integer.toString(id),user); }else{ //將空對象存進緩存 redisCache.put(Integer.toString(id),newNullValueResultDO()); } returnuser; } } }
布隆過濾器是一種基于概率的數據結構,主要用來判斷某個元素是否在集合內,它具有運行速度快(時間效率),占用內存小的優點(空間效率),但是有一定的誤識別率和刪除困難的問題。它只能告訴你某個元素一定不在集合內或可能在集合內。
在計算機科學中有一種思想:空間換時間,時間換空間。一般兩者是不可兼得,而布隆過濾器運行效率和空間大小都兼得,它是怎么做到的呢?
在布隆過濾器中引用了一個誤判率的概念,即它可能會把不屬于這個集合的元素認為可能屬于這個集合,但是不會把屬于這個集合的認為不屬于這個集合,布隆過濾器的特點如下:
一個非常大的二進制位數組 (數組里只有0和1)
若干個哈希函數
空間效率和查詢效率高
不存在漏報(False Negative):某個元素在某個集合中,肯定能報出來。
可能存在誤報(False Positive):某個元素不在某個集合中,可能也被爆出來。
不提供刪除方法,代碼維護困難。
位數組初始化都為0,它不存元素的具體值,當元素經過哈希函數哈希后的值(也就是數組下標)對應的數組位置值改為1。
實際布隆過濾器存儲數據和查詢數據的原理圖如下:
緩存擊穿是指一個key非常熱點,在不停的扛著大并發,大并發集中對這一個點進行訪問,當這個key在失效的瞬間,持續的大并發就穿破緩存,直接請求數據庫,瞬間對數據庫的訪問壓力增大。
緩存擊穿這里強調的是并發,造成緩存擊穿的原因有以下兩個:
該數據沒有人查詢過 ,第一次就大并發的訪問。(冷門數據)
添加到了緩存,reids有設置數據失效的時間 ,這條數據剛好失效,大并發訪問(熱點數據)
對于緩存擊穿的解決方案:
加鎖,用戶出現大并發訪問的時候,在查詢緩存的時候和查詢數據庫的過程加鎖,只能第一個進來的請求進行執行,當第一個請求把該數據放進緩存中,接下來的訪問就會直接集中緩存,防止了緩存擊穿。
不設置熱點key的失效時間
緩存雪崩 是指在某一個時間段,緩存集中過期失效。此刻無數的請求直接繞開緩存,直接請求數據庫。
造成緩存雪崩的可能原因有:
reids宕機
大部分數據失效
對于緩存雪崩的解決方案有以下兩種:
搭建高可用的集群,防止單機的redis宕機。
設置不同的過期時間,防止同一時間內大量的key失效。
接口超時&操作時間復雜度高
Redis數據第三方緩存中間件,要與Redis通信,必須經過網絡,那么經過網絡就有可能出現網絡超時的現象。
之前我們也出現過,某個機房因為網絡波動,出現了一系列的Redis查詢網絡超時的告警。
所以為了解決一時的網絡超時,我們有可能還要做好接口重試的機制,提高接口的可用性。
并且對Redis五種基本數據類型的底層數據結構熟悉的,Redis中對集合類型的操作HGETALL、SMEMBERS,以及對集合進行聚合統計 等,時間復雜度都是O(N)
那么Redis中存儲的數據越多,這個N就越大,操作的復雜度就越高,這就是所謂的bidkey現象,已經出現查詢阻塞了。
當然出現這種問題時,可以將bigkey按照一定規律進行拆分,這樣分成多個key進行存儲,查詢的效率就會變高。
當然Redis的數據分片解決方案也可以,將原來一個實例中存儲全量數據,按照16384進行crc16(key) % 16384 決定數據存儲于哪個槽中。
這樣擴展性也比較好,不過一般優先推薦拆分key的方案,這樣實現成本低,實現簡單。
緩存消息隊列玩法
有一些場景還可以使用消息隊列進行更新緩存,用戶更新數據,異步的發送消息隊列,消費者就可以監聽消息隊列的消息,消費消息后更新緩存。
因為有些數據的更新是需要發送消息隊列的,被其他消費者監聽使用,所以你只要監聽消息隊列就行了。
并且消息的隊列的消息由消息隊列的方式來保證,包括生產者可靠的發送消息隊列,通過ack以及重試保證,消息隊列本身通過持久化機制來保證,而消費者也是通過消費后手動ack來確認消息消費。
消息對壘更新緩存
定時任務
定時任務其實就是本地緩存了,在分布式系統中,定時任務就是每個服務中都會緩存一份,這樣數據不一致性也會加大。
但是在某些場景下,他帶來的收益也是非常可觀的,比如說某個場景下你要查詢一些安全中臺的白名單/黑名單列表,而且這些列表不會經常變,可能需求上線后只要配置一下就ok了,后面的更改頻率也是非常的低。
但是你的接口可能是高流量接口,每次用戶進來都會請求一次,進行判斷,而且用戶是千萬級別的,那有可能一天的請求就是上百萬次的請求。
那你有兩種選擇來請求安全中臺的白名單/黑名單列表,要么就是實時請求,要么就是定時任務請求本地緩存一份,然后查詢只要從本地獲取就行了。
在這種情況下肯定是定時任務請求,帶來的效益更大,在SprngBoot項目中開啟定時任務很簡單,只需要在你的啟動主類上加上這個注解:**@EnableScheduling**
然后在需要定時任務的執行類的方法上加上這個注解:**@Scheduled(cron = "0 0 2 * * ?")** , 其實就是cron表達式,執行的規律隔多久執行一次。
只要你的時間配置的足夠短,這樣數據也是近實時的,不會差太遠,你可以配置成30秒或者幾十秒執行一次,或者幾分鐘執行一次都可以,這個可以和產品進行協商,看產品可以接受多久的延遲。
然后,查詢的中臺的列表數據緩存在本地的一個map里面,用戶的uid作為map的key,然后后面需要查詢的時候,直接從map里面獲取。
這樣就不用每次請求過來都會實時的調用中臺的http/rpc接口查詢數據,直接從本地獲取提高效率,這也是空間換時間的思想。
接口超時
這里需要注意的是,就是要提高你的接口調用的可用性 ,畢竟中臺屬于另一個服務,那么服務之間涉及遠程調用,就有可能存在超時的現象。
那么你就要確保你的接口99.9%可用,對于接口超時,你可以就要設置接口重試 。
因為有時候可能是網絡的原因導致的一時超時,設置被調用方一時因為網絡抖動導致超時,那么重試成功的概率就可能比較高。
一般重試的次數會設置為2-3 次比較合理,除非網絡故障了或者接口一直調不通,這樣的話就需要及時告警,通知到開發人員,及時檢查到底是哪里的問題,確保好接口的兜底方案。
并且還要設置每次的超時時間,設置超時時間也是非常的重要,假如超時時間設置的太短,還沒有查出來就已經超時了,這樣就會導致頻繁超時,浪費資源。
要是設置的超時間太長,那么線程就會一直阻塞在那里等待調用的結果返回,這樣在高并發場景下,就會資源耗盡,系統崩潰。
所以我給你的建議就是可以結合線上服務所在服務器的配置以及qps進行配置,配置一個合理的超時時間,合理的時間內能夠超時返回并且不會導致資源耗盡。
重試這種機制,在很多中間件的思想中都會涉及到,比如:在分布式事務中2PC和3PC 。
2PC在第二階段提交失敗,那么只能不斷重試,直到所有參與者都成功(回滾或者提交成功)。
因為除了重試,沒有更好的辦法,只能不斷重試直到都成功,而且多數情況可能都是一時的網絡抖動的原因導致的,這樣重試成功的概率就非常高。
批量查詢數據
定時任務緩存其實也是一種集中式緩存 ,假如緩存的數據量也比較大,那么在接口調用時就需要批量獲取,但是一次性又不能查詢太多,一般嚴謹的中臺設計,都會都傳參進行參數校驗。
因為對于調用方完全是透明的,不可信任的 ,什么參數都有可能傳過來,假如調用方一下子查幾萬個或者是幾萬個數據集,那不是接口都爆了。
所以,必須要做好分批調用,調用方分批、分頁調用 ,中臺對參數做校驗一次只能查詢幾百個,這樣子去規定,保證接口的可用性。
調用方的偽代碼如下:
booleanend=true; intpage=1; intpageSize=500; while(end){ //設置好超時,失敗重試 Datadata=getData(page,pageSize); Mapmap=data.getDataMap(); //data里面的字段hashMore表示查詢下一個分頁是否還有數據 end=Objects.equals(data.getHasMore(),1); page++; }
本地緩存@Cacheable
@Cacheable是springframework下提供的緩存注解類,在spring中定義了 org.springframework.cache.Cache 和 org.springframework.cache.CacheManager 接口來統一實現cache。
除了@Cacheable用戶緩存數據,也可以使用@CachePut用于緩存更新,這兩個是比較常用的。
他們緩存的數據也是緩存在本地和定時任務一樣,除了使用@Cacheable還可使用谷歌研發的cache工具類LoadingCache ,他也是本地緩存的一種,并且可以設置緩存的大小,重新刷新的時間。
相對比Cacheable會更加方便,因為你發現Cacheable還缺少緩存時間和緩存更新的屬性配置實現,可能還需要自己再二級開發,比如加入緩存失效時間、多少秒后自動更新更新緩存,這樣Cacheable才能更加完善。
privatefinalLoadingCache>tagCache=CacheBuilder.newBuilder() .concurrencyLevel(4) .expireAfterWrite(5,TimeUnit.SECONDS) .initialCapacity(8) .maximumSize(10000) .build(newCacheLoader >(){ @Override publicList load(StringcacheKey){ returnget(cacheKey); } @Override publicListenableFuture >reload(Stringkey,List
oldValue){ ListenableFutureTask >task=ListenableFutureTask.create(()->get(key)); executorService.execute(task); returntask; } } );
相比@Cacheable就是代碼比較冗余,注解方式會更加直觀簡潔,不過LoadingCache的靈活性更高。
我們自己對Cacheable進行了擴展,加入了實效時間以及自動更新的方案,這樣的Cacheable更加適用于我們的業務。
總結
在項目中可能多種緩存并行使用,使用不同的緩存都要基于業務進行考量,包括成本,數據一致性,性能問題等,不同的緩存方式有不同的特點。
redis緩存分布式系統中共享數據,性能高效,擴展性強,redis可以基于數據分片、哨兵模式進行擴展,但是要額外的費用進行運維,并且引入第三方中間件,系統的復雜度也高,排查困難,而且每次都要經過網絡調用,有可能存在網絡超時的現象,數據丟失,所以要做好數據兼容,兜底方案。
本地緩存使用簡單方便,低成本,每個服務實例都會冗余一份數據,一致性問題加大,但是效率非常高效,不用通過網絡傳輸獲取數據。
一般我們的項目中都會分配6-8G的內存,所以一般本地緩存都夠使用的,所以一般能用本地緩存的話,都可以優先使用本地緩存。
一些場景不得不使用分布式緩存的,就是用Redis緩存來共享數據,綜合使用不同的緩存來解決項目中的問題。
從上面的幾種緩存方案中可以看到重試方案,重試是解決很多問題的重要手段之一,但是重試次數,重試的超時時間也要控制,防止資源耗盡,在大多數場景下,重試都可以解決,要是重試次數達到限制都不成功,就有可能是網絡故障或者接口問題,此時就需要應用發送告警通知開發人員進行排查,這是兜底方案。
客戶端緩存和CDN緩存這兩個對于服務端來說,比較少使用,一般公司都是用不到,大家可以把關注點放在服務端緩存中。
審核編輯:劉清
-
過濾器
+關注
關注
1文章
430瀏覽量
19653 -
Redis
+關注
關注
0文章
376瀏覽量
10891
原文標題:大型分布式系統中,緩存就該這么玩
文章出處:【微信號:芋道源碼,微信公眾號:芋道源碼】歡迎添加關注!文章轉載請注明出處。
發布評論請先 登錄
相關推薦
評論