緩存系統設計精要

在計算機領域,緩存在程序設計過程當中扮演着重要角色。瀏覽器的資源緩存策略是影響web網站性能的一個關鍵因素;mysql的Buffer Pool極大的提升了數據庫的查詢效率;redis做爲被普遍應用的緩存數據庫,提供了豐富的數據結構和緩存策略來知足開發者的需求。緩存在現代計算機系統中無處不在,是一個很是深刻、普遍的話題,本文的目的是從衆多已有的系統設計中,提取出有關係統緩存設計的精要,主要從三個方面展開:php

  • 多級緩存
  • 緩存淘汰策略
  • 緩存安全

在討論這些話題的同時,筆者會結合不少實際的例子闡述,其中會涉及到PHP源碼、redis源碼、瀏覽器的緩存策略、mysql數據存儲設計、緩存淘汰算法等偏底層的內容,若是你是一個比較資深的開發工程師,對這些內容都比較瞭解,本文將很是適合您。爲了照顧初學者,涉及到難懂重要的知識點筆者也將會盡量的描述清楚。本文篇幅有點長,請讀者作好心理預期!css

1.多級緩存

1.1 存儲器層次結構

計算機使用不一樣的存儲技術對數據進行訪問,時間差別很大。速度較快的技術每一個字節的成本要比速度較慢的技術高,並且容量較小。CPU和主存(內存)之間的速度差距在增大。基於這種特性,全部的現代計算機系統都使用了金字塔式的存儲器層次結構。以下圖所示:html

存儲器層次結構

從高層往低層走,存儲設備變得更慢、更便宜、容量更大。最高層(L0)是少許快速的CPU寄存器,CPU能夠在一個時鐘週期內訪問它們 (一個時鐘週期一般小於1ns);接下來是一個或多箇中小型SRAM高速緩存存儲器,能夠在幾個CPU時鐘週期內訪問它們;而後是一個大的基於DRAM的主存,能夠在幾十到幾百個CPU時鐘週期內訪問它們;接下來是慢速可是容量很大的本地磁盤(一般是SSD);最後有些系統甚至包括了一層附加的網絡文件系統(Network File System,NFS),好比說騰訊雲提供的CFS服務就能夠經過掛載到多個服務器實現分佈式的數據共享。前端

下面咱們從 CPU 的角度以具體的數據來量化對比CPU、磁盤、網絡的速度,對計算機各個組件不一樣的速度差別有個更直觀的認識。node

類型 緩存內容 緩存位置 讀取數據須要時長 對比基本單位時長
CPU寄存器 4字節 & 8字節 芯片上的CPU寄存器 0.38ns 1s
L1高速緩存 64字節塊 芯片上的L1高速緩存 0.5ns 1.3s
L2高速緩存 64字節塊 芯片上的L2高速緩存 7ns 18.2s
L3高速緩存 64字節塊 芯片上的L3高速緩存 35ns 91s
內存 4KB頁 主存 100ns 4分鐘
固態硬盤SSD 磁盤扇區 磁盤控制器 150us 4.5天
網絡磁盤(NFS本地掛載) 部分文件 本地磁盤 1.5ms 一個半月

CPU 和內存之間的瓶頸被稱爲馮諾依曼瓶頸, 它們之間至少有着幾百倍的速差,能夠想象整天上人間,天上一天,地下一年。普通的磁盤讀取數據就更慢了,尋址時間10ms,對比CPU的基本單位時長是10個月,能夠生一次孩子了!因此說IO 設備是計算機系統的瓶頸。以此想到咱們網絡中的API請求,動不動就是一百多毫秒,對比CPU的基本單位就是十幾年!mysql

相信經過以上描述,讀者對計算機組件之間數據處理速度有了深刻的印象。程序員

1.2 局部性原理

一個編寫良好的計算機程序一般具備良好的局部性(locality)。也就是,它們傾向於引用鄰近於其餘最近引用過的數據項的數據項,或者最近引用過的數據項自己。這種傾向性,被稱爲局部性原理(principle of locality),是一個持久的概念,對硬件和軟件系統的設計和性能都有着極大的影響。web

局部性一般有兩種不一樣的形式: 時間局部性(temporal locality)空間局部性(spatial locality)面試

  • 時間局部性:被引用過一次的內存位置極可能在不遠的未來再被屢次引用。
  • 空間局部性:若是一個內存位置被引用了一次,那麼程序極可能在不遠的未來引用附近的一個內存位置。

Mysql數據庫Innodb引擎的設計就很好的考慮了局部性原理。redis

Innodb引擎以頁的形式組織數據,頁的默認大小是16KB,存放用戶數據記錄的頁叫「數據頁」,爲了實現數據更快的查找,InnoDB使用B+樹的結構組織存放數據,最下層的葉子節點存放「數據頁」,在「數據頁」的上層抽出相應的「目錄頁」,最終造成的基本結構以下圖所示:

InnoDB數據存儲

數據頁中記錄是連續存放的,當須要訪問某個頁的數據時,就會把完整的頁的數據所有加載到內存中,也就是說即便咱們只須要訪問一個頁的一條記錄,那也須要先把整個頁的數據加載到內存中。這就利用了局部性原理中的「空間局部性」。將整個頁加載到內存中後就能夠進行讀寫訪問了,在進行完讀寫訪問以後並不着急把該頁對應的內存空間釋放掉,而是將其緩存到Buffer Pool中,這樣未來有請求再次訪問該頁面時,就能夠省去磁盤IO的開銷了,這又利用了局部性原理中的「時間局部性」。

局部性原理是系統緩存設計中很是重要的理論基石,下文還會屢次提到。

1.3 Cpu 高速緩存

本節咱們來聊一聊Cpu 高速緩存,Cpu 高速緩存是影響程序性能的一個關鍵因素。

1.3.1 什麼是Cpu 高速緩存?

筆者直接引用維基百科中對Cpu 高速緩存的描述:

在計算機系統中,CPU高速緩存(英語:CPU Cache,在本文中簡稱緩存)是用於減小處理器訪問內存所需平均時間的部件。在金字塔式存儲體系中它位於自頂向下的第二層,僅次於CPU寄存器。其容量遠小於內存,但速度卻能夠接近處理器的頻率。 當處理器發出內存訪問請求時,會先查看緩存內是否有請求數據。若是存在(命中),則不經訪問內存直接返回該數據;若是不存在(失效),則要先把內存中的相應數據載入緩存,再將其返回處理器。 緩存之因此有效,主要是由於程序運行時對內存的訪問呈現局部性(Locality)特徵。這種局部性既包括空間局部性(Spatial Locality),也包括時間局部性(Temporal Locality)。有效利用這種局部性,緩存能夠達到極高的命中率。 在處理器看來,緩存是一個透明部件。所以,程序員一般沒法直接干預對緩存的操做。可是,確實能夠根據緩存的特色對程序代碼實施特定優化,從而更好地利用緩存。

1.3.2 爲何須要有Cpu 高速緩存?

上文中咱們提到馮諾依曼瓶頸。隨着工藝的提高,最近幾十年CPU的頻率不斷提高,而受制於製造工藝和成本限制,目前計算機的內存在訪問速度上沒有質的突破。所以,CPU的處理速度和內存的訪問速度差距愈來愈大,這種狀況下傳統的 CPU 直連內存的方式顯然就會由於內存訪問的等待,致使計算資源大量閒置,下降 CPU 總體吞吐量。同時又因爲內存數據訪問的熱點集中性,在 CPU 和內存之間用較爲快速而成本較高(相對於內存)的介質作一層緩存,就顯得性價比極高了。

1.3.3 Cpu 高速緩存架構

早期計算機系統的存儲器層次結構只有三層:CPU寄存器、DRAM主存儲器和磁盤存儲。因爲CPU和主存之間逐漸增大的差距,系統設計者被迫在CPU寄存器文件和主存之間插入了一個小的SRAM高速緩存存儲器,稱爲L1高速緩存(一級緩存),以下圖所示。L1高速緩存的訪問速度幾乎和寄存器相差無幾。

CPU芯片示意圖

隨着CPU和主存之間的性能差距不斷增大,系統設計者在L1高速緩存和主存之間又插入了一個更大的高速緩存,稱爲L2高速緩存,能夠在大約10個時鐘週期內訪問到它。現代的操做系統還包括一個更大的高速緩存,稱爲L3高速緩存,在存儲器的層次結構中,它位於L2高速緩存和主存之間,能夠在大約50個週期內訪問到它。下圖是簡單的高速緩存架構:

高速緩存架構

數據的讀取和存儲都通過高速緩存,CPU 核心與高速緩存有一條特殊的快速通道;主存與高速緩存都連在系統總線上(BUS),這條總線還用於其餘組件的通訊。

1.3.4 PHP7數組的設計

關於Cpu 高速緩存的應用,PHP7數組的設計是一個很是經典的案例。在PHP中數組是最經常使用的一種數據類型,提高數組的性能會使程序總體性能獲得提高。咱們經過對比PHP7和PHP5 數組的設計,來學習一下PHP 設計者爲了提高PHP數組的性能,都進行了哪些思考。

