硬核 | 使用spring cache讓我的接口性能瞬間提升了100倍

2022-01-01 小強的進階之路

筆者之前做商城項目時,做過商城首頁的商品分類功能。當時考慮分類是放在商城首頁,以後流量大,而且不經常變動,為了提升首頁訪問速度,我考慮使用緩存。對於java開發而言,首先的緩存當然是redis。

優化前系統流程圖:

我們從圖中可以看到,分類功能分為生成分類數據 和 獲取分類數據兩個流程,生成分類數據流程是有個JOB每隔5分鐘執行一次,從mysql中獲取分類數據封裝成首頁需要展示的分類數據結構,然後保存到redis中。獲取分類數據流程是商城首頁調用分類接口,接口先從redis中獲取數據,如果沒有獲取到再從mysql中獲取。

一般情況下從redis就都能獲取數據,因為相應的key是沒有設置過期時間的,數據會一直都存在。以防萬一,我們做了一次兜底,如果獲取不到數據,就會從mysql中獲取。

本以為萬事大吉,後來,在系統上線之前,測試對商城首頁做了一次性能壓測,發現qps是100多,一直上不去。我們仔細分析了一下原因,發現了兩個主要的優化點:去掉多餘的接口日誌列印 和 分類接口引入redis cache做一次二級緩存。日誌列印我在這裡就不多說了,不是本文的重點,我們重點說一下redis cache。

優化後的系統流程圖:

我們看到,其他的流程都沒有變,只是在獲取分類接口中增加了先從spring cache中獲取分類數據的功能,如果獲取不到再從redis中獲取,再獲取不到才從mysql中獲取。

經過這樣一次小小的調整,再重新壓測接口,性能一下子提升了N倍,滿足了業務要求。如此美妙的一次優化經驗,有必要跟大家分析一下。

我將從以下幾個方面給大家分享一下spring cache。

基本用法

項目中如何使用

工作原理

一、基本用法

SpringCache緩存功能的實現是依靠下面的這幾個註解完成的。

@EnableCaching註解是緩存的開關,如果要使用緩存功能,就必要打開這個開關,這個註解可以定義在Configuration類或者springboot的啟動類上面。

@Cacheable、@CachePut、@CacheEvict 這三個註解的用戶差不多,定義在需要緩存的具體類或方法上面。

   @Cacheable(key="'id:'+#id")   public User getUser(int id) {        return userService.getUserById(id);   }
   @CachePut(key="'id:'+#user.id")   public User insertUser(User user) {       userService.insertUser(user);       return user; }
   @CacheEvict(key="'id:'+#id")   public int deleteUserById(int id) {        userService.deleteUserById(id);        return id; }

需要注意的是@Caching註解跟另外三個註解不同,它可以組合另外三種註解,自定義新註解。

