用好SpringCache公司再也不用擔心我搞不定AOP了

前言

今天這篇文章是比較偏“教程”一點的文章。但也由淺入深,認真地分析了源碼,並且介紹了一些在使用Spring Cache中常見的問題和解決方案,肯定是比簡單的入門文檔更有深度一些的,相信大家看了之後會有一定的收穫。

為什麼使用緩存

前幾天我在文章《我是如何把一個15分鐘的程序優化到了10秒的》中,提到了一些在代碼層面優化性能的方法。其中第一個就是使用緩存。

使用緩存是一個很“高性價比”的性能優化方式,尤其是對於有大量重複查詢的程序來說。通常來說,在WEB後端應用程序來說,耗時比較大的往往有兩個地方:一個是查數據庫,一個是調用其它服務的API(因為其它服務最終也要去做查數據庫等耗時操作)。

重複查詢也有兩種。一種是我們在應用程序中代碼寫得不好,寫的for循環,可能每次循環都用重複的參數去查詢了。這種情況,比較聰明一點的程序員都會對這段代碼進行重構,用Map來把查出來的東西暫時放在內存裡,後續去查詢之前先看看Map裡面有沒有,沒有再去查數據庫,其實這就是一種緩存的思想。

另一種重複查詢是大量的相同或相似請求造成的。比如資訊網站首頁的文章列表、電商網站首頁的商品列表、微博等社交媒體熱搜的文章等等,當大量的用戶都去請求同樣的接口,同樣的數據,如果每次都去查數據庫,那對數據庫來說是一個不可承受的壓力。所以我們通常會把高頻的查詢進行緩存,我們稱它為“熱點”。

為什麼使用Spring Cache

前面提到了緩存有諸多的好處,於是大家就摩拳擦掌準備給自己的應用加上緩存的功能。但是網上一搜卻發現緩存的框架太多了,各有各的優勢,比如Redis、Memcached、Guava、Caffeine等等。

如果我們的程序想要使用緩存,就要與這些框架耦合。聰明的架構師已經在利用接口來降低耦合了,利用面向對象的抽象和多態的特性,做到業務代碼與具體的框架分離。

但我們仍然需要顯式地在代碼中去調用與緩存有關的接口和方法,在合適的時候插入數據到緩存裡,在合適的時候從緩存中讀取數據。

想一想AOP

的適用場景,這不就是天生就應該AOP去做的嗎?

是的,Spring Cache就是一個這個框架。它利用了AOP,實現了基於註解的緩存功能,並且進行了合理的抽象,業務代碼不用關心底層是使用了什麼緩存框架,只需要簡單地加一個註解,就能實現緩存功能了。而且Spring Cache也提供了很多默認的配置,用戶可以3秒鐘就使用上一個很不錯的緩存功能。

既然有這麼好的輪子,幹嘛不用呢?

如何使用Spring Cache

上面的3秒鐘,絕對不誇張。使用SpringCache分為很簡單的三步:加依賴,開啟緩存,加緩存註解。

本文示例代碼使用的是官方的示例代碼,git地址:github.com/spring-guid…

1 加依賴

gradle:

<code>implementation 'org.springframework.boot:spring-boot-starter-cache'

/<code>

maven:

<code>
    org.springframework.boot
    spring-boot-starter-cache


/<code>

2 開啟緩存

在啟動類加上@EnableCaching註解即可開啟使用緩存。

<code>@SpringBootApplication
@EnableCaching
public class CachingApplication {

    public static void main(String[] args) {
        SpringApplication.run(CachingApplication.class, args);
    }

}

/<code>

3 加緩存註解

在要緩存的方法上面添加@Cacheable註解,即可緩存這個方法的返回值。

<code>@Override
@Cacheable("books")
public Book getByIsbn(String isbn) {
    simulateSlowService();
    return new Book(isbn, "Some book");
}

// Don't do this at home
private void simulateSlowService() {
    try {
        long time = 3000L;
        Thread.sleep(time);
    } catch (InterruptedException e) {
        throw new IllegalStateException(e);
    }
}

/<code>

測試

<code>@Override
public void run(String... args) {
    logger.info(".... Fetching books");
    logger.info("isbn-1234 -->" + bookRepository.getByIsbn("isbn-1234"));
    logger.info("isbn-4567 -->" + bookRepository.getByIsbn("isbn-4567"));
    logger.info("isbn-1234 -->" + bookRepository.getByIsbn("isbn-1234"));
    logger.info("isbn-4567 -->" + bookRepository.getByIsbn("isbn-4567"));
    logger.info("isbn-1234 -->" + bookRepository.getByIsbn("isbn-1234"));
    logger.info("isbn-1234 -->" + bookRepository.getByIsbn("isbn-1234"));
}