咱們先來總結一下PHP數組的特性:

  • php中的數組是一個字典dict,存儲着key-value對,經過key能夠快速的找到value,其中key能夠是整形,也能夠是字符串(hash array 和 packed array)。
  • 數組是有序的:插入有序、遍歷有序。

PHP中數組使用hashTable實現,咱們先來了解一下什麼是hashTable。

hashTable是一種經過某種hash函數將特定的key映射到特定value的一種數據結構,它維護着鍵和值的一一對應關係,而且能夠快速的根據鍵找到值(o(1)). 通常HashTable的示意圖以下:

hashTable示意圖

1) key: 經過key能夠快速找到value。通常能夠爲數字或者字符串。
2) value: 值能夠爲複雜結構
3) bucket: 桶,HashTable存儲數據的單元,用來存儲key/value以及其餘輔助信息的容器
4) slot:槽,HashTable有多少個槽,一個bucket必須屬於具體的某一個slot,一個slot能夠有多個
   bucket
5) 哈希函數:通常都是本身實現(time33),在存儲的時候,會將key經過hash函數肯定所在的slot
6) 哈希衝突: 當多個key通過哈希計算後,獲得的slot位置是同一個,那麼就會衝突,通常這個時候會有
   2種解決辦法:鏈地址法和開放地址法。其中php採用的是鏈地址法,即將同一個slot中的bucket經過
   鏈表鏈接起來
複製代碼

爲了實現數組的插入有序和遍歷有序,PHP5使用hashTable + 雙鏈表實現數組,以下圖是將key分別爲:「a」,"b","c","d",值爲「1」,"2","3","4" 插入到數組中後的效果:

PHP5數組實現

上圖中b,c,d所在的bucket被存分配到了同一個slot,爲了解決hash衝突,slot中多個bucket經過雙向鏈表關聯,稱爲局部雙向鏈表;爲了實現有序,slot之間也經過雙向鏈表關聯,稱爲全局雙向鏈表

瞭解php5數組的實現原理以後,咱們發現其中影響性能的兩個關鍵點:

  1. 頻繁的內存分配!每向數組中添加一個元素,都須要申請分配一個bucket大小的內存,而後維護多個指針。雖然php是基於內存池的管理方式(預先申請大塊內存進行按需分配),可是內存分配帶來的性能損耗是不可忽略的。
  2. Cpu 高速緩存命中率低!由於bucket與bucket之間是經過鏈表指針鏈接的,bucket隨機分配,內存基本不連續,致使Cpu cache下降,不能給遍歷數組帶來性能提高。

針對以上兩點,php7對hashTable的設計進行了改造。既然是字典,仍是要使用hashTable,但php7數組的實現去掉了slot,bucket的分配使用了連續內存;bucket間再也不使用真實存在的指針進行維護,bucket只維護一個在數組中的索引,bucket與bucket之間經過內存地址進行關聯,以下圖所示:

PHP7中hashTable實現

PHP7中數組是如何解決hash衝突的

PHP7對zval進行了改造,zval中有一個u2的union聯合體,佔用4字節大小,存放一些輔助信息。PHP7數組中的value也是一個個的zval指針,當發生hash衝突時,zval中u2部分存放的next指針存放指向下一個bucket數組中的索引,經過這樣一種邏輯上的鏈表就巧妙解決hash衝突。關於PHP7中zval的設計,推薦你們閱讀鳥哥的文章:深刻理解PHP7內核之zval

能夠看到,php7中hashTable對性能提高最重要的改動是bucket的分配使用了連續內存。這樣不只省去了數組元素插入時頻繁的內存分配開銷;遍歷數組也只須要一個bucket一個bucket的訪問就行了,Cpu 高速緩存命中率很是高,極大的提高了數組性能;符合局部性原理。

更多關於PHP5和PHP7數組的設計細節,推薦你們研究這篇文章:【PHP7源碼學習】系列之數組實現

1.4 瀏覽器的多級緩存機制

舉一個多級緩存的應用案例:瀏覽器對咱們生活的影響是不言而喻的,現代的瀏覽器已經不一樣往昔了,快速的信息加載,順暢的用戶交互,這一切都得益於網絡技術的普及、H5標準特性的推廣應用,固然還有一個重要因素,那就是瀏覽器的緩存策略。本節咱們就來簡單介紹一下瀏覽器的多級緩存機制。

對於一個數據請求來講,能夠分爲發起網絡請求、後端處理、瀏覽器響應三個步驟。瀏覽器緩存能夠幫助咱們在第一和第三步驟中優化性能。好比說直接使用緩存而不發起請求,或者發起了請求但後端存儲的數據和前端一致,那麼就沒有必要再將數據回傳回來,這樣就減小了響應數據。

瀏覽器有四種緩存級別,它們各自都有優先級。

  • Service Worker
  • Memory Cache
  • Disk Cache
  • Push Cache

當依次查找緩存且都沒有命中的時候,纔會去請求網絡。

1.4.1 Service Worker

Service Worker 是運行在瀏覽器背後的獨立線程,通常能夠用來實現緩存功能。使用 Service Worker的話,傳輸協議必須爲 HTTPS。由於 Service Worker 中涉及到請求攔截,因此必須使用 HTTPS 協議來保障安全。Service Worker 的緩存與瀏覽器其餘內建的緩存機制不一樣,它可讓咱們自由控制緩存哪些文件、如何匹配緩存、如何讀取緩存,而且緩存是持續性的

Service Worker 實現緩存功能通常分爲三個步驟:首先須要先註冊 Service Worker,而後監聽到 install 事件之後就能夠緩存須要的文件,那麼在下次用戶訪問的時候就能夠經過攔截請求的方式查詢是否存在緩存,存在緩存的話就能夠直接讀取緩存文件,不然就去請求數據。

當 Service Worker 沒有命中緩存的時候,咱們須要去調用 fetch 函數獲取數據。也就是說,若是咱們沒有在 Service Worker 命中緩存的話,會根據緩存查找優先級去查找數據。可是無論咱們是從 Memory Cache 中仍是從網絡請求中獲取的數據,瀏覽器都會顯示咱們是從 Service Worker 中獲取的內容。

1.4.2 Memory Cache

Memory Cache 也就是內存中的緩存,主要包含的是當前頁面中已經抓取到的資源,例如頁面上已經下載的樣式、腳本、圖片等。讀取內存中的數據確定比磁盤快,內存緩存雖然讀取高效,但是緩存持續性很短,會隨着進程的釋放而釋放。 一旦咱們關閉 Tab 頁面,內存中的緩存也就被釋放了。

當咱們訪問過頁面之後,再次刷新頁面,能夠發現不少數據都來自於內存緩存

瀏覽器內存緩存

內存緩存中有一塊重要的緩存資源是preloader相關指令(例如<link rel="prefetch">)下載的資源。衆所周知preloader的相關指令已是頁面優化的常見手段之一,它能夠一邊解析js/css文件,一邊網絡請求下一個資源。

須要注意的事情是,內存緩存在緩存資源時並不關心返回資源的HTTP緩存頭Cache-Control是什麼值,同時資源的匹配也並不是僅僅是對URL作匹配,還可能會對Content-Type,CORS等其餘特徵作校驗。

爲何瀏覽器數據不都存放在內存中

這個問題就算我不回答,讀者確定也都想明白了,計算機中的內存必定比硬盤容量小得多,操做系統須要精打細算內存的使用,因此能讓咱們使用的內存必然很少。谷歌瀏覽器是公認的性能出衆的,可是它佔用的內存也是很大的。

1.4.3 Disk Cache

Disk Cache 也就是存儲在硬盤中的緩存,讀取速度慢點,可是什麼都能存儲到磁盤中,比之 Memory Cache 勝在容量和存儲時效性上。

在全部瀏覽器緩存中,Disk Cache 覆蓋面基本是最大的。它會根據 HTTP Herder 中的字段判斷哪些資源須要緩存,哪些資源能夠不請求直接使用,哪些資源已通過期須要從新請求。而且即便在跨站點的狀況下,相同地址的資源一旦被硬盤緩存下來,就不會再次去請求數據。絕大部分的緩存都來自 Disk Cache。

瀏覽器會把哪些文件丟進內存中?哪些丟進硬盤中

關於這點,網上說法不一,不過有些觀點比較靠得住:對於大文件來講,大機率是不存儲在內存中的,另外若是當前系統內存使用率高的話,文件優先存儲進硬盤。

1.4.4 Push Cache

Push Cache(推送緩存)是 HTTP/2 中的內容,當以上三種緩存都沒有命中時,它纔會被使用。它只在會話(Session)中存在,一旦會話結束就被釋放,而且緩存時間也很短暫,在Chrome瀏覽器中只有5分鐘左右,同時它也並不是嚴格執行HTTP頭中的緩存指令。

