今天這一篇沒想到會這麼長,後面有一段是寫網絡模型的,和緩存自己的關係不大,只是寫到那裏就想到了這個問題,多寫了一些,那一段是我本身的理解,確定有不對的地方,歡迎討論拍磚。node
前面花了一篇文章說數據庫這個輪子,其實說得還很淺很淺的,真正的數據庫比這複雜很多,今天咱們繼續輪子系列,今天說說緩存系統吧。golang
緩存是後端使用得最多的東西了,由於性能
是後端開發一個重要的特徵,因此緩存就應運而生了,並且如今緩存已經到了氾濫的程度了,我幾乎沒見過沒有緩存的後端,一遇到性能問題,首先想到的不是看代碼,而是加緩存,我也是醉了,好了,不扯這些,這些和今天的文章無關,今天咱們來專門講講緩存吧。數據庫
緩存和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)吧,而對於兩個整數的比較,一個異或操做就搞定了,誰快就不用說了吧,這個槽變成這樣了。
對於不斷增加的數據而言,重哈希是一個必不可少的過程,所謂重哈希,就是當你的桶使用到必定程度之後碰撞的機率就變很大了,這時候就須要把桶加大了。
把桶加大,必然須要進行一次從新哈希的過程,這個過程的處理辦法也有一些技巧。
直接從新哈希,這是最簡單的,就是把全部的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
模型。
關於
libevent
和libev
這兩個模型(其實差不太多),咱們能夠專門寫一篇文章分析一下源碼,他們的源碼都很少,正好我也看過,能夠寫一篇文章,固然網上這類文章也不少。
對於網絡模型,實際上現代的高級語言已經基本上封裝到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的協程網上資料不少,關於協程的詳細設計能夠找到很多文章,總的來講就是這是一個輕量級的線程,有多輕呢?輕到它其實就是一個代碼段加上一個本身的棧,光這個還不夠,線程也能夠說是一個代碼片斷加上一個本身的棧,只是協程的棧比線程的小而已,除了這個覺得,協程主要的輕表如今它不經過操做系統調度,他是經過代碼
內部進行顯示調度
的。
什麼叫代碼內部
進行顯示調度
呢?
咱們先看看目前流行的兩個事件模型【同時也是處理網絡鏈接的網絡模型】,一種是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或者搜索中文西加加語言就行