後端的輪子(三)--- 快取

吳YH堅發表於2016-07-30

今天這一篇沒想到會這麼長,後面有一段是寫網路模型的,和快取本身的關係不大,只是寫到那裡就想到了這個問題,多寫了一些,那一段是我自己的理解,肯定有不對的地方,歡迎討論拍磚。

前言

前面花了一篇文章說資料庫這個輪子,其實說得還很淺很淺的,真正的資料庫比這複雜不少,今天我們繼續輪子系列,今天說說快取系統吧。

快取是後端使用得最多的東西了,因為效能是後端開發一個重要的特徵,所以快取就應運而生了,而且現在快取已經到了氾濫的程度了,我幾乎沒見過沒有快取的後端,一遇到效能問題,首先想到的不是看程式碼,而是加快取,我也是醉了,好了,不扯這些,這些和今天的文章無關,今天我們來專門講講快取吧。

快取和KVDB

快取和KVDB兩個東西經常一起出現,兩者在使用上沒有明顯的界限,當一個KVDB速度夠快,效能夠強勁,那麼就可以當快取來用了,我們使用Redis來做快取,實際上就是把一個KVDB來當快取用。但一般情況下,KVDB能提供更多的資料結構,所以象Redis這樣的KVDB中有很多實用的資料結構,比如List啊,hashtable啊之類的,而且KVDB一般都提供持久化的儲存,而像memcached這樣的純快取一般不提供持久化儲存功能,而且資料結構也比較簡單,僅僅提供key和value都是字串的形式。

現在KVDB的代表Redis效能已經越來越強勁了,雖然它是個單執行緒的服務,但目前基本上能用memcached的都可以用Redis代替,而且Redis因為支援更多的資料結構,所以擴充套件性更好。現在很多情況下所說的快取,實際上都是指的是Redis快取。

快取的型別

我們這裡拋開Redis,來單獨說說快取,所謂快取,實際上是為了給資料提供一個更快的訪問方式,這個更快一般是相對於最終資料而言的,最終資料可以是KVDB中的資料,也可以是檔案資料,還可以是其他機器上的資料庫資料,只要比這些個資料訪問的快的,都可以叫快取,那麼一般快取分成一下幾種。

  • 資料庫型快取,比如最終資料是其他機器上的MySql資料庫中,那麼我們做一個KVDB的資料庫,查詢的時候按照key查詢,總比資料庫要快點吧,那這個KVDB就是個快取。
  • 檔案型的快取,進一步說,遠端的KVDB還是有網路延遲,慢了點,這時候我在本地做一個檔案快取,這個檔案的訪問速度比遠端資料庫要快吧,那這個檔案也是個快取。
  • 記憶體型的快取,再進一步,本地檔案還是嫌慢了,那麼我們在做一個記憶體快取,把最熱的資料存到內容中,那這個記憶體的訪問速度一定比檔案要快,那這個記憶體塊也是個快取。

所以說,只要比最終資料訪問得快的資料結構,就是一個快取系統。

為了一般性,我們這裡所說的快取輪子,將會說一個記憶體型的快取,只提供簡單字串型別的key和value的操作。

如何設計一個快取

設計一個獨立的記憶體型的快取系統,首先先要確定快取最關心的東西,那就是效能,所有的需要考慮的東西都是圍繞效能兩個字來進行設計的,所以最重要的部分,設計一個快取需要考慮以下三個方面,底層資料結構記憶體管理網路模型

底層資料結構

又看到資料結構這個詞了,我們所說的所有輪子,都跑不掉資料結構這個東西,對於快取來說,一般都是KV形式的資料結構,所以底層一般會使用或者雜湊表來儲存資料,而快取對效能的要求更高,所以一般使用雜湊表來儲存資料,所以底層的資料結構就是雜湊表了。

雜湊表

雜湊函式和型別