Push Cache 在國內可以查到的資料不多,也是由於 HTTP/2 在國內不夠普及。這裏推薦閱讀Jake Archibald的 HTTP/2 push is tougher than I thought 這篇文章,文章中的幾個結論是:

  • 全部的資源都能被推送,而且可以被緩存,可是 Edge 和 Safari 瀏覽器支持相對比較差
  • 能夠推送 no-cache 和 no-store 的資源
  • 一旦鏈接被關閉,Push Cache 就被釋放
  • 多個頁面能夠使用同一個HTTP/2的鏈接,也就能夠使用同一個Push Cache。這主要仍是依賴瀏覽器的實現而定,出於對性能的考慮,有的瀏覽器會對相同域名但不一樣的tab標籤使用同一個HTTP鏈接。
  • Push Cache 中的緩存只能被使用一次
  • 瀏覽器能夠拒絕接受已經存在的資源推送
  • 你能夠給其餘域名推送資源

若是以上四種緩存都沒有命中的話,那麼只能發起請求來獲取資源了。

瀏覽器的多級緩存機制到這裏就介紹完了,你們能夠看到,瀏覽器多級緩存機制的實現思路,和咱們前邊說到的計算機系統多級緩存的知識是相呼應的,而且也知足局部性原理。瀏覽器緩存還有更多值得咱們深刻學習的內容,感興趣的讀者能夠研究一下這篇文章:深刻理解瀏覽器的緩存機制

2. 緩存淘汰策略

根據金字塔存儲器層次模型咱們知道:CPU訪問速度越快的存儲組件容量越小。在業務場景中,最經常使用的存儲組件是內存和磁盤,咱們每每將經常使用的數據緩存到內存中加快數據的讀取速度,redis做爲內存緩存數據庫的設計也是基於這點考慮的。可是服務器的內存有限,不可能不斷的將數據存入內存中而不淘汰。何況內存佔用過大,也會影響服務器其它的任務,因此咱們要作到經過淘汰算法讓內存中的緩存數據發揮最大的價值。

本節將介紹業務中最經常使用的三種緩存淘汰算法:

  • Least Recently Used(LRU)淘汰最長時間未被使用的頁面,以時間做爲參考
  • Least Frequently Used(LFU)淘汰必定時期內被訪問次數最少的頁面,以次數做爲參考
  • 先進先出算法(FIFO)

筆者會結合Redis、Mysql中緩存淘汰的具體實現機制來幫助讀者學習到,Mysql和Redis的開發者是怎樣結合各自的業務場景,對已有的緩存算法進行改進來知足業務需求的。

2.1 Least Recently Used(LRU)

LRU是Least Recently Used的縮寫,這種算法認爲最近使用的數據是熱門數據,下一次很大機率將會再次被使用。而最近不多被使用的數據,很大機率下一次再也不用到。該思想很是契合業務場景 ,而且能夠解決不少實際開發中的問題,因此咱們常常經過LRU的思想來做緩存,通常也將其稱爲LRU緩存機制。

2.1.1 LRU緩存淘汰算法實現

本節筆者以leetcode上的一道算法題爲例,使用代碼(Go語言)實現LRU算法,幫助讀者更深刻的理解LRU算法的思想。

leetcode 146: LRU緩存機制

運用你所掌握的數據結構,設計和實現一個  LRU (最近最少使用) 緩存機制。它應該支持如下操做: 獲取數據 get 和 寫入數據 put 。

獲取數據 get(key) - 若是密鑰 (key) 存在於緩存中,則獲取密鑰的值(老是正數),不然返回 -1。 寫入數據 put(key, value) - 若是密鑰已經存在,則變動其數據值;若是密鑰不存在,則插入該組「密鑰/數據值」。當緩存容量達到上限時,它應該在寫入新數據以前刪除最久未使用的數據值,從而爲新的數據值留出空間。

示例:

LRUCache cache = new LRUCache( 2 /* 緩存容量 */ );
cache.put(1, 1);
cache.put(2, 2);
cache.get(1);       // 返回  1
cache.put(3, 3);    // 該操做會使得密鑰 2 做廢
cache.get(2);       // 返回 -1 (未找到)
cache.put(4, 4);    // 該操做會使得密鑰 1 做廢
cache.get(1);       // 返回 -1 (未找到)
cache.get(3);       // 返回  3
cache.get(4);       // 返回  4
複製代碼

咱們採用hashmap+雙向鏈表的方式進行實現。能夠在 O(1)時間內完成 put 和 get 操做,同時也支持 O(1) 刪除第一個添加的節點。

LRU數據結構實現

使用雙向鏈表的一個好處是不須要額外信息刪除一個節點,同時能夠在常數時間內從頭部或尾部插入刪除節點。

一個須要注意的是,在雙向鏈表實現中,這裏使用一個僞頭部和僞尾部標記界限,這樣在更新的時候就不須要檢查是不是 null 節點。

代碼以下:

type LinkNode struct {
    key, val  int
    pre, next *LinkNode
}

type LRUCache struct {
    m          map[int]*LinkNode
    cap        int
    head, tail *LinkNode
}

func Constructor(capacity int) LRUCache {
    head := &LinkNode{0, 0, nil, nil}
    tail := &LinkNode{0, 0, nil, nil}
    head.next = tail
    tail.pre = head
    return LRUCache{make(map[int]*LinkNode), capacity, head, tail}
}
複製代碼

這樣就初始化了一個基本的數據結構,大概是這樣:

初始化LRU數據結構

接下來咱們爲Node節點添加一些必要的操做方法,用於完成接下來的Get和Put操做:

func (this *LRUCache) RemoveNode(node *LinkNode) {
    node.pre.next = node.next
    node.next.pre = node.pre
}

func (this *LRUCache) AddNode(node *LinkNode) {
    head := this.head
    node.next = head.next
    head.next.pre = node
    node.pre = head
    head.next = node
}

func (this *LRUCache) MoveToHead(node *LinkNode) {
    this.RemoveNode(node)
    this.AddNode(node)
}
複製代碼

由於Get比較簡單,咱們先完成Get方法。這裏分兩種狀況考慮:

  • 若是沒有找到元素,咱們返回-1。
  • 若是元素存在,咱們須要把這個元素移動到首位置上去。
func (this *LRUCache) Get(key int) int {
    cache := this.m
    if v, exist := cache[key]; exist {
        this.MoveToHead(v)
        return v.val
    } else {
        return -1
    }
}
複製代碼

如今咱們開始完成Put。實現Put時,有兩種狀況須要考慮。

  • 倘若元素存在,其實至關於作一個Get操做,也是移動到最前面(可是須要注意的是,這裏多了一個更新值的步驟)。
  • 倘若元素不存在,咱們將其插入到元素首,並把該元素值放入到map中。若是剛好此時Cache中元素滿了,須要刪掉最後的元素。

處理完畢,附上Put函數完整代碼。

func (this *LRUCache) Put(key int, value int) {
    tail := this.tail
    cache := this.m
    if v, exist := cache[key]; exist {
        v.val = value
        this.MoveToHead(v)
    } else {
        v := &LinkNode{key, value, nil, nil}
        if len(cache) == this.cap {
            delete(cache, tail.pre.key)
            this.RemoveNode(tail.pre)
        }
        this.AddNode(v)
        cache[key] = v
    }
}
複製代碼

至此,咱們就完成了一個LRU算法,附上完整的代碼:

type LinkNode struct {
    key, val  int
    pre, next *LinkNode
}

type LRUCache struct {
    m          map[int]*LinkNode
    cap        int
    head, tail *LinkNode
}

func Constructor(capacity int) LRUCache {
    head := &LinkNode{0, 0, nil, nil}
    tail := &LinkNode{0, 0, nil, nil}
    head.next = tail
    tail.pre = head
    return LRUCache{make(map[int]*LinkNode), capacity, head, tail}
}

func (this *LRUCache) Get(key int) int {
    cache := this.m
    if v, exist := cache[key]; exist {
        this.MoveToHead(v)
        return v.val
    } else {
        return -1
    }
}

func (this *LRUCache) RemoveNode(node *LinkNode) {
    node.pre.next = node.next
    node.next.pre = node.pre
}

func (this *LRUCache) AddNode(node *LinkNode) {
    head := this.head
    node.next = head.next
    head.next.pre = node
    node.pre = head
    head.next = node
}

func (this *LRUCache) MoveToHead(node *LinkNode) {
    this.RemoveNode(node)
    this.AddNode(node)
}

func (this *LRUCache) Put(key int, value int) {
    tail := this.tail
    cache := this.m
    if v, exist := cache[key]; exist {
        v.val = value
        this.MoveToHead(v)
    } else {
        v := &LinkNode{key, value, nil, nil}
        if len(cache) == this.cap {
            delete(cache, tail.pre.key)
            this.RemoveNode(tail.pre)
        }
        this.AddNode(v)
        cache[key] = v
    }
}
複製代碼

2.1.2 Mysql緩衝池LRU算法

在使用Mysql的業務場景中,若是使用上述咱們描述的LRU算法來淘汰策略,會有一個問題。例如執行下面一條查詢語句:

select * from table_a;
複製代碼

