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