Springboot应用中private方法上的@Cache注解不生效的问题解析

Spring Cache “错用”

背景是这样的, 一个同事开发的一个功能模块代码,大概是查询一个下游的内容接口,查询到数据并转发给端侧接口。这个功能模块 流量非常大 ,下游的内容接口的内容数据量也有限(内容id数有限,量级在几个w),同事也意识到了需要在自己服务内对下游内容接口加本地缓存。做法和下面图一模一样:

image

这个代码发布到线上,当端侧访问入口开放时,我们后端服务就开始疯狂告警,都是内容接口不负重压、响应超时的告警。

然后我们先去看的调用链,发现内容接口QPS已经飙升到10W!

为什么?明明已经加了接口缓存,按我们加缓存的预期,很多请求应该打到缓存上,而不应该再查下游内容接口才对啊?或许很多人都这么认为,但错了就是错了。

像上图的错误是很“低级”的,如果都这么使用,对于稍微不健壮的下游系统,将是灾难,如果真到那样子,今年的绩效也就好不到哪了。

错在哪里?

上述代码对spring cache使用,总结来说有下面的错误:

  • 1、在私有方法上加缓存
  • 2、类内部方法调用加缓存

这些问题都是比较致命的,我们很多使用缓存的朋友根本不知道也不清楚不能这么使用。下面我把问题一一地分析透了。

##*从Spring Cache原理解释为什么私有方法不能加缓存

Spring Cache通过注解,并借助Spring AOP实现缓存。打开源码包,定位到我们的@Cacheable注解位置:

image

cache的实现都在context的org.springframework.cache包下。我的spring版本是5.3.14,其他版本也基本一样。

image

matches方法判断类或者方法中有没有cache相关的缓存注解,这个是怎么判断的呢?我们一路跟进去,cas.getCacheOperations(method, targetClass)),到了AbstractFallbackCacheOperationSource类的getCacheOperations方法:

image

attributeCache是一个ConcurrentHashmap,只是把computeCacheOperations(method, targetClass)计算得到的结果缓存一下,下次再进来就不用消耗cpu重新计算获取。

image

computeCacheOperations方法中,真正解析 方法上 cache注解的地方在findCacheOperations方法:

image

一路跟进去,当我们看到SpringCacheAnnotationParser类的parseCacheAnnotations方法时,就到看了spring把cache的相关注解进行解析,并把注解包装为CacheOperation类

image

看到这里就明白了吧, spring把类和方法上的cache注解包装起来并放到一个集合Collection中,在aop切面上通过判断是否有CacheOperation作为切入点。
然后,到这里我想重点说一下的是,spring cache已经做了判断,不支持非public方法上的缓存注解,逻辑在哪里呢?细心的可以发现:

image

很显然的不支持非public方法,即使是protected方法都不行,更不用说private了!

这里再多说一下,看完cache的切入点代码,我们也很容易找到方法拦截器:

image

我们从execute方法一路跟进去,可以看到最后是在CacheAspectSupport类的execute方法实现对接缓存的读取或者更新。

image

如果我们引入了三方的cache,比如Caffeine,那么,底层就是使用Caffeine.Cache来存储的。

image

image

spring cache通过AbstractFallbackCacheOperation#computeCacheOperations方法显式地不支持非public方法的注解缓存。

从Spring AOP原理解释为什么私有方法上不能加缓存

上面讲到了spring cache自己做了一层限制,不支持非public方法加缓存注解,那么,spring cache为什么这么做?如果只是看spring cache源码的逻辑,不加这个限制,不也一样是可以“走得通”么?

要解释这个问题,那就要从Spring AOP原理说起了。

我们先把BookService的缓存注解位置调整一下,让方法能够正常走缓存逻辑:

image

启动我们的spring容器,断点到我们业务代码bookService.findByBookNameWithSpringCache(bookName)的地方:

image

看到了没,BookService引用是一个代理类,这也侧面说明spring cache借用了aop的能力。

问题来了,为什么是cglib代理?

在我们的常识里面,spring aop默认都是采用java的动态代理,其次才会使用cglib代理。从spring官网文档也可以证实:

image

em……我没有使用接口,所以采用了cglib代理。这个解释也只算对一半吧。(因为即使你换成了接口实现,最后还是没能如你所愿,还是cglib代理,感兴趣的朋友自行尝试)

还是说下为什么我们运行一直都是cglib代理的原因吧。

这是spring boot搞的鬼,在我们的启动类上有一个@SpringBootApplication注解,这是一套组合注解,我们顺着这个注解内部的定义,找到@EnableAutoConfiguration,再找到@Import(AutoConfigurationImportSelector.class)

image

AutoConfigurationImportSelector类的process方法:

image

这里面有很多自动装配信息,根据AopAutoConfiguration(这个类定义在spring-boot下而不是spring-context下)的定义:

image

AopAutoConfiguration类的主要任务是根据配置参数使用注解@EnableAspectJAutoProxy,注释也有说明:

image

该类启用的条件是:配置参数spring.aop.auto值不为false,我们的spring-configuration-metadata.json中有配置:

image

AopAutoConfiguration又包含了如下两个内置配置类,分别对应配置参数spring.aop.proxy-target-class=true/false两种情况 :

image

当spring.aop.proxy-target-class缺省配置时默认也是true,我们的spring-boot里面默认就是true,所以默认使用aop的cglib代理。

到这里,我们就基本知道spring-cache中使用到的aop为何一直使用cglib代理的原因。

说完cglib,终于可以回到主题上了,“为何不能在私有方法上使用cache注解”,如果从aop的角度去分析,那么答案就是: 因为cglib

cglib实现动态代理,其底层采用了ASM字节码生成框架,直接对需要代理的类的字节码进行操作,生成这个类的一个子类,并重写了类的所有可以重写的方法。

由于cglib的代理类使用的是继承,这也就意味着 cglib不能代理final类,同时也不能对private方法进行代理!子类无法重写private方法啊!

至于cglib是如何生成代理类的,这里不展开说了,后面有机会再专门出一个文章写一写,我们到这里只要知道,spring cache的实现使用了aop功能,而aop不支持对private私有方法的拦截,所以也就不支持私有方法上的spring cache注解。

类内部方法调用不支持加缓存

通过上面的分析,spring cahe的缓存功能是因为使用了aop,如此可知我们的类是被cglib重新增强代理过的类。

如果是类内部方法调用,为什么就不能生效?

这个问题很简单,我们在内部调用方法的地方打个断点,一看便知:

image

是吧,没有走代理,怎么能够使用得上缓存功能呢?


原文:https://segmentfault.com/a/1190000041215082