问题背景

需求线上验收环节,发现数据写库失败。查看日志是使用了错误的数据源,而错误的数据源为读账号没有写权限。而代码已使用相关注解指定数据源,分析问题为注解失效导致。

之前有了解到 @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 本身的参数不完整(如图)。

image.png

进一步 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 内代码(非开源),上线代码为企业内部代码,如需测试代码可以联系博主。