手寫本地緩存實戰2—— 打造正規軍,構建通用本地緩存框架

来源:https://www.cnblogs.com/softwarearch/archive/2022/11/09/16870925.html
-Advertisement-
Play Games

作為緩存系列專欄的第四篇文章,我們將在上一篇的基礎之上進行升華,一起思考如何構建一個完整且通用的本地緩存框架,併在過程中體會緩存實現的關鍵點與架構設計的思路。 ...


大家好,又見面了。


本文是筆者作為掘金技術社區簽約作者的身份輸出的緩存專欄系列內容,將會通過系列專題,講清楚緩存的方方面面。如果感興趣,歡迎關註以獲取後續更新。


村上春樹有本著名的小說名叫《當我談跑步時我談些什麼》,講述了一個人怎麼樣通過跑步去悟道出人生的很多哲理與感悟。而讀書的價值,就是讓我們可以將別人參悟出的道理化為己用,將別人走過的路化為充實自己的養料。

在上一篇文章《手寫本地緩存實戰1——各個擊破,按需應對實際使用場景》中,我們領略了實際項目中一些零散的緩存場景的實現方式,並對緩存實現中的LRU淘汰策略TTL過期清理機制實現方案進行了探討。作為《深入理解緩存原理與實戰設計》系列專欄的第四篇文章,我們將在上一篇的基礎之上進行升華,一起思考如何構建一個完整且通用的本地緩存框架,併在過程中體會緩存實現的關鍵點與架構設計的思路。

有的小伙伴可能會有疑問,現在有很多成熟的開源庫,比如JAVA項目的Guava cacheCaffeine CacheSpring 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並寫入緩存中。

從上面的示例場景中,可以提煉出緩存框架需要關註到的兩個管理能力訴求:

  1. 需要支持托管多個緩存容器,分別存儲不同的數據,比如部門信息和員工信息,需要存儲在兩個獨立的緩存容器中,需要支持獲取各自獨立的緩存容器進行操作。

  2. 需要支持選擇多種不同能力的緩存容器,比如常規的容器、支持數據過期的緩存容器等。

  3. 需要能夠支持對緩存容器的管理,以及緩存基礎維護能力的支持,比如銷毀緩存容器、比如清理容器內的過期數據。

基於上述訴求,我們敲定管理介面類如下:

介面名稱 含義說明
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 cacheCaffeine CacheSpring Cache等)都提供了相對完善、開箱即用的本地緩存能力,可以直接使用,在後面的系列文章中,我們將逐個剖析。