若是 table_a 中有大量數據而且讀取以後不會繼續使用,則LRU頭部會被大量的 table_a 中的數據佔據,這樣會形成熱點數據被逐出緩存從而致使大量的磁盤IO。因此Mysql緩衝池採用the least recently used(LRU)算法的變體,將緩衝池做爲列表進行管理

Mysql的緩衝池

緩衝池(Buffer Pool)是主緩存器的一個區域,用於緩存索引、行的數據、自適應哈希索引、插入緩存(Insert Buffer)、鎖 還有其餘的內部數據結構。Buffer Pool的大小是能夠根據咱們實際的需求進行更改,那麼Buffer Pool的size取多少比較合適?MySQL官網上是這麼介紹,在專用服務器上(也就是服務器只承載一個MySQL實例),會將80%的物理內存給到Buffer Pool。Buffer Pool容許直接從內存中讀經常使用數據去處理,會加快不少的速度。爲了提升大容量讀取操做的效率,Buffer Pool被分紅能夠容納多行的頁面。爲了提升緩存管理的效率,Buffer Pool被實現爲頁面對應的連接的列表(a linked list of pages)。

Mysql數據庫Buffer Pool結構:

Mysql中Buffer Pool結構

當須要空間將新頁面緩存到緩衝池的時候,最近最少使用的頁面將被釋放出去,並將新的頁面加入列表的中間。這個中間點插入的策略將列表分爲兩個子列表:

  • 頭部是存放最近訪問過的新頁面的子列表

  • 尾部是存放那些最近訪問較少的舊頁面的子列表

該算法將保留 new sublist(也就是結構圖中頭部)中大量被查詢使用的頁面。而old sublist 裏面較少被使用的頁面做爲被釋放的候選項。

Buffer Pool的工做原理

理解如下幾個關鍵點是比較重要的:

  • 3/8的緩衝池專用於old sublist(也就是3/8來保存舊的頁面,其他部分用來存儲熱數據,存儲熱數據的部分相對大一些,固然這個值能夠本身去調整,經過innodb_old_blocks_pct,其默認值是37,也就是3/8*100,上限100,可調整 5-95,能夠改小一些,可是調整後儘可能保證這個比例內的數據也就是old sublist中的數據只會被讀取一次,若不是瞭解很是業務數據負載建議不要去修改。)
  • 列表的中點是新子列表的尾部與舊子列表的頭部相交的邊界。
  • 當InnoDB將頁面讀入緩衝池時,它最初將其插入中點(舊子列表的頭部)。一個頁面會被讀取是由於它是用戶指定的操做(如SQL查詢)所需,或者是由InnoDB自動執行的預讀操做的一部分 。
  • 訪問舊子列表中的頁面使其 「 年輕 」,將其移動到緩衝池的頭部(新子列表的頭部)。若是頁面是被須要好比(SQL)讀取的,它會立刻訪問舊列表並將頁面推入新列表頭部。若是預讀須要讀取的頁面,則不會發生對舊列表的first access。
  • 隨着數據庫的運行,在緩衝池的頁面沒有被訪問則向列表的尾部移動。新舊子列表中的頁面隨着其餘頁面的變化而變舊。舊子列表中的頁面也會隨着頁面插入中點而老化。最終,仍未使用的頁面到達舊子列表的尾部並被釋放。默認狀況下,頁面被查詢讀取將被當即移入新列表中,在Buffer Pool中存在更長的時間。

經過對LRU算法的改進,InnoDB引擎解決了查詢數據量大時,熱點數據被逐出緩存從而致使大量的磁盤IO的問題

2.1.3 Redis近似LRU實現

因爲真實LRU須要過多的內存(在數據量比較大時);而且Redis以高性能著稱,真實的LRU須要每次訪問數據時都作相關的調整,勢必會影響Redis的性能發揮;這些都是Redis開發者所不能接受的,因此Redis使用一種隨機抽樣的方式,來實現一個近似LRU的效果。

在Redis中有一個參數,叫作 「maxmemory-samples」,是幹嗎用的呢?

1 # LRU and minimal TTL algorithms are not precise algorithms but approximated 
 2 # algorithms (in order to save memory), so you can tune it for speed or 
 3 # accuracy. For default Redis will check five keys and pick the one that was 
 4 # used less recently, you can change the sample size using the following 
 5 # configuration directive. 
 6 # 
 7 # The default of 5 produces good enough results. 10 Approximates very closely 
 8 # true LRU but costs a bit more CPU. 3 is very fast but not very accurate. 
 9 # 
10 maxmemory-samples 5
複製代碼

上面咱們說過了,近似LRU是用隨機抽樣的方式來實現一個近似的LRU效果。這個參數其實就是做者提供了一種方式,可讓咱們人爲干預樣本數大小,將其設的越大,就越接近真實LRU的效果,固然也就意味着越耗內存。(初始值爲5是做者默認的最佳)

Redis中近LRU的實現

左上圖爲理想中的LRU算法,新增長的key和最近被訪問的key都不該該被逐出。能夠看到,Redis2.8當每次隨機採樣5個key時,新增長的key和最近訪問的key都有必定機率被逐出;Redis3.0增長了pool後效果好一些(右下角的圖)。當Redis3.0增長了pool而且將採樣key增長到10個後,基本等同於理想中的LRU(雖然仍是有一點差距)。

Redis中對LRU代碼實現也比較簡單。Redis維護了一個24位時鐘,能夠簡單理解爲當前系統的時間戳,每隔必定時間會更新這個時鐘。每一個key對象內部一樣維護了一個24位的時鐘,當新增key對象的時候會把系統的時鐘賦值到這個內部對象時鐘。好比我如今要進行LRU,那麼首先拿到當前的全局時鐘,而後再找到內部時鐘與全局時鐘距離時間最久的(差最大)進行淘汰,這裏值得注意的是全局時鐘只有24位,按秒爲單位來表示才能存儲194天,因此可能會出現key的時鐘大於全局時鐘的狀況,若是這種狀況出現那麼就兩個相加而不是相減來求最久的key。

struct redisServer {
       pid_t pid; 
       char *configfile; 
       //全局時鐘
       unsigned lruclock:LRU_BITS; 
       ...
};
複製代碼
typedef struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    /* key對象內部時鐘 */
    unsigned lru:LRU_BITS;
    int refcount;
    void *ptr;
} robj;
複製代碼

總結一下:Redis中的LRU與常規的LRU實現並不相同,常規LRU會準確的淘汰掉隊頭的元素,可是Redis的LRU並不維護隊列,只是根據配置的策略要麼從全部的key中隨機選擇N個(N能夠配置),要麼從全部的設置了過時時間的key中選出N個鍵,而後再從這N個鍵中選出最久沒有使用的一個key進行淘汰

2.2 Least Frequently Used(LFU)

LFU(Least Frequently Used)算法根據數據的歷史訪問頻率來淘汰數據,其核心思想是「若是數據過去被訪問屢次,那麼未來被訪問的頻率也更高」。LFU的每一個數據塊都有一個引用計數,全部數據塊按照引用計數排序,具備相同引用計數的數據塊則按照時間排序。LFU須要記錄全部數據的訪問記錄,內存消耗較高;須要基於引用計數排序,性能消耗較高。在算法實現複雜度上,LFU要遠大於LRU。

2.2.1 LFU緩存淘汰算法實現

本節筆者以leetcode上的一道算法題爲例,使用代碼實現LFU算法,幫助讀者更深刻的理解LFU算法的思想。

leetcode 460: LFU緩存

請你爲 最不常用(LFU)緩存算法設計並實現數據結構。它應該支持如下操做:get 和 put。

  • get(key) - 若是鍵存在於緩存中,則獲取鍵的值(老是正數),不然返回 -1。
  • put(key, value) - 若是鍵已存在,則變動其值;若是鍵不存在,請插入鍵值對。當緩存達到其容量時,則應該在插入新項以前,使最不常用的項無效。在此問題中,當存在平局(即兩個或更多個鍵具備相同使用頻率)時,應該去除最久未使用的鍵。 「項的使用次數」就是自插入該項以來對其調用 get 和 put 函數的次數之和。使用次數會在對應項被移除後置爲 0 。 示例:
LFUCache cache = new LFUCache( 2 /* capacity (緩存容量) */ );
cache.put(1, 1);
cache.put(2, 2);
cache.get(1);       // 返回 1
cache.put(3, 3);    // 去除 key 2
cache.get(2);       // 返回 -1 (未找到key 2)
cache.get(3);       // 返回 3
cache.put(4, 4);    // 去除 key 1
cache.get(1);       // 返回 -1 (未找到 key 1)
cache.get(3);       // 返回 3
cache.get(4);       // 返回 4
複製代碼

上一節咱們聊到LRU算法,LRU的實現是一個哈希表加上一個雙鏈表,比較簡單。而LFU則要複雜多了,須要用兩個哈希表再加上N個雙鏈表才能實現 咱們先看看LFU的兩個哈希表裏面都存了什麼。

第一個哈希表是key-value的哈希表(如下簡稱kv哈希表)

LFU表中的kv

