天天看點

資料-緩存漫談

注:本文不涉及系統的技術實作細節

資料對網際網路而言并不陌生,甚至是每天都需要打交道的部分,根據不同的行業不同的業務,都有不同的資料,而但凡規模增大到一定程度,即使分類後的資料也會龐大到一定的數量級。

幾百-幾萬-幾百萬-億級别的資料。

當資料量到達一個數量級之後,如何快速檢索到使用者需要的資料乃至經曆過一層層資料邏輯篩選後最終吐露給使用者所需的耗時也是開發者需要考慮的問題了。

與資料庫通路優化漏鬥法則相似,資料的通路優化也在大體上遵循類似的法則。

資料-緩存漫談

減少資料通路

傳回更少資料

減少與底層互動次數

減少cpu開銷和利用更多的機器資源

當所有這些優化手段到達瓶頸,而仍然無法滿足目前的業務需求量,我們引入了資料的緩存。

緩存的使用更加貼近于“減少資料通路”這一層。

java開發緩存大緻可以分為兩種:本地緩存以及叢集緩存。

本地緩存的優缺點:

優點:資料直接來源于jvm,通路速度極快,且使用門檻極低。最常見的map就可以拿來當本地緩存使用,市場上也有發展成熟的插件以供使用,例如ehcache、oscache、guava cache等(當然ehcache、oscache兩者本身已經提供叢集方法)。

缺點:伺服器叢集上會出現各台機器資料不一緻的,差異性問題。當本地緩存過大時往往會引發伺服器的gc問題。

叢集緩存的優缺點:

優點:資料一緻性得到保障,且資料擴容友善,對使用者透明無感覺。比較成熟的中間件有tair、redis、memcached等。

缺點:需要選擇成熟的符合業務的叢集緩存中間件以及叢集架構,搭建并維護一套緩存叢集伺服器。使用門檻較高。存在緩存熱點問題(例如tair熱點問題)。

緩存的使用根據資料量的差異以及不同的場景都會有所不同。

這裡推薦大家去看外網的一篇文章,講雙11大促的緩存使用:

<a href="http://blog.lichengwu.cn/architecture/2015/06/14/distributed-cache/">基于“哨兵”的分布式緩存設計</a>

原文講的已經比較清楚、條理分明,這裡就不再贅述。

簡而言之就是三種不同的使用方式:

1、被動式緩存處理:命中則傳回,未命中則查詢底層,緩存結果集并傳回

2、資料全量緩存預熱或關鍵資料緩存預熱

3、基于“哨兵”式緩存實作方式

每種不同的實作方式都有不同的代價,第一種最簡單,适用于資料量較小的應用場景,實作也最為簡便;第二種實作需要比較良好的設計,适用于資料量中等的熱點通路應用場景;第三種方式各個層面都可以使用,但是全方位的使用比較考量技術。

以上三種不同的緩存使用方式不拘于本地緩存或者叢集緩存。無論本地緩存還是叢集緩存從這幾個方面延伸出去都有很多可深挖的内容,但是本文不會過多的讨論叢集緩存的實作,感興趣的同學可以自行去挖掘學習。

這裡講講本地緩存的使用瓶頸,jvm的本地緩存,大部分情況下指的都是堆記憶體。

阿裡系大部分正常應用的伺服器單機虛拟機标配都是:4核cpu、8g記憶體。其中8g記憶體主要配置設定給兩個:jvm以及伺服器日常開銷。劃分給jvm的記憶體正常會按照以下的方式進行配置設定:2g年輕代、2g老年代、最大1g堆外記憶體、幾百m的持久代permsize。

而年輕代又會進一步以10:1:1的比例規劃出eden、so1、so2,即一個so所占大小約170m左右。

有沒有人曾經考慮過這樣劃分的意義在哪裡。

如果給年輕代分了3個g呢,會發生怎樣的事情?

如果調整年輕代分布,不以10:1:1的方式劃分呢,又會發生什麼事?

總而言之有人可能會說,這些跟本地緩存有什麼關系?

衆所周知java的開發不同于c的一個最大的特點在于java的開發人員無需關注記憶體的申請以及釋放,而由jvm的垃圾回收機制進行動态管理。而當jvm的堆記憶體占用達到一定比例的時候就會觸發臭名昭著的gc,也就是stw(stop the world),當jvm發生gc的時候,對應用程式其實是無感覺的,也就是說,假如使用者來請求資料,緩存命中後正要傳回的時候碰上jvm發生gc,整個應用程式會“僵住”,使用者的請求也會hold在那裡,等到gc結束後一切才會恢複如常,使用者能夠得到他想要的資料(如果gc沒有超過請求的最大逾時時間),程式能夠正常運轉下去。