@Caching(        cacheable = {@Cacheable(/*value = "emp",*/key = "#lastName")        put = {@CachePut(/*value = "emp",*/key = "#result.id")})public Employee getEmpByLastName(String lastName){    return  employeeMapper.getEmpByLastName(lastName);}

@CacheConfig一般定義在配置類上面,可以抽取緩存的公共配置,可以定義這個類全局的緩存名稱,其他的緩存方法就可以不配置緩存名稱了。

@CacheConfig(cacheNames = "emp")@Servicepublic class EmployeeService 

二、項目中如何使用

引入caffeine的相關jar包

我們這裡使用caffeine,而非guava,因為Spring Boot 2.0中取代了guava

<dependency>    <groupId>org.springframework.boot</groupId>    <artifactId>spring-boot-starter-cache</artifactId></dependency><dependency>    <groupId>com.github.ben-manes.caffeine</groupId>    <artifactId>caffeine</artifactId>    <version>2.6.0</version></dependency>

  2. 配置CacheManager,開啟EnableCaching

@Configuration@EnableCachingpublic class CacheConfig {    @Bean    public CacheManager cacheManager(){        CaffeineCacheManager cacheManager = new CaffeineCacheManager();                Caffeine<Object, Object> caffeine = Caffeine.newBuilder()                                .expireAfterWrite(10, TimeUnit.SECONDS)                                .maximumSize(1000);        cacheManager.setCaffeine(caffeine);        return cacheManager;    }}

  3.使用Cacheable註解獲取數據

@Servicepublic class CategoryService {            @Cacheable(value = "category", key = "#type")    public CategoryModel getCategory(Integer type) {        return getCategoryByType(type);    }
private CategoryModel getCategoryByType(Integer type) { System.out.println("根據不同的type:" + type + "獲取不同的分類數據"); CategoryModel categoryModel = new CategoryModel(); categoryModel.setId(1L); categoryModel.setParentId(0L); categoryModel.setName("電器"); categoryModel.setLevel(3); return categoryModel; }}

4.測試

@Api(tags = "category", description = "分類相關接口")@RestController@RequestMapping("/category")public class CategoryController {
@Autowired private CategoryService categoryService;
@GetMapping("/getCategory") public CategoryModel getCategory(@RequestParam("type") Integer type) { return categoryService.getCategory(type); }}

在瀏覽器中調用接口:

可以看到,有數據返回。

再看看控制臺的列印。

有數據列印,說明第一次請求進入了categoryService.getCategory方法的內部。

然後再重新請求一次,

還是有數據,返回。但是控制臺沒有重新列印新數據,還是以前的數據,說明這一次請求走的是緩存,沒有進入categoryService.getCategory方法的內部。在5分鐘以內,再重複請求該接口,一直都是直接從緩存中獲取數據。

說明緩存生效了,下面我介紹一下spring cache的工作原理

三、工作原理

通過上面的例子,相當朋友們對spring cache在項目中的用法有了一定的認識。那麼它的工作原理是什麼呢?

相信聰明的朋友們,肯定會想到,它用了AOP。

沒錯,它就是用了AOP。那麼具體是怎麼用的?

我們先看看EnableCaching註解

@Target(ElementType.TYPE)@Retention(RetentionPolicy.RUNTIME)@Documented@Import(CachingConfigurationSelector.class)public @interface EnableCaching {
   boolean proxyTargetClass() default false; AdviceMode mode() default AdviceMode.PROXY;    int order() default Ordered.LOWEST_PRECEDENCE;
}

這個數據很簡單,定義了代理相關參數,引入了CachingConfigurationSelector類。再看看該類的getProxyImports方法

private String[] getProxyImports() {    List<String> result = new ArrayList<>(3);    result.add(AutoProxyRegistrar.class.getName());    result.add(ProxyCachingConfiguration.class.getName());    if (jsr107Present && jcacheImplPresent) {      result.add(PROXY_JCACHE_CONFIGURATION_CLASS);    }    return StringUtils.toStringArray(result);  }

該方法引入了AutoProxyRegistrar和ProxyCachingConfiguration兩個類

AutoProxyRegistrar是讓spring cache擁有AOP的能力(至於如何擁有AOP的能力,這個是單獨的話題,感興趣的朋友可以自己閱讀一下源碼。或者關注一下我的公眾帳號,後面會有專門AOP的專題)。

重點看看ProxyCachingConfiguration

@Configuration@Role(BeanDefinition.ROLE_INFRASTRUCTURE)public class ProxyCachingConfiguration extends AbstractCachingConfiguration {
@Bean(name = CacheManagementConfigUtils.CACHE_ADVISOR_BEAN_NAME) @Role(BeanDefinition.ROLE_INFRASTRUCTURE) public BeanFactoryCacheOperationSourceAdvisor cacheAdvisor() { BeanFactoryCacheOperationSourceAdvisor advisor = new BeanFactoryCacheOperationSourceAdvisor(); advisor.setCacheOperationSource(cacheOperationSource()); advisor.setAdvice(cacheInterceptor()); if (this.enableCaching != null) { advisor.setOrder(this.enableCaching.<Integer>getNumber("order")); } return advisor; }
@Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) public CacheOperationSource cacheOperationSource() { return new AnnotationCacheOperationSource(); }
@Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) public CacheInterceptor cacheInterceptor() { CacheInterceptor interceptor = new CacheInterceptor(); interceptor.setCacheOperationSources(cacheOperationSource()); if (this.cacheResolver != null) { interceptor.setCacheResolver(this.cacheResolver); } else if (this.cacheManager != null) { interceptor.setCacheManager(this.cacheManager); } if (this.keyGenerator != null) { interceptor.setKeyGenerator(this.keyGenerator); } if (this.errorHandler != null) { interceptor.setErrorHandler(this.errorHandler); } return interceptor; }
}