那麼,關於緩存模塊的設計與實現,你是否也曾手動編寫過呢?你是如何解決這些問題的呢?你關於這些問題你是否有更好的理解與應對策略呢?歡迎評論區一起交流下,期待和各位小伙伴們一起切磋、共同成長。


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 前言 大家早好、午好、晚好吖~ 這不光棍節快到了,表弟準備寫一封情書給他的女神,想在光棍節之前脫單。 為了提高成功率,於是跑來找我給他參謀參謀,本來我是不想理他的。 不過誰讓他是我表弟呢(請我洗jio),於是教給他程式員的終極浪漫絕招 先假裝給女神拍照,然後再把情書寫到她的照片上列印出來送給她,嘿嘿 ...
  • HTTP協議 1.什麼是HTTP協議? 超文本傳輸協議(HTTP,HyperText Transfer Protocol)是互聯網上應用廣泛的一種網路協議。是工作在tcp/ip協議基礎上的,所有的www文件都遵守這個標準 http1.0 短連接 http1.1 長連接 HTTP是TCP/IP協議的一 ...
  • “心有所向,日復一日,必有精進” 前言: 想必大家看完我之前寫的搭建redis伺服器,大家都已經把redis搭建起來了吧~如果沒有搭建起來的小可愛請移步這裡哦~[從0到1搭建redis6](https://www.cnblogs.com/qsmm/p/16871488.html "從0到1搭建red ...
  • 文章有點長,我決定用半個小時來和你分享~😂 廢話不多說,上代碼。。。 基於Seata 1.5.2,項目中用 seata-spring-boot-starter 1. SeataDataSourceAutoConfiguration SeataDataSourceAutoConfiguration ...
  • 1、開發文檔 微信開發文檔:https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=9_1 安全規範:https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=4_3 1、簽名演算法 (簽 ...
  • 簡介: 命令模式,又稱之為動作模式或者事務模式,屬於行為型的設計模式。 將不同的請求封裝成不同的請求對象,以便使用不同的請求; 角色都會用飯館來舉例子: 命令下達者:顧客 命令接受者:服務員 命令本身: 菜單 命令執行者:廚師 適用場景: Laravel的事件調度機制有用到了命令模式。 想要解耦服務 ...
  • Java 基礎一 【註釋】 comment 對代碼進行解釋說明1.Java規範的註釋有3種單行註釋://多行註釋:/* */文檔註釋(java特有)2.單行註釋和多行註釋的作用:對所寫的程式進行解釋說明,增強可讀性。方便自己,方便別人。可以調試所寫的代碼3.特點單行註釋和多行註釋,註釋了的內容不參與 ...
  • RPC(Remote Procedure Call) 是 Hadoop 服務通信的關鍵庫,支撐上層分散式環境下複雜的進程間(Inter-Process Communication, IPC)通信邏輯,是分散式系統的基礎。允許運行於一臺電腦上的程式像調用本地方法一樣,調用另一臺電腦的子程式。由於 ...
一周排行
    -Advertisement-
    Play Games
  • 下麵是一個標準的IDistributedCache用例: public class SomeService(IDistributedCache cache) { public async Task<SomeInformation> GetSomeInformationAsync (string na ...
  • 這個庫提供了在啟動期間實例化已註冊的單例,而不是在首次使用它時實例化。 單例通常在首次使用時創建,這可能會導致響應傳入請求的延遲高於平時。在註冊時創建實例有助於防止第一次Request請求的SLA 以往我們要在註冊的時候實例單例可能會這樣寫: //註冊: services.AddSingleton< ...
  • 最近公司的很多項目都要改單點登錄了,不過大部分都還沒敲定,目前立刻要做的就只有一個比較老的項目 先改一個試試手,主要目標就是最短最快實現功能 首先因為要保留原登錄方式,所以頁面上的改動就是在原來登錄頁面下加一個SSO登錄入口 用超鏈接寫的入口,頁面改造後如下圖: 其中超鏈接的 href="Staff ...
  • Like運算符很好用,特別是它所提供的其中*、?這兩種通配符,在Windows文件系統和各類項目中運用非常廣泛。 但Like運算符僅在VB中支持,在C#中,如何實現呢? 以下是關於LikeString的四種實現方式,其中第四種為Regex正則表達式實現,且在.NET Standard 2.0及以上平... ...
  • 一:背景 1. 講故事 前些天有位朋友找到我,說他們的程式記憶體會偶發性暴漲,自己分析了下是非托管記憶體問題,讓我幫忙看下怎麼回事?哈哈,看到這個dump我還是非常有興趣的,居然還有這種游戲幣自助機類型的程式,下次去大玩家看看他們出幣的機器後端是不是C#寫的?由於dump是linux上的程式,剛好win ...
  • 前言 大家好,我是老馬。很高興遇到你。 我們為 java 開發者實現了 java 版本的 nginx https://github.com/houbb/nginx4j 如果你想知道 servlet 如何處理的,可以參考我的另一個項目: 手寫從零實現簡易版 tomcat minicat 手寫 ngin ...
  • 上一次的介紹,主要圍繞如何統一去捕獲異常,以及為每一種異常添加自己的Mapper實現,並且我們知道,當在ExceptionMapper中返回非200的Response,不支持application/json的響應類型,而是寫死的text/plain類型。 Filter為二方包異常手動捕獲 參考:ht ...
  • 大家好,我是R哥。 今天分享一個爽飛了的面試輔導 case: 這個杭州兄弟空窗期 1 個月+,面試了 6 家公司 0 Offer,不知道問題出在哪,難道是杭州的 IT 崩盤了麽? 報名面試輔導後,經過一個多月的輔導打磨,現在成功入職某上市公司,漲薪 30%+,955 工作制,不咋加班,還不捲。 其他 ...
  • 引入依賴 <!--Freemarker wls--> <dependency> <groupId>org.freemarker</groupId> <artifactId>freemarker</artifactId> <version>2.3.30</version> </dependency> ...
  • 你應如何運行程式 互動式命令模式 開始一個互動式會話 一般是在操作系統命令行下輸入python,且不帶任何參數 系統路徑 如果沒有設置系統的PATH環境變數來包括Python的安裝路徑,可能需要機器上Python可執行文件的完整路徑來代替python 運行的位置:代碼位置 不要輸入的內容:提示符和註 ...