緩存(Cache) 是指將程序或系統中常用的數據對象存儲在像內存這樣特定的介質中,以避免在每次程序調用時,重新創建或組織數據所帶來的性能損耗,從而提高了系統的整體運行速度。
以目前的系統架構來說,用戶的請求一般會先經過緩存系統,如果緩存中沒有相關的數據,就會在其他系統中查詢到相應的數據並保存在緩存中,最後返回給調用方。
緩存既然如此重要,那本課時我們就來重點看一下,應該如何實現本地緩存和分佈式緩存?
典型回答
本地緩存是指程序級別的緩存組件,它的特點是本地緩存和應用程序會運行在同一個進程中,所以本地緩存的操作會非常快,因爲在同一個進程內也意味着不會有網絡上的延遲和開銷。
本地緩存適用於單節點非集羣的應用場景,它的優點是快,缺點是多程序無法共享緩存,比如分佈式用戶 Session 會話信息保存,由於每次用戶訪問的服務器可能是不同的,如果不能共享緩存,那麼就意味着每次的請求操作都有可能被系統阻止,因爲會話信息只保存在某一個服務器上,當請求沒有被轉發到這臺存儲了用戶信息的服務器時,就會被認爲是非登錄的違規操作。
除此之外,無法共享緩存可能會造成系統資源的浪費,這是因爲每個系統都單獨維護了一份屬於自己的緩存,而同一份緩存有可能被多個系統單獨進行存儲,從而浪費了系統資源。
分佈式緩存是指將應用系統和緩存組件進行分離的緩存機制,這樣多個應用系統就可以共享一套緩存數據了,它的特點是共享緩存服務和可集羣部署,爲緩存系統提供了高可用的運行環境,以及緩存共享的程序運行機制。
本地緩存可以使用 EhCache 和 Google 的 Guava 來實現,而分佈式緩存可以使用 Redis 或 Memcached 來實現。
由於 Redis 本身就是獨立的緩存系統,因此可以作爲第三方來提供共享的數據緩存,而 Redis 的分佈式支持主從、哨兵和集羣的模式,所以它就可以支持分佈式的緩存,而 Memcached 的情況也是類似的。
考點分析
本課時的面試題顯然不只是爲了問你如何實現本地緩存和分佈式緩存這麼簡單,主要考察的是你對緩存系統的理解,以及對緩存本質原理的洞察,和緩存相關的面試題還有這些:
更加深入的談談 EhCache 和 Guava。
如何自己手動實現一個緩存系統?
知識擴展
1. EhCache 和 Guava 的使用及特點分析
EhCache 是目前比較流行的開源緩存框架,是用純 Java 語言實現的簡單、快速的 Cache 組件。EhCache 支持內存緩存和磁盤緩存,支持 LRU(Least Recently Used,最近很少使用)、LFU(Least Frequently Used,最近不常被使用)和 FIFO(First In First Out,先進先出)等多種淘汰算法,並且支持分佈式的緩存系統。
EhCache 最初是獨立的本地緩存框架組件,在後期的發展中(從 1.2 版)開始支持分佈式緩存,分佈式緩存主要支持 RMI、JGroups、EhCache Server 等方式。
LRU 和 LFU 的區別
LRU 算法有一個缺點,比如說很久沒有使用的一個鍵值,如果最近被訪問了一次,那麼即使它是使用次數最少的緩存,它也不會被淘汰;而 LFU 算法解決了偶爾被訪問一次之後,數據就不會被淘汰的問題,它是根據總訪問次數來淘汰數據的,其核心思想是“如果數據過去被訪問多次,那麼將來它被訪問次數也會比較多”。因此 LFU 可以理解爲比 LRU 更加合理的淘汰算法。
EhCache 基礎使用
首先,需要在項目中添加 EhCache 框架,如果爲 Maven 項目,則需要在 pom.xml 中添加如下配置:
<!-- https://mvnrepository.com/artifact/org.ehcache/ehcache -->
<dependency>
<groupId>org.ehcache</groupId>
<artifactId>ehcache</artifactId>
<version>3.8.1</version>
</dependency>
無配置參數的 EhCache 3.x 使用代碼如下:
import org.ehcache.Cache;
import org.ehcache.CacheManager;
import org.ehcache.config.builders.CacheConfigurationBuilder;
import org.ehcache.config.builders.CacheManagerBuilder;
import org.ehcache.config.builders.ResourcePoolsBuilder;
public class EhCacheExample {
public static void main(String[] args) {
// 創建緩存管理器
CacheManager cacheManager = CacheManagerBuilder.newCacheManagerBuilder().build();
// 初始化 EhCache
cacheManager.init();
// 創建緩存(存儲器)
Cache<String, String> myCache = cacheManager.createCache("MYCACHE",
CacheConfigurationBuilder.newCacheConfigurationBuilder(
String.class, String.class,
ResourcePoolsBuilder.heap(10))); // 設置緩存的最大容量
// 設置緩存
myCache.put("key", "Hello,Java.");
// 讀取緩存
String value = myCache.get("key");
// 輸出緩存
System.out.println(value);
// 關閉緩存
cacheManager.close();
}
}
其中:
CacheManager:是緩存管理器,可以通過單例或者多例的方式創建,也是 Ehcache 的入口類;
Cache:每個 CacheManager 可以管理多個 Cache,每個 Cache 可以採用 hash 的方式存儲多個元素。
它們的關係如下圖所示:
更多使用方法,請參考官方文檔。
EhCache 的特點是,它使用起來比較簡單,並且本身的 jar 包不是不大,簡單的配置之後就可以正常使用了。EhCache 的使用比較靈活,它支持多種緩存策略的配置,它同時支持內存和磁盤緩存兩種方式,在 EhCache 1.2 之後也開始支持分佈式緩存了。
Guava Cache 是 Google 開源的 Guava 裏的一個子功能,它是一個內存型的本地緩存實現方案,提供了線程安全的緩存操作機制。
Guava Cache 的架構設計靈感來源於 ConcurrentHashMap,它使用了多個 segments 方式的細粒度鎖,在保證線程安全的同時,支持了高併發的使用場景。Guava Cache 類似於 Map 集合的方式對鍵值對進行操作,只不過多了過期淘汰等處理邏輯。
在使用 Guava Cache 之前,我們需要先在 pom.xml 中添加 Guava 框架,配置如下:
<!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>28.2-jre</version>
</dependency>
Guava Cache 的創建有兩種方式,一種是 LoadingCache,另一種是 Callable,代碼示例如下:
import com.google.common.cache.*;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
public class GuavaExample {
public static void main(String[] args) throws ExecutionException {
// 創建方式一:LoadingCache
LoadingCache<String, String> loadCache = CacheBuilder.newBuilder()
// 併發級別設置爲 5,是指可以同時寫緩存的線程數
.concurrencyLevel(5)
// 設置 8 秒鐘過期
.expireAfterWrite(8, TimeUnit.SECONDS)
//設置緩存容器的初始容量爲 10
.initialCapacity(10)
// 設置緩存最大容量爲 100,超過之後就會按照 LRU 算法移除緩存項
.maximumSize(100)
// 設置要統計緩存的命中率
.recordStats()
// 設置緩存的移除通知
.removalListener(new RemovalListener<Object, Object>() {
public void onRemoval(RemovalNotification<Object, Object> notification) {
System.out.println(notification.getKey() + " was removed, cause is " + notification.getCause());
}
})
// 指定 CacheLoader,緩存不存在時,可自動加載緩存
.build(
new CacheLoader<String, String>() {
@Override
public String load(String key) throws Exception {
// 自動加載緩存的業務
return "cache-value:" + key;
}
}
);
// 設置緩存
loadCache.put("c1", "Hello, c1.");
// 查詢緩存
String val = loadCache.get("c1");
System.out.println(val);
// 查詢不存在的緩存
String noval = loadCache.get("noval");
System.out.println(noval);
// 創建方式二:Callable
Cache<String, String> cache = CacheBuilder.newBuilder()
.maximumSize(2) // 設置緩存最大長度
.build();
// 設置緩存
cache.put("k1", "Hello, k1.");
// 查詢緩存
String value = cache.get("k1", new Callable<String>() {
@Override
public String call() {
// 緩存不存在時,執行
return "nil";
}
});
// 輸出緩存值
System.out.println(value);
// 查詢緩存
String nokey = cache.get("nokey", new Callable<String>() {
@Override
public String call() {
// 緩存不存在時,執行
return "nil";
}
});
// 輸出緩存值
System.out.println(nokey);
}
}
以上程序的執行結果爲:
Hello, c1.
cache-value:noval
Hello, k1.
nil
可以看出 Guava Cache 使用了編程式的 build 生成器進行創建和管理,讓使用者可以更加靈活地操縱代碼,並且 Guava Cache 提供了靈活多樣的個性化配置,以適應各種使用場景。
2. 手動實現一個緩存系統
上面我們講了通過 EhCache 和 Guava 實現緩存的方式,接下來我們來看看自己如何自定義一個緩存系統,當然這裏說的是自己手動實現一個本地緩存。
要自定義一個緩存,首先要考慮的是數據類型,我們可以使用 Map 集合中的 HashMap、Hashtable 或 ConcurrentHashMap 來實現,非併發情況下我們可以使用 HashMap,併發情況下可以使用 Hashtable 或 ConcurrentHashMap,由於 ConcurrentHashMap 的性能比 Hashtable 的高,因此在高併發環境下我們可以傾向於選擇 ConcurrentHashMap,不過它們對元素的操作都是類似的。
選定了數據類型之後,我們還需要考慮緩存過期和緩存淘汰等問題,在這裏我們可以借鑑 Redis 對待過期鍵的處理策略。
目前比較常見的過期策略有以下三種:
- 定時刪除
- 惰性刪除
- 定期刪除
定時刪除是指在設置鍵值的過期時間時,創建一個定時事件,當到達過期時間後,事件處理器會執行刪除過期鍵的操作。它的優點是可以及時的釋放內存空間,缺點是需要開啓多個延遲執行事件來處理清除任務,這樣就會造成大量任務事件堆積,佔用了很多系統資源。
惰性刪除不會主動刪除過期鍵,而是在每次請求時纔會判斷此值是否過期,如果過期則刪除鍵值,否則就返回 null。它的優點是隻會佔用少量的系統資源,缺點是清除不夠及時,會造成一定的空間浪費。
定期刪除是指每隔一段時間檢查一次數據庫,隨機刪除一些過期鍵值。
Redis 使用的是定期刪除和惰性刪除這兩種策略,我們本課時也會參照這兩種策略。
先來說一下自定義緩存的實現思路,首先需要定義一個存放緩存值的實體類,這個類裏包含了緩存的相關信息,比如緩存的 key 和 value,緩存的存入時間、最後使用時間和命中次數(預留字段,用於支持 LFU 緩存淘汰),再使用 ConcurrentHashMap 保存緩存的 key 和 value 對象(緩存值的實體類),然後再新增一個緩存操作的工具類,用於添加和刪除緩存,最後再緩存啓動時,開啓一個無限循環的線程用於檢測並刪除過期的緩存,實現代碼如下。
首先,定義一個緩存值實體類,代碼如下:
import lombok.Getter;
import lombok.Setter;
/**
* 緩存實體類
*/
@Getter
@Setter
public class CacheValue implements Comparable<CacheValue> {
// 緩存鍵
private Object key;
// 緩存值
private Object value;
// 最後訪問時間
private long lastTime;
// 創建時間
private long writeTime;
// 存活時間
private long expireTime;
// 命中次數
private Integer hitCount;
@Override
public int compareTo(CacheValue o) {
return hitCount.compareTo(o.hitCount);
}
}
然後定義一個全局緩存對象,代碼如下:
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
/**
* Cache 全局類
*/
public class CacheGlobal {
// 全局緩存對象
public static ConcurrentMap<String, MyCache> concurrentMap = new ConcurrentHashMap<>();
}
定義過期緩存檢測類的代碼如下:
import java.util.concurrent.TimeUnit;
/**
* 過期緩存檢測線程
*/
public class ExpireThread implements Runnable {
@Override
public void run() {
while (true) {
try {
// 每十秒檢測一次
TimeUnit.SECONDS.sleep(10);
// 緩存檢測和清除的方法
expireCache();
} catch (Exception e) {
e.printStackTrace();
}
}
}
/**
* 緩存檢測和清除的方法
*/
private void expireCache() {
System.out.println("檢測緩存是否過期緩存");
for (String key : CacheGlobal.concurrentMap.keySet()) {
MyCache cache = CacheGlobal.concurrentMap.get(key);
// 當前時間 - 寫入時間
long timoutTime = TimeUnit.NANOSECONDS.toSeconds(
System.nanoTime() - cache.getWriteTime());
if (cache.getExpireTime() > timoutTime) {
// 沒過期
continue;
}
// 清除過期緩存
CacheGlobal.concurrentMap.remove(key);
}
}
}
接着,我們要新增一個緩存操作的工具類,用於查詢和存入緩存,實現代碼如下:
import org.apache.commons.lang3.StringUtils;
import java.util.concurrent.TimeUnit;
/**
* 緩存操作工具類
*/
public class CacheUtils {
/**
* 添加緩存
* @param key
* @param value
* @param expire
*/
public void put(String key, Object value, long expire) {
// 非空判斷,藉助 commons-lang3
if (StringUtils.isBlank(key)) return;
// 當緩存存在時,更新緩存
if (CacheGlobal.concurrentMap.containsKey(key)) {
MyCache cache = CacheGlobal.concurrentMap.get(key);
cache.setHitCount(cache.getHitCount() + 1);
cache.setWriteTime(System.currentTimeMillis());
cache.setLastTime(System.currentTimeMillis());
cache.setExpireTime(expire);
cache.setValue(value);
return;
}
// 創建緩存
MyCache cache = new MyCache();
cache.setKey(key);
cache.setValue(value);
cache.setWriteTime(System.currentTimeMillis());
cache.setLastTime(System.currentTimeMillis());
cache.setHitCount(1);
cache.setExpireTime(expire);
CacheGlobal.concurrentMap.put(key, cache);
}
/**
* 獲取緩存
* @param key
* @return
*/
public Object get(String key) {
// 非空判斷
if (StringUtils.isBlank(key)) return null;
// 字典中不存在
if (CacheGlobal.concurrentMap.isEmpty()) return null;
if (!CacheGlobal.concurrentMap.containsKey(key)) return null;
MyCache cache = CacheGlobal.concurrentMap.get(key);
if (cache == null) return null;
// 惰性刪除,判斷緩存是否過期
long timoutTime = TimeUnit.NANOSECONDS.toSeconds(
System.nanoTime() - cache.getWriteTime());
// 緩存過期
if (cache.getExpireTime() <= timoutTime) {
// 清除過期緩存
CacheGlobal.concurrentMap.remove(k
return null;
}
cache.setHitCount(cache.getHitCount() + 1);
cache.setLastTime(System.currentTimeMillis());
return cache.getValue();
}
}
最後是調用緩存的測試代碼:
public class MyCacheTest {
public static void main(String[] args) {
CacheUtils cache = new CacheUtils();
// 存入緩存
cache.put("key", "老王", 10);
// 查詢緩存
String val = (String) cache.get("key");
System.out.println(val);
// 查詢不存在的緩存
String noval = (String) cache.get("noval");
System.out.println(noval);
}
}
以上程序的執行結果如下:
老王
null
到目前爲止,自定義緩存系統就已經實現完了。
小結
本課時講解了本地緩存和分佈式緩存這兩個概念和實現的具體方式,其中本地緩存可以通過自己手動編碼或藉助 Guava Cache 來實現,而分佈式緩存可以使用 Redis 或 EhCache 來實現。此外,本課時重點演示了手動實現緩存代碼的方式和實現思路,並使用定期刪除和惰性刪除策略來實現緩存的清除,希望學完本課時後能對你有所幫助。