哈哈哈,這個類裡面定義了AOP的三大要素:advisor、interceptor和Pointcut,只是Pointcut是在BeanFactoryCacheOperationSourceAdvisor內部定義的。

另外定義了CacheOperationSource類,該類封裝了cache方法籤名註解的解析工作,形成CacheOperation的集合。它的構造方法會實例化SpringCacheAnnotationParser,現在看看這個類的parseCacheAnnotations方法。

private Collection<CacheOperation> parseCacheAnnotations(    DefaultCacheConfig cachingConfig, AnnotatedElement ae, boolean localOnly) {
Collection<CacheOperation> ops = null; Collection<Cacheable> cacheables = (localOnly ? AnnotatedElementUtils.getAllMergedAnnotations(ae, Cacheable.class) : AnnotatedElementUtils.findAllMergedAnnotations(ae, Cacheable.class)); if (!cacheables.isEmpty()) { ops = lazyInit(null); for (Cacheable cacheable : cacheables) { ops.add(parseCacheableAnnotation(ae, cachingConfig, cacheable)); } } Collection<CacheEvict> evicts = (localOnly ? AnnotatedElementUtils.getAllMergedAnnotations(ae, CacheEvict.class) : AnnotatedElementUtils.findAllMergedAnnotations(ae, CacheEvict.class)); if (!evicts.isEmpty()) { ops = lazyInit(ops); for (CacheEvict evict : evicts) { ops.add(parseEvictAnnotation(ae, cachingConfig, evict)); } } Collection<CachePut> puts = (localOnly ? AnnotatedElementUtils.getAllMergedAnnotations(ae, CachePut.class) : AnnotatedElementUtils.findAllMergedAnnotations(ae, CachePut.class)); if (!puts.isEmpty()) { ops = lazyInit(ops); for (CachePut put : puts) { ops.add(parsePutAnnotation(ae, cachingConfig, put)); } }
  Collection<Caching> cachings = (localOnly ? AnnotatedElementUtils.getAllMergedAnnotations(ae, Caching.class) : AnnotatedElementUtils.findAllMergedAnnotations(ae, Caching.class)); if (!cachings.isEmpty()) { ops = lazyInit(ops); for (Caching caching : cachings) { Collection<CacheOperation> cachingOps = parseCachingAnnotation(ae, cachingConfig, caching); if (cachingOps != null) { ops.addAll(cachingOps); } } }
return ops;}

我們看到這個類會解析@cacheable、@cacheEvict、@cachePut 和 @Caching註解的參數,封裝到CacheOperation集合中。

此外,spring cache 功能的關鍵就是上面的攔截器:CacheInterceptor,它最終會調到這個方法:

