基于Spring?Cache實(shí)現(xiàn)Caffeine+Redis二級緩存
本文主要介紹了基于Spring Cache實(shí)現(xiàn)二級緩存(Caffeine+Redis),具體如下:
一、聊聊什么是硬編碼使用緩存?
在學(xué)習(xí)Spring Cache之前,筆者經(jīng)常會硬編碼的方式使用緩存。
我們來舉個實(shí)際中的例子,為了提升用戶信息的查詢效率,我們對用戶信息使用了緩存,示例代碼如下:
@Autowire private UserMapper userMapper; @Autowire private RedisCache redisCache; //查詢用戶 public User getUserById(Long userId) { //定義緩存key String cacheKey = "userId_" + userId; //先查詢redis緩存 User user = redisCache.get(cacheKey); //如果緩存中有就直接返回,不再查詢數(shù)據(jù)庫 if (user != null) { return user; } //沒有再查詢數(shù)據(jù)庫 user = userMapper.getUserById(userId); //數(shù)據(jù)存入緩存,這樣下次查詢就能到緩存中獲取 if (user != null) { stringCommand.set(cacheKey, user); } return user; }
相信很多同學(xué)都寫過類似風(fēng)格的代碼,這種風(fēng)格符合面向過程的編程思維,非常容易理解。但它也有一些缺點(diǎn):
代碼不夠優(yōu)雅。業(yè)務(wù)邏輯有四個典型動作:存儲,讀取,修改,刪除。每次操作都需要定義緩存Key ,調(diào)用緩存命令的API,產(chǎn)生較多的重復(fù)代碼;
緩存操作和業(yè)務(wù)邏輯之間的代碼耦合度高,對業(yè)務(wù)邏輯有較強(qiáng)的侵入性。侵入性主要體現(xiàn)如下兩點(diǎn):
開發(fā)聯(lián)調(diào)階段,需要去掉緩存,只能注釋或者臨時刪除緩存操作代碼,也容易出錯;
某些場景下,需要更換緩存組件,每個緩存組件有自己的API,更換成本頗高。
如果說是下面這樣的,是不是就優(yōu)雅多了。
@Mapper public interface UserMapper { /** * 根據(jù)用戶id獲取用戶信息 * * 如果緩存中有直接返回緩存數(shù)據(jù),如果沒有那么就去數(shù)據(jù)庫查詢,查詢完再插入緩存中,這里緩存的key前綴為cache_user_id_,+傳入的用戶ID */ @Cacheable(key = "'cache_user_id_' + #userId") User getUserById(Long userId); }
再看實(shí)現(xiàn)類
@Autowire private UserMapper userMapper; //查詢用戶 public User getUserById(Long userId) { return userMapper.getUserById(userId); }
這么一看是不是完全和緩存分離開來,如果開發(fā)聯(lián)調(diào)階段,需要去掉緩存那么直接注釋掉注解就好了,是不是非常完美。
而且這一整套實(shí)現(xiàn)都不要自己手動寫,Spring Cache就已經(jīng)幫我定義好相關(guān)注解和接口,我們可以輕易實(shí)現(xiàn)上面的功能。
二、Spring Cache簡介
Spring Cache是Spring-context包中提供的基于注解方式使用的緩存組件,定義了一些標(biāo)準(zhǔn)接口,通過實(shí)現(xiàn)這些接口,就可以通過在
方法上增加注解來實(shí)現(xiàn)緩存。這樣就能夠避免緩存代碼與業(yè)務(wù)處理耦合在一起的問題。
Spring Cache核心的接口就兩個:Cache
和CacheManager
1、Cache接口
該接口定義提供緩存的具體操作,比如緩存的放入、讀取、清理:
package org.Springframework.cache; import java.util.concurrent.Callable; public interface Cache { // cacheName,緩存的名字,默認(rèn)實(shí)現(xiàn)中一般是CacheManager創(chuàng)建Cache的bean時傳入cacheName String getName(); //得到底層使用的緩存,如Ehcache Object getNativeCache(); // 通過key獲取緩存值,注意返回的是ValueWrapper,為了兼容存儲空值的情況,將返回值包裝了一層,通過get方法獲取實(shí)際值 ValueWrapper get(Object key); // 通過key獲取緩存值,返回的是實(shí)際值,即方法的返回值類型 <T> T get(Object key, Class<T> type); // 通過key獲取緩存值,可以使用valueLoader.call()來調(diào)使用@Cacheable注解的方法。當(dāng)@Cacheable注解的sync屬性配置為true時使用此方法。 // 因此方法內(nèi)需要保證回源到數(shù)據(jù)庫的同步性。避免在緩存失效時大量請求回源到數(shù)據(jù)庫 <T> T get(Object key, Callable<T> valueLoader); // 將@Cacheable注解方法返回的數(shù)據(jù)放入緩存中 void put(Object key, Object value); // 當(dāng)緩存中不存在key時才放入緩存。返回值是當(dāng)key存在時原有的數(shù)據(jù) ValueWrapper putIfAbsent(Object key, Object value); // 刪除緩存 void evict(Object key); // 清空緩存 void clear(); // 緩存返回值的包裝 interface ValueWrapper { // 返回實(shí)際緩存的對象 Object get(); } }
2、CacheManager接口
主要提供Cache實(shí)現(xiàn)bean的創(chuàng)建,每個應(yīng)用里可以通過cacheName來對Cache進(jìn)行隔離,每個cacheName對應(yīng)一個Cache實(shí)現(xiàn)。
package org.Springframework.cache; import java.util.Collection; public interface CacheManager { // 通過cacheName創(chuàng)建Cache的實(shí)現(xiàn)bean,具體實(shí)現(xiàn)中需要存儲已創(chuàng)建的Cache實(shí)現(xiàn)bean,避免重復(fù)創(chuàng)建,也避免內(nèi)存緩存 //對象(如Caffeine)重新創(chuàng)建后原來緩存內(nèi)容丟失的情況 Cache getCache(String name); // 返回所有的cacheName Collection<String> getCacheNames(); }
3、常用注解說明
@Cacheable
:主要應(yīng)用到查詢數(shù)據(jù)的方法上。
public @interface Cacheable { // cacheNames,CacheManager就是通過這個名稱創(chuàng)建對應(yīng)的Cache實(shí)現(xiàn)bean @AliasFor("cacheNames") String[] value() default {}; @AliasFor("value") String[] cacheNames() default {}; // 緩存的key,支持SpEL表達(dá)式。默認(rèn)是使用所有參數(shù)及其計(jì)算的hashCode包裝后的對象(SimpleKey) String key() default ""; // 緩存key生成器,默認(rèn)實(shí)現(xiàn)是SimpleKeyGenerator String keyGenerator() default ""; // 指定使用哪個CacheManager,如果只有一個可以不用指定 String cacheManager() default ""; // 緩存解析器 String cacheResolver() default ""; // 緩存的條件,支持SpEL表達(dá)式,當(dāng)達(dá)到滿足的條件時才緩存數(shù)據(jù)。在調(diào)用方法前后都會判斷 String condition() default ""; // 滿足條件時不更新緩存,支持SpEL表達(dá)式,只在調(diào)用方法后判斷 String unless() default ""; // 回源到實(shí)際方法獲取數(shù)據(jù)時,是否要保持同步,如果為false,調(diào)用的是Cache.get(key)方法;如果為true,調(diào)用的是Cache.get(key, Callable)方法 boolean sync() default false; }
@CacheEvict
:清除緩存,主要應(yīng)用到刪除數(shù)據(jù)的方法上。相比Cacheable多了兩個屬性
public @interface CacheEvict { // ...相同屬性說明請參考@Cacheable中的說明 // 是否要清除所有緩存的數(shù)據(jù),為false時調(diào)用的是Cache.evict(key)方法;為true時調(diào)用的是Cache.clear()方法 boolean allEntries() default false; // 調(diào)用方法之前或之后清除緩存 boolean beforeInvocation() default false; }
@CachePut
:放入緩存,主要用到對數(shù)據(jù)有更新的方法上。屬性說明參考@Cacheable
@Caching
:用于在一個方法上配置多種注解
@EnableCaching
:啟用Spring cache緩存,作為總的開關(guān),在SpringBoot的啟動類或配置類上需要加上此注解才會生效
三、使用二級緩存需要思考的一些問題?
我們知道關(guān)系數(shù)據(jù)庫(Mysql)數(shù)據(jù)最終存儲在磁盤上,如果每次都從數(shù)據(jù)庫里去讀取,會因?yàn)榇疟P本身的IO影響讀取速度,所以就有了
像redis這種的內(nèi)存緩存。
通過內(nèi)存緩存確實(shí)能夠很大程度的提高查詢速度,但如果同一查詢并發(fā)量非常的大,頻繁的查詢redis,也會有明顯的網(wǎng)絡(luò)IO上的消耗,
那我們針對這種查詢非常頻繁的數(shù)據(jù)(熱點(diǎn)key),我們是不是可以考慮存到應(yīng)用內(nèi)緩存,如:caffeine。
當(dāng)應(yīng)用內(nèi)緩存有符合條件的數(shù)據(jù)時,就可以直接使用,而不用通過網(wǎng)絡(luò)到redis中去獲取,這樣就形成了兩級緩存。
應(yīng)用內(nèi)緩存叫做一級緩存,遠(yuǎn)程緩存(如redis)叫做二級緩存。
整個流程如下
流程看著是很清新,但其實(shí)二級緩存需要考慮的點(diǎn)還很多。
1.如何保證分布式節(jié)點(diǎn)一級緩存的一致性?
我們說一級緩存是應(yīng)用內(nèi)緩存,那么當(dāng)你的項(xiàng)目部署在多個節(jié)點(diǎn)的時候,如何保證當(dāng)你對某個key進(jìn)行修改刪除操作時,使其它節(jié)點(diǎn)
的一級緩存一致呢?
2.是否允許存儲空值?
這個確實(shí)是需要考慮的點(diǎn)。因?yàn)槿绻硞€查詢緩存和數(shù)據(jù)庫中都沒有,那么就會導(dǎo)致頻繁查詢數(shù)據(jù)庫,導(dǎo)致數(shù)據(jù)庫Down,這也是我們
常說的緩存穿透。
但如果存儲空值呢,因?yàn)榭赡軙鎯Υ罅康目罩?,?dǎo)致緩存變大,所以這個最好是可配置,按照業(yè)務(wù)來決定是否開啟。
3.是否需要緩存預(yù)熱?
也就是說,我們會覺得某些key一開始就會非常的熱,也就是熱點(diǎn)數(shù)據(jù),那么我們是否可以一開始就先存儲到緩存中,避免緩存擊穿。
4.一級緩存存儲數(shù)量上限的考慮?
既然一級緩存是應(yīng)用內(nèi)緩存,那你是否考慮一級緩存存儲的數(shù)據(jù)給個限定最大值,避免存儲太多的一級緩存導(dǎo)致OOM。
5.一級緩存過期策略的考慮?
我們說redis作為二級緩存,redis
是淘汰策略來管理的。具體可參考redis的8種淘汰策略。那你的一級緩存策略呢?就好比你設(shè)置一級緩存
數(shù)量最大為5000個,那當(dāng)?shù)?001個進(jìn)來的時候,你是怎么處理呢?是直接不保存,還是說自定義LRU或者LFU算法去淘汰之前的數(shù)據(jù)?
6.一級緩存過期了如何清除?
我們說redis作為二級緩存,我們有它的緩存過期策略(定時、定期、惰性),那你的一級緩存呢,過期如何清除呢?
這里4、5、6小點(diǎn)如果說用我們傳統(tǒng)的Map顯然實(shí)現(xiàn)是很費(fèi)勁的,但現(xiàn)在有更好用的一級緩存庫那就是Caffeine
。
四、Caffeine 簡介
Caffeine,一個用于Java的高性能緩存庫。
緩存和Map之間的一個根本區(qū)別是緩存會清理存儲的項(xiàng)目。
1、寫入緩存策略
Caffeine有三種緩存寫入策略:手動
、同步加載
和異步加載
。
2、緩存值的清理策略
Caffeine有三種緩存值的清理策略:基于大小
、基于時間
和基于引用
。
基于容量
:當(dāng)緩存大小超過配置的大小限制時會發(fā)生回收。
基于時間
:
- 寫入后到期策略。
- 訪問后過期策略。
- 到期時間由 Expiry 實(shí)現(xiàn)獨(dú)自計(jì)算。
基于引用
:啟用基于緩存鍵值的垃圾回收。
- Java種有四種引用:強(qiáng)引用,軟引用,弱引用和虛引用,caffeine可以將值封裝成弱引用或軟引用。
- 軟引用:如果一個對象只具有軟引用,則內(nèi)存空間足夠,垃圾回收器就不會回收它;如果內(nèi)存空間不足了,就會回收這些對象的內(nèi)存。
- 弱引用:在垃圾回收器線程掃描它所管轄的內(nèi)存區(qū)域的過程中,一旦發(fā)現(xiàn)了只具有弱引用的對象,不管當(dāng)前內(nèi)存空間足夠與否,都會
回收它的內(nèi)存。
3、統(tǒng)計(jì)
Caffeine提供了一種記錄緩存使用統(tǒng)計(jì)信息的方法,可以實(shí)時監(jiān)控緩存當(dāng)前的狀態(tài),以評估緩存的健康程度以及緩存命中率等,方便后
續(xù)調(diào)整參數(shù)。
4、高效的緩存淘汰算法
緩存淘汰算法的作用是在有限的資源內(nèi),盡可能識別出哪些數(shù)據(jù)在短時間會被重復(fù)利用,從而提高緩存的命中率。常用的緩存淘汰算法有
LRU、LFU、FIFO等。
FIFO:先進(jìn)先出。選擇最先進(jìn)入的數(shù)據(jù)優(yōu)先淘汰。 LRU:最近最少使用。選擇最近最少使用的數(shù)據(jù)優(yōu)先淘汰。 LFU:最不經(jīng)常使用。選擇在一段時間內(nèi)被使用次數(shù)最少的數(shù)據(jù)優(yōu)先淘汰。
LRU(Least Recently Used)算法認(rèn)為最近訪問過的數(shù)據(jù)將來被訪問的幾率也更高。
LRU通常使用鏈表來實(shí)現(xiàn),如果數(shù)據(jù)添加或者被訪問到則把數(shù)據(jù)移動到鏈表的頭部,鏈表的頭部為熱數(shù)據(jù),鏈表的尾部如冷數(shù)據(jù),當(dāng)
數(shù)據(jù)滿時,淘汰尾部的數(shù)據(jù)。
LFU(Least Frequently Used)算法根據(jù)數(shù)據(jù)的歷史訪問頻率來淘汰數(shù)據(jù),其核心思想是“如果數(shù)據(jù)過去被訪問多次,那么將來被訪問
的頻率也更高”。根據(jù)LFU的思想,如果想要實(shí)現(xiàn)這個算法,需要額外的一套存儲用來存每個元素的訪問次數(shù),會造成內(nèi)存資源的浪費(fèi)。
Caffeine采用了一種結(jié)合LRU、LFU優(yōu)點(diǎn)的算法:W-TinyLFU
,其特點(diǎn):高命中率、低內(nèi)存占用。
5、其他說明
Caffeine的底層數(shù)據(jù)存儲采用ConcurrentHashMap。因?yàn)镃affeine面向JDK8,在jdk8中ConcurrentHashMap增加了紅黑樹,在hash沖突
嚴(yán)重時也能有良好的讀性能。
五、基于Spring Cache實(shí)現(xiàn)二級緩存(Caffeine+Redis)
前面說了,使用了redis緩存,也會存在一定程度的網(wǎng)絡(luò)傳輸上的消耗,所以會考慮應(yīng)用內(nèi)緩存,但有點(diǎn)很重要的要記住:
應(yīng)用內(nèi)緩存可以理解成比redis緩存更珍惜的資源,所以,caffeine 不適用于數(shù)據(jù)量大,并且緩存命中率極低的業(yè)務(wù)場景,如用戶維度的緩存。
當(dāng)前項(xiàng)目針對應(yīng)用都部署了多個節(jié)點(diǎn),一級緩存是在應(yīng)用內(nèi)的緩存,所以當(dāng)對數(shù)據(jù)更新和清除時,需要通知所有節(jié)點(diǎn)進(jìn)行清理緩存的操作。
可以有多種方式來實(shí)現(xiàn)這種效果,比如:zookeeper、MQ等,但是既然用了redis緩存,redis本身是有支持訂閱/發(fā)布功能的,所以就
不依賴其他組件了,直接使用redis的通道來通知其他節(jié)點(diǎn)進(jìn)行清理緩存的操作。
當(dāng)某個key進(jìn)行更新刪除操作時,通過發(fā)布訂閱的方式通知其它節(jié)點(diǎn)進(jìn)行刪除該key本地的一級緩存就可以了。
具體具體項(xiàng)目代碼這里就不再粘貼出來了,這樣只粘貼如何引用這個starter包。
1、maven引入使用
<dependency> <groupId>com.jincou</groupId> <artifactId>redis-caffeine-cache-starter</artifactId> <version>1.0.0</version> </dependency>
2、application.yml
添加二級緩存相關(guān)配置
# 二級緩存配置 # 注:caffeine 不適用于數(shù)據(jù)量大,并且緩存命中率極低的業(yè)務(wù)場景,如用戶維度的緩存。請慎重選擇。 l2cache: config: # 是否存儲空值,默認(rèn)true,防止緩存穿透 allowNullValues: true # 組合緩存配置 composite: # 是否全部啟用一級緩存,默認(rèn)false l1AllOpen: false # 是否手動啟用一級緩存,默認(rèn)false l1Manual: true # 手動配置走一級緩存的緩存key集合,針對單個key維度 l1ManualKeySet: - userCache:user01 - userCache:user02 - userCache:user03 # 手動配置走一級緩存的緩存名字集合,針對cacheName維度 l1ManualCacheNameSet: - userCache - goodsCache # 一級緩存 caffeine: # 是否自動刷新過期緩存 true 是 false 否 autoRefreshExpireCache: false # 緩存刷新調(diào)度線程池的大小 refreshPoolSize: 2 # 緩存刷新的頻率(秒) refreshPeriod: 10 # 寫入后過期時間(秒) expireAfterWrite: 180 # 訪問后過期時間(秒) expireAfterAccess: 180 # 初始化大小 initialCapacity: 1000 # 最大緩存對象個數(shù),超過此數(shù)量時之前放入的緩存將失效 maximumSize: 3000 # 二級緩存 redis: # 全局過期時間,單位毫秒,默認(rèn)不過期 defaultExpiration: 300000 # 每個cacheName的過期時間,單位毫秒,優(yōu)先級比defaultExpiration高 expires: {userCache: 300000,goodsCache: 50000} # 緩存更新時通知其他節(jié)點(diǎn)的topic名稱 默認(rèn) cache:redis:caffeine:topic topic: cache:redis:caffeine:topic
3、啟動類上增加@EnableCaching
/** * 啟動類 */ @EnableCaching @SpringBootApplication public class CacheApplication { public static void main(String[] args) { SpringApplication.run(CacheApplication.class, args); } }
4、在需要緩存的方法上增加@Cacheable注解
/** * 測試 */ @Service public class CaffeineCacheService { private final Logger logger = LoggerFactory.getLogger(CaffeineCacheService.class); /** * 用于模擬db */ private static Map<String, UserDTO> userMap = new HashMap<>(); { userMap.put("user01", new UserDTO("1", "張三")); userMap.put("user02", new UserDTO("2", "李四")); userMap.put("user03", new UserDTO("3", "王五")); userMap.put("user04", new UserDTO("4", "趙六")); } /** * 獲取或加載緩存項(xiàng) */ @Cacheable(key = "'cache_user_id_' + #userId", value = "userCache") public UserDTO queryUser(String userId) { UserDTO userDTO = userMap.get(userId); try { Thread.sleep(1000);// 模擬加載數(shù)據(jù)的耗時 } catch (InterruptedException e) { e.printStackTrace(); } logger.info("加載數(shù)據(jù):{}", userDTO); return userDTO; } /** * 獲取或加載緩存項(xiàng) * <p> * 注:因底層是基于caffeine來實(shí)現(xiàn)一級緩存,所以利用的caffeine本身的同步機(jī)制來實(shí)現(xiàn) * sync=true 則表示并發(fā)場景下同步加載緩存項(xiàng), * sync=true,是通過get(Object key, Callable<T> valueLoader)來獲取或加載緩存項(xiàng),此時valueLoader(加載緩存項(xiàng)的具體邏輯)會被緩存起來,所以CaffeineCache在定時刷新過期緩存時,緩存項(xiàng)過期則會重新加載。 * sync=false,是通過get(Object key)來獲取緩存項(xiàng),由于沒有valueLoader(加載緩存項(xiàng)的具體邏輯),所以CaffeineCache在定時刷新過期緩存時,緩存項(xiàng)過期則會被淘汰。 * <p> */ @Cacheable(value = "userCache", key = "#userId", sync = true) public List<UserDTO> queryUserSyncList(String userId) { UserDTO userDTO = userMap.get(userId); List<UserDTO> list = new ArrayList(); list.add(userDTO); logger.info("加載數(shù)據(jù):{}", list); return list; } /** * 更新緩存 */ @CachePut(value = "userCache", key = "#userId") public UserDTO putUser(String userId, UserDTO userDTO) { return userDTO; } /** * 淘汰緩存 */ @CacheEvict(value = "userCache", key = "#userId") public String evictUserSync(String userId) { return userId; } }
項(xiàng)目源碼
: https://github.com/yudiandemingzi/springboot-redis-caffeine-cache
推薦相關(guān)二級緩存相關(guān)項(xiàng)目
1.阿里巴巴jetcache: https://github.com/alibaba/jetcache
2.J2Cache: https://gitee.com/ld/J2Cache
3.l2cache: https://github.com/ck-jesse/l2cache(感謝)
這幾個現(xiàn)在業(yè)界比較常用的二級緩存項(xiàng)目,功能更加強(qiáng)大,而且性能更高效,使用也非常方便只要引入jar包,添加配置注解就可以。
到此這篇關(guān)于基于Spring Cache實(shí)現(xiàn)二級緩存(Caffeine+Redis)的文章就介紹到這了,更多相關(guān)Spring Cache二級緩存內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
教你從頭開始用JAVA創(chuàng)建一個自己的簡單API并實(shí)現(xiàn)第三方調(diào)用
在日常開發(fā)的時候,經(jīng)常會遇到需要調(diào)用別人的接口的場景,下面這篇文章主要給大家介紹了關(guān)于如何從頭開始用JAVA創(chuàng)建一個自己的簡單API并實(shí)現(xiàn)第三方調(diào)用的相關(guān)資料,需要的朋友可以參考下2023-12-12Mybatis Plus 字段為空值時執(zhí)行更新方法未更新解決方案
這篇文章主要介紹了Mybatis Plus 字段為空值時執(zhí)行更新方法未更新解決方案,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2020-09-09SpringBoot集成swagger-ui以及swagger分組顯示操作
這篇文章主要介紹了SpringBoot集成swagger-ui以及swagger分組顯示操作,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2020-09-09