Easy-cache:統(tǒng)一緩存解決方案,讓開(kāi)發(fā)人員告別重復(fù)的緩存代碼
1、引言
1.1 核心理念
2、核心實(shí)現(xiàn)
2.1 實(shí)現(xiàn)目標(biāo):簡(jiǎn)單易用的緩存工具
2.2 設(shè)計(jì)思路
2.3 緩存決策:多級(jí)緩存動(dòng)態(tài)升降級(jí)
2.4 數(shù)據(jù)一致性保證機(jī)制
2.5 Lua腳本預(yù)加載:解決開(kāi)銷(xiāo)問(wèn)題
3、核心特性
3.1 分布式鎖保證一致性
3.2 多級(jí)緩存架構(gòu)
3.3 彈性過(guò)期機(jī)制
3.4 注解驅(qū)動(dòng)的簡(jiǎn)化設(shè)計(jì)
4、總結(jié)
1、引言
在分布式系統(tǒng)開(kāi)發(fā)中,緩存問(wèn)題一直是開(kāi)發(fā)人員的"痛點(diǎn)":如何保證數(shù)據(jù)一致性?Redis宕機(jī)怎么辦?緩存穿透、緩存擊穿、緩存雪崩等問(wèn)題怎么處理?每個(gè)項(xiàng)目都要重復(fù)編寫(xiě)類(lèi)似的緩存處理代碼,既浪費(fèi)時(shí)間又容易出錯(cuò)。
1.1 核心理念
為了讓開(kāi)發(fā)人員告別重復(fù)的緩存代碼,專(zhuān)注于業(yè)務(wù)邏輯,把緩存問(wèn)題交給框架處理,基于RocksCache的思想實(shí)現(xiàn)了一個(gè)統(tǒng)一的緩存一致性解決方案:Easy-cache。方案通過(guò)Spring AOP提供簡(jiǎn)單易用的注解式緩存操作,還支持 Redis 集群緩存和本地二級(jí)緩存,具備多級(jí)緩存動(dòng)態(tài)升降級(jí)、容錯(cuò)機(jī)制、彈性過(guò)期、最終一致性保障等高級(jí)特性。開(kāi)發(fā)人員在開(kāi)發(fā)需求時(shí)不需要額外編寫(xiě)代碼保證一致性、宕機(jī)、穿透等問(wèn)題,只需要在注解設(shè)置對(duì)應(yīng)策略即可。
2、核心實(shí)現(xiàn)
2.1 實(shí)現(xiàn)目標(biāo):簡(jiǎn)單易用的緩存工具
我們的目標(biāo)是設(shè)計(jì)一個(gè)簡(jiǎn)單易用、代碼侵入性小的緩存工具。Spring AOP就是一個(gè)非常好的實(shí)現(xiàn)方式,在切面中編寫(xiě)好緩存邏輯,開(kāi)發(fā)者只需要在查詢(xún)方法上添加指定注解,就能獲得緩存能力,無(wú)需編寫(xiě)任何緩存邏輯代碼。
@Cacheable(clusterId = "cluster1", prefix = "user", keys = {"#userId"})
public User getUserById(Long userId) {
return userRepository.findById(userId);
}
@UpdateCache(clusterId = "cluster1", prefix = "user", keys = {"#userId"})
public User update(User user) {
return userRepository.update(user);
}基于常見(jiàn)的緩存問(wèn)題和場(chǎng)景,切面應(yīng)該實(shí)現(xiàn)以下功能:
- 實(shí)現(xiàn)緩存的查詢(xún)與更新邏輯
- 保證數(shù)據(jù)一致性
- 容錯(cuò)處理(防穿透、多級(jí)緩存、自動(dòng)升降級(jí))
接下來(lái),我將詳細(xì)介紹緩存切面的具體設(shè)計(jì)實(shí)現(xiàn)。
2.2 設(shè)計(jì)思路
工具的入口為AOP攔截到指定注解,通過(guò)中央調(diào)度器依次進(jìn)行容錯(cuò)處理、查詢(xún)緩存、處理結(jié)果、返回結(jié)果。具體流程如圖所示:
圖片
- 注解驅(qū)動(dòng):通過(guò)Spring AOP攔截 @Cacheable 和 @CacheUpdate注解觸發(fā)查詢(xún)和更新緩存
- 統(tǒng)一調(diào)度:調(diào)度器處理所有查詢(xún)/更新緩存邏輯
- 容錯(cuò)機(jī)制:裝飾器模式增加容錯(cuò)功能,防止緩存穿透等問(wèn)題
- 多級(jí)緩存:Redis + 本地緩存,保證高可用;監(jiān)控和維護(hù)集群健康度,緩存自動(dòng)升降級(jí),保證服務(wù)穩(wěn)定性。
- 彈性數(shù)據(jù)一致性保障:執(zhí)行Lua腳本保證一組緩存操作的原子性。支持設(shè)置數(shù)據(jù)庫(kù)緩存不一致時(shí)間,默認(rèn)為1.5s,框架在1.5s后保證最終一致性。當(dāng)用戶(hù)設(shè)置不一致時(shí)間為0s時(shí),框架保證實(shí)時(shí)一致性。
2.3 緩存決策:多級(jí)緩存動(dòng)態(tài)升降級(jí)
框架的默認(rèn)多級(jí)緩存策略為:優(yōu)先查詢(xún)并更新Redis集群,當(dāng)Redis集群不可用時(shí),查詢(xún)并更新本地緩存。為此需要一個(gè)決策器,當(dāng)Redis宕機(jī)時(shí)請(qǐng)求能夠直接請(qǐng)求本地緩存,在Redis恢復(fù)后請(qǐng)求會(huì)重新優(yōu)先請(qǐng)求Redis。決策流程如圖所示:
圖片
查詢(xún)請(qǐng)求A首先經(jīng)過(guò)決策器,當(dāng)前時(shí)刻故障信息類(lèi)集群異常事件未達(dá)到閾值,仍然優(yōu)先請(qǐng)求Redis。
此時(shí)查詢(xún)Redis異常,發(fā)送異常事件,故障動(dòng)態(tài)管理類(lèi)監(jiān)聽(tīng)到異常事件后通知異常事件+1。
故障動(dòng)態(tài)管理類(lèi)內(nèi)部定時(shí)任務(wù)查詢(xún)發(fā)現(xiàn)集群異常事件已到達(dá)閾值:
- 標(biāo)記集群不可用。
- 啟動(dòng)集群探活定時(shí)任務(wù)。
查詢(xún)請(qǐng)求B經(jīng)過(guò)決策器,發(fā)現(xiàn)集群不可用,直接與本地緩存交互,實(shí)現(xiàn)緩存降級(jí)。
當(dāng)集群探活成功后,會(huì)標(biāo)識(shí)集群可用,此時(shí)探活定時(shí)任務(wù)關(guān)閉,后續(xù)查詢(xún)請(qǐng)求會(huì)優(yōu)先請(qǐng)求Redis,實(shí)現(xiàn)緩存升級(jí)。
2.4 數(shù)據(jù)一致性保證機(jī)制
基于RocksCache思想,通過(guò)Redis-Hash結(jié)構(gòu)和Lua腳本原子操作,確保緩存數(shù)據(jù)的最終一致性。
緩存中的數(shù)據(jù)是具有以下字段的哈希結(jié)構(gòu):
- value:數(shù)據(jù)本身
- lockInfo:鎖定狀態(tài)信息('locked' 或 'unLock')
- unlockTime:數(shù)據(jù)鎖過(guò)期時(shí)間,當(dāng)一個(gè)進(jìn)程查詢(xún)緩存沒(méi)有數(shù)據(jù)時(shí),則鎖定緩存一小段時(shí)間,然后查詢(xún)DB、更新緩存
- owner:數(shù)據(jù)鎖唯一ID,標(biāo)識(shí)當(dāng)前鎖的持有者其中,owner、lockInfo、unlockTime基于Lua腳本執(zhí)行的原子性實(shí)現(xiàn)了一個(gè)分布式鎖。
如果數(shù)據(jù)為空且鎖已過(guò)期: 則鎖定緩存,返回 NEED_QUERY,同步執(zhí)行"取數(shù)據(jù)"并返回結(jié)果
如果數(shù)據(jù)為空且被鎖定: 則返回 NEED_WAIT,休眠100ms并再次查詢(xún)
如果數(shù)據(jù)不為空且被鎖定: 則立即返回SUCCESS_NEED_QUERY和緩存數(shù)據(jù),異步執(zhí)行"取數(shù)據(jù)"
如果數(shù)據(jù)不為空且未鎖定: 則立即返回SUCCESS和緩存數(shù)據(jù)
private staticfinal String GET_SH =
"local key = KEYS[1]\n"
+ "local newUnlockTime = ARGV[1]\n"
+ "local owner = ARGV[2]\n"
+ "local currentTime = tonumber(ARGV[3])\n"
+ "local value = redis.call('HGET', key, '" + VALUE + "')\n"
+ "local unlockTime = redis.call('HGET', key, '" + UNLOCK_TIME + "')\n"
+ "local lockOwner = redis.call('HGET', key, '" + OWNER + "')\n"
+ "local lockInfo = redis.call('HGET', key, '" + LOCK_INFO + "')\n"
+ "if unlockTime and currentTime > tonumber(unlockTime) then\n"
+ " redis.call('HMSET', key, '" + LOCK_INFO + "', 'locked', '" + UNLOCK_TIME + "', 'newUnlockTime', '" + OWNER + "', owner)\n"
+ " return {value, '" + NEED_QUERY + "'}\n"
+ "end\n"
+ "if not value or value == '' then\n"
+ " if lockOwner and lockOwner ~= owner then\n"
+ " return {value, '" + NEED_WAIT + "'}\n"
+ " end\n"
+ " redis.call('HMSET', key, '" + LOCK_INFO + "', 'locked', '" + UNLOCK_TIME + "', newUnlockTime, '" + OWNER + "', owner)\n"
+ " return {value, '" + NEED_QUERY + "'}\n"
+ "end\n"
+ "if lockInfo and lockInfo == 'locked' then \n"
+ " return {value, '" + SUCCESS_NEED_QUERY + "'}\n"
+ "end\n"
+ "return {value , '" + SUCCESS + "'}";"取數(shù)據(jù)"操作定義:查詢(xún)數(shù)據(jù)庫(kù)并更新緩存。如果滿(mǎn)足以下兩個(gè)條件之一,則需要更新緩存:
- 數(shù)據(jù)為空且未鎖定
- 數(shù)據(jù)鎖定已過(guò)期
更新緩存時(shí),Lua腳本會(huì)執(zhí)行以下邏輯
無(wú)論key是否被鎖定,強(qiáng)制標(biāo)識(shí)鎖過(guò)期,并刪除鎖持有者。鎖的過(guò)期時(shí)間默認(rèn)為1.5s
private staticfinal String INVALID_SH =
"local key = KEYS[1]\n"
+ "local newUnlockTime = tonumber(ARGV[1])\n"
+ "redis.call('HDEL', key, '" + OWNER + "')\n"
+ "local value = redis.call('HGET', key, '" + VALUE + "')\n"
+ "redis.call('HSET', key, '" + LOCK_INFO + "', 'locked')\n"
+ "if not value or value == '' then\n"
+ " return {true, '" + EMPTY_VALUE_SUCCESS + "'}\n"
+ "end\n"
+ "if newUnlockTime > 0 then\n"
+ " redis.call('HSET', key, '" + UNLOCK_TIME + "', newUnlockTime)\n"
+ "end\n"
+ "return {'', '" + SUCCESS + "'}";2.4.1 數(shù)據(jù)一致性
1)讀讀并發(fā)的數(shù)據(jù)一致性
圖片
假設(shè)當(dāng)前緩存沒(méi)有數(shù)據(jù)或數(shù)據(jù)鎖已過(guò)期
- 線(xiàn)程A查詢(xún)緩存,發(fā)現(xiàn)沒(méi)有數(shù)據(jù)或數(shù)據(jù)鎖已過(guò)期,會(huì)對(duì)當(dāng)前key加鎖,標(biāo)識(shí)鎖持有者為當(dāng)前線(xiàn)程,鎖時(shí)長(zhǎng)為1s。
- 線(xiàn)程B查詢(xún)緩存:發(fā)現(xiàn)key已經(jīng)被鎖定且鎖未過(guò)期,會(huì)sleep 100ms再次嘗試查詢(xún)
- 線(xiàn)程A查詢(xún)數(shù)據(jù)庫(kù)數(shù)據(jù)后更新緩存,并釋放鎖
- 線(xiàn)程B查詢(xún)緩存,返回緩存數(shù)據(jù)。
執(zhí)行第2步時(shí)線(xiàn)程B若發(fā)現(xiàn)key被鎖定但鎖已過(guò)期,會(huì)將鎖持有者更新為線(xiàn)程B,查詢(xún)數(shù)據(jù)庫(kù)并更新緩存、釋放鎖,這樣可以保證鎖不會(huì)被同一線(xiàn)程一直占有。線(xiàn)程A更新緩存時(shí)發(fā)現(xiàn)鎖持有者不是自己,不會(huì)更新緩存。 讀讀并發(fā)場(chǎng)景下,通過(guò)分布式鎖確保只有一個(gè)線(xiàn)程查詢(xún)數(shù)據(jù)庫(kù)并更新緩存,保證了數(shù)據(jù)一致性。
2)讀寫(xiě)并發(fā)的數(shù)據(jù)一致性
圖片
- 線(xiàn)程A查詢(xún)緩存,發(fā)現(xiàn)沒(méi)有數(shù)據(jù),于是對(duì)當(dāng)前key加鎖,標(biāo)識(shí)鎖持有者為當(dāng)前線(xiàn)程,鎖時(shí)長(zhǎng)為1s。
- 在線(xiàn)程A查詢(xún)數(shù)據(jù)庫(kù)的過(guò)程中,線(xiàn)程B更新了數(shù)據(jù)庫(kù),同時(shí)更新緩存。此時(shí)更新線(xiàn)程不會(huì)關(guān)注鎖信息,會(huì)強(qiáng)制刪除鎖持有者,并標(biāo)識(shí)key被鎖定。
- 線(xiàn)程A更新緩存,發(fā)現(xiàn)鎖持有者不是當(dāng)前線(xiàn)程(此時(shí)鎖持有者為空),不會(huì)更新緩存
- 線(xiàn)程C查詢(xún)緩存,發(fā)現(xiàn)沒(méi)有數(shù)據(jù),于是對(duì)當(dāng)前key加鎖,標(biāo)識(shí)鎖持有者為當(dāng)前線(xiàn)程,鎖時(shí)長(zhǎng)為1s。
- 線(xiàn)程C查詢(xún)數(shù)據(jù)庫(kù)成功,更新緩存并釋放鎖
- 讀寫(xiě)并發(fā)場(chǎng)景下,框架保證了更新線(xiàn)程將key標(biāo)記刪除后,進(jìn)行中的查詢(xún)線(xiàn)程不會(huì)再將舊值寫(xiě)入緩存,保證了數(shù)據(jù)一致性。
2.4.2 標(biāo)記刪除:彈性過(guò)期時(shí)間
通常情況下為了防止在key過(guò)期或主動(dòng)刪除的瞬間有大量請(qǐng)求擊穿緩存打到數(shù)據(jù)庫(kù),我們會(huì)讓所有請(qǐng)求搶同一把分布式鎖。但是這樣做可能出現(xiàn)一個(gè)場(chǎng)景:搶到鎖的線(xiàn)程訪(fǎng)問(wèn)數(shù)據(jù)庫(kù)時(shí)間較長(zhǎng),大量等待線(xiàn)程響應(yīng)時(shí)間過(guò)慢,導(dǎo)致當(dāng)前服務(wù)響應(yīng)上游服務(wù)請(qǐng)求超時(shí)。
為此我在框架中增加了彈性過(guò)期機(jī)制:更新線(xiàn)程不會(huì)真正的刪除緩存,而是標(biāo)記當(dāng)前key為過(guò)期,過(guò)期時(shí)間默認(rèn)1.5s。在這1.5s內(nèi),所有的查詢(xún)請(qǐng)求會(huì)返回舊值(即1.5s內(nèi)可能出現(xiàn)數(shù)據(jù)庫(kù)緩存不一致),同時(shí)嘗試異步查庫(kù)并更新緩存(異步操作需要搶分布式鎖),此時(shí)可能出現(xiàn)兩種情況:
- 1.5s內(nèi)有一個(gè)線(xiàn)程查庫(kù)成功并更新了緩存,那么就完成了一次平滑更新,實(shí)現(xiàn)數(shù)據(jù)的最終一致,后續(xù)查詢(xún)線(xiàn)程會(huì)從緩存拿到新值。
- 1.5s內(nèi)沒(méi)有線(xiàn)程更新成功,1.5s后鎖過(guò)期,所有查詢(xún)線(xiàn)程會(huì)變成“讀讀并發(fā)”場(chǎng)景,保證了1.5s后的數(shù)據(jù)一致性。
如果業(yè)務(wù)場(chǎng)景無(wú)法容忍最終一致,必須保證實(shí)時(shí)一致,可以設(shè)置彈性過(guò)期時(shí)間為0s,此時(shí)如果緩存被更新,會(huì)立刻變成“讀讀并發(fā)”場(chǎng)景,保證實(shí)時(shí)一致性。
2.5 Lua腳本預(yù)加載:解決開(kāi)銷(xiāo)問(wèn)題
2.5.1 設(shè)計(jì)帶來(lái)的性能開(kāi)銷(xiāo)
在數(shù)據(jù)一致性保證機(jī)制中,為了保證Redis操作的原子性,使用提交Lua腳本的方式操作Redis緩存。這種設(shè)計(jì)雖然保證了功能的正確性,但也帶來(lái)了明顯的性能開(kāi)銷(xiāo):
內(nèi)存開(kāi)銷(xiāo):緩存鎖信息的存儲(chǔ)為了支持分布式鎖機(jī)制,Redis中存儲(chǔ)的不僅僅是數(shù)據(jù)本身,還需要額外的鎖相關(guān)信息。這種設(shè)計(jì)確實(shí)增加了Redis的內(nèi)存開(kāi)銷(xiāo):每個(gè)key最多增加50 bytes(非更新和緩存過(guò)期場(chǎng)景不會(huì)增加額外的內(nèi)存開(kāi)銷(xiāo)),但相比數(shù)據(jù)不一致帶來(lái)的業(yè)務(wù)風(fēng)險(xiǎn),這個(gè)內(nèi)存開(kāi)銷(xiāo)是可以接受的。
網(wǎng)絡(luò)IO開(kāi)銷(xiāo):Lua腳本傳輸更大的性能開(kāi)銷(xiāo)來(lái)自于網(wǎng)絡(luò)IO。以獲取緩存值的操作為例,每次都需要傳輸完整的Lua腳本,腳本大小約為500 bytes。在高并發(fā)場(chǎng)景下,這個(gè)網(wǎng)絡(luò)開(kāi)銷(xiāo)會(huì)迅速累積,成為性能瓶頸。
在解決網(wǎng)絡(luò)IO開(kāi)銷(xiāo)問(wèn)題之前,我們需要簡(jiǎn)單了解一下,常用的Redis執(zhí)行Lua腳本命令方式有以下兩種:
特性\命令方式 | EVAL | EVALSHA |
腳本傳輸 | 每次傳輸完整腳本 | 僅傳輸腳本對(duì)應(yīng)SHA1哈希值 |
性能 | 較低(網(wǎng)絡(luò)開(kāi)銷(xiāo)大) | 較高(適合頻繁調(diào)用) |
適用場(chǎng)景 | 一次性腳本或調(diào)試 | 生產(chǎn)環(huán)境高頻調(diào)用的腳本 |
本文采用EVALSHA命令執(zhí)行Lua腳本,相比于EVAL方式,從每次傳輸500字節(jié)的腳本內(nèi)容,減少到只需要傳輸40字節(jié)的哈希值,網(wǎng)絡(luò)開(kāi)銷(xiāo)減少了約92%。
2.5.2 Lua腳本預(yù)加載
圖片
在服務(wù)啟動(dòng)時(shí)觸發(fā)Lua腳本的預(yù)加載機(jī)制。具體流程如下:
- 啟動(dòng)檢測(cè):服務(wù)啟動(dòng)時(shí),LuaShPublisher組件會(huì)自動(dòng)初始化
- 腳本收集:組件會(huì)收集所有預(yù)定義的Lua腳本,包括獲取緩存、設(shè)置緩存、解鎖緩存、失效緩存等操作
- 腳本上傳:對(duì)每個(gè)集群,通過(guò)scriptLoad命令上傳所有Lua腳本
- 哈希值記錄:將Redis返回的SHA1哈希值記錄到本地緩存中
考慮到網(wǎng)絡(luò)不穩(wěn)定或Redis服務(wù)器臨時(shí)不可用的情況,還需要考慮重試機(jī)制:
- 異常捕獲:當(dāng)腳本上傳失敗時(shí),系統(tǒng)會(huì)捕獲異常信息
- 重試判斷:系統(tǒng)會(huì)判斷是否需要重試,避免無(wú)限重試導(dǎo)致服務(wù)啟動(dòng)失敗
- 延遲重試:采用指數(shù)退避策略,每次重試的間隔逐漸增加
- 成功退出:當(dāng)所有腳本都成功上傳后,重試任務(wù)會(huì)自動(dòng)退出
3、核心特性
3.1 分布式鎖保證一致性
- 原子性操作:Lua腳本保證Redis緩存操作的原子性
- 最終一致性:通過(guò)Lua腳本實(shí)現(xiàn)分布式鎖,保證數(shù)據(jù)一致性
- 性能優(yōu)化:服務(wù)啟動(dòng)時(shí)會(huì)自動(dòng)將需要執(zhí)行的Lua腳本同步到Redis服務(wù)器,減少網(wǎng)絡(luò)傳輸開(kāi)銷(xiāo)
3.2 多級(jí)緩存架構(gòu)
- 高可用性:實(shí)時(shí)監(jiān)控集群健康狀態(tài),Redis宕機(jī)時(shí)自動(dòng)切換到本地緩存
- 智能升級(jí):集群恢復(fù)后自動(dòng)升級(jí)
3.3 彈性過(guò)期機(jī)制
- 標(biāo)記刪除:通過(guò)標(biāo)記機(jī)制實(shí)現(xiàn)軟刪除
- 彈性過(guò)期:支持動(dòng)態(tài)調(diào)整過(guò)期時(shí)間,默認(rèn)為1.5s,框架保證最終一致性。當(dāng)用戶(hù)設(shè)置不一致時(shí)間為0s時(shí),框架保證實(shí)時(shí)一致性。
- 一致性保證:解決緩存與數(shù)據(jù)庫(kù)不一致問(wèn)題
3.4 注解驅(qū)動(dòng)的簡(jiǎn)化設(shè)計(jì)
- 開(kāi)發(fā)效率提升:一行注解替代緩存代碼
- 降低學(xué)習(xí)成本:開(kāi)發(fā)者只需了解注解參數(shù)
- 統(tǒng)一規(guī)范:所有緩存操作遵循相同模式
4、總結(jié)
Easy-cache通過(guò)統(tǒng)一的設(shè)計(jì)解決了開(kāi)發(fā)人員在緩存使用中的痛點(diǎn),實(shí)現(xiàn)了以下核心價(jià)值:
- 重復(fù)代碼問(wèn)題:通過(guò)注解驅(qū)動(dòng),讓開(kāi)發(fā)者告別重復(fù)的緩存處理代碼
- 緩存穿透問(wèn)題:通過(guò)空值緩存和智能防護(hù)機(jī)制,有效防止惡意請(qǐng)求穿透到數(shù)據(jù)庫(kù)
- 緩存擊穿問(wèn)題:通過(guò)分布式鎖機(jī)制和標(biāo)記刪除方式,防止熱點(diǎn)數(shù)據(jù)失效導(dǎo)致的數(shù)據(jù)庫(kù)崩潰
- 數(shù)據(jù)不一致問(wèn)題:通過(guò)Redis-Hash結(jié)構(gòu)+Lua腳本實(shí)現(xiàn)分布式鎖,確保緩存與數(shù)據(jù)庫(kù)的數(shù)據(jù)同步
- Redis宕機(jī)問(wèn)題:通過(guò)自動(dòng)降級(jí)和探活機(jī)制,保證服務(wù)的高可用性
以上就是Easy-cache的核心內(nèi)容,希望能為分布式系統(tǒng)的緩存使用提供一些參考和思路。
關(guān)于作者
伊鑫海,轉(zhuǎn)轉(zhuǎn)履約中臺(tái)研發(fā)工程師,主要負(fù)責(zé)售后業(yè)務(wù)

























