緩存穿透問題導(dǎo)致Facebook史上嚴(yán)重事故之一
2010年9月23,這個(gè)世界上最大的社交平臺(tái)項(xiàng)目facebook,遭遇了最嚴(yán)重宕機(jī)故障之一,以至于facebook網(wǎng)站4個(gè)小時(shí)后才恢復(fù)運(yùn)行。而且這次事故非常極端,工程師不得不先讓facebook下線,才能恢復(fù)。雖然10年前的facebook遠(yuǎn)沒有現(xiàn)在這么大,不過仍然有超過10億用戶,人們?nèi)witter上抱怨或者取笑這次故障。
那么,導(dǎo)致是什么原因?qū)е逻@次facebook宕機(jī)呢?
Today we made a change to the persistent copy of a configuration value that was interpreted as invalid. This meant that every single client saw the invalid value and attempted to fix it. Because the fix involves making a query to a cluster of databases, that cluster was quickly overwhelmed by hundreds of thousands of queries a second.
一個(gè)錯(cuò)誤的配置變更,導(dǎo)致大量的請(qǐng)求擊穿緩存,直達(dá)數(shù)據(jù)庫。我們把這種現(xiàn)象稱之為cache stampede,wiki地址:https://en.wikipedia.org/wiki/Cache_stampede。這在技術(shù)行業(yè)是一個(gè)非常普遍的問題,很多公司都出現(xiàn)過類似的事故,無數(shù)工程師為了不讓自己的項(xiàng)目遭遇這樣的問題做了大量的工作。
1、什么是緩存踩踏
cache stampede是指很多線程嘗試并行訪問緩存,如果緩存中不存在要訪問的數(shù)據(jù),那么這時(shí)候,線程一般會(huì)請(qǐng)求數(shù)據(jù)庫獲取它們需要的數(shù)據(jù)(所以cache stampede可以翻譯成緩存踩踏。和緩存穿透有點(diǎn)不一樣,Cache Stampede的重點(diǎn)是很多的線程穿透緩存)。
緩存踩踏破壞性這么大的主要原因是,它可能會(huì)導(dǎo)致故障雪崩,也就是說一個(gè)故障接著一個(gè)故障:
- 大量線程并發(fā)請(qǐng)求沒有從緩存中獲取到數(shù)據(jù),導(dǎo)致這些請(qǐng)求都會(huì)落到數(shù)據(jù)庫上。
- 數(shù)據(jù)庫由于恐怖的CPU毛刺而宕機(jī),從而導(dǎo)致大量的超時(shí)錯(cuò)誤。
- 請(qǐng)求線程接收到超時(shí)后,又不斷重試請(qǐng)求,從而又導(dǎo)致新一輪的災(zāi)難。
- 反反復(fù)復(fù),無窮無盡。
需要說明的是,即使你沒有 Facebook 那樣的規(guī)模,也會(huì)遇到這個(gè)問題,因?yàn)樗c規(guī)模無關(guān)。這個(gè)問題一直困擾著初創(chuàng)公司和科技巨頭。
2、如何阻止緩存踩踏
這是個(gè)很好的問題,在這篇文章中,我們將探索不同的策略來緩解甚至阻止緩存踩踏的出現(xiàn)。畢竟,你也不想等到你自己的服務(wù)出現(xiàn)問題后,才想到要學(xué)習(xí)如何預(yù)防。
2.1 增加更多的緩存
一個(gè)很簡(jiǎn)單的方法就是增加更多的緩存,它的原理有點(diǎn)類似操作系統(tǒng)的多級(jí)緩存。操作系統(tǒng)使用了一個(gè)緩存層次結(jié)構(gòu)(L1、L2、L3),為了更快速的訪問。參考操作系統(tǒng),你也能在你的應(yīng)用中引入多級(jí)緩存。比如本地內(nèi)存緩存叫做L1緩存(例如Guava Cache,Caffeine),遠(yuǎn)程緩存叫做L2緩存(例如Redis,memcached):
這個(gè)策略對(duì)那些頻繁訪問的數(shù)據(jù)來說是非常有用的。即使L2緩存中的Key失效了,L1緩存中仍然有值,能夠擋住大量請(qǐng)求不會(huì)打到數(shù)據(jù)庫上。
然后,這種方法需要做一些取舍,在應(yīng)用服務(wù)器本地緩存中緩存數(shù)據(jù)可能會(huì)導(dǎo)致OOM。在使用本地緩存的時(shí)候要非常小心,尤其當(dāng)你會(huì)緩存一些大量數(shù)據(jù)的時(shí)候。
另外,這個(gè)策略在接下來我要說的這種情況下仍然沒有作用。例如,當(dāng)一個(gè)有很多粉絲的大V上傳了一個(gè)新的照片或者視頻到他們的社交賬號(hào)上,這時(shí)候大量粉絲被提醒大V有新的內(nèi)容發(fā)布,這時(shí)候粉絲會(huì)集中在相同的時(shí)間點(diǎn)上登陸社交平臺(tái)查看新的內(nèi)容。但是可能大V發(fā)送的新內(nèi)容數(shù)據(jù)還沒有加載到緩存中,這就會(huì)導(dǎo)致可怕的緩存踩踏。那么,我們還能做什么呢?
2.2 鎖和Promise
緩存踩踏的核心問題是競(jìng)態(tài)條件(race condition),即很多的線程爭(zhēng)奪共享資源。只不過這里爭(zhēng)奪的共享資源是緩存。
通常在高并發(fā)的系統(tǒng)中,一種阻止共享資源競(jìng)態(tài)的方法是加鎖。一般來講,鎖是用在相同機(jī)器上的不同線程,不過也可以使用分布式鎖來應(yīng)對(duì)不同機(jī)器對(duì)共享資源的競(jìng)爭(zhēng)(參考redis分布式鎖:http://redis.cn/topics/distlock.html)。
通過給緩存KEY加鎖,就會(huì)在同一時(shí)間只有一個(gè)調(diào)用者能訪問爭(zhēng)奪的緩存。如果KEY不存在或者已經(jīng)過期,調(diào)用者就會(huì)拿到鎖。這時(shí)候其他爭(zhēng)奪的處理線程必須等待直到這個(gè)鎖被釋放。
用鎖來解決這個(gè)問題,它也會(huì)引入另一個(gè)問題:系統(tǒng)如何處理所有正在等待鎖釋放的那些線程?
你想嘗試自旋鎖(spinlock),讓這些線程持續(xù)不斷的輪詢?nèi)カ@取鎖?這就會(huì)導(dǎo)致出現(xiàn)非常busy的場(chǎng)景,消耗大量的CPU?;蛘咦尵€程在檢查鎖是否可用之前隨機(jī)等待一段時(shí)間?這樣的話,你又會(huì)碰到驚群效應(yīng)問題(thundering herd problem)。
引入退避和抖動(dòng)機(jī)制來防止驚群效應(yīng)?這可能行得通,但還有另外一個(gè)問題。持有鎖的線程必須重新計(jì)算值,并在釋放鎖之前更新緩存鍵。這個(gè)過程可能需要耗費(fèi)一點(diǎn)時(shí)間,特別是當(dāng)計(jì)算成本很高或存在網(wǎng)絡(luò)問題時(shí),如果因?yàn)橛?jì)算緩存而耗盡了可用的連接池,仍然可能導(dǎo)致宕機(jī)。
- backoff-and-jitter
幸運(yùn)的是,一些大公司也碰到過這樣的問題,他們使用promises來解決這樣的問題。
2.3 Promises如何防止自旋
引用instagram工程師博客(Thundering Herds & Promises)中的內(nèi)容:
在instagram, 當(dāng)我們啟用一個(gè)新的集群,并且因?yàn)榧褐械木彺媸强盏模覀兙蜁?huì)碰到緩存stampede問題。這時(shí)候,我們就會(huì)用promises來解決這個(gè)問題。它的核心思想是:不緩存實(shí)際的值,而是緩存一個(gè)promise,這個(gè)promise最終會(huì)提供我們需要的值。當(dāng)我們使用緩存時(shí),如果碰到一個(gè)不存在的KEY,我們不立即去數(shù)據(jù)庫中查詢,而是創(chuàng)建一個(gè)promise然后放到緩存中,這個(gè)緩存中的promise會(huì)去查詢數(shù)據(jù)庫,其他的并發(fā)請(qǐng)求發(fā)現(xiàn)這個(gè)promise就不會(huì)把請(qǐng)求打到數(shù)據(jù)庫上,它們都會(huì)等待第一個(gè)線程放進(jìn)去的promise去數(shù)據(jù)庫中查詢結(jié)果。
通過緩存promise而不是實(shí)際的值,就不會(huì)自旋鎖了。第一個(gè)線程發(fā)現(xiàn)緩存中沒有數(shù)據(jù),就會(huì)用原子性的操作創(chuàng)建并緩存一個(gè)異步的promise,所有后續(xù)的請(qǐng)求都能立即返回這個(gè)promise:
你仍然需要使用鎖來防止多個(gè)線程訪問緩存KEY,假設(shè)創(chuàng)建 Promise 是一個(gè)近乎即時(shí)的操作,那么線程停留在自旋鎖中的時(shí)間長(zhǎng)度就可以忽略不計(jì)了。但是,如果重新計(jì)算緩存數(shù)據(jù)需要相當(dāng)長(zhǎng)的時(shí)間,那該怎么辦?即使線程能夠立即獲取到緩存的 Promise,它們?nèi)匀恍枰却惒竭M(jìn)程完成后才能將數(shù)據(jù)返回。雖然這種場(chǎng)景不一定會(huì)導(dǎo)致宕機(jī),但仍然會(huì)導(dǎo)致尾部延遲和影響整體用戶體驗(yàn)。如果保持較低的尾部延遲對(duì)于應(yīng)用程序來說很重要,那么就需要考慮另外一種策略。
2.4 預(yù)先重新計(jì)算
預(yù)先重新計(jì)算(也被稱為提前過期)原理很簡(jiǎn)單:在緩存KEY失效發(fā)生前,重新計(jì)算緩存的值然后延長(zhǎng)失效時(shí)間,這就能確保緩存總是最新的,緩存缺失的問題也永遠(yuǎn)不會(huì)發(fā)生。
最簡(jiǎn)單的實(shí)現(xiàn)方式就是開啟一個(gè)后臺(tái)處理線程,或者一個(gè)定時(shí)任務(wù)。例如。假設(shè)緩存KEY過期時(shí)間時(shí)一個(gè)小時(shí),它需要花兩分鐘來計(jì)算值。那么,定時(shí)任務(wù)可以在過期時(shí)間到來之前的5分鐘運(yùn)行,更新緩存的值并延長(zhǎng)失效時(shí)間一個(gè)小時(shí)。
雖然原理非常簡(jiǎn)單,但是有一個(gè)明顯的缺點(diǎn),除非你很清楚哪個(gè)緩存KEY會(huì)被使用,否則你需要重新計(jì)算緩存中每個(gè)KEY的值,這將是一個(gè)非常耗時(shí)的過程。而且如果考慮到高可用,某個(gè)節(jié)點(diǎn)上計(jì)算任務(wù)失敗了,還需要轉(zhuǎn)移到另一個(gè)可用的節(jié)點(diǎn)上繼續(xù)計(jì)算。
基于這個(gè)原因,生產(chǎn)環(huán)境上很少有這么做的。當(dāng)然,也有一個(gè)例外。
2.5 概率性重新計(jì)算
在2015年,一組研究員發(fā)布了一份白皮書 Optimal Probabilistic Cache Stampede Prevention,即最優(yōu)概率性預(yù)防緩存踩踏。在這份白皮書中,他們描述了一個(gè)算法來預(yù)測(cè)在緩存失效之前,什么時(shí)候需要重新計(jì)算緩存的值。這里涉及到很多數(shù)學(xué)理論,但是可以做一個(gè)簡(jiǎn)單的總結(jié):
currentTime - ( timeToCompute * beta * log(rand()) ) > expiry
這個(gè)公式中各變量的含義如下所示:
- currentTime 表示當(dāng)前時(shí)間;
- timeToCompute 表示重新計(jì)算緩存值需要的時(shí)間;
- beta是一個(gè)大于0的非負(fù)數(shù),默認(rèn)為1,可配置;
- rand() 一個(gè)返回0~1之間隨機(jī)數(shù)的方法;
- expiry 下一次需要設(shè)置的失效時(shí)間戳;
它的思想是,每次線程從緩存中獲取數(shù)據(jù)時(shí),它都需要運(yùn)行這個(gè)算法,如果返回true,那么這個(gè)線程將主動(dòng)去重新計(jì)算緩存值。而且離失效時(shí)間越近,這個(gè)算法返回true的概率就越大。
這個(gè)策略不是很好理解,但是實(shí)現(xiàn)非常簡(jiǎn)單,不需要考慮失敗轉(zhuǎn)移,也不需要到重新計(jì)算緩存中每一個(gè)KEY的值。當(dāng)然,預(yù)先重計(jì)算假設(shè)有一個(gè)值需要重新計(jì)算,它本身并不能防止其他線程引起緩存踩踏問題。為此,你需要將其與鎖和 Promise 結(jié)合起來使用。
3、如何停止正在發(fā)生的緩存踩踏
facebook緩存踩踏之所以如此嚴(yán)重的原因之一是,即使當(dāng)工程師找到了解決方案,他們并不能通過部署來解決。因?yàn)椴忍と栽诶^續(xù)。事后診斷報(bào)告提到:
更糟糕的是,每次客戶端接收到數(shù)據(jù)庫查詢錯(cuò)誤時(shí),都會(huì)把它當(dāng)作一個(gè)無效的值,然后就會(huì)刪除緩存中相關(guān)的KEY,這就意味著即使原來的問題被修復(fù)了,但是查詢還在繼續(xù)。一旦數(shù)據(jù)庫無法正確響應(yīng)某一部分請(qǐng)求,那么就會(huì)導(dǎo)致緩存KEY被刪除,從而引起更多的請(qǐng)求打到數(shù)據(jù)庫上。
所幸的是,有一種已知的模型能處理這個(gè)問題。
熔斷器
這個(gè)想法不是很新的事情,2007年Michael Nygard發(fā)布了 Release It!后就慢慢流行了。熔斷器(Circuit breaking)的原理非常簡(jiǎn)單,我們會(huì)在熔斷器中封裝一個(gè)方法,當(dāng)監(jiān)測(cè)到失敗時(shí)進(jìn)行計(jì)數(shù),并且一旦失敗達(dá)到一定閾值時(shí),調(diào)用就會(huì)收到熔斷器直接返回的錯(cuò)誤碼,而不會(huì)調(diào)用到受到熔斷器保護(hù)的地方,例如數(shù)據(jù)庫等。如下圖所示,第一次supplier能正常服務(wù),但是第二次、第三次訪問都是超時(shí)。達(dá)到熔斷器閾值后,第四次直接返回錯(cuò)誤碼,而不會(huì)將請(qǐng)求直接打給supplier:
熔斷器是響應(yīng)式的,所以它不能阻止宕機(jī)。不過它可以防止連鎖故障的發(fā)生。而且它提供了一個(gè)終止開關(guān),當(dāng)事態(tài)已經(jīng)徹底失控時(shí)可以開啟。如果 Facebook 使用了熔斷機(jī)制,就可以避免讓整個(gè)網(wǎng)站癱瘓下線。2010年的時(shí)候熔斷器還不是很流行,不過今天已經(jīng)有很多熔斷的開源組件,例如:Resilience4j, Istio和 Envoy。
4、學(xué)到了什么
這篇文章中談?wù)摿撕芏鄳?yīng)對(duì)緩存踩踏問題的策略,以及其他的科技公司是如何使用這些策略的。那么facebook呢?他們從這次事故中學(xué)到了什么?以及他們采取了什么措施來防止事故再次發(fā)生?他們的工程師寫了一篇文章:Under the hood: Broadcasting live video to millions,討論了他們對(duì)架構(gòu)所做的改進(jìn)。和本文我們提到的一樣,比如二級(jí)緩存。當(dāng)然,也提到了一些新的方法,比如 HTTP請(qǐng)求合并。總之,這篇文章非常值得一讀
5、寫在最后
我相信理解緩存踩踏對(duì)系統(tǒng)的破壞性是非常有必要的,當(dāng)然,并不是說每個(gè)團(tuán)隊(duì)必須馬上把這些策略用到他們的系統(tǒng)中。因?yàn)椋x擇何種策略要應(yīng)對(duì)緩存踩踏并不是一件容易的事情,它依賴你的實(shí)際用戶場(chǎng)景,架構(gòu),以及流量負(fù)載情況。但是了解緩存踩踏以及對(duì)可能的解決方案對(duì)您將來有所幫助,當(dāng)你以后面對(duì)類型問題時(shí),能從容應(yīng)對(duì)。
原文地址:https://betterprogramming.pub/how-a-cache-stampede-caused-one-of-facebooks-biggest-outages-dbb964ffc8ed
本文轉(zhuǎn)載自微信公眾號(hào)「阿飛的博客」,可以通過以下二維碼關(guān)注。轉(zhuǎn)載本文請(qǐng)聯(lián)系阿飛的博客公眾號(hào)。