這是jvm堆對本地緩存使用影響的第一個點。有人會說,那可以增大jvm堆大小,這樣gc發生的周期也就被延長了。

我們先來看另一個問題,jvm年輕代的比例設定對本地記憶體産生的影響。

目前淘系的伺服器jvm垃圾收集采用的都是cms回收機制。

對應的年輕代回收機制是:标記-複制。(圖檔來自網絡)

資料-緩存漫談

對應的老年代回收機制是:标記-清除(或标記-整理)。(圖檔來自網絡)

資料-緩存漫談

至于為什麼采用分代收集的方式以及年輕代采用“标記-複制”的方法,在于資料在不同階段的特性不同。年輕代采用“标記-複制”算法的好處在于資料在年輕代的時候絕大多數的存活周期都不長,類似于方法體裡的臨時變量一樣,使用結束之後都會直接廢棄掉。而年輕代在進行ygc之後存活下來的對象寥寥無幾,這時候轉移這些存活的對象到另一個so裡面的代價極低。是以“标記-複制”算法在此處得到了最大效果的使用。對使用者産生的影響也微乎其微。

而且相比起ygc,開發人員更加關注fgc,因為fgc産生的停頓耗時一般來說遠遠超過ygc。如圖

資料-緩存漫談

這是一段gc耗時的對比,一般情況下ygc的耗時在10ms-200ms左右,而fgc的耗時可能在秒(s)級别。

但是請注意,上面說的情況是,“正常情況”下。你知道當年輕代分布按照10:1:1的比例時,一個so的最大大小也就是170m,經過回收後從so1拷貝到so2的剩餘對象遠遠小于170m,可能就幾m,如果調整eden與so的比例,縮小eden,增大so呢(例如調整比例到1:1:1)?

so的大小最大就會有682m,經過對象回收後可能存活的對象也會大大增多,從so1拷貝到so2的耗時也會增加,嚴重情況下會遠超秒(s)級,甚至超過fgc帶來的影響都有可能。

想象一下你用u盤拷貝電影的場景,幾個g的電影從u盤拷貝到硬碟的耗時(當然記憶體拷貝速度遠超硬碟拷貝,但是一個是分鐘級别,一個再快,也會是秒級)。如圖

資料-緩存漫談

這是一個類似問題帶來的ygc影響,耗時已經達到fgc的級别。

那你們可能會說,能否調整年輕代的分布比例,讓so盡可能的小呢。這樣做帶來的影響就是,對象從so1拷貝到so2之後會發現不夠放,過早的進入老年代,對象過早進入老年代就會過早的觸發fgc的發生(大對象會導緻老年代産生更多的記憶體碎片,這些碎片無法得到有效利用,緻使提前産生fgc)。是以比例的大小設定是一個資料平衡的問題。10:1:1的比例設定,想必是阿裡的前輩工程師經過無數次的實驗驗證的最優設定方案之一吧(在此緻敬)。

年輕代的比例設定會影響本地緩存的存取,本地緩存的使用不當也會反過來影響jvm。也就是我們所說的“大對象”的存在。比如在一個類裡申明一個list屬性,存放百萬級别的資料量,list占整體堆記憶體大小1g-2g,這種情況下年輕代已經完全不夠吃。肯定會引發惡性gc。

這是比較極端的情況下,一般的情況可能這個對象占幾十到幾百m,這時候因為當做本地緩存使用,在年輕代的存活周期肯定比較長,而如果list大小又沒超過so的大小,jvm不會提前把它丢到老年代,那麼在年輕代回收的過程中就會重複十幾次的so拷貝過程(預設值是15次),想象一下u盤拷貝電影的場景。這十幾次的拷貝就會帶來十分惡劣的效果,而這種情況下無論縮小還是擴大eden與so的比例,似乎都已經無效了。

總結一下以上的内容:

1、年輕代的比例設定不當會影響ygc,使ygc無法達到“短平快”的效果,“标記-複制”算法反而成為雷卓

2、當本地緩存使用過多,占用過大的堆記憶體時、或者當本地緩存使用不當,存在大對象時。同樣會影響ygc的效果,“标記-複制”算法再次成為累贅。

當然老年代的設定不當同樣會對本地緩存的使用産生影響(我知道,但是我不說)

至于調大年輕代會帶來的影響(我也知道,但是我不說)

ps:補充一個番外知識,為什麼多線程的使用不當會引發gc問題。

