如何用GO語言編寫快取服務?

非同步社群發表於2018-12-03

隨著網際網路的飛速發展,各行各業對網際網路服務的要求也越來越高,服務架構能撐起多大的業務資料?服務響應的速度能不能達到要求?我們的架構師每天都在思考這些問題。

對於資料庫或者物件儲存等服務來說,它們受限於自己先天的設計目標,往往不能具有很好的效能,響應時間通常是秒級。此時就需要高效能的快取來為我們的服務提速了,快取服務的響應時間通常是毫秒級,甚至小於1ms。

快取服務需要被設定在其他服務的前端,客戶端首先訪問快取,查詢自己的資料,僅當客戶端需要的資料不存在於快取中時,才去訪問實際的服務。從實際的服務中獲取到的資料會被放在快取中,以備下次使用。

快取的設計目標就是儘可能地快,但它引起了其他的問題。比如目前業界使用較多的快取服務有Memcached和Redis等,它們都是記憶體內快取,單節點最大的容量不能超過整個系統的記憶體。

1240

且一旦伺服器重啟,對於Memcached來說就是內容徹底丟失;Redis稍好一點,但也要花費不少時間從磁碟上的資料檔案中重新讀入記憶體。

當我們決定要用Go語言編寫一個快取服務的時候,首先想到的就是HTTP服務。因為用Go語言寫基於HTTP的快取服務真的是太方便了,我們只需要一個map來儲存資料,寫一個handler負責處理請求,然後呼叫http.ListenAndServe,最後用go run執行。一切就是這麼簡單,你不需要去考慮複雜的併發問題,也不需要自己設計一套網路協議,Go語言的HTTP服務框架會幫你處理好底層的一切。

我們在本文將要實現的是一個簡單的記憶體快取服務,所有的快取資料都儲存在伺服器的記憶體中。一旦伺服器重啟,所有的資料都將被清零。

快取服務的介面

1.1.1 REST介面

本章的介面支援快取的設定(Set)、獲取(Get)和刪除(Del)這3個基本操作,同時還支援對快取服務狀態的查詢。Set操作用於將一對鍵值對(key value pair)設定進快取伺服器,它通過HTTP的PUT方法進行;Get操作用於查詢某個鍵並獲取其值,它通過HTTP的GET方法進行;Del操作用於從快取中刪除某個鍵,它通過HTTP的DELETE方法進行。我們可以查詢的快取服務狀態包括當前快取了多少對鍵值對,所有的鍵一共佔據了多少位元組,所有的值一共佔據了多少位元組

客戶端通過HTTP的PUT方法將一對鍵值對設定進快取伺服器,伺服器將該鍵值對儲存在記憶體堆上建立的map裡。

1240

​這裡/cache/是一個URL,它標識了快取的值(value)所在的位置。URL是Uniform Resource Locator的縮寫,它是一個網路地址,用於引用某個網路資源在網路上的位置。HTTP的請求正文(request body)裡包含了該key對應的value的內容。

客戶端通過HTTP的GET方法從快取伺服器上獲取key對應的value,伺服器在map中查詢該key,如果key不存在,伺服器返回HTTP錯誤程式碼404 NOT FOUND;如果key存在,則伺服器在HTTP響應正文(response body)中返回相應的value。

1240

客戶端通過HTTP的GET方法從快取伺服器上獲取key對應的value,伺服器在map中查詢該key,如果key不存在,伺服器返回HTTP錯誤程式碼404 NOT FOUND;如果key存在,則伺服器在HTTP響應正文(response body)中返回相應的value。

1240

客戶端通過HTTP的DELETE方法將key從快取中刪除。無論之前該key是否存在,之後它都將不存在,伺服器始終返回HTTP錯誤程式碼200 OK。

1240

​客戶端通過這個介面獲取快取服務的狀態,在HTTP響應正文中返回的狀態是以JSON格式編碼的一個cache.Stat結構體(見例1-3)。

1.1.2 快取Set流程

我們可以用一張簡單的圖來概括Set流程,見圖1-1。

1240

