1. 程式人生 > >高併發服務設計—快取

高併發服務設計—快取

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()
}