@Nullableprivate Object execute(final CacheOperationInvoker invoker, Method method, CacheOperationContexts contexts) {    if (contexts.isSynchronized()) {    CacheOperationContext context = contexts.get(CacheableOperation.class).iterator().next();    if (isConditionPassing(context, CacheOperationExpressionEvaluator.NO_RESULT)) {      Object key = generateKey(context, CacheOperationExpressionEvaluator.NO_RESULT);      Cache cache = context.getCaches().iterator().next();      try {        return wrapCacheValue(method, cache.get(key, () -> unwrapReturnValue(invokeOperation(invoker))));      }      catch (Cache.ValueRetrievalException ex) {                        throw (CacheOperationInvoker.ThrowableWrapper) ex.getCause();      }    }    else {            return invokeOperation(invoker);    }  }

processCacheEvicts(contexts.get(CacheEvictOperation.class), true, CacheOperationExpressionEvaluator.NO_RESULT);
   Cache.ValueWrapper cacheHit = findCachedItem(contexts.get(CacheableOperation.class));
List<CachePutRequest> cachePutRequests = new LinkedList<>(); if (cacheHit == null) { collectPutRequests(contexts.get(CacheableOperation.class), CacheOperationExpressionEvaluator.NO_RESULT, cachePutRequests); }
Object cacheValue; Object returnValue;
if (cacheHit != null && !hasCachePut(contexts)) { cacheValue = cacheHit.get(); returnValue = wrapCacheValue(method, cacheValue); } else {     returnValue = invokeOperation(invoker); cacheValue = unwrapReturnValue(returnValue); }
   collectPutRequests(contexts.get(CachePutOperation.class), cacheValue, cachePutRequests);
   for (CachePutRequest cachePutRequest : cachePutRequests) { cachePutRequest.apply(cacheValue); }
processCacheEvicts(contexts.get(CacheEvictOperation.class), false, cacheValue);
return returnValue;}

也行有些朋友看到這裡會有一個疑問:

既然spring cache的增刪改查都有了,為啥還要 @Caching 註解呢?

其實是這樣的:spring考慮如果除了增刪改查之外,如果用戶需要自定義自己的註解,或者有些比較複雜的功能需要增刪改查的情況,這時就可以用@Caching 註解來實現。

還要一個問題:

上面的例子中使用的緩存key是#type,但是如果有些緩存key比較複雜,是實體中的幾個欄位組成的,這種情況要如何定義呢?

一起看看下面的例子:

@Datapublic class QueryCategoryModel {
private Long id;
private Long parentId;
private String name;
private Integer level;
private Integer type;
}
@Cacheable(value = "category", key = "#type")public CategoryModel getCategory2(QueryCategoryModel queryCategoryModel) { return getCategoryByType(queryCategoryModel.getType());}

這個例子中需要用到QueryCategoryModel實體對象的所有欄位,作為一個key,這種情況要如何定義呢?

1.自定義一個類實現KeyGenerator接口

public class CategoryGenerator  implements KeyGenerator {
@Override public Object generate(Object target, Method method, Object... params) { return target.getClass().getSimpleName() + "_" + method.getName() + "_" + StringUtils.arrayToDelimitedString(params, "_"); }}

2.在CacheConfig類中定義CategoryGenerator的bean實例

@Beanpublic CategoryGenerator categoryGenerator() {    return new CategoryGenerator();}

3.修改之前定義的key

@Cacheable(value = "category", key = "categoryGenerator")public CategoryModel getCategory2(QueryCategoryModel queryCategoryModel) {    return getCategoryByType(queryCategoryModel.getType());}