這裏的key就是輸入的key,沒什麼特別的。關鍵是value,它的value不是一個簡單的value,而是一個節點對象。 節點對象Node包含了key,value,以及頻率,這個Node又會出如今第二個哈希表的value中。 至於爲何Node中又重複包含了key,由於某些狀況下咱們不是經過k-v哈希表拿到Node的,而是經過其餘方式得到了Node,以後須要用Node中的key去k-v哈希表中作一些操做,因此Node中包含了一些冗餘信息。

第二張哈希表,頻率哈希表,這個就要複雜多了

LFU中的hash表

這張哈希表中的key是頻率,也就是元素被訪問的頻率(被訪問了1次,被訪問了兩次等等),它的value是一個雙向鏈表 剛纔說的Node對象,如今又出現了,這裏的Node實際上是雙向鏈表中的一個節點。 第一張圖中咱們介紹了Node中包含了一個冗餘的key,其實它還包含了一個冗餘的頻率值,由於某些狀況下,咱們須要經過Node中的頻率值,去頻率哈希表中作查找,因此也須要一個冗餘的頻率值。

下面咱們將兩個哈希表整合起來看看完整的結構:

LFU整合數據結構

k-v哈希表中key1指向一個Node,這個Node的頻率爲1,位於頻率哈希表中key=1下面的雙鏈表中(處於第一個節點)。

根據上邊的描述就能夠定義出咱們要使用到的數據結構和雙鏈表的基本操做代碼(使用Go語言):

type LFUCache struct {
    cache               map[int]*Node
	freq                map[int]*DoubleList
	ncap, size, minFreq int
}

//節點node
type Node struct {
	key, val, freq int
	prev, next     *Node
}

//雙鏈表
type DoubleList struct {
	tail, head *Node
}

//建立一個雙鏈表
func createDL() *DoubleList {
	head, tail := &Node{}, &Node{}
	head.next, tail.prev = tail, head

	return &DoubleList{
		tail: tail,
		head: head,
	}
}

func (this *DoubleList) IsEmpty() bool {
	return this.head.next == this.tail
}

//將node添加爲雙鏈表的第一個元素
func (this *DoubleList) AddFirst(node *Node) {
	node.next = this.head.next
	node.prev = this.head

	this.head.next.prev = node
	this.head.next = node
}

func (this *DoubleList) RemoveNode(node *Node) {
	node.next.prev = node.prev
	node.prev.next = node.next

	node.next = nil
	node.prev = nil
}

func (this *DoubleList) RemoveLast() *Node {
	if this.IsEmpty() {
		return nil
	}

	lastNode := this.tail.prev
	this.RemoveNode(lastNode)

	return lastNode
}
複製代碼

下邊咱們來看一下LFU算法的具體的實現吧,get操做相對簡單一些,咱們就先說get操做吧。 get操做的具體邏輯大體是這樣:

  • 若是key不存在則返回-1
  • 若是key存在,則返回對應的value,同時:
    • 將元素的訪問頻率+1
      • 將元素從訪問頻率i的鏈表中移除,放到頻率i+1的鏈表中
      • 若是頻率i的鏈表爲空,則從頻率哈希表中移除這個鏈表

第一個很簡單就不用說了,咱們看下第二點的執行過程:

LFU中get的實現

假設某個元素的訪問頻率是3,如今又被訪問了一次,那麼就須要將這個元素移動到頻率4的鏈表中。若是這個元素被移除後,頻率3的那個鏈表變成空了(只剩下頭結點和尾節點)就須要刪除這個鏈表,同時刪除對應的頻率(也就是刪除key=3),咱們看下執行過程:

LFU中get元素刪除鏈表

LFU中Get方法代碼實現:

func (this *LFUCache) Get(key int) int {
    if node, ok := this.cache[key]; ok {
		this.IncrFreq(node)
		return node.val
	}

	return -1
}

func(this *LFUCache) IncrFreq(node *Node) {
	_freq := node.freq
	this.freq[_freq].RemoveNode(node)
	if this.minFreq == _freq && this.freq[_freq].IsEmpty() {
		this.minFreq++
		delete(this.freq, _freq)
	}
	node.freq++

	if this.freq[node.freq] == nil {
		this.freq[node.freq] = createDL()
	}
	this.freq[node.freq].AddFirst(node)
}
複製代碼

put操做就要複雜多了,大體包括下面幾種狀況

  • 若是key已經存在,修改對應的value,並將訪問頻率+1
    • 將元素從訪問頻率i的鏈表中移除,放到頻率i+1的鏈表中
    • 若是頻率i的鏈表爲空,則從頻率哈希表中移除這個鏈表
  • 若是key不存在
    • 緩存超過最大容量,則先刪除訪問頻率最低的元素,再插入新元素
      • 新元素的訪問頻率爲1,若是頻率哈希表中不存在對應的鏈表須要建立
    • 緩存沒有超過最大容量,則插入新元素
      • 新元素的訪問頻率爲1,若是頻率哈希表中不存在對應的鏈表須要建立

咱們在代碼實現中還須要維護一個minFreq的變量,用來記錄LFU緩存中頻率最小的元素,在緩存滿的時候,能夠快速定位到最小頻繁的鏈表,以達到 O(1) 時間複雜度刪除一個元素。 具體作法是:

  • 更新/查找的時候,將元素頻率+1,以後若是minFreq不在頻率哈希表中了,說明頻率哈希表中已經沒有元素了,那麼minFreq須要+1,不然minFreq不變。
  • 插入的時候,這個簡單,由於新元素的頻率都是1,因此只須要將minFreq改成1便可。

咱們重點看下緩存超過最大容量時是怎麼處理的:

LFU中put操做

LFU中Put方法代碼實現:

func (this *LFUCache) Put(key int, value int)  {
    if this.ncap == 0 {
		return
	}
	//節點存在
    if node, ok := this.cache[key]; ok {
		node.val = value
		this.IncrFreq(node)
	} else {
		if this.size >= this.ncap {
			node := this.freq[this.minFreq].RemoveLast()
			delete(this.cache, node.key)
			this.size--
		}
		x := &Node{key: key, val: value, freq: 1}
		this.cache[key] = x
		if this.freq[1] == nil {
			this.freq[1] = createDL()
		}
		this.freq[1].AddFirst(x)
		this.minFreq = 1
		this.size++
	}
}
複製代碼

經過對一道LFU基本算法的分析與實現,相信讀者已經領悟到了LFU算法的思想及其複雜性。不少算法自己就是複雜的,不但要整合各類數據結構,還要根據應用場景進行分析,並不斷改進。可是算法確確實實的解決不少實際的問題,咱們已經知道了緩存的重要性,但一個好的緩存策略除了要充分利用各類計算機存儲組件,良好的算法設計也是必不可少的。因此咱們再來整體回顧一下本節LFU算法的實現吧:

type LFUCache struct {
    cache               map[int]*Node
	freq                map[int]*DoubleList
	ncap, size, minFreq int
}

func(this *LFUCache) IncrFreq(node *Node) {
	_freq := node.freq
	this.freq[_freq].RemoveNode(node)
	if this.minFreq == _freq && this.freq[_freq].IsEmpty() {
		this.minFreq++
		delete(this.freq, _freq)
	}
	node.freq++

	if this.freq[node.freq] == nil {
		this.freq[node.freq] = createDL()
	}
	this.freq[node.freq].AddFirst(node)
}


func Constructor(capacity int) LFUCache {
    return LFUCache{
		cache: make(map[int]*Node),
		freq:  make(map[int]*DoubleList),
		ncap:  capacity,
	}
}


func (this *LFUCache) Get(key int) int {
    if node, ok := this.cache[key]; ok {
		this.IncrFreq(node)
		return node.val
	}

	return -1
}


func (this *LFUCache) Put(key int, value int)  {
    if this.ncap == 0 {
		return
	}
	//節點存在
    if node, ok := this.cache[key]; ok {
		node.val = value
		this.IncrFreq(node)
	} else {
		if this.size >= this.ncap {
			node := this.freq[this.minFreq].RemoveLast()
			delete(this.cache, node.key)
			this.size--
		}
		x := &Node{key: key, val: value, freq: 1}
		this.cache[key] = x
		if this.freq[1] == nil {
			this.freq[1] = createDL()
		}
		this.freq[1].AddFirst(x)
		this.minFreq = 1
		this.size++
	}
}

//節點node
type Node struct {
	key, val, freq int
	prev, next     *Node
}

//雙鏈表
type DoubleList struct {
	tail, head *Node
}

//建立一個雙鏈表
func createDL() *DoubleList {
	head, tail := &Node{}, &Node{}
	head.next, tail.prev = tail, head

	return &DoubleList{
		tail: tail,
		head: head,
	}
}

func (this *DoubleList) IsEmpty() bool {
	return this.head.next == this.tail
}

//將node添加爲雙鏈表的第一個元素
func (this *DoubleList) AddFirst(node *Node) {
	node.next = this.head.next
	node.prev = this.head

	this.head.next.prev = node
	this.head.next = node
}

func (this *DoubleList) RemoveNode(node *Node) {
	node.next.prev = node.prev
	node.prev.next = node.next

	node.next = nil
	node.prev = nil
}