/<code>

測試一下,可以發現。第一次和第二次(第二次參數和第一次不同)調用getByIsbn方法,會等待3秒,而後面四個調用,都會立即返回。

常用註解

Spring Cache有幾個常用註解,分別為@Cacheable、@CachePut、@CacheEvict、@Caching、 @CacheConfig。除了最後一個CacheConfig外,其餘四個都可以用在類上或者方法級別上,如果用在類上,就是對該類的所有public方法生效,下面分別介紹一下這幾個註解。

@Cacheable

@Cacheble註解表示這個方法有了緩存的功能,方法的返回值會被緩存下來,下一次調用該方法前,會去檢查是否緩存中已經有值,如果有就直接返回,不調用方法。如果沒有,就調用方法,然後把結果緩存起來。這個註解一般用在查詢方法上

@CachePut

加了@CachePut註解的方法,會把方法的返回值put到緩存裡面緩存起來,供其它地方使用。它通常用在新增方法上

@CacheEvict

使用了CacheEvict註解的方法,會清空指定緩存。一般用在更新或者刪除的方法上

@Caching

Java註解的機制決定了,一個方法上只能有一個相同的註解生效。那有時候可能一個方法會操作多個緩存(這個在刪除緩存操作中比較常見,在添加操作中不太常見)。

Spring Cache當然也考慮到了這種情況,@Caching註解就是用來解決這類情況的,大家一看它的源碼就明白了。

<code>public @interface Caching {
    Cacheable[] cacheable() default {};
    CachePut[] put() default {};
    CacheEvict[] evict() default {};
}

/<code>

@CacheConfig

前面提到的四個註解,都是Spring Cache常用的註解。每個註解都有很多可以配置的屬性,這個我們在下一節再詳細解釋。但這幾個註解通常都是作用在方法上的,而有些配置可能又是一個類通用的,這種情況就可以使用@CacheConfig了,它是一個類級別的註解,可以在類級別上配置cacheNames、keyGenerator、cacheManager、cacheResolver等。

常用註解的配置

這部分我們最好是結合源碼來看,才能更好地理解這些配置的運作機制。

源碼:解析註解的時機

這一節主要是源碼解析,有點晦澀,對源碼不感興趣的同學可以跳過。但如果想要弄清楚Spring Cache運作的原理,還是推薦一看的。

前面提到的幾個註解@Cacheable、@CachePut、@CacheEvict、@CacheConfig,都有一些可配置的屬性。這些配置的屬性都可以在抽象類CacheOperation及其子類中可以找到。它們大概是這樣的關係:

用好SpringCache公司再也不用擔心我搞不定AOP了


看到這裡不得不佩服,這繼承用得,妙啊。

解析每個註解的代碼在SpringCacheAnnotationParser類中可以找到,比如parseEvictAnnotation方法,裡面就有這麼一句:

<code>builder.setCacheWide(cacheEvict.allEntries());

/<code>

明明註解裡叫allEntries,但是CacheEvictOperation裡卻叫cacheWide?看了下作者,都是多個作者,但第一作者都是一個叫Costin Leau的哥們,我對這個命名還是有一點小小的困惑。。。看來大佬們寫代碼也會有命名不一致的問題

那這個SpringCacheAnnotationParser是在什麼時候被調用的呢?很簡單,我們在這個類的某個方法上打個斷點,然後debug就行了,比如parseCacheableAnnotation方法。

用好SpringCache公司再也不用擔心我搞不定AOP了


在debug界面,可以看到調用鏈非常長,前面是我們熟悉的IOC註冊Bean的一個流程,直到我們看到了一個叫做AbstractAutowireCapableBeanFactory的BeanFactory,然後這個類在創建Bean的時候會去找是否有Advisor。正好Spring Cache源碼裡就定義了這麼一個Advisor:BeanFactoryCacheOperationSourceAdvisor。

這個Advisor返回的PointCut是一個CacheOperationSourcePointcut,這個PointCut複寫了matches方法,在裡面去獲取了一個CacheOperationSource,調用它的getCacheOperations方法。這個CacheOperationSource是個接口,主要的實現類是AnnotationCacheOperationSource。在findCacheOperations方法裡,就會調用到我們最開始說的SpringCacheAnnotationParser了。

這樣就完成了基於註解的解析。

入口:基於AOP的攔截器

