快取、快取演算法和快取框架簡介

LiXiang發表於2012-12-04

引言

  我們都聽過 cache,當你問他們是什麼是快取的時候,他們會給你一個完美的答案,可是他們不知道快取是怎麼構建的,或者沒有告訴你應該採用什麼標準去選擇快取框架。在這邊文章,我們會去討論快取,快取演算法,快取框架以及哪個快取框架會更好。

面試

  “快取就是存貯資料(使用頻繁的資料)的臨時地方,因為取原始資料的代價太大了,所以我可以取得快一些。”

  這就是 programmer one (programmer one 是一個面試者)在面試中的回答(一個月前,他向公司提交了簡歷,想要應聘要求在快取,快取框架,大規模資料操作有著豐富經驗的 java 開發職位)。

  programmer one 通過 hash table 實現了他自己的快取,但是他知道的只是他的快取和他那儲存著150條記錄的 hash table,這就是他認為的大規模資料(快取 = hashtable,只需要在 hash table 查詢就好了),所以,讓我們來看看面試的過程吧。

  面試官:你選擇的快取方案,是基於什麼標準的?

  programmer one:呃,(想了5分鐘)嗯,基於,基於,基於資料(咳嗽……)

  面試官:excese me ! 能不能重複一下?

  programmer one:資料?!

  面試官:好的。說說幾種快取演算法以及它們的作用

  programmer one:(凝視著面試官,臉上露出了很奇怪的表情,沒有人知道原來人類可以做出這種表情  )

  面試官:好吧,那我換個說法,當快取達到容量時,會怎麼做?

  programmer one:容量?嗯(思考……hash table 的容量時沒有限制的,我能任意增加條目,它會自動擴充容量的)(這是 programmer one 的想法,但是他沒有說出來)

  面試官對 programmer one 表示感謝(面試過程持續了10分鐘),之後一個女士走過來說:謝謝你的時間,我們會給你打電話的,祝你好心情。這是 programmer one 最糟糕的面試(他沒有看到招聘對求職者有豐富的快取經驗背景要求,實際上,他只看到了豐厚的報酬  )。

說到做到

  programmer one 離開之後,他想要知道這個面試者說的問題和答案,所以他上網去查,programmer one 對快取一無所知,除了:當我需要快取的時候,我就會用 hash table。

  在他使用了他最愛的搜尋引擎搜尋之後,他找到了一篇很不錯的關於快取文章,並且開始去閱讀……

為什麼我們需要快取?

  很久很久以前,在還沒有快取的時候……使用者經常是去請求一個物件,而這個物件是從資料庫去取,然後,這個物件變得越來越大,這個使用者每次的請求時間也越來越長了,這也把資料庫弄得很痛苦,他無時不刻不在工作。所以,這個事情就把使用者和資料庫弄得很生氣,接著就有可能發生下面兩件事情:

  1、使用者很煩,在抱怨,甚至不去用這個應用了(這是大多數情況下都會發生的)

  2、資料庫為打包回家,離開這個應用,然後,就出現了大麻煩(沒地方去儲存資料了)(發生在極少數情況下)

上帝派來了快取

  在幾年之後,IBM(60年代)的研究人員引進了一個新概念,它叫“快取”。

什麼是快取?

  正如開篇所講,快取是“存貯資料(使用頻繁的資料)的臨時地方,因為取原始資料的代價太大了,所以我可以取得快一些。”

  快取可以認為是資料的池,這些資料是從資料庫裡的真實資料複製出來的,並且為了能別取回,被標上了標籤(鍵 ID)。太棒了

  programmer one 已經知道這點了,但是他還不知道下面的快取術語。

快取、快取演算法和快取框架簡介

命中:

  當客戶發起一個請求(我們說他想要檢視一個產品資訊),我們的應用接受這個請求,並且如果是在第一次檢查快取的時候,需要去資料庫讀取產品資訊。

  如果在快取中,一個條目通過一個標記被找到了,這個條目就會被使用、我們就叫它快取命中。所以,命中率也就不難理解了。

