现象描述

上周同事发现其基于mySql实现的分布式锁的线上代码存在问题,代码简化如下:

@Controller
class XService {
    @Autowired
    private YService yService;
    public void doOutside(){
        this.doInside(); //或者直接doInside();效果是一样的
    }
    @Transactional
    private void doInside(){
        //do sql statement
    }
}
@Controller
class Test {
    @Autowired
    private XService xService;
    public void test(){
        xService.doOutside();
    }
}

实际执行test()后发现doInside()的Sql执行过程没有被Spring Transaction Manager管理起来。

发现的两个问题

  1. 在一个实例方法中调用被@Transactional注解标记的另一个方法,且两个方法都属于同一个类时,事务不会生效。
  2. 调用被@Transactional注解标记的非public方法,事务不会生效。

首先复习下相关知识:Spring AOP、JDK动态代理、CGLIB、AspectJ、@Aspect

@Transactional的实现原理是在业务方法外边通过Spring AOP包上一层事务管理器的代码(即插入切面),这是Java设计模式中常见的通过代理增强被代理类的做法。

Spring AOP的底层有2种实现:JDK动态代理、CGLIB。前者的原理是JDK反射,并且只支持Java接口的代理;后者的原理是继承(extend)与覆写(override),因此能支持普通的Java类的代理。两种方式都是动态代理,即运行时实时生成代理。

由于JVM的限制,CGLIB无法替换被代理类已经被载入的字节码,只能生成并载入一个新的子类作为代理类,被代理类的字节码依然存在于JVM中。

区别于前两者,AspectJ是一种静态代理的实现,即在编译时或者载入类时直接修改被代理类文件的字节码,而非运行时实时生成代理。因此这种方式需要额外的编译器或者JVM Agent支持,通过一些配置Spring和AspectJ也可以配合使用。

@Aspect一开始是AspectJ推出的Java注解形式,后来Spring AOP也支持使用这种形式表示切面,但实际上底层实现和AspectJ毫无关系,毕竟Spring AOP是动态代理,和静态代理是不兼容的。

进一步分析

既然事务管理器没有生效,那么首先需要确定一个问题:this到底是指向哪个对象,是未增强的XService还是增强后的XService?并且而且有没有可能已经调用增强后的实例和方法,但由于其他原因而导致事务管理器没有生效?

回忆下Java基础,this表示的是类的当前实例,那么关键就是确定类的实例是未被增强的XService(下面称其为XService),还是被CGLIB增强过的XService(下面称其为XService$$Cglib)。

在Test中,XService类的实例变量是一个由Spring框架管理的Bean,当执行test()时,根据@Autowired注解进行相应的注入,因此XService的实例实际为XService$$Cglib而不XService。被增强过的类的代码可以简化如下:

class XService$$Cglib extend XService {
    @Override
    public doInside(){
        //开始事务的增强代码
        super.doInside();
        //结束事务的增强代码
    }
}

当执行XService$$Cglib.doOutside()时,由于子类没有覆写父类同名方法,因此实际上执行了父类XServicedoOutside()方法,所以在执行其this.doInside()时实际上调用的是父类未增强过的doInside(),因此事务管理器失效了。

这个问题在Spring AOP中广泛存在,即自调用,本质上是动态代理无法解决的盲区,只有AspectJ这类静态代理才能解决。

第二个问题则是Spring AOP不支持非public方法增强,与自调用类似,也是动态代理无法解决的盲区。

虽然CGLIB通过继承的方式是可以支持public、protected、package级别的方法增强的,但是由于JDK动态代理必须通过Java接口,只能支持public级别的方法,因此Spring AOP不得不取消非public方法的支持。

“自调用”的解决方法

1. 最好在被代理类的外部调用其方法

2. 自注入(Self Injection, from Spring 4.3)

@Controller
class XService {
    @Autowired
    private YService yService;
    @Autowired
    private XService xService;
    public void doOutside(){
        xService.doInside();//从this换成了xService
    }
    @Transactional
    private void doInside(){
        //do sql statement
    }
}
@Controller
class Test {
    @Autowired
    private XService xService;
    public void test(){
        xService.doOutside();
    }
}

由于xService变量是被Spring注入的,因此实际上指向XService$$Cglib对象,xService.doInside()因此也能正确的指向增强后的方法。

一种错误的解决办法:改造为Java接口的形式

@Controller
class XService implements IXService {
    @Autowired
    private YService yService;
    @Override
    public void doOutside(){
        this.doInside();
    }
    @Transactional
    private void doInside(){
        //do sql statement
    }
}
@Controller
class Test {
    @Autowired
    private IXService iXService;
    public test(){
        iXService.doOutside();
    }
}

原因是之前错误地理解事务未生效的原理:如果没有在xml中要设置只用CGLIB,@Transactional只能使用JDK动态代理,所以如果没有用Java接口方式进行代理就不会生效。

实际上,这还是避免不了自调用的问题,因为这是动态代理的普遍问题,无论是JDK动态代理还是CGLIB动态代理。

总结

使用Spring AOP的时候一定要小心,如果是使用注解形式声明AOP,要保证在被代理类的外部调用被增强的方法。

Reference

  1. Spring AOP 实现原理与 CGLIB 应用
  2. 关于spring的aop拦截的问题 protected方法代理问题
  3. 透彻的掌握 Spring 中@transactional 的使用
  4. Spring @Transactional原理及使用

你可能感兴趣的文章

omsfuk · 10月10日

其实如果不考虑与spring耦合的话,还可以使用AOPContext获取当前代理类

+1 回复

0

不考虑优雅的话,直接获取target也是一种办法

Jiadong 作者 · 10月11日
奋斗的小鸟 · 10月11日

使用ecache注解也遇到类似的问题

回复

0

是Spring aop的问题

Jiadong 作者 · 10月11日
hogantry · 10月13日

[在执行其this.doInside()时也不会去执行父类未增强过的doInside()],这句话没懂啊,不会执行父类未增强的方法,那么应该执行而且确实是应该执行了子类已增强的方法,因为this是指向子类的。或者可以这么理解,因为父类的inner方法是私有的,子类无法复写该方法,导致调用父类outer方法后,只能调用继续调用父类的inner方法,如果是这样的话,那将private改为public是不是就能解决问题了呢?烦请博主回复讲解下,谢谢

回复

0

是我的笔误,谢谢指出

Jiadong 作者 · 10月13日
载入中...
Jiadong Jiadong

290 声望

发布于专栏

MO的后端奇妙之旅

Nothing gone but code

5 人关注