一個快取設計,讓團隊的效能得到了明顯的提升

前言

在網際網路後端架構中,快取是一種非常常見的解決方案,它有效解決了以下場景:

請求數過多,打垮資料庫,緩解資料庫壓力。

降低呼叫第三方api出錯率。

某些極端場景下,有效可靠的降級方案。

快取的設計和實現,在架構上一般可以分2級。為什麼不做3級,如果覺得有必要做,可以做,但是目前這種場景還很少。

一級快取,最常規的方案,就是Redis。Redis是分散式一級快取,多api節點可以共用redis,使得快取源一致。一級快取作用顯而易見,是為了緩解資料庫壓力。

二級快取,是api節點記憶體。二級快取的作用,是降低redis通訊io,降低序列化成本。

在使用快取時,務必遵循一個原則:

只要是快取,必定要追加失效機制,禁止使用永久不失效快取。

整個快取體系,在後端架構中,如圖:

一個快取設計,讓團隊的效能得到了明顯的提升

使用快取必須要遵循的規範

快取的使用,一定要遵循一些規範。群魔亂舞的設計,必然會導致維護隱患。健康的快取設計,有哪些規範呢?

讀取策略

先從二級快取中讀取,找到了則返回。

再從一級快取中讀取,找到了則返回,並追加進二級快取。

最後,從資料庫中讀取,找到了則返回,並追加進一級快取,二級快取。

最重要的是,不論是一級快取,還是二級快取,永遠應該統一在讀取時,被注入。而不是在真實資料來源修改時,同步注入。因為修改的場景,是併發的,那麼可能存在後修改的操作,被先注入,導致快取髒了。

失效期

快取,是作為真實資料來源的降級方案,它背後必須有真實可靠的資料來源,一般是資料庫。

一級快取,以Redis為例,建議失效時間為5-12分鐘隨機時間失效。

二級快取,以應用記憶體為例,建議失效時間,為固定15秒。

沒有失效期,會有哪些後果?

第一,快取一旦出現不一致,將會永久不一致。在發現不一致和改正的過程,是非常花時間的。不論是使用了怎樣的快取策略,都有可能出現不一致。畢竟redis不是強事務保證。

第二,在快取模型,增減欄位時,當user_info快取中追加了欄位age,要如何給不失效的快取追加上這個欄位是一個經典問題。最佳實踐是,給快取key追加版本,假設原來的10086使用者為

key: user_info:10086value: {“id”:1, “username”:“張三”}

追加版本後,為

key: user_info:v2:10086value: {“id”:1, “username”:“張三”, “age”:18}

總而言之,舊key丟棄,等待自然失效。新key投入使用。而不是蠢蠢得,懶載入等使用者讀到了快取後,再修改快取內容,設定回原來的key中。

第三,Redis存在靜默刪除冷key,不熱門key的機制,永久不刪除的快取存在被意外刪除的場景。

第四,關於狀態。如果Redis內的資料存在永久不失效的,那麼這個Redis是一個有狀態的redis,如果Redis內資料全部都有失效期,那麼它是一個無狀態的。對一個無狀態的Redis,當存在資料庫遷移時,比如單點redis升遷叢集,無狀態的redis可以直接切,無須考慮切換策略。對有狀態的Redis,需要執行資料遷移計劃,存在影響流量的風險。

刪除策略

一級快取和二級快取,遵循不同的刪除策略。

一級快取存在兩種刪除場景。第一,資料庫記錄存在修改,那麼一級快取應該被刪除,並且此時不應該重新注入,而應該等待二次查詢時懶注入。第二,一級快取自然失效,那麼它也被刪除了。

二級快取存在兩種刪除場景。第一,15秒後自然失效。第二,一級快取存在修改/刪除。

哪些資料能開二級快取

二級快取,是應用內快取,屬於偽狀態。那麼,當資料來源修改時,我們要怎麼通知所有應用幾點二級快取更新呢?

一般不通知,二級快取刪除的操作會被繫結進刪除一級快取的方法中,假設應用節點有3個,那麼請求透過負載均衡打入某一個節點,那麼該節點的二級快取會被有效刪除,其它兩個節點,存在隨機0-15秒幻讀。

所以,哪些資料能開二級快取,哪些資料不能,必須有明確規定。在業務開發中,我們往往將業務模型抽象為兩類: 配置類,進度類。配置類表名往往以config結尾,進度類我們以process結尾。