Cache Miss:

  但是這裡需要注意兩點:

  1. 如果還有快取的空間,那麼,沒有命中的物件會被儲存到快取中來。

  2. 如果快取慢了,而又沒有命中快取,那麼就會按照某一種策略,把快取中的舊物件踢出,而把新的物件加入快取池。而這些策略統稱為替代策略(快取演算法),這些策略會決定到底應該提出哪些物件。

儲存成本:

  當沒有命中時,我們會從資料庫取出資料,然後放入快取。而把這個資料放入快取所需要的時間和空間,就是儲存成本。

索引成本:

  和儲存成本相仿。

失效:

  當存在快取中的資料需要更新時,就意味著快取中的這個資料失效了。

替代策略:

  當快取沒有命中時,並且快取容量已經滿了,就需要在快取中踢出一個老的條目,加入一條新的條目,而到底應該踢出什麼條目,就由替代策略決定。

最優替代策略:

  最優的替代策略就是想把快取中最沒用的條目給踢出去,但是未來是不能夠被預知的,所以這種策略是不可能實現的。但是有很多策略,都是朝著這個目前去努力。

Java 街惡夢:

  當 programmer one 在讀這篇文章的時候,他睡著了,並且做了個惡夢(每個人都有做惡夢的時候)。

  programmer one:nihahha,我要把你弄失效!(瘋狂的狀態)

  快取物件:別別,讓我活著,他們還需要我,我還有孩子。

  programmer one:每個快取物件在失效之前都會那樣說。你從什麼時候開始有孩子的?不用擔心,現在就永遠消失吧!

  哈哈哈哈哈……programmer one 恐怖的笑著,但是警笛打破了沉靜,警察把 programmer one 抓了起來,並且控告他殺死了(失效)一個仍需被使用的快取物件,他被押到了監獄。

  programmer one 突然醒了,他被嚇到了,渾身是汗,他開始環顧四周,發現這確實是個夢,然後趕緊繼續閱讀這篇文章,努力的消除自己的恐慌。

  在programmer one 醒來之後,他又開始閱讀文章了。

快取演算法

  沒有人能說清哪種快取演算法優於其他的快取演算法

Least Frequently Used(LFU):

  大家好,我是 LFU,我會計算為每個快取物件計算他們被使用的頻率。我會把最不常用的快取物件踢走。

Least Recently User(LRU):

  我是 LRU 快取演算法,我把最近最少使用的快取物件給踢走。

  我總是需要去了解在什麼時候,用了哪個快取物件。如果有人想要了解我為什麼總能把最近最少使用的物件踢掉,是非常困難的。

  瀏覽器就是使用了我(LRU)作為快取演算法。新的物件會被放在快取的頂部,當快取達到了容量極限,我會把底部的物件踢走,而技巧就是:我會把最新被訪問的快取物件,放到快取池的頂部。

  所以,經常被讀取的快取物件就會一直呆在快取池中。有兩種方法可以實現我,array 或者是 linked list。

  我的速度很快,我也可以被資料訪問模式適配。我有一個大家庭,他們都可以完善我,甚至做的比我更好(我確實有時會嫉妒,但是沒關係)。我家庭的一些成員包括 LRU2 和 2Q,他們就是為了完善 LRU 而存在的。

Least Recently Used 2(LRU2):

  我是 Least Recently Used 2,有人叫我最近最少使用 twice,我更喜歡這個叫法。我會把被兩次訪問過的物件放入快取池,當快取池滿了之後,我會把有兩次最少使用的快取物件踢走。因為需要跟蹤物件2次,訪問負載就會隨著快取池的增加而增加。如果把我用在大容量的快取池中,就會有問題。另外,我還需要跟蹤那麼不在快取的物件,因為他們還沒有被第二次讀取。我比LRU好,而且是 adoptive to access 模式 。

Two Queues(2Q):

  我是 Two Queues;我把被訪問的資料放到 LRU 的快取中,如果這個物件再一次被訪問,我就把他轉移到第二個、更大的 LRU 快取。

  我踢走快取物件是為了保持第一個快取池是第二個快取池的1/3。當快取的訪問負載是固定的時候,把 LRU 換成 LRU2,就比增加快取的容量更好。這種機制使得我比 LRU2 更好,我也是 LRU 家族中的一員,而且是 adoptive to access 模式 。

