
大家好,又見面了,
本文是筆者作為掘金技術社區簽約作者的身份輸出的快取專欄系列內容,將會通過系列專題,講清楚快取的方方面面,如果感興趣,歡迎關注以獲取后續更新,
村上春樹有本著名的小說名叫《當我談跑步時我談些什么》,講述了一個人怎么樣通過跑步去悟道出人生的很多哲理與感悟,而讀書的價值,就是讓我們可以將別人參悟出的道理化為己用,將別人走過的路化為充實自己的養料,
在上一篇文章《手寫本地快取實戰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等)都提供了相對完善、開箱即用的本地快取能力,可以直接使用,在后面的系列文章中,我們將逐個剖析,
那么,關于快取模塊的設計與實作,你是否也曾手動撰寫過呢?你是如何解決這些問題的呢?你關于這些問題你是否有更好的理解與應對策略呢?歡迎評論區一起交流下,期待和各位小伙伴們一起切磋、共同成長,
?? 補充說明1 :
本文屬于《深入理解快取原理與實戰設計》系列專欄的內容之一,該專欄圍繞快取這個宏大命題進行展開闡述,全方位、系統性地深度剖析各種快取實作策略與原理、以及快取的各種用法、各種問題應對策略,并一起探討下快取設計的哲學,
如果有興趣,也歡迎關注此專欄,
?? 補充說明2 :
- 關于本文中涉及的演示代碼的完整示例,我已經整理并提交到github中,如果您有需要,可以自取:https://github.com/veezean/JavaBasicSkills

我是悟道,聊技術、又不僅僅聊技術~
如果覺得有用,請點贊 + 關注讓我感受到您的支持,也可以關注下我的公眾號【架構悟道】,獲取更及時的更新,
期待與你一起探討,一起成長為更好的自己,

本文來自博客園,作者:架構悟道,歡迎關注公眾號[架構悟道]持續獲取更多干貨,轉載請注明原文鏈接:https://www.cnblogs.com/softwarearch/p/16870925.html
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/530514.html
標籤:Java
上一篇:jps命令的簡介及使用方法說明
