1. 程式人生 > >用Go開發支援百萬級資料量的高效能快取服務

用Go開發支援百萬級資料量的高效能快取服務

最近,我們的團隊負責編寫一個高效能的快取服務。目標很明確,但可以通過多種方式實現。最後,我們決定嘗試新的技術使用Go實現該服務

目錄:

  1. 需求
  2. 為什麼用GO
  3. 快取
    1. 併發
    2. 過期
    3. 省略垃圾收集器
    4. BigCache
  4. HTTP伺服器
  5. JSON反序列化
  6. 結論
  7. 概要

需求

根據需求,我們的服務應該:

  • 使用HTTP協議來處理請求
  • 處理10k rps(寫入為5k,讀取為5k)
  • 快取資料至少10分鐘
  • 響應時間(不包括在網路上花費的時間)低於
    • 5ms - 平均
    • 10ms - 99.9%滿足
    • 400ms - 99.999%滿足
  • 處理包含JSON訊息的POST請求,其中每條訊息:
    • 包含一個條目及其ID
    • 不大於500位元組
  • 在通過POST請求新增條目後立即通過GET請求檢索條目並返回int(一致性)

簡單來說,我們的任務是編寫一個帶有過期和REST介面的快速字典。

為什麼用GO

我們公司的大多數微服務都是用Java或其他基於JVM的語言編寫的,有些是用Python編寫的。我們還有一個用PHP編寫的單一的遺留平臺,但除非必須,否則我們不會觸控它。我們已經瞭解這些技術,但我們願意探索新技術。我們的任務可以用任何語言實現,因此我們決定在Go中編寫它。

Go已經有一段時間了,有大公司和不斷增長的使用者社群支援。它被宣傳為編譯的,併發的,命令式的,結構化的程式語言。它還具有託管記憶體,因此它看起來比C / C ++更安全,更容易使用。我們對使用Go編寫的工具有很好的經驗,並決定在這裡使用它。我們在Go有一個

開源專案,現在我們想知道Go如何處理大流量。使用Go我們相信整個專案只需要不到100行程式碼,並且足夠快可以滿足我們的要求。

快取

為了滿足要求,快取本身需要:

  • 即使有數百萬條目,也要非常快
  • 提供併發訪問
  • 過期後清除

考慮到第一點,我們決定放棄外部快取,如RedisMemcachedCouchbase,主要是因為網路需要額外的時間。因此,我們專注於記憶體快取。在Go中已經存在這種型別的快取,即LRU組快取, go-cachettlcachefreecache只有freecache滿足了我們的需求。接下來的子章節揭示了為什麼我們決定自己推銷自己,並描述如何實現上述特徵。

併發

我們的服務會同時收到許多請求,因此我們需要提供對快取的併發訪問。

實現這一目標的簡單方法是放在sync.RWMutex快取訪問功能之前,以確保一次只能修改一個goroutine。然而,其他想要對其進行修改的goroutine也會被阻止,從而成為瓶頸。為了消除這個問題,可以使用切片。切片背後的想法很簡單。建立N個切片的陣列,每個切片包含其自己的具有鎖的快取例項。當需要快取具有唯一鍵的項時,首先由該函式選擇它的切片hash(key) % N在獲取快取鎖併發生對快取的寫入之後。專案讀數是類似的。切片的數量相對較高並且雜湊函式返回唯一鍵的正確分佈的數字時,則鎖競爭幾乎可以最小化為零。這就是我們決定在快取中使用切片的原因。

過期

從快取中刪除過期元素的最簡單方法是將它與FIFO佇列一起使用將條目新增到快取時,會發生另外兩個操作:

  1. 在佇列末尾新增包含金鑰和建立時間戳的條目。
  2. 從佇列中讀取最舊的元素。將其建立時間戳與當前時間進行比較。當它晚於驅逐時間時,佇列中的元素與其在快取中的相應條目一起被刪除。

由於已經獲取了鎖,因此在寫入快取期間執行刪除。

省略垃圾收集器

在Go中,如果使用Map,垃圾收集器(GC)將在標記和掃描階段查詢該Map的每個元素。當Map足夠大(包含數百萬個物件)時,這會對應用程式效能產生巨大影響。

我們對我們的服務進行了一些測試,我們在其中為數百萬條目提供快取,之後我們開始向一些不相關的REST端點發送請求,只執行靜態JSON序列化(它根本沒有觸及快取)。對於空快取,此端點的最大響應延遲為10k rps,為10ms。當快取填滿時,它有超過第99%的延遲。度量標準表明堆中有超過40萬個物件,GC標記和掃描階段耗時超過4秒。測試結果表明,如果我們想要滿足與響應時間相關的要求,我們需要跳過GC以獲取快取條目。我們該如何做有下面三種解決辦法。

GC僅限於堆,所以第一種就是堆外。有一個專案可以幫助解決這個問題,稱為offheap它提供自定義功能Malloc()Free()管理堆外部的記憶體。但是,需要實現依賴於這些功能的快取。

第二種方法是使用freecacheFreecache通過減少指標數來實現零GC開銷的對映。它將鍵和值儲存在環形緩衝區中,並使用索引切片查詢條目。

省略GC用於快取條目的第三種方法與Go 1.5提供的優化有關此優化表明,如果您在鍵和值中使用沒有指標的對映,則GC將省略其內容。這是一種保持堆積並省略GC以獲取Map中條目的方法。但是,它不是最終解決方案,因為Go中的所有內容基本上都是基於指標構建的:結構,切片,甚至是固定陣列。只有原函式喜歡intbool不接觸指標。那麼我們可以用map[int]int做些什麼呢?因為我們已經生成了雜湊鍵以便從快取中選擇正確的切片(在併發中描述),所以我們將它們重用為我們的金鑰map[int]int但是int型別的價值呢?我們可以保留哪些資訊做為int我們可以保留條目的偏移量。另一個問題是,為了再次省略GC,可以保留這些條目嗎?可以分配大量位元組,並且可以將條目序列化為位元組並保留在其中。在這方面,值map[int]int可以指向一個條目,其中條目在建議的陣列中開始。並且由於FIFO佇列用於儲存條目並控制它們的刪除(在Eviction中描述),因此可以重建它並基於巨大的位元組陣列,該對映的值也將指向該陣列。