Adaptive Replacement Cache(ARC):

  我是 ARC,有人說我是介於 LRU 和 LFU 之間,為了提高效果,我是由2個 LRU 組成,第一個,也就是 L1,包含的條目是最近只被使用過一次的,而第二個 LRU,也就是 L2,包含的是最近被使用過兩次的條目。因此, L1 放的是新的物件,而 L2 放的是常用的物件。所以,別人才會認為我是介於 LRU 和 LFU 之間的,不過沒關係,我不介意。

  我被認為是效能最好的快取演算法之一,能夠自調,並且是低負載的。我也儲存著歷史物件,這樣,我就可以記住那些被移除的物件,同時,也讓我可以看到被移除的物件是否可以留下,取而代之的是踢走別的物件。我的記憶力很差,但是我很快,適用性也強。

Most Recently Used(MRU):

  我是 MRU,和 LRU 是對應的。我會移除最近最多被使用的物件,你一定會問我為什麼。好吧,讓我告訴你,當一次訪問過來的時候,有些事情是無法預測的,並且在快取系統中找出最少最近使用的物件是一項時間複雜度非常高的運算,這就是為什麼我是最好的選擇。

  我是資料庫記憶體快取中是多麼的常見!每當一次快取記錄的使用,我會把它放到棧的頂端。當棧滿了的時候,你猜怎麼著?我會把棧頂的物件給換成新進來的物件!

First in First out(FIFO):

  我是先進先出,我是一個低負載的演算法,並且對快取物件的管理要求不高。我通過一個佇列去跟蹤所有的快取物件,最近最常用的快取物件放在後面,而更早的快取物件放在前面,當快取容量滿時,排在前面的快取物件會被踢走,然後把新的快取物件加進去。我很快,但是我並不適用。

Second Chance:

  大家好,我是 second chance,我是通過 FIFO 修改而來的,被大家叫做 second chance 快取演算法,我比 FIFO 好的地方是我改善了 FIFO 的成本。我是 FIFO 一樣也是在觀察佇列的前端,但是很FIFO的立刻踢出不同,我會檢查即將要被踢出的物件有沒有之前被使用過的標誌(1一個 bit 表示),沒有沒有被使用過,我就把他踢出;否則,我會把這個標誌位清除,然後把這個快取物件當做新增快取物件加入佇列。你可以想象就這就像一個環佇列。當我再一次在隊頭碰到這個物件時,由於他已經沒有這個標誌位了,所以我立刻就把他踢開了。我在速度上比 FIFO 快。

CLock:

  我是 Clock,一個更好的 FIFO,也比 second chance 更好。因為我不會像 second chance 那樣把有標誌的快取物件放到佇列的尾部,但是也可以達到 second chance 的效果。

  我持有一個裝有快取物件的環形列表,頭指標指向列表中最老的快取物件。當快取 miss 發生並且沒有新的快取空間時,我會問問指標指向的快取物件的標誌位去決定我應該怎麼做。如果標誌是0,我會直接用新的快取物件替代這個快取物件;如果標誌位是1,我會把頭指標遞增,然後重複這個過程,知道新的快取物件能夠被放入。我比 second chance 更快。

Simple time-based:

  我是 simple time-based 快取演算法,我通過絕對的時間週期去失效那些快取物件。對於新增的物件,我會儲存特定的時間。我很快,但是我並不適用。

Extended time-based expiration:

  我是 extended time-based expiration 快取演算法,我是通過相對時間去失效快取物件的;對於新增的快取物件,我會儲存特定的時間,比如是每5分鐘,每天的12點。

Sliding time-based expiration:

  我是 sliding time-based expiration,與前面不同的是,被我管理的快取物件的生命起點是在這個快取的最後被訪問時間算起的。我很快,但是我也不太適用。

  其他的快取演算法還考慮到了下面幾點:

  成本:如果快取物件有不同的成本,應該把那些難以獲得的物件儲存下來。

  容量:如果快取物件有不同的大小,應該把那些大的快取物件清除,這樣就可以讓更多的小快取物件進來了。

  時間:一些快取還儲存著快取的過期時間。電腦會失效他們,因為他們已經過期了。

  根據快取物件的大小而不管其他的快取演算法可能是有必要的。