func (this *DoubleList) RemoveLast() *Node {
	if this.IsEmpty() {
		return nil
	}

	lastNode := this.tail.prev
	this.RemoveNode(lastNode)

	return lastNode
}
複製代碼

2.2.2 Redis LFU淘汰策略

通常狀況下,LFU效率要優於LRU,且可以避免週期性或者偶發性的操做致使緩存命中率降低的問題,在以下狀況下:

~~~~~A~~~~~A~~~~~A~~~~A~~~~~A~~~~~A~~|
~~B~~B~~B~~B~~B~~B~~B~~B~~B~~B~~B~~B~|
~~~~~~~~~~C~~~~~~~~~C~~~~~~~~~C~~~~~~|
~~~~~D~~~~~~~~~~D~~~~~~~~~D~~~~~~~~~D|
複製代碼

會將數據D誤認爲未來最有可能被訪問到的數據。

Redis做者曾想改進LRU算法,但發現Redis的LRU算法受制於隨機採樣數maxmemory_samples,在maxmemory_samples等於10的狀況下已經很接近於理想的LRU算法性能,也就是說,LRU算法自己已經很難再進一步了。

因而,將思路回到原點,淘汰算法的本意是保留那些未來最有可能被再次訪問的數據,而LRU算法只是預測最近被訪問的數據未來最有可能被訪問到。咱們能夠轉變思路,採用LFU(Least Frequently Used)算法,也就是最頻繁被訪問的數據未來最有可能被訪問到。在上面的狀況中,根據訪問頻繁狀況,能夠肯定保留優先級:B>A>C=D。

在LFU算法中,能夠爲每一個key維護一個計數器。每次key被訪問的時候,計數器增大。計數器越大,能夠約等於訪問越頻繁。

上述簡單算法存在兩個問題:

  • 在LRU算法中能夠維護一個雙向鏈表,而後簡單的把被訪問的節點移至鏈表開頭,但在LFU中是不可行的,節點要嚴格按照計數器進行排序,新增節點或者更新節點位置時,時間複雜度可能達到O(N)。
  • 只是簡單的增長計數器的方法並不完美。訪問模式是會頻繁變化的,一段時間內頻繁訪問的key一段時間以後可能會不多被訪問到,只增長計數器並不能體現這種趨勢。 第一個問題很好解決,能夠借鑑LRU實現的經驗,維護一個待淘汰key的pool。第二個問題的解決辦法是,記錄key最後一個被訪問的時間,而後隨着時間推移,下降計數器。

咱們前邊說過Redis對象的結構:

typedef struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
                            * LFU data (least significant 8 bits frequency
                            * and most significant 16 bits access time). */
    int refcount;
    void *ptr;
} robj;
複製代碼

在LRU算法中,24 bits的lru是用來記錄LRU time的,在LFU中也能夠使用這個字段,不過是分紅16 bits與8 bits使用:

16 bits      8 bits
      +----------------+--------+
      + Last decr time | LOG_C  |
      +----------------+--------+
複製代碼

高16 bits用來記錄最近一次計數器下降的時間ldt,單位是分鐘,低8 bits記錄計數器數值counter。

Redis4.0以後爲maxmemory_policy淘汰策略添加了兩個LFU模式:

  • volatile-lfu:對有過時時間的key採用LFU淘汰算法
  • allkeys-lfu:對所有key採用LFU淘汰算法

還有2個配置能夠調整LFU算法:

lfu-log-factor 10
lfu-decay-time 1
複製代碼
  • lfu-log-factor能夠調整計數器counter的增加速度,lfu-log-factor越大,counter增加的越慢。

  • lfu-decay-time是一個以分鐘爲單位的數值,能夠調整counter的減小速度

源碼實現

在lookupKey中:

robj *lookupKey(redisDb *db, robj *key, int flags) {
    dictEntry *de = dictFind(db->dict,key->ptr);
    if (de) {
        robj *val = dictGetVal(de);

        /* Update the access time for the ageing algorithm.
         * Don't do it if we have a saving child, as this will trigger * a copy on write madness. */ if (server.rdb_child_pid == -1 && server.aof_child_pid == -1 && !(flags & LOOKUP_NOTOUCH)) { if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) { updateLFU(val); } else { val->lru = LRU_CLOCK(); } } return val; } else { return NULL; } } 複製代碼

當採用LFU策略時,updateLFU更新lru:

/* Update LFU when an object is accessed.
 * Firstly, decrement the counter if the decrement time is reached.
 * Then logarithmically increment the counter, and update the access time. */
void updateLFU(robj *val) {
    unsigned long counter = LFUDecrAndReturn(val);
    counter = LFULogIncr(counter);
    val->lru = (LFUGetTimeInMinutes()<<8) | counter;
}
複製代碼

下降LFUDecrAndReturn

首先,LFUDecrAndReturn對counter進行減小操做:

/* If the object decrement time is reached decrement the LFU counter but
 * do not update LFU fields of the object, we update the access time
 * and counter in an explicit way when the object is really accessed.
 * And we will times halve the counter according to the times of
 * elapsed time than server.lfu_decay_time.
 * Return the object frequency counter.
 *
 * This function is used in order to scan the dataset for the best object
 * to fit: as we check for the candidate, we incrementally decrement the
 * counter of the scanned objects if needed. */
unsigned long LFUDecrAndReturn(robj *o) {
    unsigned long ldt = o->lru >> 8;
    unsigned long counter = o->lru & 255;
    unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0;
    if (num_periods)
        counter = (num_periods > counter) ? 0 : counter - num_periods;
    return counter;
}
複製代碼

函數首先取得高16 bits的最近下降時間ldt與低8 bits的計數器counter,而後根據配置的lfu_decay_time計算應該下降多少。

LFUTimeElapsed用來計算當前時間與ldt的差值:

/* Return the current time in minutes, just taking the least significant
 * 16 bits. The returned time is suitable to be stored as LDT (last decrement
 * time) for the LFU implementation. */
unsigned long LFUGetTimeInMinutes(void) {
    return (server.unixtime/60) & 65535;
}

/* Given an object last access time, compute the minimum number of minutes
 * that elapsed since the last access. Handle overflow (ldt greater than
 * the current 16 bits minutes time) considering the time as wrapping
 * exactly once. */
unsigned long LFUTimeElapsed(unsigned long ldt) {
    unsigned long now = LFUGetTimeInMinutes();
    if (now >= ldt) return now-ldt;
    return 65535-ldt+now;
}
複製代碼

具體是當前時間轉化成分鐘數後取低16 bits,而後計算與ldt的差值now-ldt。當ldt > now時,默認爲過了一個週期(16 bits,最大65535),取值65535-ldt+now。

而後用差值與配置lfu_decay_time相除,LFUTimeElapsed(ldt) / server.lfu_decay_time,已過去n個lfu_decay_time,則將counter減小n,counter - num_periods。

增加LFULogIncr

增加函數LFULogIncr以下:

/* Logarithmically increment a counter. The greater is the current counter value
 * the less likely is that it gets really implemented. Saturate it at 255. */
uint8_t LFULogIncr(uint8_t counter) {
    if (counter == 255) return 255;
    double r = (double)rand()/RAND_MAX;
    double baseval = counter - LFU_INIT_VAL;
    if (baseval < 0) baseval = 0;
    double p = 1.0/(baseval*server.lfu_log_factor+1);
    if (r < p) counter++;
    return counter;
}
複製代碼

counter並非簡單的訪問一次就+1,而是採用了一個0-1之間的p因子控制增加。counter最大值爲255。取一個0-1之間的隨機數r與p比較,當r<p時,才增長counter,這和比特幣中控制產出的策略相似。p取決於當前counter值與lfu_log_factor因子,counter值與lfu_log_factor因子越大,p越小,r<p的機率也越小,counter增加的機率也就越小。增加狀況以下:

# +--------+------------+------------+------------+------------+------------+
# | factor | 100 hits | 1000 hits | 100K hits | 1M hits | 10M hits |
# +--------+------------+------------+------------+------------+------------+
# | 0 | 104 | 255 | 255 | 255 | 255 |
# +--------+------------+------------+------------+------------+------------+
# | 1 | 18 | 49 | 255 | 255 | 255 |
# +--------+------------+------------+------------+------------+------------+
# | 10 | 10 | 18 | 142 | 255 | 255 |
# +--------+------------+------------+------------+------------+------------+
# | 100 | 8 | 11 | 49 | 143 | 255 |
# +--------+------------+------------+------------+------------+------------+
複製代碼

可見counter增加與訪問次數呈現對數增加的趨勢,隨着訪問次數愈來愈大,counter增加的愈來愈慢。

新生key策略

另一個問題是,當建立新對象的時候,對象的counter若是爲0,很容易就會被淘汰掉,還須要爲新生key設置一個初始counter,createObject:

