为什么私有方法上的 Spring Cache 注解不生效?
大家好,我是 tin,这是我的第 12 篇原创文章
Spring Cache "错用"
背景是这样的, 一个同事开发的一个功能模块代码,大概是查询一个下游的内容接口,查询到数据并转发给端侧接口。这个功能模块流量非常大,下游的内容接口的内容数据量也有限(内容 id 数有限,量级在几个 w),同事也意识到了需要在自己服务内对下游内容接口加本地缓存。做法和下面图一模一样:
这个代码发布到线上,当端侧访问入口开放时,我们后端服务就开始疯狂告警,都是内容接口不负重压、响应超时的告警。
然后我们先去看的调用链,发现内容接口 QPS 已经飙升到 10W!
为什么?明明已经加了接口缓存,按我们加缓存的预期,很多请求应该打到缓存上,而不应该再查下游内容接口才对啊?或许很多人都这么认为,但错了就是错了。
像上图的错误是很“低级”的,如果都这么使用,对于稍微不健壮的下游系统,将是灾难,如果真到那样子,今年的绩效也就好不到哪了。
错在哪里?
上述代码对 spring cache 使用,总结来说有下面的错误:
1、在私有方法上加缓存
2、类内部方法调用加缓存
这些问题都是比较致命的,我们很多使用缓存的朋友根本不知道也不清楚不能这么使用。下面我把问题一一地分析透了。
Spring Cache 原理解释为什么不能在私有方法上加缓存
Spring Cache 通过注解,并借助 Spring AOP 实现缓存。打开源码包,定位到我们的 @Cacheable 注解位置:
cache 的实现都在 context 的 org.springframework.cache 包下。我的 spring 版本是 5.3.14,其他版本也基本一样。
matches 方法判断类或者方法中有没有 cache 相关的缓存注解,这个是怎么判断的呢?我们一路跟进去,cas.getCacheOperations(method, targetClass)),到了 AbstractFallbackCacheOperationSource 类的 getCacheOperations 方法:
attributeCache 是一个 ConcurrentHashmap,只是把 computeCacheOperations(method, targetClass)计算得到的结果缓存一下,下次再进来就不用消耗 cpu 重新计算获取。
computeCacheOperations 方法中,真正解析方法上 cache 注解的地方在 findCacheOperations 方法:
一路跟进去,当我们看到 SpringCacheAnnotationParser 类的 parseCacheAnnotations 方法时,就到看了 spring 把 cache 的相关注解进行解析,并把注解包装为 CacheOperation 类
看到这里就明白了吧,spring 把类和方法上的 cache 注解包装起来并放到一个集合 Collection 中,在 aop 切面上通过判断是否有 CacheOperation 作为切入点。
然后,到这里我想重点说一下的是,spring cache 已经做了判断,不支持非 public 方法上的缓存注解,逻辑在哪里呢?细心的可以发现:
很显然的不支持非 public 方法,即使是 protected 方法都不行,更不用说 private 了!
这里再多说一下,看完 cache 的切入点代码,我们也很容易找到方法拦截器:
我们从 execute 方法一路跟进去,可以看到最后是在 CacheAspectSupport 类的 execute 方法实现对接缓存的读取或者更新。
如果我们引入了三方的 cache,比如 Caffeine,那么,底层就是使用 Caffeine.Cache 来存储的。
如果想知道如何使用三方缓存工具,可以看我另外一篇文章:
《人人都说好的Spring Cache!用起来!【文末送书】》
结论:
spring cache 通过
AbstractFallbackCacheOperation#computeCacheOperations 方法显式地不支持非 public 方法的注解缓存。
从 Spring AOP 解释为什么不能在私有方法上加缓存
上面讲到了 spring cache 自己做了一层限制,不支持非 public 方法加缓存注解,那么,spring cache 为什么这么做?如果只是看 spring cache 源码的逻辑,不加这个限制,不也一样是可以“走得通”么?
要解释这个问题,那就要从 Spring AOP 原理说起了。
我们先把 BookService 的缓存注解位置调整一下,让方法能够正常走缓存逻辑:
启动我们的 spring 容器,断点到我们业务代码 bookService.findByBookNameWithSpringCache(bookName)的地方:
看到了没,BookService 引用是一个代理类,这也侧面说明 spring cache 借用了 aop 的能力。
问题来了,为什么是 cglib 代理?
在我们的常识里面,spring aop 默认都是采用 java 的动态代理,其次才会使用 cglib 代理。从 spring 官网文档也可以证实:
em……我没有使用接口,所以采用了 cglib 代理。这个解释也只算对一半吧。(因为即使你换成了接口实现,最后还是没能如你所愿,还是 cglib 代理,感兴趣的朋友自行尝试)
还是说下为什么我们运行一直都是 cglib 代理的原因吧。
这是 spring boot 搞的鬼,在我们的启动类上有一个 @SpringBootApplication 注解,这是一套组合注解,我们顺着这个注解内部的定义,找到 @EnableAutoConfiguration,再找到 @Import(AutoConfigurationImportSelector.class)
AutoConfigurationImportSelector 类的 process 方法:
这里面有很多自动装配信息,根据 AopAutoConfiguration(这个类定义在 spring-boot 下而不是 spring-context 下)的定义:
AopAutoConfiguration 类的主要任务是根据配置参数使用注解 @EnableAspectJAutoProxy,注释也有说明:
该类启用的条件是:配置参数 spring.aop.auto 值不为 false,我们的 spring-configuration-metadata.json 中有配置:
AopAutoConfiguration 又包含了如下两个内置配置类,分别对应配置参数 spring.aop.proxy-target-class=true/false 两种情况 :
当 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 重新增强代理过的类。
如果是类内部方法调用,为什么就不能生效?
这个问题很简单,我们在内部调用方法的地方打个断点,一看便知:
是吧,没有走代理,怎么能够使用得上缓存功能呢?
结语
我是 tin,一个在努力让自己变得更优秀的普通攻城狮。自己阅历有限、学识浅薄,如有发现文章不妥之处,非常欢迎加我提出,我一定细心推敲加以修改。
坚持创作不容易,你的正反馈是我坚持输出的最强大动力,谢谢!
版权声明: 本文为 InfoQ 作者【看点代码再上班】的原创文章。
原文链接:【http://xie.infoq.cn/article/3ca4f08336bf793f9b84d8aaa】。未经作者许可,禁止转载。
评论