1. 程式人生 > >Guava Cache記憶體快取使用實踐-定時非同步重新整理及簡單抽象封裝

Guava Cache記憶體快取使用實踐-定時非同步重新整理及簡單抽象封裝

快取在應用中是必不可少的,經常用的如redis、memcache以及記憶體快取等。Guava是Google出的一個工具包,它裡面的cache即是對本地記憶體快取的一種實現,支援多種快取過期策略。
Guava cache的快取載入方式有兩種:

  • CacheLoader
  • Callable callback

接下來看看常見的一些使用方法。
後面的示例實踐都是以CacheLoader方式載入快取值。

1.簡單使用:定時過期

LoadingCache<String, Object> caches = CacheBuilder.newBuilder()
                .maximumSize(100
) .expireAfterWrite(10, TimeUnit.MINUTES) .build(new CacheLoader<String, Object>() { @Override public Object load(String key) throws Exception { return generateValueByKey(key); } }); try
{ System.out.println(caches.get("key-zorro")); } catch (ExecutionException e) { e.printStackTrace(); }

如程式碼所示新建了名為caches的一個快取物件,maximumSize定義了快取的容量大小,當快取數量即將到達容量上線時,則會進行快取回收,回收最近沒有使用或總體上很少使用的快取項。需要注意的是在接近這個容量上限時就會發生,所以在定義這個值的時候需要視情況適量地增大一點。
另外通過expireAfterWrite這個方法定義了快取的過期時間,寫入十分鐘之後過期。
在build方法裡,傳入了一個CacheLoader物件,重寫了其中的load方法。當獲取的快取值不存在或已過期時,則會呼叫此load方法,進行快取值的計算。
這就是最簡單也是我們平常最常用的一種使用方法。定義了快取大小、過期時間及快取值生成方法。

如果用其他的快取方式,如redis,我們知道上面這種“如果有快取則返回;否則運算、快取、然後返回”的快取模式是有很大弊端的。當高併發條件下同時進行get操作,而此時快取值已過期時,會導致大量執行緒都呼叫生成快取值的方法,比如從資料庫讀取。這時候就容易造成資料庫雪崩。這也就是我們常說的“快取穿透”。
而Guava cache則對此種情況有一定控制。當大量執行緒用相同的key獲取快取值時,只會有一個執行緒進入load方法,而其他執行緒則等待,直到快取值被生成。這樣也就避免了快取穿透的危險。

2.進階使用:定時重新整理

如上的使用方法,雖然不會有快取穿透的情況,但是每當某個快取值過期時,老是會導致大量的請求執行緒被阻塞。而Guava則提供了另一種快取策略,快取值定時重新整理:更新執行緒呼叫load方法更新該快取,其他請求執行緒返回該快取的舊值。這樣對於某個key的快取來說,只會有一個執行緒被阻塞,用來生成快取值,而其他的執行緒都返回舊的快取值,不會被阻塞。
這裡就需要用到Guava cache的refreshAfterWrite方法。如下所示:

LoadingCache<String, Object> caches = CacheBuilder.newBuilder()
                .maximumSize(100)
                .refreshAfterWrite(10, TimeUnit.MINUTES)
                .build(new CacheLoader<String, Object>() {
                    @Override
                    public Object load(String key) throws Exception {
                        return generateValueByKey(key);
                    }
                });
try {
    System.out.println(caches.get("key-zorro"));
} catch (ExecutionException e) {
    e.printStackTrace();
}

如程式碼所示,每隔十分鐘快取值則會被重新整理。

此外需要注意一個點,這裡的定時並不是真正意義上的定時。Guava cache的重新整理需要依靠使用者請求執行緒,讓該執行緒去進行load方法的呼叫,所以如果一直沒有使用者嘗試獲取該快取值,則該快取也並不會重新整理。

3.進階使用:非同步重新整理

如2中的使用方法,解決了同一個key的快取過期時會讓多個執行緒阻塞的問題,只會讓用來執行重新整理快取操作的一個使用者執行緒會被阻塞。由此可以想到另一個問題,當快取的key很多時,高併發條件下大量執行緒同時獲取不同key對應的快取,此時依然會造成大量執行緒阻塞,並且給資料庫帶來很大壓力。這個問題的解決辦法就是將重新整理快取值的任務交給後臺執行緒,所有的使用者請求執行緒均返回舊的快取值,這樣就不會有使用者執行緒被阻塞了。
詳細做法如下:

ListeningExecutorService backgroundRefreshPools = 
                MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(20));
        LoadingCache<String, Object> caches = CacheBuilder.newBuilder()
                .maximumSize(100)
                .refreshAfterWrite(10, TimeUnit.MINUTES)
                .build(new CacheLoader<String, Object>() {
                    @Override
                    public Object load(String key) throws Exception {
                        return generateValueByKey(key);
                    }

                    @Override
                    public ListenableFuture<Object> reload(String key,
                            Object oldValue) throws Exception {
                        return backgroundRefreshPools.submit(new Callable<Object>() {

                            @Override
                            public Object call() throws Exception {
                                return generateValueByKey(key);
                            }
                        });
                    }
                });
try {
    System.out.println(caches.get("key-zorro"));
} catch (ExecutionException e) {
    e.printStackTrace();
}

在上面的程式碼中,我們新建了一個執行緒池,用來執行快取重新整理任務。並且重寫了CacheLoader的reload方法,在該方法中建立快取重新整理的任務並提交到執行緒池。
注意此時快取的重新整理依然需要靠使用者執行緒來驅動,只不過和2不同之處在於該使用者執行緒觸發重新整理操作之後,會立馬返回舊的快取值。