選擇雜湊表,是因為他的O(1)的查詢複雜度,這是一個很重要的效能指標,但如果雜湊函式沒有選擇好,產生了大量的雜湊碰撞,那效能就會急劇降低,所以對於雜湊函式的選擇也是一個需要考慮的問題,比較流行的雜湊函式有很多,特別的,如果是自用型的快取,雜湊函式可以根據業務場景再來調整,保證雜湊的均勻,從而讓查詢複雜度更加接近O(1)。 雜湊表的實現方式有很多中,最最基礎的就是陣列+連結串列的形式了,也叫開鏈雜湊,陣列長度就是雜湊的桶的長度,連結串列用來解決衝突,插入資料的時候如果雜湊碰撞了,把具體節點掛在該節點後面的連結串列上,查詢資料時候有衝突,就繼續線性查詢這個節點下的連結串列。

還有一種叫閉鏈雜湊,閉鏈雜湊實際是一個迴圈陣列,陣列長度就是桶的長度,插入資料的時候有衝突的話,移動到該節點的下一個,直到沒有衝突為止,如果移動到了末尾的話,轉到陣列的頭部,查詢資料的時候類似。

我們這裡說的雜湊表都是第一種開鏈雜湊表。

由於快取不僅僅有讀,還有寫操作,如果在多執行緒的場景下,勢必會產生加鎖的操作,如果設計這個鎖也是需要考慮的,如果寫操作不是很多的情況下,那麼整個雜湊表加讀寫鎖就行了,但如果寫操作也比較頻繁,那麼可以為一批雜湊槽或者每一個雜湊槽加一把鎖,這樣的話,可以把鎖等待的時間延遲給降下來,具體還是要看場景,我實現的時候是給每個槽加了一個讀寫鎖,這樣更耗費記憶體,但是效能好一些。這種型別的雜湊表的資料結構長成下面這個圖的樣子。

每一個槽配了一把讀寫鎖,每次寫的時候都對單個槽進行加鎖操作,這樣的壞處就是需要維護巨多無比的鎖,容易造成浪費,在實際中我們可以根據實測的結果,給一批槽加一把鎖,這樣也可以把鎖資源空下來,並且也能達到比較好的併發效果。

關於鎖設計,再多說一下,多執行緒(或者多協程)的情況下,加鎖是一種常規處理方式,現在的X86架構,支援一種CAS的無鎖操作模式,是在CPU層面實現的對變數的多執行緒同步技術,golang中有個atomic包,簡單的封裝了這個功能,但是這個操作在進行變數更新的時候一般要在一個迴圈中來實現,不停的嘗試直到成功為止,雖然說減少了鎖的操作,但程式碼看起來沒那麼清晰,而且如果出了問題,除錯起來也沒有鎖那麼清晰,並且雖然是CPU級別的支援,但是還是有問題的,就是執行緒切換的時候還是會造成不可預知的錯誤,這裡就不展開了,感興趣的可以自己去搜尋一下CAS無鎖操作,並且在一般的快取中還是讀多寫少,通過把鎖擴充套件到槽級別基本上效能不會出現很大的損耗,當然,如果你對效能有著極致的追求,可以考慮CAS方案,但是也要注意坑哦。

字串和整數

最後,我們看看對於單個的具體的雜湊槽,在發生雜湊碰撞的時候一個槽下面可能掛了很多節點。

當進行讀操作的時候,如果雜湊到這個槽下面來了,我們需要比較每個節點的key和查詢串的值,只有相等的情況才是我們需要的。比較每個key的值是進行了一次字串的比較,效率是比較低的,這裡繼續出現一個用空間換時間的方法,就是我們在插入節點的時候給每個節點的key生成兩個雜湊值,第一個雜湊值用來進行槽的選擇,第二個雜湊值儲存在節點內,查詢的時候不進行key的字串比較而比較第二個雜湊值,由於雜湊值是整數的,所以比較效率比直接比較字串要快多了。用這樣的方式,在寫入資料和查詢資料的時候需要進行兩次雜湊計算,並且還需要有個單獨的空間來儲存第二個雜湊值,但是查詢的時候可以節省字串比較的時間。

對於兩個字串的比較,平均時間複雜度是O(n/2)吧,而對於兩個整數的比較,一個異或操作就搞定了,誰快就不用說了吧,這個槽變成這樣了。

