AmazingTalker 微服務化-快取服務

Viney Tai-Li Shih
AmazingTalker Tech
Published in
8 min readJun 2, 2022

快取 (Caching) 又被翻譯做緩存,是一個非常常見的技術,用來增進系統效能與擴充性,其原理就是把高頻率會用到的資料搬到更快、更靠近應用程式的儲存空間,不用每次要使用資料的時候都還要從遠端搬移過來或是重新運算一遍結果,以空間換取時間,可以很顯著的縮短每個請求的回應時間 (response time)。在早期提及快取可能侷限在 CPU 跟記憶體之間的中繼站,如今這觀念已被擴充至記憶體與硬碟之間、乃至硬碟與網路之間,某種意義上都可以稱之為快取。

快取與系統架構通常有著密不可分的關係,透過這個主題的分享也帶出 AmazingTalker 整個平台的迭代過程。

Photo by Ashley McNamara, via ashleymcnamara/gophers (CC BY-NC-SA 4.0)

快取三部曲

共享快取 (Share Cache)

公司在草創初期選用的語言與框架是 RoR (Ruby on Rails),架構是典型的 Monolithic Architecture,方便快速迭代功能。隨著業務的上升,單機架構很快就不能滿足需求,取而代之是在服務端前面堆疊反向代理來實現負載平衡,與此同時引入分散式的共享快取 (i.e. Redis) 來分擔資料庫 (i.e. MySQL) 的壓力,如圖 1 所示。

Figure 1: Monolithic Architecture.

大部分的情況下系統的壓力主要來自於首頁教師牆、聊天室 … 等頁面請求,對應資料庫的操作也是讀取遠高於寫入,透過把常用的資料預先存入快取可以把大多數的請求給消化掉,大幅度的降低對資料庫的壓力。

滿足原本的需求後面臨到新的狀況:現在分兩個地方存資料,兩邊的狀態存在著一致性的問題。而我們採取的策略是 Cache-Aside pattern,盡可能讓兩者之間的訊息快速同步,操作的流程如圖 2 所示。

Figure 2: Using a shared cache.

就像前面所提及的,大部分的情況是讀取的壓力高於寫入,所以讀取的時候優先從 Redis 找起,當找不到的時候才會從 MySQL 讀取,再回填 Redis。寫資料的時候則反過來,先寫到 MySQL,再去刪掉 Redis 的那份拷貝。它又有一個俗稱叫做 Lazy Loading,第一個發請求的人還是會面臨到緩慢的情況,當他被滿足之後,後續的請求就可以從快取擷取資料增進效率。這個策略還是有短時間內存在不一致的狀況,但對於我們要面對的應用場景,還有效能上的權衡是能被滿足的。

而共享快取對於當下的架構還有一個好處,就是易於維護。當 Cache-Aside 策略要執行回填或是刪除資料的時候,由於所有的服務都共享一份快取,所以立即受惠,即時性是非常高的。

混合模式 (Mixed Version of In-memory Cache and Share Cache)

然而共享快取也不是萬用的,隨著時間發展,過度依賴它伴隨而來的風險有兩個:一個是整個系統的穩定性轉嫁到 Redis 身上,雖然相對來說它算很穩定,可是一但 Redis 掛了,整個系統的壓力就會立刻瞬間轉嫁到資料庫上,造成雪崩效應。二來 Redis 價格不菲,遇到不夠用的情況只能一直堆錢上去,惡性循環。

俗話說得好,世界上沒有一個快取解決不了的事情,如果有?那就兩個

經過調整,我們選用服務端的記憶體 (In-memory Cache) 當作第一層快取,而 Redis 退居第二層,這樣操作的優點是它的 C/P 值遠高於 Redis,單位記憶體的價格降低不少,二來不受外部系統的影響,其他系統不穩定依然能使用。套上 Cache-Aside 的操作策略後,操作流程如圖 3 所示。

Figure 3: Using a local cache with a shared cache.