電子郵件!

  在讀完這篇文章之後,programmer one 想了一會兒,然後決定給作者發封郵件,他感覺作者的名字在哪聽過,但是已經想不起來了。不管怎樣,他還是把郵件傳送出來了,他詢問了作者在分散式環境中,快取是怎麼樣工作的。

  文章的作者收到了郵件,具有諷刺意味的是,這個作者就是面試 programmer one 的人  ,作者回復了……

  在這一部分中,我們來看看如何實現這些著名的快取演算法。以下的程式碼只是示例用的,如果你想自己實現快取演算法,可能自己還得加上一些額外的工作。

LeftOver 機制

  在 programmer one 閱讀了文章之後,他接著看了文章的評論,其中有一篇評論提到了 leftover 機制——random cache。

Random Cache

  我是隨機快取,我隨意的替換快取實體,沒人敢抱怨。你可以說那個被替換的實體很倒黴。通過這些行為,我隨意的去處快取實體。我比 FIFO 機制好,在某些情況下,我甚至比 LRU 好,但是,通常LRU都會比我好。

現在是評論時間

  當 programmer one 繼續閱讀評論的時候,他發現有個評論非常有趣,這個評論實現了一些快取演算法,應該說這個評論做了一個鏈向評論者網站的連結,programmer one順著連結到了那個網站,接著閱讀。

看看快取元素(快取實體)

public class CacheElement
 {
     private Object objectValue;
     private Object objectKey;
     private int index;
     private int hitCount; // getters and setters
 }

  這個快取實體擁有快取的key和value,這個實體的資料結構會被以下所有快取演算法用到。

快取演算法的公用程式碼

 public final synchronized void addElement(Object key, Object value)
 {
     int index;
     Object obj;
     // get the entry from the table
     obj = table.get(key);
     // If we have the entry already in our table
     // then get it and replace only its value.
     obj = table.get(key);

     if (obj != null)
     {
         CacheElement element;
         element = (CacheElement) obj;
         element.setObjectValue(value);
         element.setObjectKey(key);
         return;
     }
 }

  上面的程式碼會被所有的快取演算法實現用到。這段程式碼是用來檢查快取元素是否在快取中了,如果是,我們就替換它,但是如果我們找不到這個 key 對應的快取,我們會怎麼做呢?那我們就來深入的看看會發生什麼吧!

現場訪問

  今天的專題很特殊,因為我們有特殊的客人,事實上他們是我們想要聽的與會者,但是首先,先介紹一下我們的客人:Random Cache,FIFO Cache。讓我們從 Random Cache開始。

看看隨機快取的實現

 public final synchronized void addElement(Object key, Object value)
 {
     int index;
     Object obj;
     obj = table.get(key);
     if (obj != null)
     {
         CacheElement element;// Just replace the value.
         element = (CacheElement) obj;
         element.setObjectValue(value);
         element.setObjectKey(key);
         return;
     }// If we haven't filled the cache yet, put it at the end.
     if (!isFull())
     {
         index = numEntries;
         ++numEntries;
     }
     else { // Otherwise, replace a random entry.
         index = (int) (cache.length * random.nextFloat());
         table.remove(cache[index].getObjectKey());
     }
     cache[index].setObjectValue(value);
     cache[index].setObjectKey(key);
     table.put(key, cache[index]);
 }

看看FIFO緩演算法的實現

 public final synchronized void addElement(Objectkey, Object value)
 {
     int index;
     Object obj;
     obj = table.get(key);
     if (obj != null)
     {
         CacheElement element; // Just replace the value.
         element = (CacheElement) obj;
         element.setObjectValue(value);
         element.setObjectKey(key);
         return;
     }
     // If we haven't filled the cache yet, put it at the end.
     if (!isFull())
     {
         index = numEntries;
         ++numEntries;
     }
     else { // Otherwise, replace the current pointer,
            // entry with the new one.
         index = current;
         // in order to make Circular FIFO
         if (++current >= cache.length)
             current = 0;
         table.remove(cache[index].getObjectKey());
     }
     cache[index].setObjectValue(value);
     cache[index].setObjectKey(key);
     table.put(key, cache[index]);
 }