那我們實際調用方法的時候,是怎麼處理的呢?我們知道,使用了AOP的Bean,會生成一個代理對象,實際調用的時候,會執行這個代理對象的一系列的Interceptor。Spring Cache使用的是一個叫做CacheInterceptor的攔截器。我們如果加了緩存相應的註解,就會走到這個攔截器上。這個攔截器繼承了CacheAspectSupport類,會執行這個類的execute方法,這個方法就是我們要分析的核心方法了。

@Cacheable的sync

我們繼續看之前提到的execute方法,該方法首先會判斷是否是同步。這裡的同步配置是用的@Cacheable的sync屬性,默認是false。如果配置了同步的話,多個線程嘗試用相同的key去緩存拿數據的時候,會是一個同步的操作

用好SpringCache公司再也不用擔心我搞不定AOP了


我們來看看同步操作的源碼。如果判斷當前需要同步操作(1),首先會去判斷當前的condition是不是符合條件(2)。這裡的condition也是@Cacheable中定義的一個配置,它是一個EL表達式,比如我們可以這樣用來緩存id大於1的Book:

<code>@Override
@Cacheable(cacheNames = "books", condition = "#id > 1", sync = true)
public Book getById(Long id) {
    return new Book(String.valueOf(id), "some book");
}

/<code>

如果不符合條件,就不使用緩存,也不把結果放入緩存,直接跳到5。否則,嘗試獲取key(3)。在獲取key的時候,會先判斷用戶有沒有定義key,它也是一個EL表達式。如果沒有的話,就用keyGenerator生成一個key:

<code>@Nullable
protected Object generateKey(@Nullable Object result) {
    if (StringUtils.hasText(this.metadata.operation.getKey())) {
        EvaluationContext evaluationContext = createEvaluationContext(result);
        return evaluator.key(this.metadata.operation.getKey(), this.metadata.methodKey, evaluationContext);
    }
    return this.metadata.keyGenerator.generate(this.target, this.metadata.method, this.args);
}

/<code>

我們可以用這種方式手動指定根據id生成book-1,book-2這樣的key:

<code>@Override
@Cacheable(cacheNames = "books",  sync = true, key = "'book-' + #id")
public Book getById(Long id) {
    return new Book(String.valueOf(id), "some book");
}

/<code>

這裡的key是一個Object對象,如果我們不在註解上面指定key,會使用keyGenerator生成的key。默認的keyGenerator是SimpleKeyGenerator,它生成的是一個SimpleKey對象,方法也很簡單,如果沒有入參,就返回一個EMPTY的對象,如果有入參,且只有一個入參,並且不是空或者數組,就用這個參數(注意這裡用的是參數本身,而不是SimpleKey對象。否則,用所有入參包一個SimpleKey。

源碼:

<code>@Override
public Object generate(Object target, Method method, Object... params) {
    return generateKey(params);
}

/**
     * Generate a key based on the specified parameters.
     */
public static Object generateKey(Object... params) {
    if (params.length == 0) {
        return SimpleKey.EMPTY;
    }
    if (params.length == 1) {
        Object param = params[0];
        if (param != null && !param.getClass().isArray()) {
            return param;
        }
    }
    return new SimpleKey(params);
}

/<code>

看到這裡你一定有一個疑問吧,這裡只用入參,沒有類名和方法名的區別,那如果兩個方法入參一樣,豈不是key衝突了?

你的感覺沒錯,大家可以試一下這兩個方法:

<code>// 定義兩個參數都是String的方法
@Override
@Cacheable(cacheNames = "books", sync = true)
public Book getByIsbn(String isbn) {
    simulateSlowService();
    return new Book(isbn, "Some book");
}

@Override
@Cacheable(cacheNames = "books", sync = true)
public String test(String test) {
    return test;
}

// 調用這兩個方法,用相同的參數"test"
logger.info("test getByIsbn -->" + bookRepository.getByIsbn("test"));
logger.info("test test -->" + bookRepository.test("test"));

/<code>

你會發現兩次生成的key相同,然後在調用test方法的時候,控制檯會報錯:

<code>Caused by: java.lang.ClassCastException: class com.example.caching.Book cannot be cast to class java.lang.String (com.example.caching.Book is in unnamed module of loader 'app'; java.lang.String is in module java.base of loader 'bootstrap')
    at com.sun.proxy.$Proxy33.test(Unknown Source) ~[na:na]
    at com.example.caching.AppRunner.run(AppRunner.java:23) ~[main/:na]
    at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:795) ~[spring-boot-2.3.2.RELEASE.jar:2.3.2.RELEASE]
    ... 5 common frames omitted