robj *createObject(int type, void *ptr) {
    robj *o = zmalloc(sizeof(*o));
    o->type = type;
    o->encoding = OBJ_ENCODING_RAW;
    o->ptr = ptr;
    o->refcount = 1;

    /* Set the LRU to the current lruclock (minutes resolution), or
     * alternatively the LFU counter. */
    if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
        o->lru = (LFUGetTimeInMinutes()<<8) | LFU_INIT_VAL;
    } else {
        o->lru = LRU_CLOCK();
    }
    return o;
}
複製代碼

counter會被初始化爲LFU_INIT_VAL,默認5。

總結一下:Redis的LFU緩存淘汰策略複用了redis對象中的24 bits lru, 不過度成了分紅16 bits與8 bits使用,高16 bits用來記錄最近一次計數器下降的時間ldt,單位是分鐘,低8 bits記錄計數器數值counter。Redis對象的計數器並非線性增加的,而是提供了lfu-log-factor配置項來控制技術器的增加速度。爲了解決歷史數據影響未來數據的「緩存污染」問題,Redis對象的計數會根據lfu_decay_time配置項隨時間作調整。redis爲每個新增的key都設置了初始counter,目的是防止新增的key很容易就被淘汰掉

2.3 先進先出算法(FIFO)

FIFO(First in First out),先進先出。在FIFO Cache設計中,核心原則就是:若是一個數據最早進入緩存中,則應該最先淘汰掉。實現方法很簡單,只要使用隊列數據結構便可實現。

FIFO緩存淘汰過程

由於緩存命中率比較低,FIFO緩存策略一般不會在項目中使用。客觀惟心主義的理論:存在即合理,下邊筆者就描述一下FIFO隊列在Redis主從複製過程當中的應用。

在Redis主從結構中,主節點要將數據同步給從節點,從一開始主從創建鏈接到數據同步一共分爲三個階段:

Redis主從同步過程

第一階段首先創建鏈接,而後第二階段主節點發送rdb文件給從節點同步全量數據;咱們主要看第三階段主節點反覆同步增量數據給從節點的過程是什麼樣的。

從節點和主節點創建鏈接時,主節點服務器會維護一個複製積壓緩衝區來暫存增量的同步命令;當從節點向主節點要求同步數據時,主節點根據從節點同步數據的offset將數據增量的同步給從節點,反覆進行。

複製積壓緩衝區是一個先進先出(FIFO)的環形隊列,用於存儲服務端執行過的命令,每次傳播命令,master節點都會將傳播的命令記錄下來,保存在這裏。

複製積壓緩衝區結構

複製積壓緩衝區由兩部分組成:偏移量和字節值。字節值是redis指令字節的存儲(redis指令以一種Redis序列化文本協議的格式存儲),偏移量offset就是當前字節值在環形隊列中的偏移量。

複製積壓緩衝區組成

主節點維護了每一個從節點的offset,這樣每次同步數據時,主節點就知道該同步哪一部分數據給從節點了。經過這樣一個複製積壓緩衝區,Redis的主從架構實現了數據的增量同步,想要了解更多主從同步細節的讀者能夠參考個人另外一篇博客:Redis高可用——主從複製

2.4 FIFO、LRU、LFU緩存淘汰策略對比

本節花費了大量篇幅介紹緩存淘汰策略,咱們再來從緩存命中率、實現複雜度、存儲成本、缺陷這四個方面來對這三種緩存策略作一個對比:

緩存淘汰策略 緩存命中率 實現複雜度 存儲成本 缺陷
FIFO 很是簡單 很低 速度很快,不過沒有什麼實用價值
LRU 相對簡單 偶發性的、週期性的批量操做會致使LRU命中率急劇降低,緩存污染狀況比較嚴重。
LFU 很是高 相對複雜 很高,須要很大的存儲空間 L存在歷史數據影響未來數據的「緩存污染」

Redis、Mysql的緩存設計都考慮了本節講到的緩存淘汰策略的思想,並結合自身的業務場景進行了改進實現。緩存淘汰策略沒有十全十美的,根據具體的業務和需求選擇合適緩存淘汰算法,提高緩存命中率是咱們學習本節的目的。

3. 緩存安全

緩存安全是很是熱點的話題,大多數企業在的架構師在設計緩存系統時都會考慮「緩存安全」相關的問題,尤爲是當服務到達必定量級以後,「緩存安全」就是一個不得不考慮的問題了。另外在面試過程當中,「緩存安全」相關的問題也是熱點,由於懂得這些過重要了,每個問題產生的緣由多是什麼?有效的解決方案有哪些?怎樣避免這些問題的產生?這些都是一個合格的程序員應該知道的。

本節就結合redis緩存中間件的使用,和你們一塊談談「緩存安全」中最多見的問題。並介紹相關的解決方案和規避方法。

3.1 緩存預熱

俗話說萬事開頭難,對於應用了redis緩存中間件的系統來講也會存在這樣的問題。當系統併發很高,並採用了主從架構的時候,服務啓動就是一件很困難的事情!究其緣由,系統剛啓動時,緩存中沒有數據,那麼服務啓動就須要大量的訪問數據庫,並瞬間產生大量的緩存數據,主從之間吞吐量增大,數據同步頻度增長,可能服務剛啓動不久就會宕機。

解決方案也很簡單,咱們最好先統計出訪問頻度比較高的熱點數據,根據統計結果將數據分類,讓redis緩存服務優先加載那些訪問頻度高的熱點數據。固然了,這個過程最好作成「預加載」,在服務啓動以前先加載了熱點數據,手工執行方案難以實現時,能夠藉助腳本或者CDN的方式進行。

總結來說:緩存預熱就是系統啓動前,提早將相關的緩存數據直接加載到緩存系統。避免用戶請求的時候,先查詢數據庫,而後再將數據緩存的問題,讓用戶直接查詢被預熱的緩存數據。

3.2 緩存雪崩

緩存雪崩是緩存服務中常常碰見的問題,下邊是一個緩存雪崩事故發生的大體過程:

  • 系統平穩運行中,突然數據庫鏈接量激增
  • 應用服務器沒法及時處理請求,客戶端開始大量出現40八、500錯誤頁面
  • 客戶反覆刷新頁面嘗試獲取數據,致使服務端數據庫訪問激增,數據庫崩潰
  • 數據庫崩潰之後,應用服務器也就隨之崩潰了
  • 嘗試重啓服務器無效,這時候redis服務器崩潰,雪崩開始出現,redis集羣開始崩潰
  • 重啓數據庫無效,由於流量過高,數據庫啓動即崩潰

排查上述緩存雪崩問題出現的緣由,咱們就會獲得這樣一個結論:

產生「緩存雪崩」的根本緣由是:在一個較短的時間內,緩存中較多的key集中過時了

咱們能夠使用這個結論來解釋一下上述現象發生的過程。緩存中大量的key同時過時之後,redis緩存沒法命中,請求就會到達數據庫;若是併發量很大,數據庫根本沒法及時處理,Redis的請求就會被積壓,並逐漸出現超時現象;數據庫隨着流量的激增崩潰了,這個時候重啓服務器是沒有意義的,由於系統的關鍵問題是緩存中無可用數據,Redis的服務器資源被佔用,服務器也隨着崩潰,集羣崩塌,應用服務器也會隨着客戶端的請求增長而崩潰。出現了這個局面之後,咱們重啓服務器、redis和數據庫都是沒有意義的!下面緩存雪崩的簡單示意圖:

緩存中大量的key同時過時

若是「緩存雪崩」問題已經發生了,應該怎樣解決呢?下邊列舉一些有效的方案:

  • 頁面靜態化處理,一旦使用了頁面靜態化,客戶端的數據就不用從緩存中獲取了,這樣能夠減輕緩存的壓力,缺點是數據更新不及時。

  • 構建多級緩存,構建多級緩存能夠給每一級的緩存提供更多的數據保障,好比在redis和mysql數據庫中間再加上一層ehcache緩存,當緩存中大量key過時時,ehcache緩存能夠替mysql數據庫暫時抵擋一部分流量。構建多級緩存會增長系統的複雜性。

  • 優化mysql。好比給mysql增長buffer pool,這樣當大量流量到達mysql時,mysql的吞吐量能夠支撐併發,不至於立刻崩潰,也能防止雪崩現象發生。

  • 監控redis的性能指標。根據分析咱們知道,「雪崩」伴隨着的確定CPU佔用率急劇上升,同時客戶端請求的平均響應時間也會增大,對這些性能指標進行監控能幫助咱們更早的發現問題。

  • 限流、降級。這是從客戶端的角度來考慮,犧牲一部分客戶體驗,限制一些客戶端請求,能有效的下降應用服務器的壓力。待系統平穩運行後再逐漸恢復。

既然咱們知道了「緩存雪崩」產生的緣由是一個較短的時間內,大量的熱點key集中過時致使的,咱們有必要學習一些方法來預防「緩存雪崩」的發生。最有效的方法就是根據業務數據將緩存的有效期錯峯,數據的過時時間採用固定時間 + 隨機時間戳的方式,稀釋集中到期key的數量,甚至說超熱的數據,採用永久key也是能夠的。還記得咱們第二部分提到的緩存淘汰策略嗎?將LRU切換爲LFU淘汰策略,也是能夠有效預防「緩存雪崩」的。若是你認爲問題仍是不太好解決,想要採用手動維護的方式也是能夠的,能夠按期對訪問數據作統計,給熱點數據續租過時時間也是能夠的。