在所有呈現的場景中,都需要進入(de)序列化。最後,我們決定嘗試第三種解決方案,因為我們很好奇它是否能夠工作並且我們已經擁有大多數元素 - 雜湊鍵(在切片選擇階段計算)和條目佇列。

BigCache

為了滿足本章開頭提出的要求,我們實現了自己的快取並將其命名為BigCache。BigCache提供切片,過期刪除,並省略了GC用於快取條目。因此,即使對於大量資料,它也是非常快速的快取。

Freecache是​​Go中唯一可用的記憶體快取,它提供了這種功能。Bigcache是​​它的替代解決方案,並以不同的方式減少GC開銷,因此我們決定與它共享:bigcache有關freecache和bigcache之間比較的更多資訊,請訪問github

HTTP伺服器

記憶體分析器向我們顯示在請求處理期間分配了一些物件。我們知道HTTP處理程式將成為我們系統的熱點。我們的API非常簡單。我們只接受POST和GET來上傳和下載快取中的元素。我們實際上只支援一個URL模板,因此不需要功能齊全的路由器。我們通過剪下前7個字母從URL中提取ID,它執行的很好。

當我們開始開發時,Go 1.6在RC中。我們減少請求處理時間的第一個努力是更新到最新的RC版本。在我們的案例中,表現幾乎相同。我們開始尋找更高效的東西,我們找到了 fasthttp它是一個提供零分配HTTP伺服器的庫。根據文件,它在合成測試中比標準HTTP處理程式快10倍。在我們的測試中,結果發現它只快了1.5倍,但仍然更好!

fasthttp通過減少HTTP Go包的工作來提高其效能。例如:

  • 它將請求生命週期限制在實際處理的時間
  • 請求頭是懶惰解析(我們真的不需要請求頭)

不幸的是,fasthttp並不是標準http的真正替代品。它不支援路由或HTTP / 2並聲稱不支援所有HTTP邊緣技術。它適用於具有簡單API的小型專案,因此我們會堅持使用預設HTTP進行正常(非超級效能)專案。


JSON反序列化

在分析我們的應用程式時,我們發現該程式在JSON反序列化上花費了大量時間。記憶體分析器還報告說,處理了大量資料json.Marshal它並沒有讓我們感到驚訝。對於10k rps,每個請求350個位元組可能是任何應用程式的重要負載。然而,我們的目標是速度,所以我們研究了它。

我們聽說Go JSON序列化程式沒有其他語言那麼快。大多數基準測試都是在2013年完成的,所以在1.3版之前。當我們看到問題-5683聲稱Go比Python慢​​3倍並且 郵件列表說它比Python simplejson慢5倍時,我們開始尋找更好的解決方案。

如果您需要速度,JSON over HTTP絕對不是最佳選擇。不幸的是,我們所有的服務都以JSON相互通訊,因此合併新協議超出了此任務的範圍(但我們正在考慮使用avro,就像我們為Kafka所做的那樣)。我們決定堅持使用JSON。快速搜尋為我們提供了一個名為ffjson的解決方案

ffjson文件聲稱它比標準快2-3倍json.Unmarshal,並且使用更少的記憶體來完成它。

JSON16154 ns / op1875年B / op37 allocs / op
ffjson8417 ns / op1555 B / op31 allocs / op

我們的測試證實,ffjson比內建的解組器快了近2倍並且執行的分配更少。怎麼可能實現這個目標?

首先,為了從ffjson的所有功能中受益,我們需要為struct生成一個unmarshaller。生成的程式碼實際上是一個掃描位元組的解析器,並用資料填充物件。如果你看一下JSON語法,你會發現它非常簡單。ffjson利用瞭解結構的確切內容,只解析結構中指定的欄位,並在發生錯誤時快速失敗。標準編組程式使用昂貴的反射呼叫來在執行時獲取結構定義。另一個優化是減少不必要的錯誤檢查。json.Unmarshal將更快地執行更少的alloc,並跳過反射呼叫。

json(無效的json)1027 ns / op384 B / op9 allocs / op
ffjson(無效的json)2598 ns / op528 B / op13 allocs / op

有關ffjson如何工作的更多資訊,請點選此處基準測試可在此處獲得

結論

最後,我們將應用程式從2.5秒以上加速到不到250毫秒,以獲得最長的請求。這些時間只發生在我們的用例中。我們相信,對於更多的寫入或更長的過期時間,訪問標準快取可能需要更多的時間,但是使用bigcache或freecache它可以保持毫秒級別,因為消除了長GC暫停的問題。

下圖顯示了優化服務之前和之後的響應時間的比較。在測試期間,我們傳送了10k rps,其中5k是寫入,另外5k是讀取。過期時間設定為10分鐘。測試時間為35分鐘。

優化之前和之後的響應時間

最終結果是隔離的,具有與上述相同的設定。

最終結果

概要

如果您不需要高效能,請堅持使用標準庫。它們保證可以維護,並且具有向後相容性,因此升級Go版本應該是順暢的。

我們用Go編寫的快取服務終於滿足了我們的要求。我們花費大部分時間來確定GC停頓會對應用程式響應能力產生巨大影響,因為它控制著數百萬個物件。幸運的是,像bigcachefreecache這樣的快取解決了這個問題。