/<code>

Book不能強轉成String,因為我們第一次調用getByIsbn方法的時候,生成的key是test,然後換成了返回值Book對象到緩存裡面。而調用test方法的時候,生成的key還是test,就會取出Book,但是test方法的返回值是String,所以會嘗試強轉到String,結果發現強轉失敗。

我們可以自定義一個keyGenerator來解決這個問題:

<code>@Component
public class MyKeyGenerator implements KeyGenerator {
    @Override
    public Object generate(Object target, Method method, Object... params) {
        return target.getClass().getName() + method.getName() + 
                Stream.of(params).map(Object::toString).collect(Collectors.joining(","));
    }
}

/<code>

然後就可以在配置裡面使用這個自定義的MyKeyGenerator了,再次運行程序,就不會出現上述問題。

<code>@Override
@Cacheable(cacheNames = "books", sync = true, keyGenerator = "myKeyGenerator")
public Book getByIsbn(String isbn) {
    simulateSlowService();
    return new Book(isbn, "Some book");
}

@Override
@Cacheable(cacheNames = "books", sync = true, keyGenerator = "myKeyGenerator")
public String test(String test) {
    return test;
}

/<code>

接著往下看,可以看到我們得到了一個Cache。這個Cache是在我們調用CacheAspectSupport的execute方法的時候,會new一個CacheOperationContext。在這個Context的構造方法裡,會用cacheResolver去解析註解中的Cache,生成Cache對象。

默認的cacheResolver是SimpleCacheResolver,它從CacheOperation中取得配置的cacheNames,然後用cacheManager去get一個Cache。這裡的cacheManager是用於管理Cache的一個容器,默認的cacheManager是ConcurrentMapCacheManager。聽名字就知道是基於ConcurrentMap來做的了,底層是ConcurrentHashMap。

那這裡的Cache是什麼東西呢?Cache就對“緩存容器”的一個抽象,包含了緩存會用到的get、put、evict、putIfAbsent等方法。

不同的cacheNames會對應不同的Cache對象,比如我們可以在一個方法上定義兩個cacheNames,雖然也可以用value,它是cacheNames的別名,但如果有多個配置的時候,更推薦用cacheNames,因為這樣具有更好的可讀性。

<code>@Override
@Cacheable(cacheNames = {"book", "test"})
public Book getByIsbn(String isbn) {
    simulateSlowService();
    return new Book(isbn, "Some book");
}

/<code>

默認的Cache是ConcurrentMapCache,它也是基於ConcurrentHashMap的。

但這裡有個問題,我們回到上面的execute方法的代碼,發現如果設置了sync為true,它取的是第一個Cache,而沒有管剩下的Cache。所以如果你配置了sync為true,只支持配置一個cacheNames,如果配了多個,就會報錯:

<code>@Cacheable(sync=true) only allows a single cache on...

/<code>

繼續往下看,發現調用的是Cache的get(Object, Callcable)方法。這個方法會先嚐試去緩存中用key取值,如果取不到在調用callable函數,然後加到緩存裡。Spring Cache也是期望Cache的實現類在這個方法內部實現“同步”的功能。

所以我們再回過頭去看Cacheable中sync屬性上方的註釋,它寫到:使用sync為true,會有這些限制:

  1. 不支持unless,這個從代碼可以看到,只支持了condition,沒有支持unless;這個我沒想清楚為什麼。。。但Interceptor代碼就是這樣寫的。

  2. 只能有一個cache,因為代碼就寫死了一個。我猜這是為了更好地支持同步,它把同步放到了Cache裡面去實現。

  3. 沒有不支持其它的Cache操作,代碼裡面寫死了,只支持Cachable,我猜這也是為了支持同步。

其它操作

如果sync為false呢?

繼續往下看execute的代碼,大概經歷了下面這些步驟:

  1. 嘗試在方法調用前刪除緩存,這個在@CacheEvict配置的beforeInvocation,默認為false(如果為true才會在這一步刪除緩存);

  2. 嘗試獲取緩存;

  3. 如果第2步獲取不到,嘗試獲取Cachable的註解,生成相應的CachePutRequest;

  4. 如果第2步獲取到了,並且沒有CachPut註解,就直接從緩存中獲取值。否則,調用目標方法;

  5. 解析CachePut註解,同樣生成相應的CachePutRequest;

  6. 執行所有的CachePutRequest;

  7. 嘗試在方法調用後刪除緩存,如果@CacheEvict配置的beforeInvocation為false會刪除緩存