總結來說:緩存雪崩就是瞬間過時數據量太大,給數據庫服務器形成壓力。若是可以有效避免過時時間集中,就能夠有效防止雪崩問題的出現,再配合其它策略的一塊兒使用,並監控服務器的運行數據並作及時的調整,基本是能夠防止「緩存雪崩」狀況發生的。

3.3 緩存擊穿

瞭解了緩存雪崩以後,咱們不妨思考這樣一個問題:形成緩存雪崩的緣由是大量的熱點key在集中過時,假如一個超熱的key過時,會不會也形成這種問題呢?

答案是確定的!咱們試想這樣的場景,雙11秒殺活動一臺mac電腦99元,秒殺活動一開始,幾百萬的QPS上來了,緩存數據過時了,這麼大的訪問量達到了數據庫,數據庫確定掛了!這就是咱們本節要講的「緩存擊穿」。

對比「緩存雪崩」咱們會發現,「緩存擊穿」和「緩存雪崩」在不少現象上來講是比較類似的,並且咱們上節說到的解決「緩存雪崩」的方案拿到這裏,大都是可以適用的。和「緩存雪崩」不一樣的是,「緩存擊穿」發生後,redis的內存和CPU並不會有異常,也不會形成應用服務器的崩潰,也就是說「緩存擊穿」不太容易發生,形成的後果也沒有「緩存雪崩」嚴重。可是在「秒殺」場景下確確實實會存在這樣的問題。

咱們仍是先來講一下如何解決和預防「緩存擊穿」的問題吧。通常來說,「緩存擊穿」的問題發生前都是能夠預見的,好比「秒殺」活動開始先後,確定會有大量的客戶端請求,那麼當系統中高熱的緩存key過時後,手動的加載到redis確定是能夠的。再或者活動期間咱們就使用永久的key,並配置LFU的緩存淘汰策略,也是能夠解決這個問題的。固然還有其它的一些解決方案,就好比做者以前曾分析過12306是怎樣承受高併發流量的,其中使用了本地緩存配合redis緩存的多級緩存方式,這種方式對於解決緩存擊穿也是有效的,只要本地緩存和redis中的緩存不要同時過時就行,大量的流量也不會壓到數據庫。

總結一下:緩存過時就是單個高熱數據過時的瞬間,數據訪問量較大,未命中redis後,流量到達了數據庫。應對策略是以預防爲主,配合監控及時調整就能夠,主要是在設計緩存系統時要考慮到這個問題。

3.4 緩存穿透

本小節咱們來討論一下「緩存穿透」,首先「緩存穿透」和「緩存擊穿」是不同的,「緩存穿透」常常被黑客利用,目的是爲了拖垮咱們的服務器

咱們看一下發生「緩存穿透」是一種什麼樣的現象:系統平穩運行時,有了突發流量,Redis緩存的命中率開始降低,內存並無什麼異常,可是CPU開始激增,數據庫訪問也開始激增,直到數據庫崩潰

追查問題發生的本質緣由居然是客戶端訪問的key在Redis緩存中不存在,而且數據庫中也沒有相關數據,基本上都是這樣的key,彷佛有人故意而爲之。遇到這種狀況,開發者必定要提升警戒,頗有多是出現了黑客攻擊!

查到問題之後,咱們該如何解決呢?大部分人最早想到的就是既然黑客知道咱們緩存中沒有key,那麼就將key緩存到Redis中,value是NULL就能夠了。若是你這麼想,只能說明你太年輕了,首先黑客不至於傻到使用相同的key作攻擊,再者大量的無效key緩存到redis內存中,redis就失去了緩存的意義了。固然,若是你發現這些請求都來自同一個ip或者客戶端,能夠臨時的設置黑名單將攻擊流量拒之門外,可是黑客通常都會採用DDos(分佈式拒絕服務攻擊),這種方法每每是無效的。

面對這樣一種困境,業內最經常使用的方案是使用「布隆過濾器」。雖然有必定的誤判機率,可是隻要參數設置的合理,確實可以有效的解決問題。

布隆過濾器是什麼?

能夠把布隆過濾器理解爲一個不怎麼精確的set結構,當你使用它的contains方法判斷某個對象是否存在時,它可能會誤判。可是布隆過濾器也不是特別的不精確,只要參數設置的合理,它的精確度是能夠獲得保障的。當布隆過濾器說某個值存在時,這個值可能不存在;可是它只要說某個值不存在,這個值就確定不存在。

布隆過濾器的實現原理

每一個布隆過濾器對應到Redis的數據結構裏邊就是一個大型的位數組和幾個不同的無偏hash函數(可以把元素的hash值算的比較均勻,讓函數被hash映射到位數組中的位置比較隨機)。若是所示,f、g、h就是這樣的hash函數。

布隆過濾器數據結構
向布隆過濾器中添加key時,會使用多個hash函數對key進行hash,先算得一個整數索引值,而後對位數組長度進行取模運算獲得一個位置,每一個hash函數都會算得一個不一樣的位置,再把位數組的這幾個位置都設置爲1。 向布隆過濾器中詢問key是否存在時,跟添加同樣,也會把key經過hash獲得的幾個位置都算出來,看看數組中這幾個位置是否都爲1,只要有一個位置爲0,那麼說明這個值在布隆過濾器中是不存在的。

除了布隆過濾器這種方案,筆者認爲,對業務中的key進行加密也是頗有必要的,由於只要業務中的key若是是有規律可循的,黑客通常是不會知道的,咱們就能夠經過key的判斷將黑客的攻擊流量抵擋到redis緩存服務器前邊。此外緩存數據的監控必定要作好,可以及時的發現緩存命中率降低,就可以越早的採起措施。

總結一下:「緩存穿透」是客戶端訪問了緩存和數據庫中都不存在的數據,大量的這種訪問會擊垮數據庫,當運維人員監控到這種狀況時,必定要及時給出報警信息,根據上邊提到的措施進行有效處理。

3.5 性能指標監控

前邊幾個小節,咱們在討論各類緩存隱患時,反覆的強調作好監控的重要性。這裏筆者單獨抽出一個小節來總結一下在平時的緩存系統業務場景中,咱們應該監控哪些性能指標,有哪些命令可以幫助咱們分析問題,這些措施和技能對於保證咱們的系統安全是很是有用的。

對於性能指標監控,筆者這裏列出五大類,分別以表格的形式呈現。

  • 性能指標:Performance
Name Description
latency Redis響應一個請求的時間
instantaneous_ops_per_sec 平均每秒處理請求總數
hit rate(calculated) 緩存命中率(計算出來的)
  • 內存指標:Memory
Name Description
use_memory 已使用內存
mem_fragmentation_ratio 內存碎片率
evicted_keys 因爲最大內存限制移除key的數量
blocked_clients 因爲BLPOP、BRPOP or BLPUSH、BRPUSH而被受阻塞的客戶端
  • 基本活動指標:Basic activity
Name Description
connected_clients 客戶端鏈接數
connected_slaves Slave數量
master_last_io_seconds_ago 最近一次主從交互以後的秒數
key_space 數據庫中key值總數
  • 持久性指標:Persistence
Name Description
rdb_last_save_time 最後一次持久化保存到數據庫的時間戳
rdb_change_since_last_save 自最後一次持久化以來數據庫的更改次數
  • 錯誤指標:Error
Name Description
rejected_connections 因爲達到maxclient限制而被拒絕的鏈接數
keyspace_misses key值查找失敗(沒有命中)次數
master_link_down_since_seconds 主從斷開的持續時間(以秒爲單位)

以上所列舉的都是服務中比較重要的指標,監控redis的這些指標有一些開源的工具,例如:Cloud Insight RedisPrometheusRedis-statRedis-faina。可是這些工具在咱們比較定製化的業務中,每每不能起到太大效果,每每企業會針對本身的業務開發本身的監控服務。

Redis提供了一些命令也能幫助咱們排查問題。好比showlog、monitor;此外redis還提供了benchmark指令,經過使用它咱們能夠獲得redis的吞吐量、緩存命中率這些性能指標數據。

4.後記

這篇文章斷斷續續的整理了半個月,筆者參考了大量的資料才提煉出了以上內容。文章中不少地方確實不夠深刻,也有一些空洞的理論,加上筆者自己工做經驗不是很豐富,關於mysql、redis和php,不少底層的知識也沒有研究到位,文章中不免有表達不恰當和錯誤之處,但願讀者查找出之後必定在留言區進行指正。

回過頭來看這篇文章,筆者在這裏大談「緩存」確實有點大言不慚,可是都是個人心血,也就當是個人一篇學習筆記了。

涉及到「緩存」相關的系統設計和應用毫不僅僅是我提到的這些,好比:緩存數據結構的選擇和壓縮、多級緩存之間的數據同步等等,這些都沒有談到,若是未來有機會,但願能深刻的學習一下這些知識,再對文章作一些補充。

相關文章
相關標籤/搜索