重雜湊(reHash)

對於不斷增長的資料而言,重雜湊是一個必不可少的過程,所謂重雜湊,就是當你的桶使用到一定程度以後碰撞的概率就變很大了,這時候就需要把桶加大了。

把桶加大,必然需要進行一次重新雜湊的過程,這個過程的處理辦法也有一些技巧。

  • 直接重新雜湊,這是最簡單的,就是把所有的key重新雜湊一遍放到新的桶中,簡單粗暴,但是缺點也很明顯,就是當key很多的時候非常耗時間和資源,在這段時間中,服務是不可用的。
  • 逐步遷移的重雜湊,因為桶的變化,重新雜湊是無法避免的,這時候我們主要要考慮的是讓服務儘可能的保持可用,那麼除了直接雜湊,還有一種策略上的優化,簡單的描述就是申請兩個桶A和B,B的桶數量大於A

    • 初始化申請的時候可以並不實際申請記憶體空間,首先用第一個桶A進行資料儲存,當第一個桶A使用到一定比例,比如80%的時候,開始進行雜湊遷移。

    • 新來的寫操作先雜湊到桶B,然後在雜湊到桶A上,把A桶上命中的節點上的所有資料重新雜湊到B上,然後在A上打一個標記,表示這個節點失效了。

    • 新來的讀操作,先雜湊到A進行命中,如果A的這個節點標記為失效,再雜湊到B上讀取正確資料,如果A節點沒有標記失效,那麼把這個節點下的所有資料重新雜湊到B上,並在A上打一個標記,表示這個節點失效了。

    • 直到所有的A的資料都遷移完成,把A和B交換一下,並且把A的桶資料增加,作為下一次遷移使用。

這樣,當進行重新雜湊的時候,多了幾次雜湊運算,效能損失了一些,但是服務始終是可用的。

記憶體管理

還有一個方面就是記憶體了,因為有寫操作,那麼就需要申請記憶體,如果寫操作比較多的話,再加上有刪除操作的話,那就會不停的申請釋放記憶體。記憶體的申請和釋放也是比較損耗效能的,所以一般會用自己的記憶體池來進行記憶體的分配。關於記憶體池這一塊,有很多記憶體池的實現方式,這裡就不詳細說了。

其實我覺得這種對效能有強要求的服務,不太適合使用帶GC的語言進行編寫,最直接的還是用C這種系統語言來編寫,特別是涉及到記憶體池這種比較靠底層的東西,有GC實際上是很麻煩的事情,在Golang中,因為是自帶GC的,如果需要進一步榨乾系統的效能,那麼這麼底層的東西要用CGO來實現了,把GC丟一邊,所有的記憶體都自己管。

網路模型

除了在底層資料結構層的效能損耗外,網路模型的選擇也是很重要的,選擇效能儘可能高的網路模型也能極大的提升效能,比如memcached就用了libevent這個事件模型,總的來說就是先通過一個主執行緒監聽埠,接收到網路檔案描述符以後,然後通過master-worker這種結構將網路的套接字分發到各個worker執行緒的獨立佇列中,各個執行緒利用libevent模型對佇列中的套接字進行讀寫。

Redis的網路模型更簡明一點,但也是基於epoll的IO多路複用,感興趣的朋友可以自己去看看Redis的原始碼,他相當於是一個稍微簡化版本的libevent模型。

關於libeventlibev這兩個模型(其實差不太多),我們可以專門寫一篇文章分析一下原始碼,他們的原始碼都不多,正好我也看過,可以寫一篇文章,當然網上這類文章也很多。

對於網路模型,實際上現代的高階語言已經基本上封裝到http這個層次了,比如golang這種現代語言,http的包都可以直接用,並且併發效能也挺好的,但是對於一個快取系統,如果配合一個http的模型,就顯得太重了,http底下的TCP模型就可以很好的解決問題,對於這一塊,我們可以用個簡單的模型:

  • 根據CPU的核心數啟動相應數量的協程,每個協程配合一個channel
  • 啟動一個協程負責接收tcp連線,accpet連結以後通過channel交給相應的協程處理