圖1-1 in memory快取的Set流程

客戶端的PUT請求提供了key和value。cacheHandler實現了http.Handler介面,其ServeHTTP方法對HTTP請求進行解析,並呼叫cache.Cache介面的Set方法。

在cache模組中,inMemoryCache結構體實現Cache介面,其Set方法最終將鍵值對儲存在記憶體的map中。cacheHandler最後會返回客戶端一個HTTP錯誤號來表示結果,如果成功則返回的是200 OK,否則返回500 Internal Server Error。

Go語言中的map的含義和用法跟大多數現代程式語言中的map一樣,map是一種用於儲存鍵值對的雜湊表資料結構,可以通過中括號 [ ] 進行key的查詢和設定。

由於程式會對key進行雜湊和掩碼運算以直接獲取儲存key的偏移量,所以能獲得近乎O(1)的查詢和設定複雜度。之所以說近乎O(1)是因為兩個key在經過雜湊和掩碼運算後有可能會具有相同的偏移量,此時將不得不繼續進行線性搜尋,不過發生這種不幸情況的概率很小。

1.1.3 快取Get流程

快取Get流程見圖1-2。

1240

圖1-2 in memory快取的Get流程

客戶端的Get請求提供了key。cacheHandler的ServeHTTP方法對HTTP請求進行解析,並呼叫cache.Cache介面的Get方法。inMemoryCache結構體的Get方法在map中查詢key對應的value並返回。cacheHandler會將value寫入HTTP響應正文並返回200 OK,如果cache.Cache.Get方法返回錯誤,cacheHandler會返回500 Internal Server Error。如果value長度為0,說明該key不存在,cacheHandler會返回404 Not Found。

1.1.4 快取Del流程

快取Del流程見圖1-3。

1240

圖1-3 in memory快取的Del流程

客戶端的DELETE請求提供了key。cacheHandler的ServeHTTP方法對HTTP請求進行解析,並呼叫cache.Cache介面的Del方法。inMemoryCache結構體的Del方法在map中查詢key是否存在,如果存在則呼叫delete函式刪除該key。如果cache.Cache.Del方法返回錯誤,cacheHandler會返回500 Internal Server Error,否則返回200 OK。

REST介面和處理流程介紹完了,接下來我們來看看如何實現。

Go語言實現

1.2.1 main包的實現

快取服務的main包只有一個函式,就是main函式。在Go語言中,如果某個專案需要被編譯為可執行程式,那麼它的原始碼需要有一個main包,其中需要有一個main函式,它用來作為可執行程式的入口函式。如果某個專案不需要被編譯為可執行程式,只是實現一個庫,則可以沒有main包和main函式。我們的快取服務需要被編譯成一個可執行程式,所以需要提供main包和main函式。main函式的實現見例1-1:

例1-1 main函式

1240

我們的main函式非常簡單,它需要做的只是呼叫cache.New函式建立一個新的cache.Cache介面的例項c,然後以c為引數呼叫http.New函式建立一個指向http.Server結構體的指標並呼叫其Listen方法。

cache.New這樣的寫法則是指定我們呼叫的New函式屬於cache包。Go語言呼叫同一個包內的函式不需要在函式前面帶上包名,Go編譯器會預設在當前包內查詢。呼叫另一個包中的函式則需要指定包名,讓Go編譯器知道去哪裡查詢這個函式。這裡我們是在main包中呼叫cache包的New函式,所以需要指定包名。

1.2.2 cache包的實現

我們在cache包中實現服務的快取功能。在cache包內,我們首先宣告瞭一個Cache介面,見例1-2。

例1-2 Cache介面

1240

​在Go語言中,介面和實現是完全分開的。介面甚至擁有它自己的型別(type interface)。開發者可以自由宣告一個介面,然後以一種或多種方式去實現這個介面。在例1-2中,我們看到的就是一個名為Cache的介面宣告。

在介面內,我們會宣告一些方法,一個介面就是該介面內所有方法的集合。任何結構體只要實現了某個介面宣告的所有方法,我們就認為該結構體實現了該介面。實現某個介面的結構體可以不止一個,這意味著同樣的介面實現的方式可以有很多種,Go語言就是用這種方式來實現多型。