java可以用xmx、xms設定堆記憶體大小,廣義上的堆外記憶體是指虛拟機記憶體除去java堆和永久代之外的部分。包括:

1、direct memory:可通過-xx:maxdirectmemorysize設定

2、線程堆棧:可通過-xss設定

3、socket緩存區

4、jni代碼

5、虛拟機和gc:本身虛拟機和gc的代碼執行也需要消耗一部分記憶體

      線程的配置設定預設是直接在堆外記憶體進行配置設定的,xss也稱之為threadstacksize(線程的棧空間),jdk1.4的xss預設大小是256k,jdk1.5+的xss預設大小是1m.

      在虛拟機記憶體有限的情況下,堆記憶體設定的越大,留給堆外記憶體的空間就越小,線程的個數受到堆外記憶體大小的限制也有其上限。當線程數過多,超出堆外記憶體大小時,内部會調用system.gc()大喊一聲通知jvm進行gc回收。如果jvm參數設定了-xx:disableexplicitgc,該參數的作用相當于使“system.gc”無效化,這時候堆外記憶體就隻能眼睜睜看着自己爆掉,然後抛出:stackoverflowerror或者outofmemoryerror:unable to create new native thread.一般淘系的伺服器會使用-xx:explicitgcinvokesconcurrent替代disableexplicitgc,使原本的fgc變成cms的并發gc。

      是以線程使用不當是有會導緻應用程式頻繁gc的。理論上可以通過設定比較小的xss或者減小堆記憶體的大小來提升線程的個數,看具體情況而定。

pss:可以指定-xx:+usetlab參數來設定線程直接在堆記憶體進行配置設定,tlab(thread local allocation buffer)本地線程配置設定緩沖。該參數會直接在堆記憶體配置設定一塊空間大小。

本地緩存的使用要點:

1、避免大對象的産生,盡量拆分大的對象

2、避免過多的本地緩存

3、适量調整jvm配置以适應目前項目特性

然而jvm又是開發無論如何也繞不開的一個問題。于是就有開發把目光着眼于jvm非堆記憶體上,因為非堆記憶體是沒有自己的垃圾回收機制的,往往資料的回收都是在gc發生的時候一并回收。是以将資料存放在非堆記憶體似乎是一個完全可以繞開jvm-gc,同時又不用增加設計複雜性的一個好辦法。

這裡推薦一篇infoq上的好文,撰寫者深入探讨了本地緩存的進化曆史以及将來。個人覺得值得一讀:

<a href="http://www.infoq.com/cn/articles/open-jdk-and-hashmap-off-heap?utm_campaign=infoq_content&amp;utm_source=infoq&amp;utm_medium=feed&amp;utm_term=global">新的實用的非堆技巧</a>

事實上我後期研究了下市場上成熟的非堆記憶體使用,也發現了不少成熟的産品。然而無論哪種産品都沒有代碼的無縫接入方式,而需要長期的實驗乃至多方配合才有可能達成一個比較好的結果。以下羅列出java非堆記憶體的一些比較成熟的中間件。

1、mapdb:

優點:提供成熟的api,對資料的操作達到原子級别,提供類似sql的送出以及資料復原的。

缺點:資料無法以普通map的形式常駐記憶體,每次從非堆中取都需要提前與非堆進行一個connect,取出之後必須commit才能生效,且讀取效率對比普通的concurrenthashmap沒有優勢(除非堆的gc已經嚴重拖慢了hashmap的讀取)。

2、imcache

3、sharedhashmap

4、ehcache:隻有企業版支援非堆記憶體

5、bigmemory:免費版有使用期限,企業版有成熟的應用。收費

所有以上的非堆記憶體都有類似的如下限制:

1、需要的非堆記憶體大小比較多,大部分在4g以上

2、資料存儲在非堆記憶體,雖然可以完全避開gc帶來的影響,但是開發人員需要關注非堆記憶體資料溢出的問題

3、資料存儲在非堆記憶體,當用戶端通路時,資料從非堆記憶體取出到堆記憶體,然後才能傳回給用戶端。是以取資料的過程仍然需要占用一部分堆記憶體大小。

4、大部分非堆記憶體都需要應用程式擁有某個伺服器檔案目錄的讀寫權限,非堆記憶體資料其實是存儲在這個檔案裡頭的。

以上,我相信非堆記憶體是本地緩存系統的未來。而所有的本地緩存系統都需要經曆大記憶體的堆實戰以及大記憶體的非堆實戰。4g記憶體實在太小,淘系的本地緩存系統最大的已經有到40g大小的項目。