相關焦點

  • Spring Cache,從入門到真香
    為什麼使用緩存前幾天我在文章《我是如何把一個15分鐘的程序優化到了10秒的》中,提到了一些在代碼層面優化性能的方法。其中第一個就是使用緩存。如果我們的程序想要使用緩存,就要與這些框架耦合。聰明的架構師已經在利用接口來降低耦合了,利用面向對象的抽象和多態的特性,做到業務代碼與具體的框架分離。但我們仍然需要顯式地在代碼中去調用與緩存有關的接口和方法,在合適的時候插入數據到緩存裡,在合適的時候從緩存中讀取數據。
  • Oppo百萬級高並發mongodb集群性能數十倍提升優化實踐
    2.3 wiredtiger存儲引擎優化從上一節可以看出平均時延從200ms降低到了平均80ms左右,很顯然平均時延還是很高,如何進一步提升性能降低時延?繼續分析集群,我們發現磁碟IO一會兒為0,一會兒持續性100%,並且有跌 0 現象,現象如下:
  • 令機械硬碟性能暴漲3倍!
    RISC-V架構處理器憑著開源沒有IP糾紛的優勢,得到了全球各大廠商的青睞,國外有IBM,谷歌等,國內有阿里等都推出了自己的RISC-V處理器晶片,近日,又傳來國際硬碟製造大廠發布自己的RISC-V架構處理器,基於此處理器的硬碟性能提升達到3倍!
  • 啥,聽說你用了springboot,但是開發的接口還在裸奔?
    統一國際化翻譯管理器接口返回內容均支持國際化翻譯能力。已整合了spring i18n國際化翻譯能力,也支持拓展業務自定義國際化翻譯器。統一錯誤碼管理器研發人員無需再關注業務錯誤碼的定義和國際化翻譯工作,可以做到定義簡單、使用簡單、易於理解、配置靈活自動接口文檔生成器已內置swagger+knif4j,只需指定API包路徑即可實現接口文檔的自動生成。
  • 令機械硬碟性能暴漲3倍!
    RISC-V架構處理器憑著開源沒有IP糾紛的優勢,得到了全球各大廠商的青睞,國外有IBM,谷歌等,國內有阿里等都推出了自己的RISC-V處理器晶片,近日,又傳來國際硬碟製造大廠發布自己的RISC-V架構處理器,基於此處理器的硬碟性能提升達到3倍!
  • 在 Spring Boot 中使用 HikariCP 連接池
    上次幫小王解決了如何在 Spring Boot 中使用 JDBC 連接 MySQL 後,我就一直在等,等他問我第三個問題,比如說如何在 Spring Boot 中使用 HikariCP 連接池。但我等了四天也沒有等到任何音訊,似乎他從我的世界裡消失了,而我卻仍然沉醉在他拍我馬屁的美妙感覺裡。突然感覺,沒有小王的日子裡,好空虛。怎麼辦呢?
  • 8倍性能提升 麗臺Quadro 2000現售2399元
    【4月25日太平洋電腦網山東訊】麗臺專業顯卡一直被用戶所擁護,其性能和效率非常的強悍,麗臺NVIDIA Quadro 2000顯卡基於NVIDIAFermi架構,Quadro Fermi系列比上一代產品能夠在各種設計、動畫以及視頻應用程式上實現最高5倍的性能提升,在並行科學運算方面則最高可實現8倍的性能提升。
  • 快速創建 Spring Cloud 應用的 Spring Initializr 使用及原理
    相信很多人都使用過 start.spring.io 來初始化自己的 Spring Boot 工程,這個工具為開發者提供了豐富的可選組件,並且可以選擇多種打包方式,大大方便了開發人員的使用。最近,阿里的 Nacos、Sentinel 也進入 start.spring.io 的選項中,進一步的方便開發者使用阿里雲的產品。
  • 硬核性能於一身 飛利浦4KMini顯示器不簡單
    今年,飛利浦顯示器亮相第三屆進博會的多款產品當中,有一款便是專業級27英寸4KMini LED顯示器279P2MRX,它集多種硬核性能於一身。接下來,讓我們逐一揭曉它神秘的面紗。    飛利浦MiniLED顯示器279P2MRX的一大特色就是精細的畫質表現,它擁有高達100%Adobe RGB的色域,採用2304區分區調光,具備1000nits超高亮度以及DeltaE<2的精準色彩表現和還原力
  • 幾個Ceph 性能優化的新方法和思路
    在不同的緩存層次,使用不同的硬體介質,cache pool 使用的介質必須比 backing pool 使用的介質速度快:比如,在 backing pool 使用一般的存儲介質,比如常規的HDD或者 SATA SDD;在 cache pool 使用快速介質,比如 PCIe SDD。每一個 tiered cache 使用自己的 CRUSH rules,使得數據會被寫入到指定的不同存儲介質。
  • 使用Spring 框架的好處
    Spring 框架因其強大的功能以及卓越的性能而受到眾多開發人員的喜愛。Spring 框架的主要優點具體如下:1.方便解耦,便於開發,spring支持aop編程,Spring就是一個大工廠,可以將所有對象的創建和依賴關係維護都交給spring管理,Spring 不會讓你白費力氣做重複工作,它真正的利用了一些現有的技術,像ORM 框架、日誌框架、JEE、Quartz 和 JDK 計時器,其他視圖技術。
  • 集硬核性能於一身,飛利浦4K Mini LED顯示器不簡單!
    今年,飛利浦顯示器亮相第三屆進博會的多款產品當中,有一款便是專業級27英寸4K Mini LED顯示器279P2MRX,它集多種硬核性能於一身。接下來,讓我們逐一揭曉它神秘的面紗。  飛利浦Mini LED顯示器279P2MRX的一大特色就是精細的畫質表現,它擁有高達100%
  • AI推理性能最高提升20倍,IBM首款7nm商用處理器POWER10面世
    作為 Power 9 的繼任者,POWER10 的處理效率是前者的三倍,同時又提供了更高的工作負載量和容器密度。基於 Power 9 處理器並使用 Nvidia Tesla GPU 作為加速器的 Summit,就是世界上最快的超級計算機之一。目前看來,繼任者 POWER10 更加值得期待。
  • ARM高效C編程和優化--編譯器,內存和Cache優化以及功耗管理
    這也是使用intrinsic相對於手寫彙編的優勢,你可以利用編譯器的特性來把C代碼周邊的環境考慮進來做針對目標平臺的優化。Data Cache使用大多數應用程式員往往把cache當做作業系統OS層面需要考慮的問題。當然,cache的配置與管理是作業系統負責的,應用程式一般不允許幹涉cache操作。
  • Spring Shiro 學習系統 Spring-Shiro-training
    Spring-Shiro-training 詳細介紹Spring Shiro 學習系統簡介基於 springmvc、spring、mybatis-plus、shiro技術和功能Spring-cache、Spring-data-redis、Spring-Task、Shiro、Spring-cache-shiro、maven profile 多環境配置權限管理、角色管理、用戶管理、部門管理、登陸日誌、圖標管理
  • 棄用JDK動態代理,Spring5 默認使用 CGLIB 了?
    Spring5 AOP 默認使用 Cglib 了?我第一次聽到這個說法是在一個微信群裡:群聊天真的假的?查閱文檔剛看到這個說法的時候,我是保持懷疑態度的。大家都知道 Spring5 之前的版本 AOP 在默認情況下是使用 JDK 動態代理的,那是不是 Spring5 版本真的做了修改呢?
  • 幾個Ceph 性能優化的新方法和思路
    在不同的緩存層次,使用不同的硬體介質,cache pool 使用的介質必須比 backing pool 使用的介質速度快:比如,在 backing pool 使用一般的存儲介質,比如常規的HDD或者 SATA SDD;在 cache pool 使用快速介質,比如 PCIe SDD。每一個 tiered cache 使用自己的 CRUSH rules,使得數據會被寫入到指定的不同存儲介質。
  • 137% YOLOv3加速、10倍搜索性能提升!這樣的驚喜,最新版PaddleSlim...
    與2019年第一季度初次發布相比,PaddleSlim在易用性、模型適配、端側部署、性能提升等方面都有了顯著提升。最核心的體現,在於以下的十個特性。△ 圖5 大規模蒸餾原理圖七、支持分類、檢測、分割多個場景,多種策略自由組合PaddleSlim1.0支持多種壓縮策略組合使用,以達到最高的壓縮比。在分類任務上,實現了模型大小減小70%,準確提升1%。
  • SpringSecurity代碼實現JWT接口權限授予與校驗
    這兩個接口都是向Spring Security提供用戶、角色、權限等校驗信息的接口如果你學習過Spring Security的formLogin登錄模式,請將HttpSecurity配置中的formLogin()配置段全部去掉。因為JWT完全使用JSON接口,沒有from表單提交。
  • 問答:雙路顯卡指什麼,對性能提升有多大?
    問答:雙路顯卡指什麼,對性能提升有多大?這一過程中,一塊顯卡會設為主卡,另一塊則為從卡,兩塊顯卡都通過接口最終與顯示器相連。4.接口或互聯設備需同時與兩塊顯卡的DVI接口相接。這一方式十分有利於負載均衡,至少從理論上看一幀畫面的渲染速度會達到以往的兩倍之多。不過這只是理論值,實現表現會有差別。