问题背景
需求线上验收环节,发现数据写库失败。查看日志是使用了错误的数据源,而错误的数据源为读账号没有写权限。而代码已使用相关注解指定数据源,分析问题为注解失效导致。
之前有了解到 @Transactional
注解会在自调用场景失效,没考虑到其他注解的影响。本次遇到的问题现象与 @Transactional
失效情况极其类似,因此进行探索总结。
问题解决
为尽快上线,在测试环境进行两次验证后,解决掉注解失效的问题,上线验证解决。
解决办法为将方法提升为公有方法,使用 Spring Bean 管理来调用相关方法,而不是函数内直接调用。
问题分析
次日,通过代码模拟了多种自调用场景,以复现问题,定位原因。
测试时,通过 @CacheLock 注解进行模拟,该注解生效后,可以通过 lockAspect 获取到当前生效的注解值(即参数 #lockBy 的值)。
场景1:自调用时,被调用的方法注解失效
/**
* 单测类
*/
@Slf4j
class SelfCallAnnoTests extends SpringbootBaseTests {
@Test
void testFromAnno2Anno() {
// func1 调用 func3,会因为自调用问题导致 func3 的注解失效
String lock123 = selfCallAnnoHelper.func1("lock1");
Assertions.assertEquals("lock1lock1", lock123);
}
}
/**
* 测试辅助类
*/
@Slf4j
@Component("selfCallAnnoHelper")
public class SelfCallAnnoHelper {
@Autowired
private CacheLockAspect lockAspect;
@CacheLock(value = "#lockBy")
public String func1(String lockBy) {
log.info("方法1 开始执行,time: {}", System.currentTimeMillis());
String from3 = func3("123");
log.info("方法1 执行完毕,time: {}", System.currentTimeMillis());
return lockAspect.getThreadLockSimplestKey() + from3;
}
@CacheLock(value = "#lockBy")
private String func3(String lockBy) {
log.info("方法3 执行完毕,time: {}", System.currentTimeMillis());
if (null == lockAspect) {
return "null";
}
return lockAspect.getThreadLockSimplestKey();
}
}
运行日志摘录如下:
尝试加锁: ***.lock1
方法1 开始执行,time: 1638846948501
方法3 执行完毕,time: 1638846948503
方法1 执行完毕,time: 1638846948507
释放锁: ***.lock1
上述代码可以通过单测,运行结果说明只有调用 func1("lock1") 时 func1 的注解生效,而执行 func3 期间,注解的上下文仍然只有 lock1。
func1 内调用 func3 时,即使func3 为 public(private 也不可以,结果一样),注解仍不会生效。
场景2:通过 Spring Bean 管理来调用,但是方法为私有方法。
/**
* 单测类
*/
@Slf4j
class SelfCallAnnoTests extends SpringbootBaseTests {
@Test
void testFromNormal2Anno() {
// func2 调用 func3,虽然获取了 Bean,但因为是私有方法,甚至导致 Bean 变化
String lock223 = selfCallAnnoHelper.func2("lock2");
Assertions.assertEquals("lock2null", lock223);
}
}
/**
* 测试辅助类
*/
@Slf4j
@Component("selfCallAnnoHelper")
public class SelfCallAnnoHelper {
@Autowired
private CacheLockAspect lockAspect;
@CacheLock(value = "#lockBy")
public String func2(String lockBy) {
log.info("方法2 开始执行,time: {}", System.currentTimeMillis());
// 自调用这么破就毁了
SelfCallAnnoHelper helper = SpringContextUtils.getBean(SelfCallAnnoHelper.class);
String from3 = helper.func3("223");
log.info("方法2 执行完毕,time: {}", System.currentTimeMillis());
return (null != lockAspect
? lockAspect.getThreadLockSimplestKey()
: "null")
+ from3;
}
@CacheLock(value = "#lockBy")
private String func3(String lockBy) {
log.info("方法3 执行完毕,time: {}", System.currentTimeMillis());
if (null == lockAspect) {
return "null";
}
return lockAspect.getThreadLockSimplestKey();
}
}
运行日志摘录如下:
尝试加锁: ***.lock2
方法2 开始执行,time: 1638847654625
方法3 执行完毕,time: 1638847654626
方法2 执行完毕,time: 1638847654626
释放锁: ***.lock2
上述代码可以通过单测。注意,单测中的断言为Assertions.assertEquals("lock2null", lock223)
通过 debug 分析发现,在断点进入 func3 时,注入的 lockAspect 就已经变为 null,说明这种方式在调用 private 方法时,不仅注解不会生效,甚至会导致 Bean 本身的参数不完整(如图)。
进一步 debug 分析 Bean 属性变化的原因发现有如下现象:
- 在调用栈中,func2 和 func3 紧邻,说明在调用时,没有任何其他处理(也就不存在注解生效的可能)
- 调用时但是 this 引用却发生了变化,(从 SelfCallAnnoHelper@12010 变为了 SelfCallAnnoHelper$$EnhancerBySpringCGLIB$$eb3c0c06@11864)
- 当退出 func3 的栈帧后,this 又会恢复为原来正确的 Bean 引用
分析现象可知:
- 在通过 SpringContextUtils 获取 Bean 时,得到是一个并不完整的 Bean ,此时 Bean 处于一个中间状态,此时的 Bean 是需要经过 CgLibProxy 一番折腾后才可以正常使用的(理由见下一个场景)
- 又因为是私有方法,所以直接走了方法调用,直接拿一个不完整的 Bean 去调方法(没报错就不错了)。
场景3:通过 Spring Bean 管理来调用,且方法为公共方法。
/**
* 单测类
*/
@Slf4j
class SelfCallAnnoTests extends SpringbootBaseTests {
@Test
void testFromAnno2AnnoBreak() {
// func4 调用 func5,通过 Bean 管理器得到 Bean,再调用公共方法,自调用可破
String lock425 = selfCallAnnoHelper.func4("lock4");
Assertions.assertEquals("lock4425", lock425);
}
}
/**
* 测试辅助类
*/
@Slf4j
@Component("selfCallAnnoHelper")
public class SelfCallAnnoHelper {
@Autowired
private CacheLockAspect lockAspect;
@CacheLock(value = "#lockBy")
public String func4(String lockBy) {
log.info("方法4 开始执行,time: {}", System.currentTimeMillis());
// 奥义:自调用破法
SelfCallAnnoHelper helper = (SelfCallAnnoHelper) SpringContextUtils.getBean("selfCallAnnoHelper");
String from5 = helper.func5("425");
log.info("方法4 执行完毕,time: {}", System.currentTimeMillis());
return lockAspect.getThreadLockSimplestKey() + from5;
}
@CacheLock(value = "#lockBy")
public String func5(String lockBy) {
log.info("方法5 执行完毕,time: {}", System.currentTimeMillis());
return lockAspect.getThreadLockSimplestKey();
}
}
运行日志摘录如下:
尝试加锁: ***.lock4
方法4 开始执行,time: 1638861065832
尝试加锁: ***.425
方法5 执行完毕,time: 1638861493845
释放锁: ***.425
方法4 执行完毕,time: 1638861493851
释放锁: ***.lock4
上述代码可以通过单测,且分析日志可以发现,两次的注解都生效了。
Debug 执行过程可以发现,那个不完整的 Bean 仍会出现,但在实际使用时,func4 与 func5 的调用栈中间,有大量的 CgLibAopProxy 的调用在进行处理,这里面当然也包括注解的解析。
且 func5 上下文里的 this 与 func4 上下文的 this 是同一个地址,说明也 Bean 最终也正确进行了解析,没有多生成,也没有用错。
问题结论
通过 Spring AOP 机制的注解,一定要作用于公共方法上,且自调用场景必须通过 Spring 给它处理一下。
网上的很多资料都在说明 @Transactional 注解会存在这种情况,实际上,只要是走 Spring AOP 机制的注解都存在这个问题,要加同样的小心。
获取代码
完整测试代码为 NekoMC 内代码(非开源),上线代码为企业内部代码,如需测试代码可以联系博主。