至此,我們就結合源碼解釋完了所有的配置發生作用的時機。

使用其它緩存框架

如果要使用其它的緩存框架,應該怎麼做呢?

通過上面的源碼分析我們知道,如果要使用其它的緩存框架,我們只需要重新定義好CacheManager和CacheResolver這兩個Bean就行了。

事實上,Spring會自動檢測我們是否引入了相應的緩存框架,如果我們引入了spring-data-redis,Spring就會自動使用spring-data-redis提供的RedisCacheManager,RedisCache。

如果我們要使用Caffeine框架。只需要引入Caffeine,Spring Cache就會默認使用CaffeineCacheManager和CaffeineCache。

<code>implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'com.github.ben-manes.caffeine:caffeine'

/<code>

Caffeine是一個性能非常高的緩存框架,它使用了Window TinyLfu回收策略,提供了一個近乎最佳的命中率

Spring Cache還支持各種配置,在CacheProperties類裡面,裡面還提供了各種主流的緩存框架的特殊配置。比如Redis的過期時間等(默認永不過期)。

<code>private final Caffeine caffeine = new Caffeine();

private final Couchbase couchbase = new Couchbase();

private final EhCache ehcache = new EhCache();

private final Infinispan infinispan = new Infinispan();

private final JCache jcache = new JCache();

private final Redis redis = new Redis();

/<code>

使用緩存帶來的問題

雙寫不一致

使用緩存會帶來許多問題,尤其是高併發下,包括緩存穿透、緩存擊穿、緩存雪崩、雙寫不一致等問題。具體的問題介紹和常用的解決方案可以參考我的個人網站上的文章《緩存常見問題及解決方案》。

其中主要聊一下雙寫不一致的問題,這是一個比較常見的問題,其中一個常用的解決方案是,更新的時候,先刪除緩存,再更新數據庫。所以Spring Cache的@CacheEvict會有一個beforeInvocation的配置。

但使用緩存通常會存在緩存中的數據和數據庫中不一致的問題,尤其是調用第三方接口,你不會知道它什麼時候更新了數據。但使用緩存的業務場景很多時候並不需求數據的強一致,比如首頁的熱點文章,我們可以讓緩存一分鐘失效,這樣就算一分鐘內,不是最新的熱點排行也沒關係。

佔用額外的內存

這個是無可避免的。因為總要有一個地方去放緩存。不管是ConcurrentHashMap也好,Redis也好,Caffeine也好,總歸是會佔用額外的內存資源去放緩存的。但緩存的思想正是用空間去換時間,有時候佔用這點額外的空間對於時間上的優化來說,是非常值得的。

這裡需要注意的是,SpringCache默認使用的是ConcurrentHashMap,它不會自動回收key,所以如果使用默認的這個緩存,程序就會越來越大,並且得不到回收。最終可能導致OOM。

我們來模擬實驗一下:

<code>@Component
public class MyKeyGenerator implements KeyGenerator {
    @Override
    public Object generate(Object target, Method method, Object... params) {
        // 每次都生成不同的key
        return UUID.randomUUID().toString();
    }
}

//調它個100w次
for (int i = 0; i < 1000000; i++) {
    bookRepository.test("test");
}

/<code>

然後把最大內存設置成20M: -Xmx20M。

我們先來測試默認的基於ConcurrentHashMap的緩存,發現它很快就會報OOM。

<code>Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "main"

Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "RMI TCP Connection(idle)"

/<code>

我們使用Caffeine,並且配置一下它的最大容量:

<code>spring:
  cache:
    caffeine:
      spec: maximumSize=100

/<code>

再次運行程序,發現正常運行,不會報錯。

所以如果是用基於同一個JVM內存的緩存的話,個人比較推薦使用Caffeine,強烈不推薦用默認的基於ConcurrentHashMap的實現。

那什麼情況適合用Redis這種需要調用第三方進程的緩存呢?如果你的應用程序是分佈式的,一個服務器查詢出來後,希望其它服務器也能用這個緩存,那就推薦使用基於Redis的緩存。

使用Spring Cache也有不好之處,就是屏蔽了底層緩存的特性。比如,很難做到不同的場景有不同的過期時間(但並不是做不到,也可以通過配置不同的cacheManager來實現)。但整體上來看,還是利大於弊的,大家自己衡量,適合自己就好。

最後

大家看完有什麼不懂的可以在下方留言討論.
謝謝你的觀看。
覺得文章對你有幫助的話記得關注我點個贊支持一下!

作者:Yasin
鏈接:https://juejin.im/post/6882196005731696654


分享到:


相關文章: