前面分享了《分布式系統(tǒng)設(shè)計(jì)模式》系列文章的前兩部分——彈力設(shè)計(jì)篇和管理設(shè)計(jì)篇。今天開(kāi)始這一系列的最后一部分內(nèi)容——性能設(shè)計(jì)篇,主題為《性能設(shè)計(jì)篇之“緩存”》。
基本上來(lái)說(shuō),在分布式系統(tǒng)中最耗性能的地方就是最后端的數(shù)據(jù)庫(kù)了。一般來(lái)說(shuō),只要小心維護(hù)好,數(shù)據(jù)庫(kù)四種操作(select、update、insert 和 delete)中的三個(gè)寫(xiě)操作 insert、update 和 delete 不太會(huì)出現(xiàn)性能問(wèn)題(insert 一般不會(huì)有性能問(wèn)題,update 和 delete 一般會(huì)有主鍵,所以也不會(huì)太慢)。除非索引建得太多,而數(shù)據(jù)庫(kù)里的數(shù)據(jù)又太多,這三個(gè)操作才會(huì)變慢。
絕大多數(shù)情況下,select 是出現(xiàn)性能問(wèn)題最大的地方。一方面,select 會(huì)有很多像 join、group、order、like 等這樣豐富的語(yǔ)義,而這些語(yǔ)義是非常耗性能的;另一方面,大多數(shù)應(yīng)用都是讀多寫(xiě)少,所以加劇了慢查詢(xún)的問(wèn)題。
分布式系統(tǒng)中遠(yuǎn)程調(diào)用也會(huì)消耗很多資源,因?yàn)榫W(wǎng)絡(luò)開(kāi)銷(xiāo)會(huì)導(dǎo)致整體的響應(yīng)時(shí)間下降。為了挽救這樣的性能開(kāi)銷(xiāo),在業(yè)務(wù)允許的情況(不需要太實(shí)時(shí)的數(shù)據(jù))下,使用緩存是非常必要的事情。
從另一個(gè)方面說(shuō),緩存在今天的移動(dòng)互聯(lián)網(wǎng)中是必不可少的一部分,因?yàn)榫W(wǎng)絡(luò)質(zhì)量不一定永遠(yuǎn)是最好的,所以前端也會(huì)為所有的 API 加上緩存。不然,網(wǎng)絡(luò)不通暢的時(shí)候,沒(méi)有數(shù)據(jù),前端都不知道怎么展示 UI 了。既然因?yàn)橐苿?dòng)互聯(lián)網(wǎng)的網(wǎng)絡(luò)質(zhì)量而導(dǎo)致我們必須容忍數(shù)據(jù)的不實(shí)時(shí)性,那么,從業(yè)務(wù)上來(lái)說(shuō),在大多數(shù)情況下是可以使用緩存的。
緩存是提高性能最好的方式,一般來(lái)說(shuō),緩存有以下三種模式。
Cache Aside 更新模式
這是最常用的設(shè)計(jì)模式了,其具體邏輯如下。
失效:應(yīng)用程序先從 Cache 取數(shù)據(jù),如果沒(méi)有得到,則從數(shù)據(jù)庫(kù)中取數(shù)據(jù),成功后,放到緩存中。
命中:應(yīng)用程序從 Cache 中取數(shù)據(jù),取到后返回。
更新:先把數(shù)據(jù)存到數(shù)據(jù)庫(kù)中,成功后,再讓緩存失效。
這是標(biāo)準(zhǔn)的設(shè)計(jì)模式,包括 Facebook 的論文《Scaling Memcache at Facebook》中也使用了這個(gè)策略。為什么不是寫(xiě)完數(shù)據(jù)庫(kù)后更新緩存?你可以看一下 Quora 上的這個(gè)問(wèn)答《Why does Facebook use delete to remove the key-value pair in Memcached instead of updating the Memcached during write request to the backend?》,主要是怕兩個(gè)并發(fā)的寫(xiě)操作導(dǎo)致臟數(shù)據(jù)。
那么,是不是這個(gè) Cache Aside 就不會(huì)有并發(fā)問(wèn)題了?不是的。比如,一個(gè)是讀操作,但是沒(méi)有命中緩存,就會(huì)到數(shù)據(jù)庫(kù)中取數(shù)據(jù)。而此時(shí)來(lái)了一個(gè)寫(xiě)操作,寫(xiě)完數(shù)據(jù)庫(kù)后,讓緩存失效,然后之前的那個(gè)讀操作再把老的數(shù)據(jù)放進(jìn)去,所以會(huì)造成臟數(shù)據(jù)。
這個(gè)案例理論上會(huì)出現(xiàn),但實(shí)際上出現(xiàn)的概率可能非常低,因?yàn)檫@個(gè)條件需要發(fā)生在讀緩存時(shí)緩存失效,而且有一個(gè)并發(fā)的寫(xiě)操作。實(shí)際上數(shù)據(jù)庫(kù)的寫(xiě)操作會(huì)比讀操作慢得多,而且還要鎖表,讀操作必須在寫(xiě)操作前進(jìn)入數(shù)據(jù)庫(kù)操作,又要晚于寫(xiě)操作更新緩存,所有這些條件都具備的概率并不大。
所以,這也就是 Quora 上的那個(gè)答案里說(shuō)的,要么通過(guò) 2PC 或是 Paxos 協(xié)議保證一致性,要么就是拼命地降低并發(fā)時(shí)臟數(shù)據(jù)的概率。而 Facebook 使用了這個(gè)降低概率的玩法,因?yàn)?2PC 太慢,而 Paxos 太復(fù)雜。當(dāng)然,最好還是為緩存設(shè)置好過(guò)期時(shí)間。
Read/Write Through 更新模式
我們可以看到,在上面的 Cache Aside 套路中,應(yīng)用代碼需要維護(hù)兩個(gè)數(shù)據(jù)存儲(chǔ),一個(gè)是緩存(cache),一個(gè)是數(shù)據(jù)庫(kù)(repository)。所以,應(yīng)用程序比較啰嗦。而 Read/Write Through 套路是把更新數(shù)據(jù)庫(kù)(repository)的操作由緩存自己代理了,所以,對(duì)于應(yīng)用層來(lái)說(shuō),就簡(jiǎn)單很多了??梢岳斫鉃?,應(yīng)用認(rèn)為后端就是一個(gè)單一的存儲(chǔ),而存儲(chǔ)自己維護(hù)自己的 Cache。
Read Through
Read Through 套路就是在查詢(xún)操作中更新緩存,也就是說(shuō),當(dāng)緩存失效的時(shí)候(過(guò)期或 LRU 換出),Cache Aside 是由調(diào)用方負(fù)責(zé)把數(shù)據(jù)加載入緩存,而 Read Through 則用緩存服務(wù)自己來(lái)加載,從而對(duì)應(yīng)用方是透明的。
Write Through
Write Through 套路和 Read Through 相仿,不過(guò)是在更新數(shù)據(jù)時(shí)發(fā)生。當(dāng)有數(shù)據(jù)更新的時(shí)候,如果沒(méi)有命中緩存,直接更新數(shù)據(jù)庫(kù),然后返回。如果命中了緩存,則更新緩存,然后由 Cache 自己更新數(shù)據(jù)庫(kù)(這是一個(gè)同步操作)。
下圖自來(lái) Wikipedia 的 Cache 詞條。其中的 Memory,你可以理解為就是我們例子里的數(shù)據(jù)庫(kù)。
Write Behind Caching 更新模式
Write Behind 又叫 Write Back。一些了解 Linux 操作系統(tǒng)內(nèi)核的同學(xué)對(duì) write back 應(yīng)該非常熟悉,這不就是 Linux 文件系統(tǒng)的 page cache 算法嗎?是的,你看基礎(chǔ)知識(shí)全都是相通的。所以,基礎(chǔ)很重要,我已經(jīng)說(shuō)過(guò)不止一次了。
Write Back 套路就是,在更新數(shù)據(jù)的時(shí)候,只更新緩存,不更新數(shù)據(jù)庫(kù),而我們的緩存會(huì)異步地批量更新數(shù)據(jù)庫(kù)。這個(gè)設(shè)計(jì)的好處就是讓數(shù)據(jù)的 I/O 操作飛快無(wú)比(因?yàn)橹苯硬僮鲀?nèi)存嘛)。因?yàn)楫惒?,Write Back 還可以合并對(duì)同一個(gè)數(shù)據(jù)的多次操作,所以性能的提高是相當(dāng)可觀的。
但其帶來(lái)的問(wèn)題是,數(shù)據(jù)不是強(qiáng)一致性的,而且可能會(huì)丟失(我們知道 Unix/Linux 非正常關(guān)機(jī)會(huì)導(dǎo)致數(shù)據(jù)丟失,就是因?yàn)檫@個(gè)事)。在軟件設(shè)計(jì)上,我們基本上不可能做出一個(gè)沒(méi)有缺陷的設(shè)計(jì),就像算法設(shè)計(jì)中的時(shí)間換空間、空間換時(shí)間一個(gè)道理。有時(shí)候,強(qiáng)一致性和高性能,高可用和高性能是有沖突的。軟件設(shè)計(jì)從來(lái)都是 trade-off(取舍)。
另外,Write Back 實(shí)現(xiàn)邏輯比較復(fù)雜,因?yàn)樗枰?track 有哪些數(shù)據(jù)是被更新了的,需要刷到持久層上。操作系統(tǒng)的 Write Back 會(huì)在僅當(dāng)這個(gè) Cache 需要失效的時(shí)候,才會(huì)把它真正持久起來(lái)。比如,內(nèi)存不夠了,或是進(jìn)程退出了等情況,這又叫 lazy write。
在 Wikipedia 上有一張 Write Back 的流程圖,基本邏輯可以在下圖中看到。
緩存設(shè)計(jì)的重點(diǎn)
緩存更新的模式基本如前面所說(shuō),不過(guò)這還沒(méi)完,緩存已經(jīng)成為高并發(fā)高性能架構(gòu)的一個(gè)關(guān)鍵組件了?,F(xiàn)在,很多公司都在用 Redis 來(lái)搭建他們的緩存系統(tǒng)。一方面是因?yàn)?Redis 的數(shù)據(jù)結(jié)構(gòu)比較豐富。另一方面,我們不能在 Service 內(nèi)放 Local Cache,一是每臺(tái)機(jī)器的內(nèi)存不夠大,二是我們的 Service 有多個(gè)實(shí)例,負(fù)載均衡器會(huì)把請(qǐng)求隨機(jī)分布到不同的實(shí)例。緩存需要在所有的 Service 實(shí)例上都建好,這讓我們的 Service 有了狀態(tài),更難管理了。
所以,在分布式架構(gòu)下,一般都需要一個(gè)外部的緩存集群。關(guān)于這個(gè)緩存集群,你需要保證的是內(nèi)存要足夠大,網(wǎng)絡(luò)帶寬也要好,因?yàn)榫彺姹举|(zhì)上是個(gè)內(nèi)存和 IO 密集型的應(yīng)用。
另外,如果需要內(nèi)存很大,那么你還要?jiǎng)佑脭?shù)據(jù)分片技術(shù)來(lái)把不同的緩存分布到不同的機(jī)器上。這樣,可以保證我們的緩存集群可以不斷地 scale 下去。關(guān)于數(shù)據(jù)分片的事,我會(huì)在后面講述。
緩存的好壞要看命中率。緩存的命中率高說(shuō)明緩存有效,一般來(lái)說(shuō)命中率到 80% 以上就算很高了。當(dāng)然,有的網(wǎng)絡(luò)為了追求更高的性能,要做到 95% 以上,甚至可能會(huì)把數(shù)據(jù)庫(kù)里的數(shù)據(jù)幾乎全部裝進(jìn)緩存中。這當(dāng)然是不必要的,也是沒(méi)有效率的,因?yàn)橥ǔ?lái)說(shuō),熱點(diǎn)數(shù)據(jù)只會(huì)是少數(shù)。
另外,緩存是通過(guò)犧牲強(qiáng)一致性來(lái)提高性能的,這世上任何事情都不是免費(fèi)的,所以并不是所有的業(yè)務(wù)都適合用緩存,這需要在設(shè)計(jì)的時(shí)候仔細(xì)調(diào)研好需求。使用緩存提高性能,就是會(huì)有數(shù)據(jù)更新的延遲。
緩存數(shù)據(jù)的時(shí)間周期也需要好好設(shè)計(jì),太長(zhǎng)太短都不好,過(guò)期期限不宜太短,因?yàn)榭赡軐?dǎo)致應(yīng)用程序不斷從數(shù)據(jù)存儲(chǔ)檢索數(shù)據(jù)并將其添加到緩存。同樣,過(guò)期期限不宜太長(zhǎng),因?yàn)檫@會(huì)導(dǎo)致一些沒(méi)人訪問(wèn)的數(shù)據(jù)還在內(nèi)存中不過(guò)期,而浪費(fèi)內(nèi)存。
使用緩存的時(shí)候,一般會(huì)使用 LRU 策略。也就是說(shuō),當(dāng)內(nèi)存不夠需要有數(shù)據(jù)被清出內(nèi)存時(shí),會(huì)找最不活躍的數(shù)據(jù)清除。所謂最不活躍的意思是最長(zhǎng)時(shí)間沒(méi)有被訪問(wèn)過(guò)了。所以,開(kāi)啟 LRU 策略會(huì)讓緩存在每個(gè)數(shù)據(jù)訪問(wèn)的時(shí)候把其調(diào)到前面,而要淘汰數(shù)據(jù)時(shí),就從最后面開(kāi)始淘汰。
于是,對(duì)于 LRU 的緩存系統(tǒng)來(lái)說(shuō),其需要在 key-value 這樣的非順序的數(shù)據(jù)結(jié)構(gòu)中維護(hù)一個(gè)順序的數(shù)據(jù)結(jié)構(gòu),并在讀緩存時(shí),需要改變被訪問(wèn)數(shù)據(jù)在順序結(jié)構(gòu)中的排位。于是,我們的 LRU 在讀寫(xiě)時(shí)都需要加鎖(除非是單線程無(wú)并發(fā)),因此 LRU 可能會(huì)導(dǎo)致更慢的緩存存取的時(shí)間。這點(diǎn)要小心。
最后,我們的世界是比較復(fù)雜的,很多網(wǎng)站都會(huì)被爬蟲(chóng)爬,要小心這些爬蟲(chóng)。因?yàn)檫@些爬蟲(chóng)可能會(huì)爬到一些很古老的數(shù)據(jù),而程序會(huì)把這些數(shù)據(jù)加入到緩存中去,而導(dǎo)致緩存中那些真實(shí)的熱點(diǎn)數(shù)據(jù)被擠出去(因?yàn)闄C(jī)器的速度足夠快)。對(duì)此,一般來(lái)說(shuō),我們需要有一個(gè)爬蟲(chóng)保護(hù)機(jī)制,或是我們引導(dǎo)這些人去使用我們提供的外部 API。在那邊,我們可以有針對(duì)性地做多租戶(hù)的緩存系統(tǒng)(也就是說(shuō),把用戶(hù)和第三方開(kāi)發(fā)者的緩存系統(tǒng)分離開(kāi)來(lái))。
小結(jié)
好了,我們來(lái)總結(jié)一下今天分享的主要內(nèi)容。首先,緩存是為了加速數(shù)據(jù)訪問(wèn),在數(shù)據(jù)庫(kù)之上添加的一層機(jī)制。然后,我講了幾種典型的緩存模式,包括 Cache Aside、Read/Write Through 和 Write Behind Caching 以及它們各自的優(yōu)缺點(diǎn)。
最后,我介紹了緩存設(shè)計(jì)的重點(diǎn),除了性能之外,在分布式架構(gòu)下和公網(wǎng)環(huán)境下,對(duì)緩存集群、一致性、LRU 的鎖競(jìng)爭(zhēng)、爬蟲(chóng)等多方面都需要考慮。下篇文章中,我們講述異步處理。希望對(duì)你有幫助。
也歡迎你分享一下你接觸到的緩存方式有哪些?怎樣權(quán)衡一致性和緩存的效率?
文末給出了《分布式系統(tǒng)設(shè)計(jì)模式》系列文章的目錄,希望你能在這個(gè)列表里找到自己感興趣的內(nèi)容。
彈力設(shè)計(jì)篇
認(rèn)識(shí)故障和彈力設(shè)計(jì)
隔離設(shè)計(jì) Bulkheads
異步通訊設(shè)計(jì) Asynchronous
冪等性設(shè)計(jì) Idempotency
服務(wù)的狀態(tài) State
補(bǔ)償事務(wù) Compensating Transaction
重試設(shè)計(jì) Retry
熔斷設(shè)計(jì) Circuit Breaker
限流設(shè)計(jì) Throttle
降級(jí)設(shè)計(jì) degradation
彈力設(shè)計(jì)總結(jié)
管理設(shè)計(jì)篇
分布式鎖 Distributed Lock
配置中心 Configuration Management
邊車(chē)模式 Sidecar
服務(wù)網(wǎng)格 Service Mesh
網(wǎng)關(guān)模式 Gateway
部署升級(jí)策略
性能設(shè)計(jì)篇
緩存 Cache
異步處理 Asynchronous
數(shù)據(jù)庫(kù)擴(kuò)展
秒殺 Flash Sales
邊緣計(jì)算 Edge Computing