我們的Cache介面一共宣告瞭4個方法,分別是Set、Get、Del和GetStat。

Set方法用於將鍵值對設定進快取,它接收兩個引數,型別分別是string和[ ]byte,其中string是key的型別,而[ ]byte則是value的型別,byte前面的中括號意味著它的型別是位元組(byte)的切片(slice)。Go語言中切片的內部實現可以被認為是一個指向切片第一個元素的地址和該切片的長度。切片和陣列(Array)的區別在於陣列的長度是固定的,而切片則是底層陣列的一個檢視,其長度可以動態調整。Set方法的返回值只有一個。若返回值的型別是error,則用於返回Set操作的錯誤,當Set操作成功時,返回nil。

Get方法根據key從快取中獲取value,所以它接收一個string型別的引數,返回值則是兩個,分別是 [ ]byte和error。在Go語言中,當函式具有多個返回值時,需要用小括號()將它們括在一起。

Del方法從快取中刪除key,所以它只有一個string型別的引數和一個error型別的返回值。

GetStat方法用於獲取快取的狀態,它沒有引數,只有一個Stat型別的返回值。Stat是一種結構體,見例1-3。

例1-3 Stat結構體相關實現

1240

Go語言程式設計僅僅宣告介面型別(type interface)是沒用的,還必須實現介面。而介面的實現需要依附於某個結構體型別(type struct)。Stat就是一個結構體,它的內部有3個欄位,Count用於表示快取目前儲存的鍵值對數量,KeySize和ValueSize分別表示key和value佔據的總位元組數。

結構體也可以包含方法,和介面不同的地方在於結構體必須實現這些方法,而介面只需要宣告。Stat結構體實現了add和del兩個方法,這兩個方法分別用於新加鍵值對和刪除鍵值對時改變快取的狀態。

在瞭解完整個Cache介面之後,我們就可以去看看New函式的實現了,見例1-4。

例1-4 New函式實現

1240

cache包的New函式用來建立並返回一個Cache介面,它接收一個string型別的引數typ,typ用於指定需要建立的Cache介面的具體結構體型別。

我們在函式體的第一行宣告瞭一個型別為Cache介面的變數c,當typ字串等於“inmemory”時,我們將newInMemoryCache函式的返回值賦值給c。如果c為nil,我們呼叫panic報錯並退出整個程式,否則我們列印一條日誌通知快取開始服務並將c返回。

本文實現的快取服務是一種記憶體快取(in memory),實現Cache介面的結構體名為inMemoryCache,見例1-5。

例1-5 inMemoryCache相關程式碼

1240

inMemoryCache結構體包含一個成員c,型別是以string為key、以 [ ]byte為value的map,用來儲存鍵值對;一個mutex,型別是sync.RWMutex,用來對map的併發訪問提供讀寫鎖保護;一個Stat,用來記錄快取狀態。

Go語言的map可以支援多個goroutine同時讀,但不能支援多個goroutine同時寫或同時既讀又寫,所以我們必須用一個讀寫鎖保護map的併發讀寫,當多個goroutine同時讀時,它們會呼叫mutex.RLock(),互不影響。

當有至少一個goroutine需要寫時,它會呼叫mutex.Lock(),此時它會等待所有其他讀寫鎖釋放,然後自己加鎖,在它加鎖後其他goroutine需要加鎖則必須等待它先解鎖。讀寫鎖mutex的型別是sync.RWMutex,sync是Go語言自帶的一個標準包,它提供了包括Mutex、RWMutex在內的多種互斥鎖的實現。

需要特別注意的是Stat,它的型別是Stat結構體,但是它沒有提供成員名字,這種寫法在Go語言中被稱為內嵌。結構體可以內嵌多個結構體和介面,接則只能內嵌多個介面。