TIPS

  • 可以看到防快取穿透和防使用者執行緒阻塞都是依靠返回舊值來完成的。所以如果沒有舊值,同樣會全部阻塞,因此應視情況儘量在系統啟動時將快取內容載入到記憶體中。

  • 在重新整理快取時,如果generateValueByKey方法出現異常或者返回了null,此時舊值不會更新。

  • 題外話:在使用記憶體快取時,切記拿到快取值之後不要在業務程式碼中對快取直接做修改,因為此時拿到的物件引用是指向快取真正的內容的。如果需要直接在該物件上進行修改,則在獲取到快取值後拷貝一份副本,然後傳遞該副本,進行修改操作。(我曾經就犯過這個低階錯誤 - -!)

4.簡單抽象封裝

如下為基於Guava cache抽象出來的一個快取工具類。(抽象得不好,勉強能用 - -!)。
有改進意見麻煩多多指教。

/**
 * @description: 利用guava實現的記憶體快取。快取載入之後永不過期,後臺執行緒定時重新整理快取值。重新整理失敗時將繼續返回舊快取。
 *                  在呼叫getValue之前,需要設定 refreshDuration, refreshTimeunit, maxSize 三個引數
 *                  後臺重新整理執行緒池為該系統中所有子類共享,大小為20.
 * @author: luozhuo
 * @date: 2017年6月21日 上午10:03:45 
 * @version: V1.0.0
 * @param <K>
 * @param <V>
 */
public abstract class BaseGuavaCache <K, V> {

    private Logger logger = LoggerFactory.getLogger(getClass()); 

    // 快取自動重新整理週期
    protected int refreshDuration = 10;
    // 快取重新整理週期時間格式
    protected TimeUnit refreshTimeunit = TimeUnit.MINUTES;
    // 快取過期時間(可選擇)
    protected int expireDuration = -1;
    // 快取重新整理週期時間格式
    protected TimeUnit expireTimeunit = TimeUnit.HOURS;
    // 快取最大容量
    protected int maxSize = 4;
    // 資料重新整理執行緒池
    protected static ListeningExecutorService refreshPool = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(20));

    private LoadingCache<K, V> cache = null;

    /**
     * 用於初始化快取值(某些場景下使用,例如系統啟動檢測快取載入是否征程)
     */
    public abstract void loadValueWhenStarted();

    /**
     * @description: 定義快取值的計算方法
     * @description: 新值計算失敗時丟擲異常,get操作時將繼續返回舊的快取
     * @param key
     * @author: luozhuo
     * @throws Exception 
     * @date: 2017年6月14日 下午7:11:10
     */
    protected abstract V getValueWhenExpired(K key) throws Exception;

    /**
     * @description: 從cache中拿出資料操作
     * @param key
     * @author: luozhuo
     * @throws Exception 
     * @date: 2017年6月13日 下午5:07:11
     */
    public V getValue(K key) throws Exception {
        try {
            return getCache().get(key);
        } catch (Exception e) {
            logger.error("從記憶體快取中獲取內容時發生異常,key: " + key, e);
            throw e;
        }
    }

    public V getValueOrDefault(K key, V defaultValue) {
        try {
            return getCache().get(key);
        } catch (Exception e) {
            logger.error("從記憶體快取中獲取內容時發生異常,key: " + key, e);
            return defaultValue;
        }
    }

    /**
     * 設定基本屬性
     */
    public BaseGuavaCache<K, V> setRefreshDuration( int refreshDuration ){
        this.refreshDuration = refreshDuration;
        return this;
    }

    public BaseGuavaCache<K, V> setRefreshTimeUnit(TimeUnit refreshTimeunit){
        this.refreshTimeunit = refreshTimeunit;
        return this;
    }

    public BaseGuavaCache<K, V> setExpireDuration( int expireDuration ){
        this.expireDuration = expireDuration;
        return this;
    }

    public BaseGuavaCache<K, V> setExpireTimeUnit(TimeUnit expireTimeunit){
        this.expireTimeunit = expireTimeunit;
        return this;
    }

    public BaseGuavaCache<K, V> setMaxSize( int maxSize ){
        this.maxSize = maxSize;
        return this;
    }

    public void clearAll(){
        this.getCache().invalidateAll();
    }

    /**
     * @description: 獲取cache例項
     * @author: luozhuo
     * @date: 2017年6月13日 下午2:50:11
     */
    private LoadingCache<K, V> getCache() {
        if(cache == null){
            synchronized (this) {
                if(cache == null){
                    CacheBuilder<Object, Object> cacheBuilder = CacheBuilder.newBuilder()
                            .maximumSize(maxSize);

                    if(refreshDuration > 0) {
                        cacheBuilder = cacheBuilder.refreshAfterWrite(refreshDuration, refreshTimeunit);
                    }
                    if(expireDuration > 0) {
                        cacheBuilder = cacheBuilder.expireAfterWrite(expireDuration, expireTimeunit);
                    }

                    cache = cacheBuilder.build(new CacheLoader<K, V>() {
                            @Override
                            public V load(K key) throws Exception {
                                return getValueWhenExpired(key);
                            }

                            @Override
                            public ListenableFuture<V> reload(final K key,
                                    V oldValue) throws Exception {
                                return refreshPool.submit(new Callable<V>() {
                                    public V call() throws Exception {
                                        return getValueWhenExpired(key);
                                    }
                                });
                            }
                        } );
                }
            }
        }
        return cache;
    }

    @Override
    public String toString() {
        return "GuavaCache";
    }
}