高併發服務設計—快取
1 快取回收策略
1.1 基於空間
即設定快取的儲存空間,如設定為10MB,當達到儲存空間時,按照一定的策略移除資料。
1.2 基於容量
基於容量指快取設定了最大大小,當快取的條目超過最大大小,則按照一定的策略將舊資料移除。
1.3 基於時間
TTL(Time To Live):存活期,即快取資料從快取中建立時間開始直到它到期的一個時間段(不管在這個時間段內有沒有訪問都將過期)。
TTI(Time To Idle):空閒期,即快取資料多久沒被訪問過將從快取中移除的時間。
1.4 基於Java物件引用
軟引用:如果一個物件是軟引用,那麼當JVM堆記憶體不足時,垃圾回收器可以回收這些物件。軟引用適合用來做快取,從而當JVM堆記憶體不足時,可以回收這些物件騰出一些空間供強引用物件使用,從而避免OOM。
弱引用:當垃圾回收器回收記憶體時,如果發現弱引用,則將立即回收它。相對於軟引用有更短的生命週期。
注意:弱引用/軟引用物件只有當沒有其他強引用物件引用它時,垃圾回收時才回收該引用。
即如果有一個物件(不是弱引用/軟引用)引用了弱引用/軟引用物件,那麼垃圾回收是不會回收該引用物件。
1.5 回收演算法
使用基於空間和基於容量的快取會使用一定的策略移除舊資料,常見的如下:
-
FIFO(Fisrt In Fisrt Out):先進先出演算法,即先進入快取的先被移除。
-
LRU(Least Recently Used):最近最少使用演算法,使用時間距離現在最久的資料被移除。
-
LFU(Least Frequently Used):最不常用演算法,一定時間段內使用次數(頻率)最少的資料被移除。
實際應用中基於LRU的快取較多,如Guava Cache、EhCache支援LRU。
2 Java快取型別
2.1 堆快取
使用Java堆記憶體來儲存物件。可以使用Guava Cache、Ehcache 3.x、MapDB實現。
-
優點:使用堆快取的好處是沒有序列化/反序列化,是最快的快取;
-
缺點:很明顯,當快取的資料量很大時, GC暫停時間會變長,儲存容量受限於堆空間大小;一般通過軟引用/弱引用來儲存快取物件,即當堆記憶體不足時,可以強制回收這部分記憶體釋放堆記憶體空間。一般使用堆快取儲存較熱的資料。
2.2 堆外快取
即快取資料儲存在堆外記憶體。可以使用Ehcache 3.x、MapDB實現。
-
優點:可以減少GC暫停時間(堆物件轉移到堆外,GC掃描和移動的物件變少了),可以支援更大的快取空間(只受機器記憶體大小限制,不受堆空間的影響)。
-
缺點:讀取資料時需要序列化/反序列化,會比堆快取慢很多。
2.3 磁碟快取
即快取資料的儲存在磁碟上。當JVM重啟時資料還是在的。而堆快取/堆外快取重啟時資料會丟失,需要重新載入。可以使用Ehcache 3.x、MapDB實現。
2.4 分散式快取
在多JVM例項的情況時,程序內快取和磁碟快取會存在兩個問題:1.單機容量問題; 2.資料一致性問題(既然資料允許快取,則表示允許一定時間內的不一致,因此可以設定快取資料的過期時間來定期更新資料); 3.快取不命中時,需要回源到DB/服務查詢變多:每個例項在快取不命中情況下都會回源到DB載入資料,因此,多例項後DB整體的訪問量就變多了。解決辦法可以使用如一致性雜湊分片演算法來解決。因此,這些情況可以考慮使用分散式快取來解決。可以使用ehcache-clustered(配合Terracotta server)實現Java程序間分散式快取。當然也可以使用如Redis實現分散式快取。
兩種模式如下:
-
單機時:儲存最熱的資料到堆快取,相對熱的資料到堆外快取,不熱的資料存到磁碟快取。
-
叢集時:儲存最熱的資料到堆快取,相對熱的資料到堆外快取,全量資料存到分散式快取。
3 Java快取實現
3.1 堆快取
3.1.1 Guava Cache實現
Guava Cache只提供堆快取,小巧靈活,效能最好,如果只使用堆快取,那麼使用它就夠了。
Cache myCache=
CacheBuilder.newBuilder()
.concurrencyLevel(4)
.expireAfterWrite(10, TimeUnit.SECONDS)
.maximumSize(10000)
.build();
然後可以通過put、getIfPresent 來讀寫快取。CacheBuilder有幾類引數:快取回收策略、併發設定等。
3.1.1.1 快取回收策略/基於容量
maximumSize:設定快取的容量,當超出maximumSize時,按照LRU進行快取回收。
3.1.1.2 快取回收策略/基於時間
-
expireAfterWrite:設定TTL,快取資料在給定的時間內沒有寫(建立/覆蓋)時,則被回收,即定期的會回收快取資料。
-
expireAfterAccess:設定TTI,快取資料在給定的時間內沒有讀/寫時,則被回收。每次訪問時,都會更新它的TTI,從而如果該快取是非常熱的資料,則將一直不過期,可能會導致髒資料存在很長時間(因此,建議設定expireAfterWrite)。
3.1.1.3 快取回收策略/基於Java物件引用
weakKeys/weakValues:設定弱引用快取。
softValues:設定軟引用快取。
3.1.1.4 快取回收策略/主動失效
invalidate(Object key)/invalidateAll(Iterablekeys)/invalidateAll():主動失效某些快取資料。
什麼時候觸發失效呢? Guava Cache不會在快取資料失效時立即觸發回收操作(如果要這麼做,則需要有額外的執行緒來進行清理),是在PUT時會主動進行一次清理快取,當然讀者也可以根據實際業務通過自己設計執行緒來呼叫cleanUp方法進行清理。
3.1.1.5 併發級別
concurrencyLevel:Guava Cache重寫了ConcurrentHashMap,concurrencyLevel用來設定Segment數量,concurrencyLevel越大併發能力越強。
3.1.1.6 統計命中率
recordStats:啟動記錄統計資訊,比如命中率等
3.1.2 EhCache 3.x實現
CacheManager cacheManager = CacheManagerBuilder. newCacheManagerBuilder(). build(true);
CacheConfigurationBuilder cacheConfig= CacheConfigurationBuilder.newCacheConfigurationBuilder(
String.class,
String.class,
ResourcePoolsBuilder.newResourcePoolsBuilder()
.heap(100, EntryUnit.ENTRIES))
.withDispatcherConcurrency(4)
.withExpiry(Expirations.timeToLiveExpiration(Duration.of(10,TimeUnit.SECONDS)));
Cache myCache = cacheManager.createCache("myCache",cacheConfig);
CacheManager在JVM關閉時請呼叫CacheManager.close()方法。 可以通過PUT、GET來讀寫快取。CacheConfigurationBuilder也有幾類引數:快取回收策略、併發設定、統計命中率等。
3.1.2.1 快取回收策略/基於容量
heap(100, EntryUnit.ENTRIES):設定快取的條目數量,當超出此數量時按照LRU進行快取回收。
3.1.2.2 快取回收策略/基於空間
heap(100, MemoryUnit.MB):設定快取的記憶體空間,當超出此空間時按照LRU進行快取回收。另外,應該設定withSizeOfMaxObjectGraph(2):統計物件大小時物件圖遍歷深度和withSizeOfMaxObjectSize(1, MemoryUnit.KB):可快取的最大物件大小。
3.1.2.3 快取回收策略/基於時間
withExpiry(Expirations.timeToLiveExpiration(Duration.of(10,TimeUnit.SECONDS))):設定TTL,沒有TTI。
withExpiry(Expirations.timeToIdleExpiration(Duration.of(10,TimeUnit.SECONDS))):同時設定TTL和TTI,且TTL和TTI值一樣。
3.1.2.4 快取回收策略/主動失效
remove(K key)/ removeAll(Set keys)/clear():主動失效某些快取資料。
什麼時候觸發失效呢?EhCache使用了類似於Guava Cache同樣的機制。
3.1.2.5 併發級別
目前還沒有提供API來設定,EhCache內部使用ConcurrentHashMap作為快取儲存,預設併發級別16。withDispatcherConcurrency是用來設定事件分發時的併發級別。
3.1.3 MapDB 3.x 實現
HTreeMap myCache = DBMaker.heapDB().concurrencyScale(16).make().hashMap("myCache") .expireMaxSize(10000) .expireAfterCreate(10, TimeUnit.SECONDS) .expireAfterUpdate(10,TimeUnit.SECONDS) .expireAfterGet(10, TimeUnit.SECONDS) .create();1234567
然後可以通過PUT、GET來讀寫快取。其有幾類引數:快取回收策略、併發設定、統計命中率等。
3.1.3.1 快取回收策略/基於容量
expireMaxSize:設定快取的容量,當超出expireMaxSize時,按照LRU進行快取回收。
3.1.3.2 快取回收策略/基於時間
-
expireAfterCreate/expireAfterUpdate:設定TTL,快取資料在給定的時間內沒有寫(建立/覆蓋)時,則被回收。即定期的會回收快取資料。
-
expireAfterGet:設定TTI, 快取資料在給定的時間內沒有讀/寫時,則被回收。每次訪問時都會更新它的TTI,從而如果該快取是非常熱的資料,則將一直不過期,可能會導致髒資料存在很長的時間(因此,建議要設定expireAfterCreate/expireAfterUpdate)。
3.1.3.3 快取回收策略/主動失效
-
remove(Object key) /clear():主動失效某些快取資料。
什麼時候觸發失效呢?
MapDB預設使用類似於Guava Cache的機制。不過,也支援可以通過如下配置使用執行緒池定期進行快取失效。 -
expireExecutor(scheduledExecutorService)
-
expireExecutorPeriod(3000)
3.1.3.4 併發級別
concurrencyScale:類似於Guava Cache配置。
還可以使用DBMaker.memoryDB()建立堆快取,它將資料序列化並存儲到1MB大小的byte[]陣列中,從而減少垃圾回收的影響。
3.2 堆外快取
3.2.1 EhCache 3.x實現
CacheConfigurationBuilder cacheConfig= CacheConfigurationBuilder.newCacheConfigurationBuilder(
String.class,
String.class,
ResourcePoolsBuilder.newResourcePoolsBuilder()
.offheap(100, MemoryUnit.MB))
.withDispatcherConcurrency(4)
.withExpiry(Expirations.timeToLiveExpiration(Duration.of(10,TimeUnit.SECONDS)))
.withSizeOfMaxObjectGraph(3)
.withSizeOfMaxObjectSize(1, MemoryUnit.KB);
堆外快取不支援基於容量的快取過期策略。
3.2.2 MapDB 3.x實現
HTreeMap myCache =
DBMaker.memoryDirectDB().concurrencyScale(16).make().hashMap("myCache")
.expireStoreSize(64 * 1024 * 1024) //指定堆外快取大小64MB
.expireMaxSize(10000)
.expireAfterCreate(10, TimeUnit.SECONDS)
.expireAfterUpdate(10, TimeUnit.SECONDS)
.expireAfterGet(10, TimeUnit.SECONDS)
.create();
在使用堆外快取時,請記得新增JVM啟動引數,如-XX:MaxDirectMemorySize=10G。
3.3 磁碟快取
3.3.1 EhCache 3.x實現
CacheManager cacheManager = CacheManagerBuilder. newCacheManagerBuilder()
//預設執行緒池
.using(PooledExecutionServiceConfigurationBuilder.newPooledExecutionServiceConfigurationBuilder().defaultPool("default",1, 10).build())
//磁碟檔案儲存位置
.with(new CacheManagerPersistenceConfiguration(newFile("D:\\bak")))
.build(true);
CacheConfigurationBuilder cacheConfig= CacheConfigurationBuilder. newCacheConfigurationBuilder(
String.class,
String.class,
ResourcePoolsBuilder.newResourcePoolsBuilder()
.disk(100, MemoryUnit.MB,true)) //磁碟快取
.withDiskStoreThreadPool("default", 5) //使用"default"執行緒池進行dump檔案到磁碟
.withExpiry(Expirations.timeToLiveExpiration(Duration.of(50,TimeUnit.SECONDS)))
.withSizeOfMaxObjectGraph(3)
.withSizeOfMaxObjectSize(1, MemoryUnit.KB);
在JVM停止時,記得呼叫cacheManager.close(),從而保證記憶體資料能dump到磁碟。
3.3.2 MapDB 3.x實現
DB db = DBMaker
.fileDB("D:\\bak\\a.data")//資料存哪裡
.fileMmapEnable() //啟用mmap
.fileMmapEnableIfSupported() //在支援的平臺上啟用mmap
.fileMmapPreclearDisable() //讓mmap檔案更快
.cleanerHackEnable() //一些BUG處理
.transactionEnable() //啟用事務
.closeOnJvmShutdown()
.concurrencyScale(16)
.make();
HTreeMap myCache = db.hashMap("myCache")
.expireMaxSize(10000)
.expireAfterCreate(10, TimeUnit.SECONDS)
.expireAfterUpdate(10, TimeUnit.SECONDS)
.expireAfterGet(10, TimeUnit.SECONDS)
.createOrOpen();
因為開啟了事務,MapDB則開啟了WAL。另外,操作完快取後記得呼叫db.commit方法提交事務。
myCache.put("key" + counterWriter,"value" + counterWriter);
db.commit();
3.4 分散式快取
3.4.1 Ehcache 3.1 + Terracotta Server
不建議使用。
3.4.2 Redis
效能非常好,有主從模式、叢集模式。
3.5 多級快取
如先查詢堆快取,如果沒有查詢磁碟快取,則使用MapDB可以通過如下配置實現。
HTreeMap diskCache = db.hashMap("myCache")
.expireStoreSize(8 * 1024 * 1024 * 1024)
.expireMaxSize(10000)
.expireAfterCreate(10, TimeUnit.SECONDS)
.expireAfterUpdate(10, TimeUnit.SECONDS)
.expireAfterGet(10, TimeUnit.SECONDS)
.createOrOpen();
HTreeMap heapCache = db.hashMap("myCache")
.expireMaxSize(100)
.expireAfterCreate(10, TimeUnit.SECONDS)
.expireAfterUpdate(10, TimeUnit.SECONDS)
.expireAfterGet(10, TimeUnit.SECONDS)
.expireOverflow(diskCache) //當快取溢位時儲存到disk
.createOrOpen();
4 快取使用模式
主要分兩大類:Cache-Aside和Cache-As-SoR(Read-through、Write-through、Write-behind)
-
SoR(system-of-record):記錄系統,或者可以叫做資料來源,即實際儲存原始資料的系統。
-
Cache:快取,是SoR的快照資料,Cache的訪問速度比SoR要快,放入Cache的目的是提升訪問速度,減少回源到SoR的次數。
-
回源:即回到資料來源頭獲取資料,Cache沒有命中時,需要從SoR讀取資料,這叫做回源。
4.1 Cache-Aside
Cache-Aside 即業務程式碼圍繞著Cache寫,是由業務程式碼直接維護快取,示例程式碼如下所示。
4.1.1 讀場景
先從快取獲取資料,如果沒有命中,則回源到SoR並將源資料放入快取供下次讀取使用。
//1、先從快取中獲取資料
value = myCache.getIfPresent(key);
if(value == null) {
//2.1、如果快取沒有命中,則回源到SoR獲取源資料
value = loadFromSoR(key);
//2.2、將資料放入快取,下次即可從快取中獲取資料
myCache.put(key, value);
}
4.1.2 寫場景
先將資料寫入SoR,寫入成功後立即將資料同步寫入快取。
//1、先將資料寫入SoR
writeToSoR(key,value);
//2、執行成功後立即同步寫入快取
myCache.put(key, value);
或者先將資料寫入SoR,寫入成功後將快取資料過期,下次讀取時再載入快取。
//1、先將資料寫入SoR
writeToSoR(key,value);
//2、失效快取,然後下次讀時再載入快取
myCache.invalidate(key);
Cache-Aside適合使用AOP模式去實現
4.2 Cache-As-SoR
Cache-As-SoR即把Cache看作為SoR,所有操作都是對Cache進行,然後Cache再委託給SoR進行真實的讀/寫。即業務程式碼中只看到Cache的操作,看不到關於SoR相關的程式碼。有三種實現:read-through、write-through、write-behind。
4.2.1 Read-Through
Read-Through,業務程式碼首先呼叫Cache,如果Cache不命中由Cache回源到SoR,而不是業務程式碼(即由Cache讀SoR)。使用Read-Through模式,需要配置一個CacheLoader元件用來回源到SoR載入源資料。Guava Cache和Ehcache 3.x都支援該模式。
4.2.1.1 Guava Cache實現
LoadingCache getCache =
CacheBuilder.newBuilder()
.softValues()
.maximumSize(5000).expireAfterWrite(2, TimeUnit.MINUTES)
.build(new CacheLoader() {
@Override
public Result load(final Integer sortId) throwsException {
return categoryService.get(sortId);
}
});
在build Cache時,傳入一個CacheLoader用來載入快取,操作流程如下:
-
應用業務程式碼直接呼叫getCache.get(sortId)。
-
首先查詢Cache,如果快取中有,則直接返回快取資料。
-
如果快取沒有命中,則委託給CacheLoader,CacheLoader會回源到SoR查詢源資料(返回值必須不為null,可以包裝為Null物件),然後寫入快取。
使用CacheLoader後有幾個好處:
-
應用業務程式碼更簡潔了,不需要像Cache-Aside模式那樣快取查詢程式碼和SoR程式碼交織在一起。如果快取使用邏輯散落在多處,則使用這種方式很簡單的消除了重複程式碼。
-
解決Dog-pile effect,即當某個快取失效時,又有大量相同的請求沒命中快取,從而同時請求到後端,導致後端壓力太大,此時限制一個請求去拿即可。
4.2.1.2 Ehcache 3.x實現
CacheManager cacheManager = CacheManagerBuilder. newCacheManagerBuilder(). build(true);
org.ehcache.Cache myCache =cacheManager. createCache ("myCache",
CacheConfigurationBuilder.newCacheConfigurationBuilder(String.class,String.class,
ResourcePoolsBuilder.newResourcePoolsBuilder().heap(100,MemoryUnit.MB))
.withDispatcherConcurrency(4)
.withExpiry(Expirations.timeToLiveExpiration(Duration.of(10,TimeUnit.SECONDS)))
.withLoaderWriter(newDefaultCacheLoaderWriter () {
@Override
public String load(String key) throws Exception {
return readDB(key);
}
@Override
public Map loadAll(Iterable keys) throws BulkCacheLoadingException, Exception {
return null;
}
}));
Ehcache 3.1沒有自己去解決Dog-pile effect。
4.2.2 Write-Through
Write-Through,稱之為穿透寫模式/直寫模式,業務程式碼首先呼叫Cache寫(新增/修改)資料,然後由Cache負責寫快取和寫SoR,而不是業務程式碼。
使用Write-Through模式需要配置一個CacheWriter元件用來回寫SoR。Guava Cache沒有提供支援。Ehcache 3.x支援該模式。
Ehcache需要配置一個CacheLoaderWriter,CacheLoaderWriter知道如何去寫SoR。當Cache需要寫(新增/修改)資料時,首先呼叫CacheLoaderWriter來同步(立即)到SoR,成功後會更新快取。
CacheManager cacheManager = CacheManagerBuilder.newCacheManagerBuilder().build(true);
Cache myCache =cacheManager.createCache ("myCache",
CacheConfigurationBuilder.newCacheConfigurationBuilder(String.class,String.class,
ResourcePoolsBuilder.newResourcePoolsBuilder().heap(100,MemoryUnit.MB))
.withDispatcherConcurrency(4)
.withExpiry(Expirations.timeToLiveExpiration(Duration.of(10,TimeUnit.SECONDS)))
.withLoaderWriter(newDefaultCacheLoaderWriter () {
@Override
public void write(String key, String value) throws Exception{
//write
}
@Override
public void writeAll(Iterable entries) throws BulkCacheWritingException,Exception {
for(Object entry: entries) {
//batch write
}
}
@Override
public void delete(Stringkey) throws Exception {
//delete
}
@Override
public void deleteAll(Iterablekeys) throws BulkCacheWritingException, Exception {
for(Object key :keys) {
//batch delete
}
}
}).build());
Ehcache 3.x還是使用CacheLoaderWriter來實現,通過write(String key, String value)、writeAll(Iterable> entries)和delete(String key)、deleteAll(Iterable keys)分別來支援單個寫、批量寫和單個刪除、批量刪除操作。
操作流程如下:當我們呼叫myCache.put(“e”,”123”)或者myCache.putAll(map)時,寫快取。首先,Cache會將寫操作立即委託給CacheLoaderWriter#write和#writeAll,然後由CacheLoaderWriter負責立即去寫SoR。當寫SoR成功後,再寫入Cache。
4.2.3 Write-Behind
Write-Behind,也叫Write-Back,稱之為回寫模式,不同於Write-Through是同步寫SoR和Cache,Write-Behind是非同步寫。非同步之後可以實現批量寫、合併寫、延時和限流。
4.2.3.1 非同步寫
略,可用EhCache實現
4.2.3.2 批量寫
略,可用EhCache實現
4.2.4 Copy Pattern
有兩種Copy Pattern, Copy-On-Read和Copy-On-Write。在Guava-Cache和EhCache中堆快取都是基於引用的,這樣如果喲人拿到快取資料並修改了它,則可能發生不可預測的問題。Guava Cache沒有提供支援,EhCache 3.x提供了支援。
public interface Copier {
T copyForRead(T obj); //Copy-On-Read,比如myCache.get()
T copyForWrite(T obj); //Copy-On-Write,比如myCache.put()
}