javascript
Spring Cache
在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…
gradle:
implementation 'org.springframework.boot:spring-boot-starter-cache'maven:
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-cache</artifactId> </dependency>在啟動類加上@EnableCaching注解即可開啟使用緩存。
@SpringBootApplication @EnableCaching public class CachingApplication {public static void main(String[] args) {SpringApplication.run(CachingApplication.class, args);} }在要緩存的方法上面添加@Cacheable注解,即可緩存這個方法的返回值。
@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);} }測試一下,可以發現。第一次和第二次(第二次參數和第一次不同)調用getByIsbn方法,會等待3秒,而后面四個調用,都會立即返回。
三、常用注解
Spring Cache有幾個常用注解,分別為@Cacheable、@CachePut、@CacheEvict、@Caching、 @CacheConfig。除了最后一個CacheConfig外,其余四個都可以用在類上或者方法級別上,如果用在類上,就是對該類的所有public方法生效,下面分別介紹一下這幾個注解。
@Cacheble注解表示這個方法有了緩存的功能,方法的返回值會被緩存下來,下一次調用該方法前,會去檢查是否緩存中已經有值,如果有就直接返回,不調用方法。如果沒有,就調用方法,然后把結果緩存起來。這個注解一般用在查詢方法上。
加了@CachePut注解的方法,會把方法的返回值put到緩存里面緩存起來,供其它地方使用。它通常用在新增方法上。
使用了CacheEvict注解的方法,會清空指定緩存。一般用在更新或者刪除的方法上。
Java注解的機制決定了,一個方法上只能有一個相同的注解生效。那有時候可能一個方法會操作多個緩存(這個在刪除緩存操作中比較常見,在添加操作中不太常見)。Spring Cache當然也考慮到了這種情況,@Caching注解就是用來解決這類情況的,大家一看它的源碼就明白了。
public @interface Caching {Cacheable[] cacheable() default {};CachePut[] put() default {};CacheEvict[] evict() default {}; }前面提到的四個注解,都是Spring Cache常用的注解。每個注解都有很多可以配置的屬性,這個我們在下一節再詳細解釋。但這幾個注解通常都是作用在方法上的,而有些配置可能又是一個類通用的,這種情況就可以使用@CacheConfig了,它是一個類級別的注解,可以在類級別上配置cacheNames、keyGenerator、cacheManager、cacheResolver等。
- key: key的來源可分為三類,分別是:默認的、keyGenerator生成的、主動指定的。
- condition:在激活注解功能前,進行condition驗證,如果condition結果為true,則表明驗證通過,緩存注解生效;否則緩存注解不生效。condition作用時機在:緩存注解檢查緩存中是否有對應的key-value 之前。注:緩存注解檢查緩存中是否有對應的key-value 在運行目標方法之前,所以 condition作用時機也在運行目標方法之前。
- cacheNames:通過cacheNames對數據進行隔離,不同cacheName下可以有相同的key。也可稱呼cacheName為命名空間。實際上(以spring-cache為例),可以通過設置RedisCacheConfiguration#usePrefix的true或false來控制是否使用前綴。如果否,那么最終的redis鍵就是key值;如果是,那么就會根據cacheName生成一個前綴,然后再追加上key作為最終的redis鍵.cacheName還有其它重要的功能:cacheName(就像其名稱【命名空間】所說)實現了數據分區的功能,一些操作可以直接按照命名空間批量進行。如:spring框架中的Cache實際對應的就是一個【命名空間】,spring會先去找到數據所在的命名空間(即:先找到對應的Cache),再由Cache結合key,最終定位到數據。
注意:若屬性cacheNames(或屬性value)指定了多個命名空間;當進行緩存存儲時,會在這些命名空間下都存一份key-value。當進行緩存讀取時,會按照cacheNames值里命名空間的順序,挨個挨個從命名空間中查找對應的key,如果在某個命名空間中查找打了對應的緩存,就不會再查找排在后面的命名空間,也不會再執行對應方法,直接返回緩存中的value值。
- unless:功能是:是否令注解(在方法執行后的功能)不生效;若unless的結果為true,則(方法執行后的功能)不生效;若unless的結果為false,則(方法執行后的)功能生效。注:unless默認為"",即相當于默認為false。unless的作用時機:目標方法運行后。注:如果(因為直接從緩存中獲取到了數據,而導致)目標方法沒有被執行,那么unless字段不生效。
- allEntries:此屬性主要出現在@CacheEvict注解中,表示是否清除指定命名空間中的所有數據,默認為false。
- beforeInvocation:此屬性主要出現在@CacheEvict注解中,表示 是否在目標方法執行前使 此注解生效。 默認為false,即:目標方法執行完畢后此注解生效。
三、常用注解的配置原理
注解使用詳細例子
這部分我們最好是結合源碼來看,才能更好地理解這些配置的運作機制。
源碼:解析注解的時機。
這一節主要是源碼解析,有點晦澀,對源碼不感興趣的同學可以跳過。但如果想要弄清楚Spring Cache運作的原理,還是推薦一看的。前面提到的幾個注解@Cacheable、@CachePut、@CacheEvict、@CacheConfig,都有一些可配置的屬性。這些配置的屬性都可以在抽象類CacheOperation及其子類中可以找到。它們大概是這樣的關系:
看到這里不得不佩服,這繼承用得,妙啊。
解析每個注解的代碼在SpringCacheAnnotationParser類中可以找到,比如parseEvictAnnotation方法,里面就有這么一句:
builder.setCacheWide(cacheEvict.allEntries());明明注解里叫allEntries,但是CacheEvictOperation里卻叫cacheWide?看了下作者,都是多個作者,但第一作者都是一個叫Costin Leau的哥們,我對這個命名還是有一點小小的困惑。。。看來大佬們寫代碼也會有命名不一致的問題
那這個SpringCacheAnnotationParser是在什么時候被調用的呢?很簡單,我們在這個類的某個方法上打個斷點,然后debug就行了,比如parseCacheableAnnotation方法。
在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去緩存拿數據的時候,會是一個同步的操作。(加鎖)
我們來看看同步操作的源碼。如果判斷當前需要同步操作
(1)、首先會去判斷當前的condition是不是符合條件
(2)、這里的condition也是@Cacheable中定義的一個配置,它是一個EL表達式,比如我們可以這樣用來緩存id大于1的Book:
如果不符合條件,就不使用緩存,也不把結果放入緩存,直接跳到5。否則,嘗試獲取key
- (3)、在獲取key的時候,會先判斷用戶有沒有定義key,它也是一個EL表達式。如果沒有的話,就用keyGenerator生成一個key:
我們可以用這種方式手動指定根據id生成book-1,book-2這樣的key:
@Override @Cacheable(cacheNames = "books", sync = true, key = "'book-' + #id") public Book getById(Long id) {return new Book(String.valueOf(id), "some book"); }這里的key是一個Object對象,如果我們不在注解上面指定key,會使用keyGenerator生成的key。默認的keyGenerator是SimpleKeyGenerator,它生成的是一個SimpleKey對象,方法也很簡單,如果沒有入參,就返回一個EMPTY的對象,如果有入參,且只有一個入參,并且不是空或者數組,就用這個參數(注意這里用的是參數本身,而不是SimpleKey對象。否則,用所有入參包一個SimpleKey。
源碼:
@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); }看到這里你一定有一個疑問吧,這里只用入參,沒有類名和方法名的區別,那如果兩個方法入參一樣,豈不是key沖突了?
你的感覺沒錯,大家可以試一下這兩個方法:
// 定義兩個參數都是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"));你會發現兩次生成的key相同,然后在調用test方法的時候,控制臺會報錯:
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 omittedBook不能強轉成String,因為我們第一次調用getByIsbn方法的時候,生成的key是test,然后換成了返回值Book對象到緩存里面。而調用test方法的時候,生成的key還是test,就會取出Book,但是test方法的返回值是String,所以會嘗試強轉到String,結果發現強轉失敗。
我們可以自定義一個keyGenerator來解決這個問題:
@Component public class MyKeyGenerator implements KeyGenerator {@Overridepublic Object generate(Object target, Method method, Object... params) {return target.getClass().getName() + method.getName() + Stream.of(params).map(Object::toString).collect(Collectors.joining(","));} }然后就可以在配置里面使用這個自定義的MyKeyGenerator了,再次運行程序,就不會出現上述問題。
@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; }接著往下看,可以看到我們得到了一個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,因為這樣具有更好的可讀性。
@Override @Cacheable(cacheNames = {"book", "test"}) public Book getByIsbn(String isbn) {simulateSlowService();return new Book(isbn, "Some book"); }默認的Cache是ConcurrentMapCache,它也是基于ConcurrentHashMap的。但這里有個問題,我們回到上面的execute方法的代碼,發現如果設置了sync為true,它取的是第一個Cache,而沒有管剩下的Cache。所以如果你配置了sync為true,只支持配置一個cacheNames,如果配了多個,就會報錯:
@Cacheable(sync=true) only allows a single cache on...繼續往下看,發現調用的是Cache的get(Object, Callcable)方法。這個方法會先嘗試去緩存中用key取值,如果取不到在調用callable函數,然后加到緩存里。Spring Cache也是期望Cache的實現類在這個方法內部實現“同步”的功能。所以我們再回過頭去看Cacheable中sync屬性上方的注釋,它寫到:`使用sync為true,會有這些限制:
- 不支持unless,這個從代碼可以看到,只支持了condition,沒有支持unless;
- 只能有一個cache,因為代碼就寫死了一個。我猜這是為了更好地支持同步,它把同步放到了Cache里面去實現。
- 沒有不支持其它的Cache操作,代碼里面寫死了,只支持Cachable,我猜這也是為了支持同步。
如果sync為false呢?
繼續往下看execute的代碼,大概經歷了下面這些步驟:
至此,我們就結合源碼解釋完了所有的配置發生作用的時機。
五、使用其它緩存框架
如果要使用其它的緩存框架,應該怎么做呢?通過上面的源碼分析我們知道,如果要使用其它的緩存框架,我們只需要重新定義好CacheManager和CacheResolver這兩個Bean就行了。事實上,Spring會自動檢測我們是否引入了相應的緩存框架,如果我們引入了spring-data-redis,Spring就會自動使用spring-data-redis提供的RedisCacheManager,RedisCache。如果我們要使用Caffeine框架。只需要引入Caffeine,Spring Cache就會默認使用CaffeineCacheManager和CaffeineCache。
implementation 'org.springframework.boot:spring-boot-starter-cache' implementation 'com.github.ben-manes.caffeine:caffeine'Caffeine是一個性能非常高的緩存框架,它使用了Window TinyLfu回收策略,提供了一個近乎最佳的命中率。Spring Cache還支持各種配置,在CacheProperties類里面,里面還提供了各種主流的緩存框架的特殊配置。比如Redis的過期時間等(默認永不過期)。
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();六、使用緩存帶來的問題
使用緩存會帶來許多問題,尤其是高并發下,包括緩存穿透、緩存擊穿、緩存雪崩、雙寫不一致等問題。具體的問題介紹和常用的解決方案可以參考我的個人網站上的文章《緩存常見問題及解決方案》。其中主要聊一下雙寫不一致的問題,這是一個比較常見的問題,其中一個常用的解決方案是,更新的時候,先刪除緩存,再更新數據庫。所以Spring Cache的@CacheEvict會有一個beforeInvocation的配置。但使用緩存通常會存在緩存中的數據和數據庫中不一致的問題,尤其是調用第三方接口,你不會知道它什么時候更新了數據。但使用緩存的業務場景很多時候并不需求數據的強一致,比如首頁的熱點文章,我們可以讓緩存一分鐘失效,這樣就算一分鐘內,不是最新的熱點排行也沒關系。
這個是無可避免的。因為總要有一個地方去放緩存。不管是ConcurrentHashMap也好,Redis也好,Caffeine也好,總歸是會占用額外的內存資源去放緩存的。但緩存的思想正是用空間去換時間,有時候占用這點額外的空間對于時間上的優化來說,是非常值得的。這里需要注意的是,SpringCache默認使用的是ConcurrentHashMap,它不會自動回收key,所以如果使用默認的這個緩存,程序就會越來越大,并且得不到回收。最終可能導致OOM。
我們來模擬實驗一下:
@Component public class MyKeyGenerator implements KeyGenerator {@Overridepublic Object generate(Object target, Method method, Object... params) {// 每次都生成不同的keyreturn UUID.randomUUID().toString();} } //調它個100w次 for (int i = 0; i < 1000000; i++) {bookRepository.test("test"); }然后把最大內存設置成20M: -Xmx20M。
我們先來測試默認的基于ConcurrentHashMap的緩存,發現它很快就會報OOM。
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)"我們使用Caffeine,并且配置一下它的最大容量:
spring:cache:caffeine:spec: maximumSize=100再次運行程序,發現正常運行,不會報錯。所以如果是用基于同一個JVM內存的緩存的話,個人比較推薦使用Caffeine,強烈不推薦用默認的基于ConcurrentHashMap的實現。那什么情況適合用Redis這種需要調用第三方進程的緩存呢?如果你的應用程序是分布式的,一個服務器查詢出來后,希望其它服務器也能用這個緩存,那就推薦使用基于Redis的緩存。使用Spring Cache也有不好之處,就是屏蔽了底層緩存的特性。比如,很難做到不同的場景有不同的過期時間(但并不是做不到,也可以通過配置不同的cacheManager來實現)。但整體上來看,還是利大于弊的,大家自己衡量,適合自己就好。
七、總結
* 每一個需要緩存的數據我們都來指定要放到那個名字的緩存。【緩存的分區(按照業務類型分)】* 代表當前方法的結果需要緩存,如果緩存中有,方法都不用調用,如果緩存中沒有,會調用方法。最后將方法的結果放入緩存* 默認行為* 如果緩存中有,方法不再調用* key是默認生成的:緩存的名字::SimpleKey::[](自動生成key值)* 緩存的value值,默認使用jdk序列化機制,將序列化的數據存到redis中* 默認時間是 -1:** 自定義操作:key的生成* 指定生成緩存的key:key屬性指定,接收一個Spel* 指定緩存的數據的存活時間:配置文檔中修改存活時間* 將數據保存為json格式*** Spring-Cache的不足之處:* 1)、讀模式* 緩存穿透:查詢一個null數據。解決方案:緩存空數據* 緩存擊穿:大量并發進來同時查詢一個正好過期的數據。解決方案:加鎖 ? 默認是無加鎖的;使用sync = true來解決擊穿問題* 緩存雪崩:大量的key同時過期。解決:加隨機時間。加上過期時間* 2)、寫模式:(緩存與數據庫一致)* 1)、讀寫加鎖。* 2)、引入Canal,感知到MySQL的更新去更新Redis* 3)、讀多寫多,直接去數據庫查詢就行** 總結:* 常規數據(讀多寫少,即時性,一致性要求不高的數據,完全可以使用Spring-Cache):寫模式(只要緩存的數據有過期時間就足夠了)* 特殊數據:特殊設計** 原理:* CacheManager(RedisCacheManager)->Cache(RedisCache)->Cache負責緩存的讀寫文章轉自
總結
以上是生活随笔為你收集整理的Spring Cache的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Redis缓存穿透、击穿、雪崩、预热、更
- 下一篇: 2020年快手母婴生态报告