以config結尾的資料,可以開二級快取。也就是,大部分配置都可以開。因為它較少修改,並且修改後,允許15秒內漸漸生效,。15秒後完全生效。

以process結尾的資料,禁止開二級快取。因為它存在實時運算,比如商城限購2次,那麼限購次數作為進度裡的欄位,是要避免出現幻讀的。

設計和使用

在設計快取時,大多數時候,我們都知道它的原理是一致的,但是現實是,每個人實現的樣式不一樣,帶來的維護成本,和故障鏈路查詢成本,都非常高。以下是我見過的群魔亂舞的快取設計場景:

讀寫分離。 資料來源的讀取和寫入,放進了兩個專案裡,兩個倉庫。

重複設計。先按照快取定義,實現了某一個業務模型的快取,等下一個場景又需要快取時,繼續仿照第一個,重複設計,週期*2。

無預判。在設計時,已經瞭解到是一個c端請求模型,可是到處修改,跨服務改,導致後續無法追加快取。因為無法統計到修改場景,導致快取更新上存在不一致。

這些設計方式,統統都能夠正確實現快取,可是實現過程,和上手成本,從實際出發,真的太高了。當你的快取設計出來以後,需要捫心自問3個問題:

這個快取維護簡單嗎?

這個快取,新手來寫會寫嗎?

這個快取,在增加欄位,快取資料遷移,快取雪崩,快取穿透上,改起來簡單嗎?

一個合理的快取設計,就是對開發使用人來說,只關注兩個值【快取key】【二級快取開關】。其它任何的過程實現,都不應該讓開發人員迴圈參與實現。

show me your code

團隊中,我們使用了【程式碼生成】作為快取設計必用工具。

當我們在資料庫中見好了表後,我們使用程式碼生成,產出了go語言的模型

import mc “github。com/fwhezfwhez/model_convert”func TestTableToStructWithTag(t *testing。T) { dataSouce := fmt。Sprintf(“host=%s port=%s user=%s dbname=%s sslmode=%s password=%s”, “localhost”, “5432”, “postgres”, “testdb”, “disable”, “123”) tableName := “match_info” fmt。Println(mc。TableToStructWithTag(dataSouce, tableName, map[string]interface{}{ “dialect”: “postgres”, “${db_instance}”: “db。DB”, “${db_instance_pkg}”: “path/to/db”, }))}

產出

type MatchInfo struct{ 。。。 // 省略了欄位說明}var MatchInfoRedisKeyFormat = “xyx:match_info:%s:%d”// 1級快取key, 二級快取keyfunc (o MatchInfo) RedisKey() string { // TODO set its redis key and required args return fmt。Sprintf(MatchInfoRedisKeyFormat, config。Mode, o。GameId)}// 一級快取失效時間func (o MatchInfo) RedisSecondDuration() int { return int(time。Now()。Unix()%7+5) *60}const ( // 單條記錄二級快取開關 MatchInfoCacheSwitch = true // 記錄集二級快取開關 ArrayMatchInfoCacheSwitch = false)func (o *MachInfo) MustGet(conn redis。Conn, engine *gorm。DB) error { 。。。。 // 省略了實現過程}// 以下省略了大部分快取設計

所以,由上可見,當一個模型在技術評審時,定性為客戶端c端模型,那麼它必然要有快取策略,那麼它必然得按照程式碼生成來制定。

以上產出程式碼,使用人,只需要關注修改【快取key】【二級快取開關】。其它都按照預設值即可。

程式碼實現的詳情,可以自行嘗試,框架是開源框架。

它是怎麼做到追加欄位的?

xyx:match_info:%s:%d  => xyx:match_info:v2:%s:%d // 棄用舊key,喚起新key

它是怎麼做到防止快取雪崩的?

int(time。Now()。Unix()%7+5) *60 // 隨機5-12分鐘失效

它是怎麼做到防止快取穿透的?

這個在被省略的快取設計中,核心原理是查詢快取查詢資料庫都查不到時,快取注入一個【Disable】值。當查詢到Disable時,自動返回找不到。

產能

在統一了快取規範和工具後,每個業務模型上,節省了500-700行程式碼,並且對新人在快取設計和使用上,幾乎沒有了門檻。哪怕是一個0基礎剛剛學會程式設計的人,也可以很好得實現具備快取的介面。

在團隊中,這個方案帶來的價值非常重量級。新人從學習到上手設計出合理規範的快取,只花了一個小時學習,就能跟進c端專案了。