作為緩存系列專欄的第四篇文章,我們將在上一篇的基礎之上進行升華,一起思考如何構建一個完整且通用的本地緩存框架,併在過程中體會緩存實現的關鍵點與架構設計的思路。 ...
大家好,又見面了。
本文是筆者作為掘金技術社區簽約作者的身份輸出的緩存專欄系列內容,將會通過系列專題,講清楚緩存的方方面面。如果感興趣,歡迎關註以獲取後續更新。
村上春樹有本著名的小說名叫《當我談跑步時我談些什麼》,講述了一個人怎麼樣通過跑步去悟道出人生的很多哲理與感悟。而讀書的價值,就是讓我們可以將別人參悟出的道理化為己用,將別人走過的路化為充實自己的養料。
在上一篇文章《手寫本地緩存實戰1——各個擊破,按需應對實際使用場景》中,我們領略了實際項目中一些零散的緩存場景的實現方式,並對緩存實現中的LRU淘汰策略
、TTL過期清理
機制實現方案進行了探討。作為《深入理解緩存原理與實戰設計》系列專欄的第四篇文章,我們將在上一篇的基礎之上進行升華,一起思考如何構建一個完整且通用的本地緩存框架,併在過程中體會緩存實現的關鍵點與架構設計的思路。
有的小伙伴可能會有疑問,現在有很多成熟的開源庫,比如JAVA項目的Guava cache
、Caffeine Cache
、Spring Cache
等(這些在我們的系列文章中,後面都會逐個介紹),它們都提供了相對完善、開箱即用的本地緩存能力,為什麼這裡還要去自己手寫本地緩存呢?這不是重覆造輪子嗎?
是也?非也!在編碼的進階之路上,“會用”永遠都只是讓自己停留在入門級別。正所謂知其然更要知其所以然,通過一起探討手寫緩存的實現與設計關鍵點,來切身的體會蘊藏在緩存架構中的設計哲學。只有真正的掌握其原理,才能在使用中更好的去發揮其最大價值。
緩存框架定調
在一個項目系統中需要緩存數據的場景會非常多,而且需要緩存的數據類型也不盡相同。如果每個使用到緩存的地方,我們都單獨的去實現一套緩存,那開發小伙伴們的工作量又要上升了,且後續各業務邏輯獨立的緩存部分代碼的維護也是一個可預見的頭疼問題。
作為應對之法,我們的本地緩存必須往一個更高層級進行演進,使得項目中不同的緩存場景都可以通用 —— 也即將其抽象封裝為一個通用的本地緩存框架
。既然定位為業務通用的本地緩存框架,那至少從規範或者能力層面,具備一些框架該有的樣子:
-
泛型化設計,不同業務維度可以通用
-
標準化介面,滿足大部分場景的使用訴求
-
輕量級集成,對業務邏輯不要有太強侵入性
-
多策略可選,允許選擇不同實現策略甚至是緩存存儲機制,打破眾口難調的困局
下麵,我們以上述幾個點要求作為出發點,一起來勾勒一個符合上述訴求的本地緩存框架的模樣。
緩存框架實現
緩存容器介面設計
在前一篇文章中,我們有介紹過項目中常見的緩存使用場景。基於提及的幾種具體應用場景,我們可以歸納出業務對本地緩存的API介面層的一些共性訴求。如下表所示:
介面名稱 | 含義說明 |
---|---|
get | 根據key查詢對應的值 |
put | 將對應的記錄添加到緩存中 |
remove | 將指定的緩存記錄刪除 |
containsKey | 判斷緩存中是否有指定的值 |
clear | 清空緩存 |
getAll | 傳入多個key,然後批量查詢各個key對應的值,批量返回,提升調用方的使用效率 |
putAll | 一次性批量將多個鍵值對添加到緩存中,提升調用方的使用效率 |
putIfAbsent | 如果不存在的情況下則添加到緩存中,如果存在則不做操作 |
putIfPresent | 如果key已存在的情況下則去更新key對應的值,如果不存在則不做操作 |
為了滿足一些場景對數據過期的支持,還需要提供或者重載一些介面用於設定過期時間:
介面名稱 | 含義說明 |
---|---|
expireAfter | 用於指定某個記錄的過期時間長度 |
put | 重載方法,增加過期時間的參數設定 |
putAll | 重載方法,增加過期時間的參數設定 |
基於上述提供的各個API方法,我們可以確定緩存的具體介面類定義:
/**
* 緩存容器介面
*
* @author 架構悟道
* @since 2022/10/15
*/
public interface ICache<K, V> {
V get(K key);
void put(K key, V value);
void put(K key, V value, int timeIntvl, TimeUnit timeUnit);
V remove(K key);
boolean containsKey(K key);
void clear();
boolean containsValue(V value);
Map<K, V> getAll(Set<K> keys);
void putAll(Map<K, V> map);
void putAll(Map<K, V> map, int timeIntvl, TimeUnit timeUnit);
boolean putIfAbsent(K key, V value);
boolean putIfPresent(K key, V value);
void expireAfter(K key, int timeIntvl, TimeUnit timeUnit);
}
此外,為了方便框架層面對緩存數據的管理與維護,我們也可以定義一套統一的管理API介面:
介面名稱 | 含義說明 |
---|---|
removeIfExpired | 如果給定的key過期則直接刪除 |
clearAllExpiredCaches | 清除當前容器中已經過期的所有緩存記錄 |
同樣地,我們可以基於上述介面說明,敲定介面定義如下:
public interface ICacheClear<K> {
void removeIfExpired(K key);
void clearAllExpiredCaches();
}
至此,我們已完成了緩存的操作與管理維護介面的定義,下麵我們看下如何對緩存進行維護管理。
緩存管理能力構建
在一個項目中,我們會涉及到多種不同業務維度的數據緩存,而不同業務緩存對應的數據存管要求也各不相同。
比如對於一個公司行政管理系統而言,其涉及到如下數據的緩存:
- 部門信息
部門信息量比較少,且部門組織架構相對固定,所以需要全量存儲,數據不允許過期。
- 員工信息
員工信息總體體量也不大,但是員工信息可能會變更,如員工可能會修改簽名、頭像或者更換部門等。這些操作對實時性的要求並不高,所以需要設置每條記錄緩存30分鐘,超時則從緩存中刪除,後續使用到之後重新查詢DB並寫入緩存中。
從上面的示例場景中,可以提煉出緩存框架需要關註到的兩個管理能力訴求:
-
需要支持托管多個緩存容器,分別存儲不同的數據,比如部門信息和員工信息,需要存儲在兩個獨立的緩存容器中,需要支持獲取各自獨立的緩存容器進行操作。
-
需要支持選擇多種不同能力的緩存容器,比如常規的容器、支持數據過期的緩存容器等。
-
需要能夠支持對緩存容器的管理,以及緩存基礎維護能力的支持,比如銷毀緩存容器、比如清理容器內的過期數據。
基於上述訴求,我們敲定管理介面類如下:
介面名稱 | 含義說明 |
---|---|
createCache | 創建一個新的緩存容器 |
getCache | 獲取指定的緩存容器 |
destoryCache | 銷毀指定的緩存容器 |
destoryAllCache | 銷毀所有的緩存容器 |
getAllCacheNames | 獲取所有的緩存容器名稱 |
對應地,可以完成介面類的定義:
public interface ICacheManager {
<K, V> ICache<K, V> getCache(String key, Class<K> keyType, Class<V> valueType);
void createCache(String key, CacheType cacheType);
void destoryCache(String key);
void destoryAllCache();
Set<String> getAllCacheNames();
}
在上一節關於緩存容器的介面劃定描述中,我們敲定了兩大類的介面,一類是提供給業務調用的,另一類是給框架管理使用的。為了簡化實現,我們的緩存容器可以同時實現這兩類介面,對應UML圖如下:
為了能讓業務自行選擇使用的容器類型,可以通過專門的容器工廠來創建,根據傳入的緩存容器類型,創建對應的緩存容器實例:
這樣,在CacheManager
管理層面,我們可以很輕鬆的完成創建緩存容器或者獲取緩存容器的介面實現:
@Override
public void createCache(String key, CacheType cacheType) {
ICache cache = CacheFactory.createCache(cacheType);
caches.put(key, cache);
}
@Override
public <K, V> ICache<K, V> getCache(String cacheCollectionKey, Class<K> keyType, Class<V>valueType) {
try {
return (ICache<K, V>) caches.get(cacheCollectionKey);
} catch (Exception e) {
throw new RuntimeException("failed to get cache", e);
}
}
過期清理
作為緩存,經常會需要設定一個緩存有效期,這個有效期可以基於Entry
維度進行實現,並且需要支持到期後自動刪除此條數據。在前一篇文章《本地緩存實現的時候需要考慮什麼——按需應對實際使用場景》中我們有詳細探討過幾種不同的過期數據清理機制,這裡我們直接套用結論,採用惰性刪除與定期清理結合的策略來實現。
我們對實際緩存數據值套個外殼,用於存儲一些管理類的屬性,比如過期時間等。然後我們的容器類實現ICacheClear
介面,併在對外提供的業務操作介面中進行惰性刪除的實現邏輯。
比如對於預設的緩存容器而言,其ICacheClear
的實現邏輯可能如下:
@Override
public synchronized void removeIfExpired(K key) {
Optional.ofNullable(data.get(key)).map(CacheItem::hasExpired).ifPresent(expired -> {
if (expired) {
data.remove(key);
}
});
}
@Override
public synchronized void clearAllExpiredCaches() {
List<K> expiredKeys = data.entrySet().stream()
.filter(cacheItemEntry -> cacheItemEntry.getValue().hasExpired())
.map(Map.Entry::getKey)
.collect(Collectors.toList());
for (K key : expiredKeys) {
data.remove(key);
}
}
這樣呢,按照惰性刪除的策略,在各個業務介面中,需要先調用removeIfExpired
方法移除已過期的數據:
@Override
public Optional<V> get(K key) {
removeIfExpired(key);
return Optional.ofNullable(data.get(key)).map(CacheItem::getValue);
}
而在框架管理層面,作為兜底,需要提供定時機制,來清理各個容器中的過期數據:
public class CacheManager implements ICacheManager {
private Map<String, ICache> caches = new ConcurrentHashMap<>();
private List<ICacheHandler> handlers = Collections.synchronizedList(new ArrayList<>());
public CacheManager() {
new Timer().schedule(new TimerTask() {
@Override
public void run() {
System.out.println("start clean expired data timely");
handlers.forEach(ICacheHandler::clearAllExpiredCaches);
}
}, 60000L, 1000L * 60 * 60 * 24);
}
// 省略其它方法
}
這樣呢,對緩存的數據過期能力的支撐便完成了。
構建不同能力的緩存容器
作為緩存框架,勢必需要面臨不同的業務各不相同的訴求。在框架搭建層面,我們整體框架的設計實現遵循著里式替換
的原則,且藉助泛型
進行構建。這樣,我們就可以實現給定的介面類,提供不同的緩存容器來滿足業務的場景需要。
比如我們需要提供兩種類型的容器:
-
普通的鍵值對容器
-
支持設定最大容量且使用LRU策略淘汰的鍵值對容器
可以直接創建兩個不同的容器類,然後分別實現介面方法即可。對應UML示意如下:
最後,需要將我們創建的不同的容器類型在CacheType
中註冊下,這樣調用方便可以通過指定不同的CacheType
來選擇使用不同的緩存容器。
@AllArgsConstructor
@Getter
public enum CacheType {
DEFAULT(DefaultCache.class),
LRU(LruCache.class);
private Class<? extends ICache> classType;
}
緩存框架使用初體驗
至此呢,我們的本地緩存框架就算是搭建完成了。在業務中有需要使用緩存的場景直接使用CacheManager
中的createCache
方法創建出對應緩存容器,然後調用緩存容器的介面進行緩存的操作即可。
我們來調用一下,看看使用體驗與功能如何。比如我們現在需要為用戶信息創建一個獨立的緩存,然後往裡面寫入一個用戶記錄並設定1s
後過期:
public static void main(String[] args) {
manager.createCache("userData", CacheType.LRU);
ICache<String, User> userDataCache = manager.getCache("userData", String.class, User.class);
userDataCache.put("user1", new User("user1"));
userDataCache.expireAfter("user1", 1, TimeUnit.SECONDS);
userDataCache.get("user1").ifPresent(value -> System.out.println("找到用戶:" + value));
try {
Thread.sleep(2000L);
} catch (Exception e) {
}
boolean present = userDataCache.get("user1").isPresent();
if (!present) {
System.out.println("用戶不存在");
}
}
執行之後,輸出結果為:
找到用戶:User(userName=user1)
用戶不存在
可以發現,完全符合我們的預期,且過期數據清理機也已生效。同樣地,如果需要為其它數據創建獨立的緩存存儲,也參考上面的邏輯,創建自己獨立的緩存容器即可。
擴展探討
分散式場景下本地緩存漂移現象應對策略
在本系列的開篇文章《聊一聊作為高併發系統基石之一的緩存,會用很簡單,用好才是技術活》中,我們有提到過一個本地緩存在分散式場景下存在的一個緩存漂移問題:
解決緩存漂移問題,一個簡單的方案就是藉助集中式緩存來解決(比如Redis
)。但是在一些簡單的小型分散式節點中,不太值得引入太多額外公共組件服務的時候,也可以考慮對本地緩存進行增強,提供一些同步更新各節點緩存的機制。
下麵介紹兩個兩個實現思路。
- 組網廣播
在一些小型組網中,當某一個節點執行緩存更新操作的時候,都同時廣播一個事件通知給其餘節點,各個節點都進行節點自身緩存數據的更新。
- 定時輪詢式
一般的系統中,都會有個資料庫節點(比如MySQL
),我們可以藉助資料庫作為一個中間輔助,每次更新之後,都將緩存的更新信息寫入一個獨立的表中,然後各個緩存節點都定時從DB中拉取增量更新的記錄,然後更新到本地緩存中。
值得註意的是,上面這些思路僅適用於寫操作不是很頻繁、並且對實時一致性要求不是特別嚴苛的場景 —— 當然,在實際項目中,真正這麼搞的情況比較少。因為本地緩存設計存在的初衷就是用來應對單進程內的緩存獨立緩存使用,而這種涉及到多節點之間緩存數據一致保證的場景,本就不是本地緩存的擅長領域。所以在分散式場景下,往往都會直接選擇使用集中式緩存。
當然啦,上面我們提到的兩種本地緩存同步的機制,都是相對簡單的一種實現。一些比較主流的本地緩存框架,也有提供一些集群化數據同步的機制,比如Ehcache
就提供了高達5種不同的集群化策略,以達到各個本地緩存節點數據保持一致的效果:
-
RMI
組播方式 -
JMS
消息方式 -
Cache Server
模式 -
JGroup
方式 -
Terracotta
方式
後續文章中我們會一起探討下Ehcache的相關內容,這裡先賣個關子,到時候我們細聊。
小結回顧
好啦,關於手寫本地通用緩存框架的內容,我們就聊這麼多。通過本篇內容,我們完成了對前面文章中提過的一些緩存設計理論原則的實踐,並一步步的闡述了緩存的設計與實現關鍵點,更展示瞭如何讓一個緩存模塊從簡單的能用變為好用、通用。
當然,本篇內容主要是為了通過手寫緩存的模式,來讓大家更切身的體會緩存實現中的關鍵點與架構設計思路,並能在後續的使用中更正確的去使用。在實際項目中,除非一些特殊定製訴求需要手動實現緩存機制外,我們倒也不必自己費時勞神地去手寫緩存框架,直接採用現有的開源方案即可。比如JAVA類的項目,目前有很多開源庫(比如Guava cache
、Caffeine Cache
、Spring Cache
等)都提供了相對完善、開箱即用的本地緩存能力,可以直接使用,在後面的系列文章中,我們將逐個剖析。
那麼,關於緩存模塊的設計與實現,你是否也曾手動編寫過呢?你是如何解決這些問題的呢?你關於這些問題你是否有更好的理解與應對策略呢?歡迎評論區一起交流下,期待和各位小伙伴們一起切磋、共同成長。