這段程式碼雖然使用了channel這個東西,但也算是一個比較標準的多執行緒程式設計方式了,在多執行緒的世界中也可以用迴圈連結串列來表示這個channel,用程式碼來體現大概就是這樣子的。

func GetConnection() error {
    tcpAddr, _ := net.ResolveTCPAddr("tcp", ":26719")
    listener, _ := net.ListenTCP("tcp", tcpAddr)
    //啟動處理協程
    for i := 0; i < cpuCores; i++ {
        go Process(i)
    }
    i:=0
    for {
        conn, _ := listener.AcceptTCP()
        select {
        case processChan[i] <- conn:  //通過管道交給協程處理
                i++
                 if i==cpuCores{ i = 0 }
        default:
        }
    }
}複製程式碼

除了上面那種模型,還有一種比較奔放的模型,也是比較golang的寫法了。

  • 啟動一個協程負責接收tcp連線,accpet連結以後go出一個協程進行處理 程式碼實現就是下面這個樣子
func GetConnection() error {
    tcpAddr, _ := net.ResolveTCPAddr("tcp", ":26719")
    listener, _ := net.ListenTCP("tcp", tcpAddr)
    for {
        conn, _ := listener.AcceptTCP()
        go Process(conn) //處理實際連線
    }
}複製程式碼

關於golang的協程分析

這種奔放的協程使用方式到底行不行?這兩種到底哪個比較好?這裡我們不討論這兩種模型哪種好,我們看看golang的協程吧。

golang的協程網上資料很多,關於協程的詳細設計可以找到不少文章,總的來說就是這是一個輕量級的執行緒,有多輕呢?輕到它其實就是一個程式碼段加上一個自己的棧,光這個還不夠,執行緒也可以說是一個程式碼片段加上一個自己的棧,只是協程的棧比執行緒的小而已,除了這個以為,協程主要的輕表現在它不通過作業系統排程,他是通過程式碼內部進行顯示排程的。

什麼叫程式碼內部進行顯示排程呢?

我們先看看目前流行的兩個事件模型【同時也是處理網路連線的網路模型】,一種是nodejs為代表的IO模型,大堆回撥函式,一種是傳統的多執行緒模型。

先看回撥模型,像下圖一樣,左邊是IO佇列,右邊是程式碼片段,每個IO事件對應一個回撥函式,接收到IO事件以後進行相應的函式處理,這樣的好處就是CPU利用率極高,不用切換資源,壞處就是程式碼被扯亂了,變成無序的了,對程式設計的要求比較高。

再看看執行緒模型,執行緒模型執行緒的切換實際上是作業系統來進行的,執行緒模型如下圖,右邊是執行緒的程式碼,左邊的舞臺就是CPU了,當在CPU上跳舞的執行緒進行系統呼叫(比如讀取檔案)或者每隔一段時間(時鐘中斷,不瞭解的請自行看計算機體系結構),作業系統就會把當前在CPU上玩耍的執行緒換下來,換個新的上去,這樣的排程方式是作業系統來進行的,不需要執行緒本身參與,執行緒什麼時候執行完全有作業系統說了算,執行緒切換也是作業系統說了算,好處就是程式設計的時候比較正常,缺點就是進行執行緒的切換是要耗費資源的,而且新開執行緒也需要資源。

有什麼辦法把這兩種模型結合起來呢?我覺得golang的協程就是幹了這個事情,golang的協程模型如下,這個圖是我自己畫的,主要是為了說明協程把上面兩個模型結合,實際的協程模型長得不是這樣子的啊。我們看到只剩下一個執行緒(或者程式)在CPU上跑了,上面的任務都跑到執行緒內部變成一個一個的程式碼片段了,執行哪個片段由執行緒內部決定,當遇到系統呼叫的時候,不用切換程式,而是直接切換執行其他的程式碼片段,這樣的話,和執行緒模型比少了執行緒切換的開銷,並且還能和回撥模型一樣,當IO操作的時候執行其他程式碼片段,最重要的是沒有回撥函式了,在開發人員看來和執行緒一樣了。

