四種快取的避坑總結

語言: CN / TW / HK

背景

分散式、快取、非同步和多執行緒被稱為網際網路開發的四大法寶。今天我總結一下專案開發中常接觸的四種快取實際專案中遇到過的問題。

JVM 堆內快取

JVM 堆內快取因為可以避免 Memcached、Redis 等集中式快取網路通訊故障問題,目前還在專案中廣泛使用。

堆內快取需要注意 GC 的問題。假如我們的設計是定時的從遠端來拉取資料更新本地快取。一定要注意兩點:第一不要全量拉取覆蓋,第二不要把一個大物件整體替換為新物件。

先說全量拉取覆蓋。全量拉取會有很大的網路開銷,會造成網路流量尖刺。有人說沒事,我們頻寬很足,內網訪問,不怕不怕。但是穩定性需要修煉的一項是削峰填谷。讓系統在平穩的環境中執行。不然,在拉取大快取新資料的資料突然來了個突發流量?根據墨菲定律,凡是有機率會發生的事情就一定會發生。程式設計需謹慎。

再說大物件整體替換的問題,這會造成 GC 問題。虛擬碼如下:

List<POJO> oldList = initList();
public void refresh() {
List<POJO> newList = dataFromNetworkService.getAll();
oldList = new List();
for(POJO pojo : newList) {
oldList.add(pojo);
}
}

如果從網上拉取的資料和在快取裡儲存的資料,物件型別沒有發生改變。引起的轉換開銷還稍微小點。因為比如物件 POJO 存在一個列表裡。這個列表雖然很大,但是裡面存的都是物件的引用。實際的 POJO 並沒有發生變化。上面虛擬碼雖然新建一個 List 物件,遍歷新增新物件比直接 oldList=newList 要傻些。但是遍歷過程實際上 POJO  物件沒有發生改變。所以這裡影響 GC 的只是 oldList 這個物件(不包括從網路上拉取回來資料的過程)。

但是如果程式碼這樣寫:

List<POJO2> oldList = initList();
public void refresh() {
List<POJO1> newList = dataFromNetworkService.getAll();
oldList = new List();
for(POJO2 pojo : newList) {
oldList.add(Beanutils.copy(new POJO2(), pojo));
}
}

遍歷過程將會將原來的 POJO1 全部新建一遍,這些物件一般情況下全部先進入堆記憶體的新生代,再經過數次 Young GC 後進入老年代。會造成GC頻繁。

我所做過的專案,一般認為一天一到兩次 Full GC 為合理值。這樣,如果比如預先知道某個時間點有大促,可通過提前觸發 GC 等方式避免高峰期爆發 Full GC。Young GC 至少是 5 分鐘一次,甚至更久觸發認為是正常。這樣可以通過控制避過秒殺等場景。

JVM 堆外快取

堆外快取的記憶體回收原理使用的是 Java 的虛引用 。這個設計可以避免 JVM 的 GC 問題,但是處理不好可能會造成更嚴重的後果:整個機器記憶體被打滿,機器可能會掛掉。 其實掛掉一臺在一般企業的生產環境還好,因為一般都會有容災的冗餘機器。 但是更常見的一種情況是機器忙於 swap 記憶體交換,機器活著但是響應很慢。 屬於半死不活。 這個問題我沒在線上遇到過,但是我同事之前在超級大廠的時候遇到過。

有的同學說那我嚴格算好記憶體,做好監控。這裡面要就要依賴人為的因素來做緊急處理。而人是穩定性中最不可靠的。因為問題通常不發生在人清醒、手裡事情很少的時候。而是一種雪上加霜的存在。比如大促時,流量上來了,執行緒數會增多,每個執行緒都會申請執行緒棧資源,系統處理 IO,這時候系統會申請更多的 buffers/cached 記憶體。

Linux 的 buffers/cached

Linux 系統上執行一下 top 命令或者 free 命令,都能夠看到 buffers 和 cached 相關的資料。需要注意的是通常我們看到的監控資料空閒記憶體百分比,並非是下面顯示的 free/total,而是 (free+buffers+cached)/total。

buffers 在 Linux 系統中通常被作為與塊儲存的 IO 快取使用。所謂塊儲存可簡單理解為將資料直接寫到裸磁碟。而 cached 則一般會用於檔案系統的 IO 快取。比如 page cache 這種記憶體換頁功能。

聽不明白也沒關係,因為事實上它們兩個經常配合使用。比如與磁碟交換資料、進行網路通訊時都會用。buffers 和 cached 是實實在在被作業系統的系統程序在使用的,但是如果使用者程序需要可以很快釋放。所以通常會將它算到剩餘可用記憶體裡。

但是這個也要注意了。比如在 IO 密集型的系統,如果 buffers/cached 被大幅佔用,會降低 IO 速度,進而降低系統吞吐。甚至有可能一個請求幾秒才能到達應用程式,造成請求超時。

集中式快取

Redis 快取其實也有本機代理,可以快取一些活躍的資料在本機上,本機可以在取 到資料時不需要跨網路通訊。但是因為 Redis 本質是 key-value 的結構。如果需要根據萬用字元取資料全量,如果網路出現故障,可能會影響資料的完整性。

但是 Redis 快取最讓人擔心的是不規範的使用方法。比如存一個很大的 value。具體這個對網路和儲存造成的問題就不詳細說了。可以想象下馬桶堵了的情景。

總結

貝爾實驗室的面向物件程式設計專家 Tom Cargill 說:

最初 90% 的開發工作將會用去你最初 90% 的開發時間,剩下的 10% 的開發量將會用去你另外一個 90% 的開發時間。

我理解剩下 10% 佔用了 90% 的時間是由於超出了原有知識貯備,需要臨時抱佛腳,甚至需要拿著錘子找釘子造成的。所以或者也可以這樣做:

每週持續投入 5% 的學習時間,10% 的思考時間,再用 100% 的時間去完成 100% 的開發。

- EOF -

看完本文有收穫?請轉發分享給更多人

關注「ImportNew」,提升Java技能

點贊和在看就是最大的支援 :heart: