如何實作本地緩存和分布式緩存?
緩存(
Cache
) 是指 将程式或系統中常用的資料對象存儲在像記憶體這樣特定的媒體中,以避免在每次程式調用時,重新建立或組織資料所帶來的性能損耗,進而提高了系統的整體運作速度
以目前的系統架構來說,使用者的請求一般會先經過緩存系統,如果緩存中沒有相關的資料,就會在其他系統中查詢到相應的資料并儲存在緩存中,最後傳回給調用方
本地緩存是指程式級别的緩存元件,它的特點是本地緩存和應用程式會運作在同一個程序中,是以本地緩存的操作會非常快,因為在同一個程序内也意味着不會有網絡上的延遲和開銷
本地緩存适用于單節點非叢集的應用場景,它的優點是快,缺點是多程式無法共享緩存,比如分布式使用者 Session 會話資訊儲存,由于每次使用者通路的伺服器可能是不同的,如果不能共享緩存,那麼就意味着每次的請求操作都有可能被系統阻止,因為會話資訊隻儲存在某一個伺服器上,當請求沒有被轉發到這台存儲了使用者資訊的伺服器時,就會被認為是非登入的違規操作,除此之外,無法共享緩存可能會造成系統資源的浪費,這是因為每個系統都單獨維護了一份屬于自己的緩存,而同一份緩存有可能被多個系統單獨進行存儲,進而浪費了系統資源
分布式緩存是指将應用系統和緩存元件進行分離的緩存機制,這樣多個應用系統就可以共享一套緩存資料了,它的特點是共享緩存服務和可叢集部署,為緩存系統提供了高可用的運作環境,以及緩存共享的程式運作機制
本地緩存可以使用
EhCache
和
Google
的
Guava
來實作,而分布式緩存可以使用
Redis
或
Memcached
來實作
由于
Redis
本身就是獨立的緩存系統,是以可以作為第三方來提供共享的資料緩存,而
Redis
的分布式支援主從、哨兵和叢集的模式,是以它就可以支援分布式的緩存,而
Memcached
的情況也是類似的
EhCache
和 Guava
的使用及特點分析
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 可以了解為比 LRU 更加合理的淘汰算法
LFU
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
提供了靈活多樣的個性化配置,以适應各種使用場景
手動實作一個緩存系統
需求:要自定義一個緩存,首先要考慮的是資料類型,我們可以使用
Map
集合中的
HashMap
、
Hashtable
或
ConcurrentHashMap
來實作,非并發情況下我們可以使用
HashMap
,并發情況下可以使用
Hashtable
或
ConcurrentHashMap
,由于
ConcurrentHashMap
的性能比
Hashtable
的高,是以在高并發環境下我們可以傾向于選擇
ConcurrentHashMap
,不過它們對元素的操作都是類似的
標明了資料類型之後,我們還需要考慮緩存過期和緩存淘汰等問題,在這裡我們可以借鑒
Redis
對待過期鍵的處理政策,目前比較常見的過期政策有以下三種
定時删除是指在設定鍵值的過期時間時,建立一個定時事件,當到達過期時間後,事件處理器會執行删除過期鍵的操作。它的優點是可以及時的釋放記憶體空間,缺點是需要開啟多個延遲執行事件來處理清除任務,這樣就會造成大量任務事件堆積,占用了很多系統資源
- 定時删除
惰性删除不會主動删除過期鍵,而是在每次請求時才會判斷此值是否過期,如果過期則删除鍵值,否則就傳回 null。它的優點是隻會占用少量的系統資源,缺點是清除不夠及時,會造成一定的空間浪費
- 惰性删除
定期删除是指每隔一段時間檢查一次資料庫,随機删除一些過期鍵值
- 定期删除
Redis
使用的是定期删除和惰性删除這兩種政策
自定義緩存的實作思路:首先需要定義一個存放緩存值的實體類,這個類裡包含了緩存的相關資訊,比如緩存的,緩存的存入時間、最後使用時間和命中次數(預留字段,用于支援 LFU 緩存淘汰),再使用
key 和 value
儲存緩存的 key 和 value 對象(緩存值的實體類),然後再新增一個緩存操作的工具類,用于添加和删除緩存,最後再緩存啟動時,開啟一個無限循環的線程用于檢測并删除過期的緩存
ConcurrentHashMap
首先,定義一個緩存值實體類,代碼如下
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(key);
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