一蓄拣、引言
本文談及的是后臺業(yè)務服務緩存問題扬虚,在構建和優(yōu)化業(yè)務服務時,第一想到的應該是優(yōu)化數(shù)據(jù)庫球恤,比如數(shù)據(jù)庫模型設計辜昵、SQL結構化查詢語句優(yōu)化,慢查詢往往是系統(tǒng)性能殺手咽斧;第二便是使用緩存堪置。在應用中使用緩存的技術手段并不新鮮,計算機組成原理中我們已經學習過张惹,早期的計算機CPU通過fsb(中央處理器數(shù)據(jù)總線)直接與主存(內存)連接的方式導致CPU吞吐率下降舀锨,內存成為性能瓶頸,同時又由于內存訪問熱點數(shù)據(jù)的集中性宛逗,誕生了位于CPU和內存之間的高速緩存坎匿。這類比于高并發(fā)的場景,數(shù)據(jù)庫讀庫性能成為瓶頸雷激,需要在DB和高并發(fā)的調用之間加上緩存替蔬。緩存所帶來的性能提升效果更直接、高效屎暇。但是相比其他優(yōu)化手段承桥,緩存的使用并不是零成本的,任何系統(tǒng)使用緩存根悼,都會遇到兩大問題:數(shù)據(jù)不一致問題和系統(tǒng)復雜度增加凶异。深入業(yè)務蜀撑,才能有好的設計。在設計緩存的時候唠帝,要根據(jù)我們的業(yè)務特點屯掖,明白什么是不容易變的玄柏,什么是相對容易變襟衰,什么是真正觸達用戶的,哪些數(shù)據(jù)是關鍵粪摘。這樣才能發(fā)現(xiàn)真正應該cache的點瀑晒。
二、緩存設計與使用
衡量緩存設計好壞的衡量指標是緩存命中率徘意,緩存的命中率=緩存命中次數(shù)/請求次數(shù)苔悦,命中率越高,緩存的使用率越高椎咧。緩存的適用場景總的來說是混存訪問高頻讀低頻寫的數(shù)據(jù)玖详。相反的對數(shù)據(jù)要求苛刻,變化頻率高勤讽、訪問頻率低的數(shù)據(jù)不能緩存蟋座。設計和使用緩存首先要了解服務、DB脚牍、Cache的性能情況向臀,一般情況下,DB的響應是10ms級(優(yōu)化充足的情況下诸狭,SQL平均耗時1ms券膀。這是因為命中了索引,并且命中了MySQL緩沖池(內存中)驯遇。如果命中索引但不命中緩沖池芹彬,且查詢數(shù)據(jù)量不大,磁盤并發(fā)量不高叉庐,則大約耗時10ms(磁盤尋道)雀监。如果這些都不滿足,耗時就大了)眨唬,分布式緩存在ms級会前,進程內緩存在ns級。圖1
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? 圖1 緩存響應時間
本地緩存可用hashmap(注意并發(fā))匾竿、guava-cache(推薦)等實現(xiàn)瓦宜。對于一致性要求不高且緩存命中率較高的數(shù)據(jù)服務,本地緩存可以減少服務端調用次數(shù)岭妖;集中緩存又稱分布式緩存临庇,實現(xiàn)由redis(推薦)反璃、memcache等。
2.1 分布式緩存的設計與使用
緩存的設計模式一般分為四種Cache aside, Read through, Write through, Write behind caching假夺。
2.1.1 Cache Aside Pattern
Cache aside pattern是最常用的方式淮蜈,其具體邏輯如下:
失效:應用程序先從cache取數(shù)據(jù),沒有得到已卷,則從數(shù)據(jù)庫中取數(shù)據(jù)梧田,成功后,放到緩存中侧蘸。
命中:應用程序從cache中取數(shù)據(jù)裁眯,取到后返回。
更新:先把數(shù)據(jù)存到數(shù)據(jù)庫中讳癌,成功后穿稳,再讓緩存失效。
這是標準的design pattern晌坤,包括Facebook的論文《Scaling Memcache at Facebook》也使用了這個策略逢艘。為什么不是寫完數(shù)據(jù)庫后更新緩存?你可以看一下Quora上的這個問答《Why does Facebook use delete to remove the key-value pair in Memcached instead of updating the Memcached during write request to the backend?》骤菠,主要是怕兩個并發(fā)的寫操作導致臟數(shù)據(jù)它改。
那么,是不是Cache Aside這個就不會有并發(fā)問題了娩怎?不是的搔课,比如,一個是讀操作截亦,但是沒有命中緩存爬泥,然后就到數(shù)據(jù)庫中取數(shù)據(jù),此時來了一個寫操作崩瓤,寫完數(shù)據(jù)庫后袍啡,讓緩存失效,然后却桶,之前的那個讀操作再把老的數(shù)據(jù)放進去境输,這時會造成臟數(shù)據(jù)。
但颖系,這個case理論上會出現(xiàn)嗅剖,不過,實際上出現(xiàn)的概率可能非常低嘁扼,因為這個條件需要發(fā)生在讀緩存時緩存失效信粮,而且并發(fā)著有一個寫操作。而實際上數(shù)據(jù)庫的寫操作會比讀操作慢得多趁啸,而且還要鎖表强缘,而讀操作必需在寫操作前進入數(shù)據(jù)庫操作督惰,而又要晚于寫操作更新緩存,所有的這些條件都具備的概率基本并不大旅掂。
所以赏胚,這也就是Quora上的那個答案里說的,要么通過2PC或是Paxos協(xié)議保證一致性商虐,要么就是拼命的降低并發(fā)時臟數(shù)據(jù)的概率觉阅,而Facebook使用了這個降低概率的玩法,因為2PC太慢称龙,而Paxos太復雜留拾。當然戳晌,最好還是為緩存設置上過期時間鲫尊。
2.1.2Read/Write Through Pattern
我們可以看到,在上面的Cache Aside套路中沦偎,我們的應用代碼需要維護兩個數(shù)據(jù)存儲疫向,一個是緩存(Cache),一個是數(shù)據(jù)庫(Repository)豪嚎。所以搔驼,應用程序比較啰嗦。而Read/Write Through套路是把更新數(shù)據(jù)庫(Repository)的操作由緩存自己代理了侈询,所以舌涨,對于應用層來說,就簡單很多了扔字∧壹危可以理解為,應用認為后端就是一個單一的存儲革为,而存儲自己維護自己的Cache扭粱。
Read Through
Read Through 套路就是在查詢操作中更新緩存,也就是說震檩,當緩存失效的時候(過期或LRU換出)琢蛤,Cache Aside是由調用方負責把數(shù)據(jù)加載入緩存,而Read Through則用緩存服務自己來加載抛虏,從而對應用方是透明的博其。
Write Through
Write Through 套路和Read Through相仿,不過是在更新數(shù)據(jù)時發(fā)生迂猴。當有數(shù)據(jù)更新的時候慕淡,如果沒有命中緩存,直接更新數(shù)據(jù)庫错忱,然后返回儡率。如果命中了緩存挂据,則更新緩存,然后再由Cache自己更新數(shù)據(jù)庫(這是一個同步操作)
下圖自來Wikipedia的Cache詞條儿普。其中的Memory你可以理解為就是我們例子里的數(shù)據(jù)庫崎逃。
2.1.3Write Behind Caching Pattern
Write Behind 又叫 Write Back。一些了解Linux操作系統(tǒng)內核的同學對write back應該非常熟悉眉孩,這不就是Linux文件系統(tǒng)的Page Cache的算法嗎个绍?是的,你看基礎這玩意全都是相通的浪汪。在更新數(shù)據(jù)的時候巴柿,只更新緩存,不更新數(shù)據(jù)庫死遭,而我們的緩存會異步地批量更新數(shù)據(jù)庫广恢。這個設計的好處就是讓數(shù)據(jù)的I/O操作飛快無比(因為直接操作內存嘛 ),因為異步呀潭,write back還可以合并對同一個數(shù)據(jù)的多次操作钉迷,所以性能的提高是相當可觀的。
但是钠署,其帶來的問題是糠聪,數(shù)據(jù)不是強一致性的,而且可能會丟失(我們知道Unix/Linux非正常關機會導致數(shù)據(jù)丟失谐鼎,就是因為這個事)舰蟆。在軟件設計上,我們基本上不可能做出一個沒有缺陷的設計狸棍,就像算法設計中的時間換空間身害,空間換時間一個道理,有時候隔缀,強一致性和高性能题造,高可用和高性性是有沖突的。軟件設計從來都是取舍Trade-Off猾瘸。
另外界赔,Write Back實現(xiàn)邏輯比較復雜,因為他需要track有哪數(shù)據(jù)是被更新了的牵触,需要刷到持久層上淮悼。操作系統(tǒng)的write back會在僅當這個cache需要失效的時候,才會被真正持久起來揽思,比如袜腥,內存不夠了,或是進程退出了等情況钉汗,這又叫l(wèi)azy write羹令。
在wikipedia上有一張write back的流程圖鲤屡,基本邏輯如下:
2.2 使用本地緩存
本地緩存是應用程序在同一進程內的緩存,通常是ConcurrentHashMap福侈。優(yōu)點是耗時低得忽略不計酒来,缺點是占用本地內存、多機會冗余肪凛、數(shù)據(jù)不同步堰汉。當集群里每個App Server都有個緩存,會有很多數(shù)據(jù)是重復的伟墙,而且某臺機器更新了一條數(shù)據(jù)翘鸭,別的機器不知道,給應用帶來了額外同步的工作戳葵。
同步問題可以用消息隊列來解決就乓,一臺有更新,廣播消息給其他機器譬淳,準實時同步档址。
同步問題也可以用Redis 發(fā)布訂閱模式來通知其它機器進行同步
同步問題也可以用etcd的事件機制來解決多機同步問題
緩存除了加快訪問以外盹兢,也能提高負載能力邻梆。因為數(shù)據(jù)庫連接是有限的資源——不支持NIO(none block IO),每個連接同時只能服務一個線程绎秒,有多少連接就有多少并發(fā)浦妄。如果有慢查詢占住了連接,系統(tǒng)性能會急劇下降见芹!緩存就能減輕對數(shù)據(jù)庫連接的依賴剂娄。
本地緩存和分布式緩存各有千秋,一般建議用一致性更高的分布式緩存玄呛,當性能需要極端調優(yōu)時阅懦,使用本地緩存,或者整體緩存數(shù)據(jù)量不是很大徘铝,具有高頻訪問幾乎不修改的特點的耳胎,建議本地緩存,典型的如業(yè)務的標簽功能惕它。
三怕午、緩存淘汰策略
在只需要緩存一部分熱點數(shù)據(jù)的場景下,比如直播平臺的熱門房間淹魄,需要設計緩存淘汰策略郁惜。
3.1 FIFO(First In, First Out)
即先進先出,淘汰最早進來的混存數(shù)據(jù)甲锡,使用一個標準的隊列即可
3.2 LRU(Least RecentlyUsed)
即最近最少使用兆蕉,淘汰最近不使用的緩存數(shù)據(jù)羽戒。即按照最后被使用時間淘汰
和FIFO不同的是,需要對鏈表做基本模型虎韵,讀寫的時間復雜度是O(1)半醉,寫入新數(shù)據(jù)進入頭部,鏈表滿了數(shù)據(jù)從尾部淘汰;
最近時間被訪問的數(shù)據(jù)移動到頭部劝术,實現(xiàn)算法有很多缩多,如hashmap+雙向鏈表等等;
問題在于若是偶發(fā)性某些key被最近頻繁訪問,而非常態(tài)养晋,則數(shù)據(jù)受到污染衬吆。
2.LFU(Least Frequently used)
即最近使用次數(shù)最少的數(shù)據(jù)被淘汰,注意和LRU的區(qū)別在于LRU的淘汰規(guī)則是基于訪問時間绳泉。LFU按照使用次數(shù)淘汰數(shù)據(jù)
LFU中的每個數(shù)據(jù)塊都有一個引用計數(shù)逊抡,數(shù)據(jù)塊按照引用計數(shù)排序,若是恰好具有相同引用計數(shù)的數(shù)據(jù)塊則按照時間排序;
因為新加入的數(shù)據(jù)訪問次數(shù)為1零酪,所以插入到隊列尾部;
隊列中的數(shù)據(jù)被新訪問后冒嫡,引用計數(shù)增加,隊列重新排序;
當需要淘汰數(shù)據(jù)時四苇,將已經排序的列表最后的數(shù)據(jù)塊刪除;
有很明顯問題是若短時間內被頻繁訪問多次孝凌,比如訪問異常或者循環(huán)沒有控制住月腋,而后很長時間未使用蟀架,則此數(shù)據(jù)會因為頻率高而被錯誤的保留下來,沒有被淘汰榆骚。尤其對于新來的數(shù)據(jù)片拍,由于其起始的次數(shù)是1,所以即便被正常使用也會因為比不過老的數(shù)據(jù)而被淘汰妓肢。所以維基百科說純粹的LFU算法不經常單獨使用而是組合在其他策略中使用捌省。
四、緩存引入的問題
緩存系統(tǒng)一定程度上極大提升系統(tǒng)并發(fā)能力碉钠,但同樣也增加額外技術考慮因素纲缓,比如緩存雪崩、緩存穿透放钦、緩存更新與數(shù)據(jù)一致性
4.1色徘、緩存穿透
緩存穿透是指查詢的key不存在,從而緩存查詢不到而查詢了數(shù)據(jù)庫操禀。若是這樣的key恰好并發(fā)請求很大褂策,那么就會對數(shù)據(jù)庫造成不必要的壓力。比如黑客用一堆不存在的key訪問數(shù)據(jù),大量請求發(fā)送到數(shù)據(jù)庫斤寂,數(shù)據(jù)庫壓力過大而宕機耿焊,怎么解決呢?
1、把所有存在的key都存到另外一個存儲的Set集合里遍搞,查詢時可以先查詢key是否存在;
2罗侯、將這些key對應的值設置為null 丟到緩存里面去。后面再出現(xiàn)查詢這個key 的請求的時候溪猿,直接返回null钩杰,再根據(jù)業(yè)務需求設置過期時間。
3诊县、BloomFilter 類似于一個hbase set 用來判斷某個元素(key)是否存在于某個集合中讲弄。這種方式在大數(shù)據(jù)場景應用比較多,比如 Hbase 中使用它去判斷數(shù)據(jù)是否在磁盤上依痊。這種方案可以加在第1/2種方案中避除,在緩存之前加一層 BloomFilter ,在查詢的時候先去 BloomFilter 去查詢 key 是否存在胸嘁,如果不存在就直接返回瓶摆,存在再走查緩存 -> 查 DB。
4.2性宏、緩存擊穿
在高并發(fā)的系統(tǒng)中群井,大量的請求同時查詢一個 key 時,這個key剛好失效了衔沼,就會導致大量的請求都打到數(shù)據(jù)庫上面去蝌借。這種現(xiàn)象我們稱為緩存擊穿。造成緩存擊穿的原因是多個線程同時去查詢數(shù)據(jù)庫指蚁,那么我們可以在第一個查詢數(shù)據(jù)的請求上使用一個 互斥鎖。其他的線程進入等待狀態(tài)自晰,等第一個線程查詢到了數(shù)據(jù)凝化,然后做緩存。后面的線程進來發(fā)現(xiàn)已經有緩存了酬荞,就直接走緩存搓劫。
4.3、緩存雪崩
定義:緩存雪崩是指緩存系統(tǒng)失效混巧,導致大量請求同時進行數(shù)據(jù)回源枪向,導致數(shù)據(jù)源壓力驟增而崩潰。兩種情況會導致此問題:1咧党、多個緩存數(shù)據(jù)同時失效秘蛔;2、緩存系統(tǒng)崩潰,緩存同時失效
針對多個緩存數(shù)據(jù)同時失效的問題深员,可以將緩存時間離散化负蠕,根據(jù)緩存數(shù)據(jù)訪問規(guī)律和緩存數(shù)據(jù)不一致的敏感性要求來選擇緩存時間。
如果是第二個問題倦畅,緩存系統(tǒng)整體故障遮糖,則整個緩存系統(tǒng)不可用,大量回源請求叠赐,且由于緩存系統(tǒng)故障無法回寫緩存欲账,導致無法快速恢復。
這是緩存系統(tǒng)的引入芭概,在解決高性能敬惦、高并發(fā)的同時,引入新的故障點谈山。
考慮此問題俄删,應從事前、事故中奏路、事后不同階段考慮:
事前:增加緩存系統(tǒng)高可用方案設計畴椰,避免出現(xiàn)系統(tǒng)性故障
事故中:增加多級緩存,在單一緩存故障時鸽粉,仍有其他緩存系統(tǒng)可用斜脂,如之前項目中使用的三級緩存方案:內存級緩存->Memcached->Redis這樣的方案;啟用熔斷限流機制触机,只允許可承受流量帚戳,避免全部流量壓垮系統(tǒng)
事后:緩存數(shù)據(jù)持久化,在故障后快速恢復緩存系統(tǒng)
五儡首、總結
使用緩存會增加系統(tǒng)復雜性片任,應根據(jù)實際業(yè)務考慮是否需要使用以及使用分布式緩存還是本地緩存,設置合理的緩存策略可以提高系統(tǒng)的性能蔬胯,同時要合理的規(guī)避可能由于引入緩存而增加的風險对供。
參考文章:
https://juejin.im/post/5c9a67ac6fb9a070cb24bf34
https://fivezh.github.io/2019/02/11/cache-things/
https://segmentfault.com/a/1190000003858392
http://m.2cto.com/kf/201608/541332.html
http://www.reibang.com/p/46759c5dc5b1
http://www.reibang.com/p/c596e1050954