緩存一致:讀多寫少時,如何解決數(shù)據(jù)更新緩存不同步?
我們之前提到過,互聯(lián)網(wǎng)大多數(shù)業(yè)務(wù)場景的數(shù)據(jù)都屬于讀多寫少,在請求的讀寫比例中,寫的比例會達(dá)到百分之一,甚至千分之一。而對于用戶中心的業(yè)務(wù)來說,這個比例會更大一些,畢竟用戶不會頻繁地更新自己的信息和密碼,所以這種讀多寫少的場景特別適合做讀取緩存。通過緩存可以大大降低系統(tǒng)數(shù)據(jù)層的查詢壓力,擁有更好的并發(fā)查詢性能。但是,使用緩存后往往會碰到更新不同步的問題,下面我們具體看一看。
緩存性價比
是的,緩存的確有可能被濫用,特別是在像用戶中心這樣對數(shù)據(jù)準(zhǔn)確性要求很高的場景中。你提到在對用戶中心進行優(yōu)化時,首要想到的就是將用戶信息放入緩存,以提高性能。這確實是一個常見的優(yōu)化思路,因為緩存能夠顯著減少數(shù)據(jù)庫的訪問頻率,提升系統(tǒng)響應(yīng)速度。
# 表結(jié)構(gòu)
CREATE TABLE `accounts` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`account` varchar(15) NOT NULL DEFAULT '',
`password` char(32) NOT NULL,
`salt` char(16) NOT NULL,
`status` tinyint(3) NOT NULL DEFAULT '0'
`update_time` int(10) NOT NULL DEFAULT '0',
`create_time` int(10) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
# 登錄查詢
select id, account, update_time from accounts
where account = 'user1'
and password = '6b9260b1e02041a665d4e4a5117cfe16'
and status = 1
確實,這是一個簡單的查詢需求。乍一看,似乎將 2000 萬條用戶數(shù)據(jù)都放入緩存可以極大地提升性能,但實際上并不完全如此。雖然緩存能提供高性能的服務(wù),但其性價比并不一定高。這個表主要用于賬號登錄的查詢,而登錄操作本身即使頻繁,也不會對系統(tǒng)帶來巨大的流量壓力。因此,即便將所有用戶數(shù)據(jù)放入緩存,大部分時間這些數(shù)據(jù)都處于閑置狀態(tài)。這樣一來,緩存資源反而被浪費,我們也不必要將并發(fā)量不高的數(shù)據(jù)緩存起來,從而增加預(yù)算開銷。
這就引出一個核心問題:緩存的使用需要考慮性價比。如果花費大量時間和資源將某些數(shù)據(jù)放入緩存,但對系統(tǒng)性能并沒有顯著的提升,甚至增加了額外的成本,那么這樣的緩存策略就是不合理的。緩存的效果需要經(jīng)過評估,通常來說,只有熱點數(shù)據(jù)才值得放入緩存。
臨時熱緩存
在推翻了將所有賬號信息都放入緩存的方案后,我們將目標(biāo)轉(zhuǎn)向那些被頻繁查詢的信息上,比如用戶信息。用戶信息的使用頻率非常高,尤其是在論壇等場景中,常常需要頻繁展示,例如用戶的頭像、昵稱和性別等。不過,由于這些數(shù)據(jù)量較大,全部緩存起來不僅浪費空間,還不具備性價比。
針對這種情況,我們可以考慮使用一種臨時緩存的策略:當(dāng)某個用戶信息首次被訪問時,將其存入緩存;在短時間內(nèi),若有類似查詢請求,就可以直接從緩存中獲取。這樣既可以有效地降低數(shù)據(jù)庫查詢壓力,又不會占用過多的緩存空間。以下是一個常用的實現(xiàn)臨時緩存的代碼示例:
# 示例代碼
def get_user_info(user_id):
# 首先嘗試從緩存中獲取用戶信息
user_info = cache.get(user_id)
if user_info:
return user_info
# 如果緩存中沒有,查詢數(shù)據(jù)庫
user_info = db.query_user_info(user_id)
# 將查詢到的信息存入緩存,并設(shè)置一個合理的過期時間
cache.set(user_id, user_info, timeout=300) # 緩存五分鐘
return user_info
正如我們看到的,這種策略將數(shù)據(jù)臨時放入緩存,在 60 秒過期后自動淘汰。如果在這段時間內(nèi)再次查詢相同數(shù)據(jù),我們的代碼會重新將數(shù)據(jù)填入緩存,繼續(xù)提供使用。這種臨時緩存策略非常適合數(shù)據(jù)量大但熱點數(shù)據(jù)較少的場景,有助于緩解數(shù)據(jù)庫的查詢壓力。
設(shè)置緩存的 TTL(Time-to-Live)是為了更有效地利用內(nèi)存資源。當(dāng)數(shù)據(jù)在指定時間內(nèi)未被再次訪問,就會被自動清除,這樣我們就能避免購買過多內(nèi)存。通過這種方式,可以在節(jié)省成本的同時,提高緩存的性價比,且實現(xiàn)起來簡單,維護也方便,是一種很常用的策略
緩存更新不及時問題
臨時緩存是有 TTL 的,如果 60 秒內(nèi)修改了用戶的昵稱,緩存是不會馬上更新的。最糟糕的情況是在 60 秒后才會刷新這個用戶的昵稱緩存,顯然這會給系統(tǒng)帶來一些不必要的麻煩。其實對于這種緩存數(shù)據(jù)刷新,可以分成幾種情況,不同情況的刷新方式有所不同,接下來我給你分別講講。
1. 單條實體數(shù)據(jù)緩存刷新
單條實體數(shù)據(jù)緩存更新是最簡單的一個方式,比如我們緩存了 9527 這個用戶的 info 信息,當(dāng)我們對這條數(shù)據(jù)做了修改,我們就可以在數(shù)據(jù)更新時同步更新對應(yīng)的數(shù)據(jù)緩存:
Type UserInfo struct {
Id int `gorm:"column:id;type:int(11);primary_key;AUTO_INCREMENT" json:"id"`
Uid int `gorm:"column:uid;type:int(4);NOT NULL" json:"uid"`
NickName string `gorm:"column:nickname;type:varchar(32) unsigned;NOT NULL" json:"nickname"`
Status int16 `gorm:"column:status;type:tinyint(4);default:1;NOT NULL" json:"status"`
CreateTime int64 `gorm:"column:create_time;type:bigint(11);NOT NULL" json:"create_time"`
UpdateTime int64 `gorm:"column:update_time;type:bigint(11);NOT NULL" json:"update_time"`
}
//更新用戶昵稱
func (m *UserInfo)UpdateUserNickname(ctx context.Context, name string, uid int) (bool, int64, error) {
//先更新數(shù)據(jù)庫
ret, err := m.db.UpdateUserNickNameById(ctx, uid, name)
if ret {
//然后清理緩存,讓下次讀取時刷新緩存,防止并發(fā)修改導(dǎo)致臨時數(shù)據(jù)進入緩存
//這個方式刷新較快,使用很方便,維護成本低
Redis.Del("user_info_" + strconv.Itoa(uid))
}
return ret, count, err
}
總體來說,我們可以先識別出被修改的數(shù)據(jù) ID,然后根據(jù)這些 ID 刪除相應(yīng)的數(shù)據(jù)緩存。在下次請求到來時,系統(tǒng)會重新獲取最新的數(shù)據(jù)并更新到緩存中,這樣可以有效減少并發(fā)操作將臟數(shù)據(jù)寫入緩存的可能性。
除了這種方法,我們還可以向隊列發(fā)送更新消息,讓子系統(tǒng)處理更新,或者開發(fā)中間件,將數(shù)據(jù)操作發(fā)送到子系統(tǒng),讓其自行決定需要更新的數(shù)據(jù)范圍。然而,通過隊列更新消息時,我們可能會遇到一個問題——條件批量更新時,可能無法直接確定具體有多少個 ID 發(fā)生了變化。常見的解決方法是:首先按照相同的條件查詢出所有受影響的 ID,然后執(zhí)行更新操作,最后使用這些相關(guān)的 ID 更新具體的緩存。
2. 關(guān)系型和統(tǒng)計型數(shù)據(jù)緩存刷新
首先,有一種人工維護緩存的方式。眾所周知,關(guān)系型數(shù)據(jù)或統(tǒng)計結(jié)果的緩存刷新具有一定的難度,主要原因在于這些統(tǒng)計數(shù)據(jù)通常是基于多條數(shù)據(jù)計算得出的。當(dāng)我們需要刷新這類數(shù)據(jù)的緩存時,很難準(zhǔn)確識別出需要更新的關(guān)聯(lián)緩存。
為了解決這個問題,可以通過人工方式,在集中管理的地方記錄或定義特定的刷新邏輯,以實現(xiàn)關(guān)聯(lián)緩存的更新。
圖片
不過這種方式比較精細(xì),如果刷新緩存很多,那么緩存更新會比較慢,并且存在延遲。而且人工書寫還需要考慮如何查找到新增數(shù)據(jù)關(guān)聯(lián)的所有 ID,因為新增數(shù)據(jù)沒有登記在 ID 內(nèi),人工編碼維護會很麻煩。除了人工維護緩存外,還有一種方式就是通過訂閱數(shù)據(jù)庫來找到 ID 數(shù)據(jù)變化。如下圖,我們可以使用 Maxwell 或 Canal,對 MySQL 的更新進行監(jiān)控。
圖片
在這種方案中,變更信息會被推送到 Kafka。我們可以根據(jù)表名和具體的 SQL 確認(rèn)哪些數(shù)據(jù) ID 發(fā)生了更新,然后依據(jù)腳本中設(shè)定的邏輯,對相關(guān)緩存 key 進行更新。比如,當(dāng)用戶更新了昵稱,緩存更新服務(wù)就能夠識別需要更新 user_info_9527 這個緩存,同時根據(jù)配置找到并刪除其他相關(guān)的緩存。這種方法的優(yōu)勢在于,可以快速地更新簡單的緩存,并且核心系統(tǒng)可以向子系統(tǒng)廣播數(shù)據(jù)變更信息,代碼實現(xiàn)也相對簡單。不過,對于復(fù)雜的關(guān)聯(lián)關(guān)系刷新,仍然需要人工書寫邏輯來實現(xiàn)。
如果表內(nèi)數(shù)據(jù)更新較少,還可以考慮使用版本號緩存策略。這種方法比較直接:一旦有任何更新,表中所有數(shù)據(jù)緩存都會過期。例如,可以為 user_info 表設(shè)置一個版本號 key,比如 user_info_version。當(dāng)表數(shù)據(jù)發(fā)生更新時,直接將 user_info_version 自增 1。寫入緩存時,同時記錄當(dāng)前版本號;讀取時,業(yè)務(wù)邏輯會檢查緩存版本號與表版本號是否一致。如果不一致,就更新緩存數(shù)據(jù)。需要注意的是,如果版本號頻繁更新,緩存命中率會大幅下降,因此該方法更適合數(shù)據(jù)更新不頻繁的表
當(dāng)然,我們還可以對這個表做一個范圍拆分,比如按 ID 范圍分塊拆分出多個 version,通過這樣的方式來減少緩存刷新的范圍和頻率。
圖片
此外,關(guān)聯(lián)型數(shù)據(jù)更新還可以通過識別主要實體 ID 來刷新緩存。這要保證其他緩存保存的 key 也是主要實體 ID,這樣當(dāng)某一條關(guān)聯(lián)數(shù)據(jù)發(fā)生變化時,就可以根據(jù)主要實體 ID 對所有緩存進行刷新。這個方式的缺點是,我們的緩存要能夠根據(jù)修改的數(shù)據(jù)反向找到它關(guān)聯(lián)的主體 ID 才行。
圖片
最后,還有一種方法是通過異步腳本遍歷數(shù)據(jù)庫來刷新所有相關(guān)緩存。這種方式適用于在兩個系統(tǒng)之間進行數(shù)據(jù)同步,能夠減少系統(tǒng)之間的接口交互頻率。其缺點是,在數(shù)據(jù)被刪除后,還需要手動刪除相應(yīng)的緩存,因此更新存在一定延遲。不過,如果結(jié)合訂閱更新消息廣播機制,這種方案可以實現(xiàn)近乎同步的數(shù)據(jù)更新。
長期熱數(shù)據(jù)緩存
回過頭來看之前提到的臨時緩存方案,雖然它能解決大部分問題,但有個潛在風(fēng)險需要考慮:當(dāng) TTL 到期時,如果有大量緩存請求未命中,透傳的流量可能會給數(shù)據(jù)庫帶來巨大的壓力,甚至可能導(dǎo)致數(shù)據(jù)庫崩潰。這就是業(yè)內(nèi)常說的緩存穿透問題。如果發(fā)生大規(guī)模的并發(fā)穿透,服務(wù)可能宕機。因此,如果數(shù)據(jù)庫無法承受日常流量,就不能依賴臨時緩存方案來設(shè)計緩存系統(tǒng),而應(yīng)該采用長期緩存的方式來實現(xiàn)熱點緩存,以避免緩存穿透對數(shù)據(jù)庫的影響。
要實現(xiàn)長期緩存,需要更多的人工操作來保證緩存與數(shù)據(jù)表的一致性。長期緩存的普及主要得益于 NoSQL 技術(shù)的發(fā)展,它與臨時緩存不同,需要業(yè)務(wù)幾乎不依賴數(shù)據(jù)庫,所有在服務(wù)運行期間所需的數(shù)據(jù)都必須在緩存中可用,并確保緩存不會在使用期間丟失。這帶來的挑戰(zhàn)是,我們需要精確知道緩存中的數(shù)據(jù),并提前對這些數(shù)據(jù)進行預(yù)熱。如果數(shù)據(jù)規(guī)模較小,還可以考慮將所有數(shù)據(jù)緩存起來,這樣的實現(xiàn)會相對簡單一些。
總結(jié)
并不是所有數(shù)據(jù)放入緩存都會帶來良好的收益,因此我們需要從數(shù)據(jù)量、使用頻率和緩存命中率三個方面進行分析。對于讀多寫少的數(shù)據(jù),雖然將其緩存能夠降低數(shù)據(jù)層的壓力,但仍需根據(jù)一致性需求來更新緩存中的數(shù)據(jù)。
在這方面,單條實體數(shù)據(jù)的緩存更新相對容易實現(xiàn),但對于需要條件查詢的統(tǒng)計結(jié)果,實時更新則較為困難。因此,在設(shè)計緩存策略時,需綜合考慮這些因素,以確保緩存的有效性和數(shù)據(jù)的一致。