利用緩存做性能優(yōu)化的案例非常多,從基礎(chǔ)的操作系統(tǒng)到數(shù)據(jù)庫(kù)、分布式緩存、本地緩存等。它們表現(xiàn)形式各異,卻有著共同的樸素的本質(zhì):彌補(bǔ)CPU的高算力和IO的慢讀寫之間巨大的鴻溝。
和架構(gòu)選型類似,每引入一個(gè)組件,都會(huì)導(dǎo)致復(fù)雜度的上升。以緩存為例,它帶來(lái)性能提升的同時(shí),也帶來(lái)一些問(wèn)題,需要開發(fā)者設(shè)計(jì)和權(quán)衡。在初期業(yè)務(wù)量小的時(shí)候,數(shù)據(jù)庫(kù)能承擔(dān)讀寫壓力,應(yīng)用可以直接和DB交互,架構(gòu)簡(jiǎn)單且強(qiáng)壯。經(jīng)過(guò)一段時(shí)間發(fā)展后,業(yè)務(wù)量迎來(lái)了大規(guī)模增長(zhǎng),此時(shí)DB查詢壓力和耗時(shí)都在增長(zhǎng)。此時(shí)引入分布式緩存,在減少DB壓力的同時(shí),還提供了更高的QPS。再往后發(fā)展,分布式緩存也成為了瓶頸,高頻的QPS是一筆負(fù)擔(dān);另外緩存驅(qū)逐以及網(wǎng)絡(luò)抖動(dòng)會(huì)影響系統(tǒng)的穩(wěn)定性,此時(shí)引入本地緩存,可以減輕分布式緩存的壓力,并減少網(wǎng)絡(luò)以及序列化開銷。緩存通過(guò)減少IO操作來(lái)獲得讀寫的性能提升。有一個(gè)表格,可以看見磁盤、網(wǎng)絡(luò)的IO操作耗時(shí),遠(yuǎn)高于內(nèi)存存取。緩存帶來(lái)的QPS、RT提升比較直觀,不補(bǔ)充介紹。緩存Miss是必然會(huì)面對(duì)的問(wèn)題,緩存需保證在有限的容量下,將熱點(diǎn)的數(shù)據(jù)維護(hù)在緩存中,從而達(dá)到性能、成本的平衡。可以先試想嚴(yán)格LRU的實(shí)現(xiàn)。假設(shè)Redis當(dāng)前有50W規(guī)模的key,先通過(guò)Keys 遍歷獲得所有Key,然后比對(duì)出空閑時(shí)間最長(zhǎng)的某個(gè)key,最后執(zhí)行淘汰。這樣的流程下來(lái),是非常昂貴的,Keys命令是一筆不小的開銷,其次大規(guī)模執(zhí)行比對(duì)也很昂貴。 當(dāng)然嚴(yán)格LRU實(shí)現(xiàn)的優(yōu)化空間還是有的,YY一下,可以通過(guò)活躍度分離出活躍Key和待回收Key, 淘汰時(shí)只關(guān)注待回收key即可;回收算法引入鏈表或者樹的結(jié)構(gòu),使Key按空閑時(shí)間有序,淘汰時(shí)直接獲取。然而這些優(yōu)化不可避免的是,在緩存讀寫時(shí),這些輔助的數(shù)據(jù)結(jié)構(gòu)需要同步更新,帶來(lái)的存儲(chǔ)以及計(jì)算的成本很高。在Redis中它采用了近似LRU的實(shí)現(xiàn),它隨機(jī)采樣5個(gè)Key,淘汰掉其中空閑時(shí)間最長(zhǎng)的那個(gè)。近似LRU實(shí)現(xiàn)起來(lái)更簡(jiǎn)單、成本更低,在效果上接近嚴(yán)格LRU。它的缺點(diǎn)是存在一定的幾率淘汰掉最近被訪問(wèn)的Key,即在TTL到期前也可能被淘汰。在一些場(chǎng)景中,程序是批量加載數(shù)據(jù)到緩存的, 比如通過(guò)Excel上傳數(shù)據(jù),系統(tǒng)解析后,批量寫入DB和緩存。此時(shí)若不經(jīng)設(shè)計(jì),這批數(shù)據(jù)的超時(shí)時(shí)間往往是一致的。緩存到期后,本該緩存承擔(dān)的流量將打到DB上,從而降低接口甚至系統(tǒng)的性能和穩(wěn)定性。可以利用隨機(jī)數(shù)打散緩存失效時(shí)間,例如設(shè)置TTL=8hr+random(8000)ms。系統(tǒng)應(yīng)盡量保證DB、緩存的數(shù)據(jù)一致性,較常使用的是cache aside設(shè)計(jì)模式。避免使用非常規(guī)的緩存設(shè)計(jì)模式:先更新緩存、后更新DB;先更新DB、后更新緩存(cache aside是直接失效緩存)。這些模式的不一致風(fēng)險(xiǎn)較高。業(yè)務(wù)系統(tǒng)通常使用cache aside 模式,操作系統(tǒng)、數(shù)據(jù)庫(kù)、分布式緩存等會(huì)使用write throgh、write back。Cache aside模式大部分時(shí)間運(yùn)行良好,在一些極端場(chǎng)景下,仍可能出現(xiàn)不一致風(fēng)險(xiǎn)。主要來(lái)自兩方面:由于中間件或者網(wǎng)絡(luò)等問(wèn)題,緩存失效失敗。
出現(xiàn)意外的緩存失效、讀取的時(shí)序。
緩存失效失敗很容易理解,不做補(bǔ)充。主要介紹時(shí)序引起的不一致問(wèn)題。考慮這樣的時(shí)間軸,A線程發(fā)現(xiàn)cache miss后重新加載緩存,此時(shí)讀的數(shù)據(jù)還是老的, 另一個(gè)線程B更新數(shù)據(jù)并失效緩存。若B線程失效緩存的操作完成時(shí)間早于A線程,A線程會(huì)寫入老的數(shù)據(jù)。 緩存不一致有一些緩解方法,例如延遲雙刪、CDC同步。這些方案都提升了系統(tǒng)復(fù)雜度,需綜合考慮業(yè)務(wù)的容忍度,方案的復(fù)雜度等。Java本地緩存分兩類,基于堆內(nèi)存的、基于直接內(nèi)存的。采用堆內(nèi)存做緩存的主要問(wèn)題是GC,由于緩存對(duì)象的生命周期往往較長(zhǎng),需要通過(guò)Major GC進(jìn)行回收。若緩存的規(guī)模很大,那么GC會(huì)非常耗時(shí)。采用直接內(nèi)存做緩存的主要問(wèn)題是內(nèi)存管理。程序需自主控制內(nèi)存的分配和回收,存在OOM或者M(jìn)emory Leak的風(fēng)險(xiǎn)。另外直接內(nèi)存不能存取對(duì)象,在操作時(shí)需進(jìn)行序列化。直接內(nèi)存能減少GC壓力,因?yàn)樗恍枰4嬷苯觾?nèi)存的引用,而對(duì)象本身是存儲(chǔ)在直接內(nèi)存中。引用晉升到老年代后占用的空間很小,對(duì)GC的負(fù)擔(dān)可忽略。直接內(nèi)存的回收依賴System。gc的調(diào)用,但這個(gè)調(diào)用JVM不保證執(zhí)行、也不保證何時(shí)執(zhí)行,它的行為是不可控的。程序一般需要自行管理,成對(duì)去調(diào)用malloc、free,依托于這種“手工、類C”的內(nèi)存管理,可以增加內(nèi)存回收的可控性和靈活性。由于直接內(nèi)存的分配和回收比較昂貴,需要通過(guò)內(nèi)核操作物理內(nèi)存。申請(qǐng)的時(shí)候一般是申請(qǐng)大的內(nèi)存快,然后再根據(jù)需求分配小塊給線程?;厥盏臅r(shí)候不直接釋放,而是放入內(nèi)存池來(lái)重用。如何快速找到一個(gè)空閑塊、如何減少內(nèi)存碎片、如何快速回收等等,它是一個(gè)系統(tǒng)性的問(wèn)題,也有很多專門的算法。Jemalloc是綜合能力較好的算法,free BSD、Redis默認(rèn)采用了該算法,OHC緩存也建議服務(wù)器配置該算法。Netty的作者實(shí)現(xiàn)了Java版本,感興趣的可以閱讀。利用上分布式緩存、本地緩存之后,還可以繼續(xù)提升的就是CPU緩存了。它雖不易察覺(jué),但在高并發(fā)下對(duì)性能存在一定的影響。CPU緩存分為L(zhǎng)1、L2、L3 三級(jí),越靠近CPU的,容量越小,命中率越高。當(dāng)L3等級(jí)的緩存都取不到數(shù)據(jù)的時(shí)候,需從主存中獲取。CPU緩存由cache line組成,每一個(gè)cache line為64字節(jié),能容納8個(gè)long值。在CPU從主存獲取數(shù)據(jù)時(shí),以cache line為單位加載,于是相鄰的數(shù)據(jù)會(huì)一并加載到緩存中。很容易想到,數(shù)組的順序遍歷、相鄰數(shù)據(jù)的計(jì)算是非常高效的。CPU緩存也存在一致性問(wèn)題,它通過(guò)MESI協(xié)議、MESIF協(xié)議來(lái)保證。偽共享來(lái)源于高并發(fā)時(shí)cache line出現(xiàn)了緩存不一致。同一個(gè)cache line中的數(shù)據(jù)會(huì)被不同線程修改,它們相互影響,導(dǎo)致處理性能降低。上圖模擬一個(gè)偽共享場(chǎng)景,NoPadding是線程共享對(duì)象,thread0會(huì)修改no0、thread1會(huì)修改no1。當(dāng)thread0修改時(shí),除了修改自身的cache line,依據(jù)CPU緩存協(xié)議還會(huì)導(dǎo)致thread1對(duì)應(yīng)的cache line失效,這時(shí)thread1發(fā)現(xiàn)cache miss后從主存加載,修改后又導(dǎo)致thread0的cache line失效。NoPadding {
long no0;
long no1;
}
通過(guò)填充,讓no0、no1落在不同的cache line中:Padding { long p1, p2, p3, p4, p5, p6, p7; volatile long no0 = 0L; long p9, p10, p11, p12, p13, p14; volatile long no1 = 0L;}
@sun.misc.Contended static final class CounterCell {
volatile long value;
CounterCell(long x) { value = x; }
}
案例:JDK源碼中LongAdder中的Cell、ConcurrentHashMap的CounterCell。
無(wú)鎖并發(fā)可以從本質(zhì)上解決偽共享問(wèn)題,它無(wú)需填充cache line,并且執(zhí)行效率是最高的。近來(lái)由于業(yè)務(wù)對(duì)接口RT提出了更高的要求,在性能優(yōu)化的過(guò)程中,緩存的使用是非常多的。借此機(jī)會(huì)記錄下在這段時(shí)間的思考。私以為,在引入某一項(xiàng)技術(shù)的時(shí)候,需整體的去看,了解其概念、原理、適用場(chǎng)景、注意事項(xiàng),這樣可以在設(shè)計(jì)之初就規(guī)避掉一些風(fēng)險(xiǎn)。分布式緩存、本地緩存、CPU緩存涵蓋的內(nèi)容非常多,本文做了一些歸納。對(duì)細(xì)節(jié)感興趣的同學(xué)可以閱讀《Redis 設(shè)計(jì)與實(shí)現(xiàn)》、disruptor設(shè)計(jì)文檔及代碼。
Alibaba Java 技術(shù)圖譜