看看LFU快取演算法的實現

 public synchronized Object getElement(Object key)
 {
     Object obj;
     obj = table.get(key);
     if (obj != null)
     {
         CacheElement element = (CacheElement) obj;
         element.setHitCount(element.getHitCount() + 1);
         return element.getObjectValue();
     }
     return null;
 }
 public final synchronized void addElement(Object key, Object value)
 {
     Object obj;
     obj = table.get(key);
     if (obj != null)
     {
         CacheElement element; // Just replace the value.
         element = (CacheElement) obj;
         element.setObjectValue(value);
         element.setObjectKey(key);
         return;
     }
     if (!isFull())
     {
         index = numEntries;
         ++numEntries;
     }
     else
     {
         CacheElement element = removeLfuElement();
         index = element.getIndex();
         table.remove(element.getObjectKey());
     }
     cache[index].setObjectValue(value);
     cache[index].setObjectKey(key);
     cache[index].setIndex(index);
     table.put(key, cache[index]);
 }
 public CacheElement removeLfuElement()
 {
     CacheElement[] elements = getElementsFromTable();
     CacheElement leastElement = leastHit(elements);
     return leastElement;
 }
 public static CacheElement leastHit(CacheElement[] elements)
 {
     CacheElement lowestElement = null;
     for (int i = 0; i < elements.length; i++)
     {
         CacheElement element = elements[i];
         if (lowestElement == null)
         {
             lowestElement = element;
         }
         else {
             if (element.getHitCount() < lowestElement.getHitCount())
             {
                 lowestElement = element;
             }
         }
     }
     return lowestElement;
 }

今天的專題很特殊,因為我們有特殊的客人,事實上他們是我們想要聽的與會者,但是首先,先介紹一下我們的客人:Random Cache, FIFO Cache。讓我們從 Random Cache開始。

  最重點的程式碼,就應該是 leastHit 這個方法,這段程式碼就是把
hitCount 最低的元素找出來,然後刪除,給新進的快取元素留位置。

看看LRU快取演算法實現

 private void moveToFront(int index)
 {
     int nextIndex, prevIndex;
     if(head != index)
     {
         nextIndex = next[index];
         prevIndex = prev[index];
         // Only the head has a prev entry that is an invalid index
         // so we don't check.
         next[prevIndex] = nextIndex;
         // Make sure index is valid. If it isn't, we're at the tail
         // and don't set prev[next].
         if(nextIndex >= 0)
             prev[nextIndex] = prevIndex;
         else
             tail = prevIndex;
         prev[index] = -1;
         next[index] = head;
         prev[head] = index;
         head = index;
     }
 }
 public final synchronized void addElement(Object key, Object value)
 {
     int index;Object obj;
     obj = table.get(key);
     if(obj != null)
     {
         CacheElement entry;
         // Just replace the value, but move it to the front.
         entry = (CacheElement)obj;
         entry.setObjectValue(value);
         entry.setObjectKey(key);
         moveToFront(entry.getIndex());
         return;
     }
     // If we haven't filled the cache yet, place in next available
     // spot and move to front.
     if(!isFull())
     {
         if(_numEntries > 0)
         {
             prev[_numEntries] = tail;
             next[_numEntries] = -1;
             moveToFront(numEntries);
         }
         ++numEntries;
     }
     else { // We replace the tail of the list.
         table.remove(cache[tail].getObjectKey());
         moveToFront(tail);
     }
     cache[head].setObjectValue(value);
     cache[head].setObjectKey(key);
     table.put(key, cache[head]);
 }

  這段程式碼的邏輯如 LRU演算法 的描述一樣,把再次用到的快取提取到最前面,而每次刪除的都是最後面的元素。

結論

  我們已經看到 LFU快取演算法 和 LRU快取演算法的實現方式,至於如何實現,採用陣列還是 LinkedHashMap,都由你決定,不夠我一般是小的快取容量用陣列,大的用 LinkedHashMap。

  英文原文:jtraining

相關文章