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

