好了,關鍵問題來了,怎麼排程的呢?上面的回撥模型和執行緒模型排程的時候都是作業系統來完成的,這裡就顯示出程式碼內部顯示呼叫了,就是說這些程式碼片段執行的時候,執行一段時間後,通過呼叫一個函式,主動放棄CPU,這些個程式碼片段就像下面這樣

func running(){
    //計算一些東西
    stop() //呼叫stop主動不跑了,請把我正在執行的這個地址和我的棧記錄下來,下次執行的時候繼續在這裡跑
    //剩下的程式碼
}複製程式碼

這就是程式碼內部的顯示呼叫了,所謂協程嘛,就是需要執行的程式碼協助進行任務的排程,怎麼協助呢?就是顯示的呼叫一個函式來進行現場儲存和切換程式碼。

很多人說我在寫golang的時候沒看到這玩意啊,恩,為了讓你程式設計容易,這東西隱藏到系統呼叫中去了,就是說你在協程中只要進行了系統呼叫(比如列印系統,讀取檔案,操作網路),那麼在呼叫類似fmt.Println的時候就呼叫了這個排程函式來切換協程了,當然,你也可以在你的程式碼中主動放棄CPU使用權,只要呼叫runtime.Gosched()就行了。

關於這部分,你可以試試下面的程式碼,在單核CPU的情況下,第二個函式是永遠執行不了的,如果是按照多執行緒的思想,第二個函式是可以執行的,這就是因為第一個函式沒有系統呼叫沒有IO操作,所以一直把持著CPU不放棄,這也是協程程式設計需要注意的地方。

func main() {
    runtime.GOMAXPROCS(1)
    var workResultLock sync.WaitGroup
    go func() {
        fmt.Println("我開始跑了哦。。。")
        i := 1
        for {
            i++
        }
    }()
    workResultLock.Add(1)
    time.Sleep(time.Second * 2)
    go func() {
        fmt.Println("我還有機會嗎????")
    }()
    workResultLock.Add(1)
    workResultLock.Wait()
}複製程式碼

好了,協程的原理說了一下,我們再看看到底什麼模型比較合適呢?我們看到,golang的協程這麼設計出來,首先建立協程的消耗很少,並且在多IO操作的時候比執行緒是要佔優勢的,因為在IO操作的時候,只是像回撥一樣換了一段程式碼來執行,沒有執行緒的切換。這也是為什麼用golang來寫伺服器程式碼比較合適的原因,因為服務端的程式碼基本上都少不了IO操作,網路讀寫是IO,資料庫讀寫是IO,這樣用golang既可以保持原來的多執行緒程式設計的連貫思維,又可以儘可能的使用事件模型的優勢,減少執行緒切換。

恩,現在回到我們的快取,雖然我們在對快取讀寫的時候沒有IO操作,但是網路讀寫還是IO操作,而且對於快取的操作本身理論上並不耗費多少時間(就是幾個雜湊操作),所以IO時間佔比還是比較大的,所以這種情況下我覺得使用奔放的協程模式是可以的,但也別太奔放了,最好限制一下協程的數量。

但對於有些系統,比如搜尋系統,廣告系統這種服務,每次都有個線上排序的過程,這是個非常大計算量的任務,基本上一次請求80%的時間都耗費在排序這種計算上了,IO反而不是瓶頸,這種情況下,多執行緒模型和golang這種協程模型差別就不是很大了,這時候8核CPU只啟動8個協程和啟80個協程,效率的差別就不大了。

總結

程式碼沒準備好,忙死了。不過放心不會太監的。而且好久沒更新了,今天先把文章寫了吧,本來沒準備寫golang協程這一塊的,後來寫著寫著就有這一段了。


如果你覺得不錯,歡迎轉發給更多人看到,也歡迎關注我的公眾號,主要聊聊搜尋,推薦,廣告技術,還有瞎扯。。文章會在這裡首先發出來:)掃描或者搜尋微訊號XJJ267或者搜尋中文西加加語言就行

相關文章