Caching(緩存)在現代的計算機系統中是一項最古老最基本的技術。它存在於計算機各類硬件和軟件系統中,好比各類CPU, 存儲系統(IBM ESS, EMC Symmetrix…),數據庫,Web服務器,中間件等。它的一個重要的做用就是用於彌補不一樣速度的硬件之間的存取速度的差距,cache能夠徹底經過硬件實現(算法也是經過硬件實現的),也能夠經過在更快硬件上經過軟件控制來實現。html
EMC Symmetrix之因此如此的昂貴,就是由於在這個系統中,提供了一個640G全相連的高速數據緩存(DRAM緩存),徹底用硬件實現,就像一個放大版的CPU一級緩存。算法
Caching技術對於現代計算機系統之因此如此重要,就是在於,任何一個小的改進都會對整個計算機系統產生巨大的影響。由於cache具有一個特性,用最高的性價比能夠實現咱們但願獲得的系統總體性能。數據庫
好比,磁盤和內存相比,磁盤具備大容量的特性,而內存具備高性能,可是對於同等容量,磁盤相比於內存來講很是廉價。這也就是咱們不可能把全部磁盤都替換成內存(先不考慮永久存儲的特性),即使這樣咱們能夠得到很是高的I/O速度。那麼如何解決這二者之間的不匹配?就是利用緩存技術。利用內存介質爲磁盤作一層緩存。這樣就能夠在很少花額外費用的同時,獲得速度和容量的平衡。後端
一樣的道理存在於內存和CPU緩存之間。下圖是Intel Core i7 5500系列各部件的訪問速度:緩存
|
訪問速度服務器 |
L1 Cache Hitpost |
4 cycles性能 |
L2 Cache hitfetch |
10 cycles優化 |
L3 Cache hit, line unshared |
40 cycles |
L3 Cache hit, shared line in another core |
65 cycles |
L3 Cache hit, modified in another core |
75 cycles |
Remote L3 cache |
100~300 cycles |
Local RAM |
60 ns |
Remote RAM |
100 ns |
從這裏咱們能夠看到緩存對於系統性能的重要性。可是咱們要達到理想的性能,還必須提升緩存的命中率,這樣緩存才能夠最大限度的獲得利用。
接下來,咱們將會詳細地描述緩存算法。而且經過對比,來看看各類算法的優劣。
每當咱們討論緩存時,老是會對以下幾個詞比較熟悉,
Write-back, write-through, write-around
彷佛,緩存主要是爲「寫」設計的,其實這是錯誤的理解,寫從緩存中得到的好處是很是有限的,緩存主要是爲「讀」服務的。
之因此咱們要順帶提一下,在一個緩存系統中,如何處理寫的順序,是由於,在寫的過程當中,須要動態的更新緩存(不然就會產生數據不一致性的問題),以及後端主存。這三個詞都是用來表示如何處理寫更新的。就是用什麼方式來處理寫。
在一個有緩存的層次結構中,如何理解緩存是爲「讀」服務的?這涉及到讀請求的處理序列。對於每個讀請求,咱們都會用以下的操做序列去處理它:
1. 在緩存中查找請求對應的數據
a. 若是找到,則直接返回給客戶
b. 若是沒找到,則把請求的數據讀入緩存(更新緩存),而後把數據返回給客戶
既然緩存主要是爲讀服務的(後面的文章,咱們會討論,用什麼方式來改善寫的性能),那麼爲了提升讀的性能,或者說減小讀的響應時間,咱們就要提升緩存的命中率,減小緩存的miss 率。這也是咱們緩存算法設計的目標。
那麼咱們來想一想,在設計緩存時,咱們應該從哪幾方面考慮來達到這個緩存的設計目標呢?根據咱們上面提到的讀請求的操做序列,咱們能夠從以下幾個方面來思考:
1. 咱們應該儘可能多的用有用的數據填滿緩存。也就是說,咱們要充分利用緩存。
a. 這是緩存模塊和其它模塊不一樣的地方,並非說緩存中的數據越少越好,而是有用的數據越多越好。
b. 這裏有個很是好的列子,就是Windows的內存佔用率老是很是高,不少人都表示過不滿。其實這是一個很是好的設計。Windows老是試圖儘可能利用那些空閒的內存,用來緩存磁盤上的數據,以此來提升系統的總體性能。不然你那麼大的內存,就爲了拿來好看?
2. 如何獲取「有用」的數據。這裏,「有用」的數據的定義就是可能在不久的未來會被client用到的數據。爲了獲得有用的數據,咱們須要預估客戶端應用的I/O 模式,好比順序讀寫,隨機讀寫等等。這裏就涉及到了「pre-fetch」算法。
a. Pre-fetch(預取算法):是一種預測客戶端應用下次讀寫的數據在哪裏的算法,而且把客戶要用的數據提早放入緩存中,以此來提升讀的響應速度。
3. 問題來了,若是緩存已經滿了,那麼如何存放新的須要緩存的單元呢?這就牽涉到緩存設計的另外一端:淘汰算法。
a. 相比於pre-fetch,淘汰算法(replacement policy)更加劇要。由於對於隨機的I/O, 預取算法是無能爲力的。
b. 淘汰算法的關鍵是如何判斷一個單元的數據比另外一個單元的數據更加劇要。但須要淘汰一個數據單元時,丟棄掉最不重要的那個數據單元,而且用它來存放新的數據。
4. 緩存算法設計的另一個重要的考慮因素是算法的複雜度。或者說是實現或運行算法帶來的額外開銷。咱們但願算法容易實現,而且額外開銷不隨着緩存大小的改變而改變。包括容量的額外開銷和計算的額外開銷。
接下來的文章,咱們會詳細討論預取算法和淘汰算法。
從前面的文章中,咱們已經瞭解到了緩存設計的目標,緩存設計應該考慮的因素。今天咱們來看看一系列緩存算法以及它們如何去解決問題的。同時,咱們也會涉及到各類緩存算法的優缺點。
這裏我並不想討論與預取(pre-fetch)相關的算法,主要是考慮各類淘汰算法。由於相比於預取算法,淘汰算法具備更大的通用性,對緩存好壞影響更大。
1. 時間(徹底從最近使用的時間角度考慮)
a. LRU(least recently used):這種策略就是永遠替換掉最近最少使用的緩存單元。它是最古老,應用最普遍的的一種淘汰算法。它的致命的缺陷就是沒有考慮緩存單元的使用頻率,在某些I/O 模式中,會把一些有價值的緩存單元替換出去。好比,假設咱們有10個緩存單元,客戶端應用來了一次順序讀寫,這樣可能把這10個現有的緩存單元替換出去,而把此次順序讀寫的數據緩存起來。可是,這種順序讀寫的數據在之後都不會被再次用到。反而,那些由於順序讀而被替換出去的緩存單元倒是更有價值的。爲此,有了各類各樣的基於LRU的優化策略被提出來。
2. 頻率(徹底從使用頻率的角度考慮)
a. LFU(least frequently used): IRM(獨立的引用模型)提供了一種用來獲取頻率的負載特性。它趨向於淘汰最近使用頻率最少的緩存單元。這種策略的弊端是:
i. 它的實現複雜度於緩存大小成對數關係(logarithmic);
ii. 對最近的緩存單元的訪問狀況基本沒考慮;
iii. 對訪問模式的改變基本上沒有應變的策略。
3. LRU-2(LRU-K):一種對LRU的改進型策略 (頻率)
a. LRU-2於LFU很類似,若是咱們不考慮它對緩存單元引用頻率進化分佈的自適應性。它的基本思想是對每個緩存單元,記住最近兩次訪問的時間。老是淘汰最近兩次時間間隔最長的緩存單元。在IRM的假設下,對於任何知道最多兩次最近引用緩存單元的在線算法,咱們能夠得出LRU-2具備最高的命中率。
b. 可是LRU-2也有一些實際的限制:
i. 它須要維護一個優先級隊列。也就是說它具備對數的實現複雜度;
ii. 它須要一個可調參數:CIP(correlated information period)。
c. 在現實中,對數的實現複雜度是一個很是嚴重的overhead(負擔)。因此另一個策略2Q被提了出來。
4. 2Q:對LRU-2的改進策略 (頻率)
a. 相對於LRU-2,2Q的主要改進是用一個簡單的LRU list取代了LRU-2中的優先級隊列。其它的2Q和LRU-2基本相同。
b. 可是在2Q中,LRU-2的第二個不足仍是存在,而且更嚴重了。由於它須要兩個可調參數:Kin和Kout。
c. 爲何可調參數一個很嚴重的限制?這是咱們在實施一個系統時,必須肯定這些參數,並且不可更改。一旦肯定了一組參數,這個緩存系統每每只能對某一類workload表現很好。也就是這種緩存系統缺乏了自適應性。
5. LIRS(Low Inter-reference Recency Set)(頻率)
a. 詳細描述參考:「LIRS: An efficient low inter-reference recency set replacement policy to improve buffer cache performance」
b. 第一個不足在於須要兩個可調參數Llirs 和Lhirs ;
c. 它的第二個缺點在於,在最壞的狀況下,它須要一個「棧修剪」。這個操做須要遍歷數量龐大的緩存單元。
6. 時間和頻率(同時考慮時間和頻率的算法:LRU和LFU)
a. FBR(Frequency-based replacement):詳細描述請參考「Data cache management using frequency-based replacement」。這個算法的不足之處在於:
i. 須要可調參數:緩存中三塊的大小,Cmax 和Amax:大小調整的時間週期。
ii. Cache pollution(解決cache污染的機制)
b. LRFU(Least Recently/Frequently Used): 參考「LRFU: A spectrum of policies that subsumes the least recently used and least frequently used policies」
c. ALRFU(adaptive LRFU): 參考「On the existence of a spectrum of policies that subsumes the least recently used and least frequently used policies」
7. 臨時距離分佈(Temporal distance distribution)
a. MQ(multi-queue replacement policy MQ ): 參考「The multi-queue replacement algorithm for second level buffer caches」
8. ARC: adaptive replacement cache(IBM), adjusted replacement cache(ZFS)
a. 一種自適應,低成本的淘汰算法
b. 它集合了LRU和LFU的優勢,而且沒有額外的使用和實現成本。
c. 它能夠更具workload的改變而自動的改變淘汰策略。
ARC是目前應用很是普遍的一種淘汰算法。咱們應該詳細的研究它,並實現它。在ZFS源碼中就是它的完整實現。固然,ZFS中的實現和IBM當初提出的內容有點改變。這個咱們留在下篇文章中講述。
在Solaris ZFS 中實現的ARC(Adjustable Replacement Cache)讀緩存淘汰算法真是頗有意義的一塊軟件代碼。它是基於IBM的Megiddo和Modha提出的ARC(Adaptive Replacement Cache)淘汰算法演化而來的。可是ZFS的開發者們對IBM 的ARC算法作了一些擴展,以更適用於ZFS的應用場景。ZFS ARC的最先實現展示在FAST 2003的會議上,並在雜誌《;Login:》的一篇文章中被詳細描述。
注:關於雜誌《;Login:》,可參考這個連接:https://www.usenix.org/publications/login/2003-08/index.html
ZFS ARC真是一個優美的設計。在接下來的描述中,我將盡可能簡化一些機制,以便於你們更容易理解ZFS ARC的工做原理。關於ZFS ARC的權威描述,能夠參考這個連接:http://src.opensolaris.org/source/xref/onnv/onnv-gate/usr/src/uts/common/fs/zfs/arc.c。在接下來的段落中,我將試着給你們深刻講解一下ZFS 讀緩存的內部工做原理。我將關注點放在數據如何進入緩存,緩存如何調整它本身以適應I/O模式的變化,以及「Adjustable Replacement Cache」這個名字是如何來的。
緩存
嗯,在一些文件系統緩存中實現的標準的LRU淘汰算法是有一些缺點的。例如,它們對掃描讀模式是沒有抵抗性的。但你一次順序讀取大量的數據塊時,這些數據塊就會填滿整個緩存空間,即便它們只是被讀一次。當緩存空間滿了以後,你若是想向緩存放入新的數據,那些最近最少被使用的頁面將會被淘汰出去。在這種大量順序讀的狀況下,咱們的緩存將會只包含這些新讀的數據,而不是那些真正被常用的數據。在這些順序讀出的數據僅僅只被使用一次的狀況下,從緩存的角度來看,它將被這些無用的數據填滿。
另一個挑戰是:一個緩存能夠根據時間進行優化(緩存那些最近使用的頁面),也能夠根據頻率進行優化(緩存那些最頻繁使用的頁面)。可是這兩種方法都不能適應全部的workload。而一個好的緩存設計是能自動根據workload來調整它的優化策略。
ARC的內部工做原理
在ARC原始的實現(IBM的實現)和ZFS中的擴展實現都解決了這些挑戰,或者說現存問題。我將描述由Megiddo和Modha提出的Adaptive Replacement Cache的一些基本概念,ZFS的實現版本做爲這個實現機制的一個擴展來介紹。這兩種實現(原始的Adaptive Replacement Cache和ZFS Adjustable Replacement Cache)共享一些基本的操做原理,因此我認爲這種簡化是一種用來解釋ZFS ARC切實可行的途徑。
首先,假設咱們的緩存中有一個固定的頁面數量。簡單起見,假設咱們有一個8個頁面大小的緩存。爲了是ARC能夠工做,在緩存中,它須要一個2倍大小的管理表。
這個管理表分紅4個鏈表。頭兩個鏈表是顯而易見的:
· 最近最多使用的頁面鏈表 (LRU list)
· 最近最頻繁使用的頁面鏈表(LFU list)
另外兩個鏈表在它們的角色上有些奇怪。它們被稱做ghost鏈表。那些最近被淘汰出去的頁面信息被存儲在這兩個鏈表中:
· 存儲那些最近從最近最多使用鏈表中淘汰的頁面信息 (Ghost list for LRU)
· 存儲那些最近從最近最頻繁使用鏈表中淘汰的頁面信息(Ghost list for LFU)
這兩個ghost鏈表不儲存數據(僅僅儲存頁面信息,好比offset,dev-id),可是在它們之中的命中對ARC緩存工做的行爲具備重要的影響,我將在後面介紹。那麼在緩存中都發生了什麼呢?
假設咱們從磁盤上讀取一個頁面,並把它放入cache中。這個頁面會放入LRU 鏈表中。
接下來咱們讀取另一個不一樣的頁面。它也會被放入緩存。顯然,他也會被放入LRU 鏈表的最近最多使用的位置(位置1):
好,如今咱們再讀一次第一個頁面。咱們能夠看到,這個頁面在緩存中將會被移到LFU鏈表中。全部進入LRU鏈表中的頁面都必須至少被訪問兩次。不管何時,一個已經在LFU鏈表中的頁面被再次訪問,它都會被放到LFU鏈表的開始位置(most frequently used)。這麼作,那些真正被頻繁訪問的頁面將永遠呆在緩存中,不常常訪問的頁面會向鏈表尾部移動,最終被淘汰出去。
隨着時間的推移,這兩個鏈表不斷的被填充,緩存也相應的被填充。這時,緩存已經滿了,而你讀進了一個沒有被緩存的頁面。因此,咱們必須從緩存中淘汰一個頁面,爲這個新的數據頁提供位置。這個數據頁可能剛剛纔被從緩存中淘汰出去,也就是說它不被緩存中任何的非ghost鏈表引用着。
假設LRU鏈表已經滿了:
這時在LRU鏈表中,最近最少使用的頁面將會被淘汰出去。這個頁面的信息會被放進LRU ghost鏈表中。
如今這個被淘汰的頁面再也不被緩存引用,因此咱們能夠把這個數據頁的數據釋放掉。新的數據頁將會被緩存表引用。
隨着更多的頁面被淘汰,這個在LRU ghost中的頁面信息也會向ghost鏈表尾部移動。在隨後的一個時間點,這個被淘汰頁面的信息也會到達鏈表尾部,LRU鏈表的下一次的淘汰過程發生以後,這個頁面信息也會從LRU ghost鏈表中移除,那是就再也沒有任何對它的引用了。
好的,若是這個頁面在被從LRU ghost鏈表中移除以前,被再一次訪問了,將會發生什麼?這樣的一個讀將會引發一次幽靈(phantom)命中。因爲這個頁面的數據已經從緩存中移除了,因此係統仍是必須從後端存儲媒介中再讀一次,可是因爲這個幽靈命中,系統知道,這是一個剛剛淘汰的頁面,而不是第一次讀取或者說好久以前讀取的一個頁面。ARC用這個信息來調整它本身,以適應當前的I/O模式(workload)。
很顯然,這個跡象說明咱們的LRU緩存過小了。在這種狀況下,LRU鏈表的長度將會被增長一。顯然,LFU鏈表的長度將會被減一。
可是一樣的機制存在於LFU這邊。若是一次命中發生在LFU ghost 鏈表中,它會減小LRU鏈表的長度(減一),以此在LFU 鏈表中加一個可用空間。
利用這種行爲,ARC使它本身自適應於工做負載。若是工做負載趨向於訪問最近訪問過的文件,將會有更多的命中發生在LRU Ghost鏈表中,也就是說這樣會增長LRU的緩存空間。反過來同樣,若是工做負載趨向於訪問最近頻繁訪問的文件,更多的命中將會發生在LFU Ghost鏈表中,這樣LFU的緩存空間將會增大。
進一步,這種行爲開啓了一個靈活的特性:假設你爲處理log文件而讀取了大量的文件。你只須要每一個文件一次。一個LRU 緩存將會把全部的數據緩存住,這樣也就把常常訪問的數據也淘汰出去了。可是因爲你僅僅訪問這些文件一次,它們不會爲你帶來任何價值一旦它們填滿了緩存。
一個ARC緩存的行爲是不一樣的。顯然這樣的工做負載僅僅會很快填滿LRU鏈表空間,而這些頁面很快就會被淘汰出去。可是因爲每一個這樣的頁面僅僅被訪問一次,它們基本不太可能在爲最近訪問的文件而設計的ghost鏈表中命中。這樣,LRU的緩存空間不會由於這些僅讀一次的頁面而增長。
假如你把這些log文件與一個大的數據塊聯繫在一塊兒(爲了簡單起見,咱們假設這個數據塊沒有本身的緩存機制)。數據文件中的數據頁應該會被頻繁的訪問。被LFU ghost鏈表引用的正在被訪問的頁面就頗有可能大大的高於LRU ghost鏈表。這樣,常常被訪問的數據庫頁面的緩存空間就會增長。最終,咱們的緩存機制就會向緩存數據塊頁面優化,而不是用log文件來污染咱們的緩存空間。
Solaris ZFS ARC的改動(相對於IBM ARC)
如我前面所說,ZFS實現的ARC和IBM提出的ARC淘汰算法並非徹底一致的。在某些方面,它作了一些擴展:
· ZFS ARC是一個緩存容量可變的緩存算法,它的容量能夠根據系統可用內存的狀態進行調整。當系統內存比較充裕的時候,它的容量能夠自動增長。當系統內存比較緊張(其它事情須要內存)的時候,它的容量能夠自動減小。
· ZFS ARC能夠同時支持多種塊大小。原始的實現假設全部的塊都是相同大小的。
· ZFS ARC容許把一些頁面鎖住,以使它們不會被淘汰。這個特性能夠防止緩存淘汰一些正在使用的頁面。原始的設計沒有這個特性,因此在ZFS ARC中,選擇淘汰頁面的算法要更復雜些。它通常選擇淘汰最舊的可淘汰頁面。
有一些其它的變動,可是我把它們留在對arc.c這個源文件講解的演講中。
L2ARC
L2ARC保持着上面幾個段落中沒涉及到的一個模型。ARC並不自動地把那些淘汰的頁面移進L2ARC,而是真正淘汰它們。雖然把淘汰頁面自動放入L2ARC是一個看起來正確的邏輯,可是這卻會帶來十分嚴重負面影響。首先,一個突發的順序讀會覆蓋掉L2ARC緩存中的不少的頁面,以致於這樣的一次突發順序讀會短期內淘汰不少L2ARC中的頁面。這是咱們不指望的動做。
另外一個問題是:讓咱們假設一下,你的應用須要大量的堆內存。這種更改過的Solaris ARC可以調整它本身的容量以提供更多的可用內存。當你的應用程序申請內存時,ARC緩存容量必須 變得愈來愈小。你必須當即淘汰大量的內存頁面。若是每一個頁面被淘汰的頁面都寫入L2ARC,這將會增長大量的延時直到你的系統可以提供更多的內存,由於你必須等待全部淘汰頁面在被淘汰以前寫入L2ARC。
L2ARC機制用另外一種稍微不一樣的手段來處理這個問題:有一個叫l2arc_feed_thread會遍歷那些很快就會被淘汰的頁面(LRU和LFU鏈表的末尾一些頁面),並把它們寫入一個8M的buffer中。從這裏開始,另外一個線程write_hand會在一個寫操做中把它們寫入L2ARC。
這個算法有如下一些好處:釋放內存的延時不會由於淘汰頁面而增長。在一次突發的順序讀而引發了大量淘汰頁面的狀況下,這些數據塊會被淘汰出去在l2arc——feed_thread遍歷到那兩個鏈表結尾以前。因此L2ARC被這種突發讀污染的概率會減小(雖然不能徹底的避免被污染)。
結論
Adjustable Replacement Cache的設計比普通的LRU緩存設計有效不少。Megiddo和 Modha用它們的Adaptive Replacement Cache得出了更好的命中率。ZFS ARC利用了它們的基本操做理論,因此命中率的好處應該與原始設計差很少。更重要的是:若是這個緩存算法幫助它們得出更好的命中率時,用SSD作大緩存的想法就變得更加切實可行。
想了解更多?
1. The theory of ARC operation in One Up on LRU, written by Megiddo and Modha, IBM Almanden Research Center
2. ZFS ARC源代碼:http://src.opensolaris.org/source/xref/onnv/onnv-gate/usr/src/uts/common/fs/zfs/arc.c