Go語言通過內嵌來實現繼承,內嵌結構體/介面可以被認為是外層結構體/介面的父類。一個內嵌結構體/介面的所有成員/方法都可以通過外層結構體/介面直接訪問,那些成員/方法的首字母不需要大寫。(通常我們從一個結構體外部只能訪問其首字母大寫的成員/方法,訪問自己的內嵌成員的成員/方法不受此限制。)當我們需要訪問某個內嵌成員本身時,我們可以直接用它的型別指代它,就如同我們在inMemoryCache.GetStat函式中做的那樣。

1.2.3 HTTP包的實現

HTTP包用來實現我們的HTTP服務功能。由於不需要使用多型,我們在HTTP包裡並沒有宣告介面,而是直接宣告瞭一個Server結構體,見例1-6。

例1-6 Server相關實現

1240

Server結構體中內嵌了cache.Cache,cache.Cache就是之前介紹的cache包的Cache介面。HTTP包的Server結構體內嵌該介面意味著http.Server也實現了cache.Cache介面,而實現的方式則由實際的內嵌結構體決定。

接下來我們看到Server的Listen方法會呼叫http.Handle函式,它會註冊兩個Handler分別用來處理/cache/和/status這兩個HTTP協議的端點。

這裡需要注意的是http.Handle函式並不屬於我們的HTTP包,而是Go語言自己的net/http標準包。還記得嗎?Server結構體自身就處於我們的HTTP包裡,引用自己包內的名字無需指定包名,所以當我們指定HTTP包名時,Go語言編譯器會知道去net/http包中查詢名字。

Server.cacheHandler方法返回的是一個http.Handler介面,它用來處理HTTP端點/cache/的請求,也就是快取的Set、Get、Del這3個基本操作,見例1-7。

例1-7 cacheHandler相關實現

1240

cacheHandler結構體內嵌了一個Server結構體的指標,並實現了ServeHTTP方法,實現該方法就意味著實現了http.Handler介面。例1-8展示了Go語言標準包net/http對Handler介面的定義。

例1-8 Go標準包net/http中Handler介面的定義

1240

​cacheHandler的ServeHTTP方法解析URL以獲取key,並根據HTTP請求的3種方式PUT/GET/DELETE決定呼叫cache.Cache的Set/Get/Del方法。

這裡我們看到了Go語言內嵌的高階使用方式——多重內嵌:cacheHandler內嵌了Server結構體指標,而Server內嵌了cache.Cache介面。於是cacheHandler就可以直接訪問cache.Cache的方法了。

Server.statusHandler方法同樣返回一個http.Handler介面,其實現見例1-9。

例1-9 statusHandler相關實現

1240

和cacheHandler一樣,statusHandler內嵌Server結構體指標並實現ServeHTTP方法。該方法呼叫cache.Cache的GetStat方法並將返回的cache.Stat結構體用JSON格式編碼成位元組切片b,寫入HTTP的響應正文。

如果你是一位程式設計師,看到這裡你的心裡可能會有一個疑問。我們這樣實現會不會太複雜了?為了處理兩個HTTP端點的請求,我們需要實現兩個Handler結構體並分別實現它們的ServeHTTP方法,能不能直接在Server結構體上實現ServeHTTP方法並根據URL區分不同的HTTP請求?

從實現上來說是可行的,但是那意味著Server的ServeHTTP需要承擔兩個不同的職責,處理兩類HTTP請求。將這兩類請求分開到不同的結構體內實現符合SOLID的單一職責原則。

Go語言的實現介紹完了,接下來我們需要把程式執行起來,並進行功能測試來驗證我們的實現。

1240

分散式快取——原理、架構及Go語言實現

胡世傑 著

點選此處購買紙書

本書共分3個部分,每個部分都有3章。第1部分為基本功能的實現,主要介紹基於HTTP的in memory快取服務、HTTP/REST協議、TCP等。第2部分介紹效能相關的內容,我們將集中全力講解從各方面提升快取服務效能的方法,主要包括pipeline的原理、RocksDB批量寫入等。最後一個部分則HE 分散式快取服務叢集有關,主要介紹分散式快取叢集、節點的再平衡功能等。


相關文章