整體看起來情況很類似於共享快取的流程,只是多增加一層本機快取的操作,但大幅度減低對 Redis 的依賴與開銷。

但仍然有一點需要關注的,就是加入本機快取的機制後,整體一致性會降低,本機端快取存活越久會越顯著。不像共享快取被修改後所有存取它的機器都能夠受惠,任何寫入的動作只對當下這台機器有效益,其他在反向代理後面的機器還是維持在當初讀取它的狀態。要解決這個問題,普遍的做法是設定一個過期時間 TTL (Time to Live),時間一到就視同這筆不存在要重新去遠端擷取一次。雖然還是會有一陣子維持不一致的狀態(取決於 TTL 的時間長短),但最終能追上最新的狀態。

另外 Redis 本身就有支援 TTL 的機制,所以在混合模式的情況下根據過往的經驗,會把存放在 Redis 的 TTL 設置比較長一點,而在本地端比較短一點。

外部緩存異步更新

時間軸再往前推進一點,對於共享快取的用量更加斤斤計較,主業務的用量已經漸漸佔滿 Redis 的預留空間,新的業務在不知道成效之前不敢貿然採用。

我們重新審視一下整體的系統架構,開始規劃把一些繁重的工作獨立成微服務 (microservice),讓服務跟服務之間的耦合度降低。而這當中就包含獨立的資源配置,讓繁重的工作能獨立擁有記憶體空間,於是目光再次聚焦到本地端快取,如何讓它的空間能被更佳的利用(畢竟服務端跑起來後沒辦法動態調整記憶體大小)?如何讓快取與資料庫之間更快達成一致性?變成這次的核心議題,高效運用的部分我們選用演算法 TinyLFU ,提升快取的命中率,而一致性的部分選用外部緩存異步更新的機制,流程如圖 4。

Figure 4: Broadcast eviction by PubSub model.

當每次寫入資料庫的時候,都會透過 PubSub pattern 把訊息給廣播出去,收到訊息的服務端就會把本地端的快取給清除,相較於前一次優化只透過 TTL 的機制,這個方法即時性更高。

實作

從 2021 年開始 AmazingTalker 的技術策略上開始往 Microservice Architecture 靠攏,選用的語言是 Go,而整套快取的經驗也隨之複製過去,因而實作 go-cache 這個套件,它擁有著以下的優點:

  • 簡單好用:它提供一個非常友善的介面,透過簡易的宣告,讓你在不同的商務邏輯之間能自由的配置本機端快取共享快取的細節。
  • 維持高一致性:支援 PubSub model,即時性的廣播哪些資料已經過期應該立即清除。
  • 解決競爭問題:引入 singleflight 套件,解決單台機器同時間內發生多個請求回填快取的狀況。
  • 資料壓縮:可以透過 callback function 宣告指定的序列化函式,壓縮存放在快取的內容。
  • 客製化的 Metric:可以透過 callback function 觀察快取內部的狀況。

範例

首先第一步需要在 main.go 初始化 Factory,並把它當作參數傳遞到各個需要用到快取的 package。

接著根據不同的場景設定快取與過期時間 (TTL)。

總結

所有的技術與架構都是因應公司的業務需求慢慢演化而來,而 go-cache 正是 AmazingTalker 微服務化快取迭代的縮影,它讓剛加入團隊的新人很容易上手,也讓資深的成員能夠持續不斷的推進與發展。團隊也開始以這樣的模式看待公司內部的技術發展,持續不斷推陳出新。很高興能有這個機會把這套機制分享給大家,歡迎有興趣的朋友一起討論,你的反饋也是讓這套機制持續成長的動力。

參考

AmazingTalker Taipei office

最後做個宣傳,AmazingTalker 工程團隊也持續徵才中,歡迎有興趣且想挑戰自我的人加入我們,歡迎從這裡進一步了解我們的團隊

--

--

Viney Tai-Li Shih
AmazingTalker Tech

It’s not who I am underneath, but what I do defines me.