昨天遇到一个非常奇怪的问题,在一个Service中使用@Transactional
注解的一个方法无论如何都不能开启事务。项目用的是Springboot和Mybatis Plus,权限验证用的是Shiro。Service层的伪代码如下:
@Transactional(rollbackFor = Exception.class)
public void register(String username, String password) {
Member member = new Member();
... ...
this.save(member);
MemberMessage memberMessage = new MemberMessage();
... ...
memberMessageService.save(memberMessage);
}
当memberMessage插入失败抛异常时,前面保存的member记录不会回滚。打断点发现,只要save(member)这行走完数据就直接插入,此时方法还没执行完,按道理事务应该还没提交,但是通过Navicat已经能够看到新增的记录了。怀疑是事务压根没开启,遂将logging.level.root
日志等级改为DEBUG发现压根就没开启事务。
找不到原因,往上层追查,这个方法是在Controller通过@Autowired
注入并调用的。之后我在这个Controller中注入其他Service添加测试方法testSave()
,Controller伪代码如下:
@Autowired
private MemberService memberService;
@Autowired
private ConfService confService;
@RequestMapping("/register")
public JsonResult register(String username, String password) {
confService.testSave();
// memberService.register(username, password);
return JsonResult.ok();
}
测试发现事务是生效的,且如果发生异常是能够回滚的,事务正常提交日志如下:
o.s.j.d.DataSourceTransactionManager : Creating new transaction with name [com.guitu18.service.base.ConfService$$EnhancerBySpringCGLIB$$82a30421.testSave]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,-java.lang.Exception
o.s.j.d.DataSourceTransactionManager : Acquired Connection [com.mysql.jdbc.JDBC4Connection@10d912c1] for JDBC transaction
o.s.j.d.DataSourceTransactionManager : Switching JDBC Connection [com.mysql.jdbc.JDBC4Connection@10d912c1] to manual commit
o.s.j.d.DataSourceTransactionManager : Participating in existing transaction
o.s.j.d.DataSourceTransactionManager : Participating in existing transaction
org.mybatis.spring.SqlSessionUtils : Creating a new SqlSession
org.mybatis.spring.SqlSessionUtils : Registering transaction synchronization for SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@734d6117]
o.m.s.t.SpringManagedTransaction : JDBC Connection [com.mysql.jdbc.JDBC4Connection@10d912c1] will be managed by Spring
c.g.mapper.base.ClanPlayerMapper.insert : ==> Preparing: INSERT INTO conf ( name, value ... ) VALUES ( ?, ? )
c.g.mapper.base.ClanPlayerMapper.insert : ==> Parameters: 123(String), 45(String)
c.g.mapper.base.ClanPlayerMapper.insert : <== Updates: 1
org.mybatis.spring.SqlSessionUtils : Releasing transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@734d6117]
org.mybatis.spring.SqlSessionUtils : Transaction synchronization committing SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@734d6117]
org.mybatis.spring.SqlSessionUtils : Transaction synchronization deregistering SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@734d6117]
org.mybatis.spring.SqlSessionUtils : Transaction synchronization closing SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@734d6117]
o.s.j.d.DataSourceTransactionManager : Initiating transaction commit
o.s.j.d.DataSourceTransactionManager : Committing JDBC transaction on Connection [com.mysql.jdbc.JDBC4Connection@10d912c1]
o.s.j.d.DataSourceTransactionManager : Releasing JDBC Connection [com.mysql.jdbc.JDBC4Connection@10d912c1] after transaction
o.s.jdbc.datasource.DataSourceUtils : Returning JDBC Connection to DataSource
这下子我就纳闷了,肯定是有什么我没留意到的地方有疏漏,继续找。先确认了数据库的表类型是InnoDB能够支持事务没错,接着检查Spring配置,所在包名,以及是否被Spring扫描等等原因,后面我直接将这两个Service挪到同一个包下继续测试,甚至修改了包结构,依然还是ConfService能正常开启事务,MemberService怎么也开启不了事务。
百度也查了,比如@Transaction
注解不生效原因,我每条都确认了没问题。
- 只对public修饰方法才起作用
@Transaction
默认检测异常为RuntimeException及其子类 如果有其他异常需要回滚事务的需要自己手动配置,例如:@Transactional(rollbackFor = Exception.class)
- 确保异常没有被try-catch{},catch以后也不会回滚
- 检查下自己的数据库是否支持事务,如mysql的mylsam
- Springboot项目默认已经支持事务,不用配置;其他类型项目需要在xml中配置是否开启事务
- 如果在同一个类中,一个非
@Transaction
的方法调用有@Transaction
的方法不会生效,因为代理问题
然后昨天为了这个问题折腾的太久,人弄疲了就先放着了。今天接着继续研究,一路打断点到TransactionAspectSupport
类中,再到ReflectiveMethodInvocation.proceed()
,invokeJoinpoint()
等方法。
protected Object invokeJoinpoint() throws Throwable {
return this.publicMethod ? this.methodProxy.invoke(this.target, this.arguments) : super.invokeJoinpoint();
}
我发现事务生效的情况下,都会一路走到上面这个方法上,这里判断如果是public方法,则通过代理对象调用实际业务,至此事务也开启并加入且生效了。然而那个事务始终不能开启的MemberService压根就不会走到这里来。
这时候我突然想到,该不会是MemberService这个类没有被代理吧,在Controller中打断点查看发现MemberService压根就不是代理对象,@Autowired
注入的是原始对象的实例。
检查该Controller中注入的另一个ConfService,确实是代理对象没错了。
那么问题来了,为什么这个MemberService没有被代理。之前已经做过各种检查了,甚至将这两个类放到同一个包下,肯定不是Spring扫描产生的问题。问题出在哪里呢?继续找。
从MemberService被引用的地方入手,一路找Shiro的授权认证器AuthorizingRealm这里。
@Component
public class MemberAuthorizingRealm extends AuthorizingRealm {
@Autowired
private MemberService memberService;
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
... ...
}
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
Member member = memberService.getById(token.getUsername());
... ...
}
}
这里乍一看也没什么不对是吧,但是经代码过测试问题就出在这里。这里我如果不注入MemberService,那么在其他地方通过@Autowired
注入的就是被代理的实例。What?为什么会这样?
不知道原因,看来还是要向上追溯,那么这个AuthorizingRealm又是在哪里引用的呢,继续顺着线索往上找。这个类在ShiroConfig中以@Bean
的方式注入到SecurityManager中了。
@Bean("securityManager")
public SecurityManager securityManager(MemberAuthorizingRealm userRealm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(userRealm);
securityManager.setRememberMeManager(null);
return securityManager;
}
既然是跟配置有关系,那么我联想可能是跟初始化顺序有关系,配置相关的东西一般都是被优先加载的。找到这里我想到了Spring的生命周期,隐约感觉真相已经呼之欲出了,赶紧去Spring的Bean初始化流程瞧一瞧,答案肯定是在那里。
Spring的初始化流程很复杂,这里只截取重要的部分记录一下,有兴趣的请自行查看Spring初始化相关源码。首先我们找到代理被创建的地方AbstractAutowireCapableBeanFactory.applyBeanPostProcessorsAfterInitialization()
@Override
public Object applyBeanPostProcessorsAfterInitialization(Object existingBean, String beanName)
throws BeansException {
Object result = existingBean;
// 这里通过getBeanPostProcessors()拿到所有的Bean后置处理器并执行
for (BeanPostProcessor processor : getBeanPostProcessors()) {
Object current = processor.postProcessAfterInitialization(result, beanName);
if (current == null) {
return result;
}
result = current;
}
return result;
}
在这里会拿到并执行所有的Bean后置处理器,先找到那个可以开启事务的ConfService,加个断点看看他的beanPostProcessors中都有些什么。
框起来的这两个DefaultAdvisorAutoProxyCreator就是创建代理对象的处理器,至于为什么会有两个现在还不知道,先解决我眼前的问题先。这里执行完所有的BeanPostProcessor之后,得到的就是代理对象了。
上面创建代理的代码在AbstractAutoProxyCreator中,分别是postProcessAfterInitialization()和wrapIfNecessary(),代码如下:
public Object postProcessAfterInitialization(@Nullable Object bean, String beanName) throws BeansException {
if (bean != null) {
Object cacheKey = this.getCacheKey(bean.getClass(), beanName);
if (!this.earlyProxyReferences.contains(cacheKey)) {
return this.wrapIfNecessary(bean, beanName, cacheKey);
}
}
return bean;
}
// 代理就是在这个方法中创建的,当然创建之前做了各种if判断
protected Object wrapIfNecessary(Object bean, String beanName, Object cacheKey) {
if (StringUtils.hasLength(beanName) && this.targetSourcedBeans.contains(beanName)) {
return bean;
} else if (Boolean.FALSE.equals(this.advisedBeans.get(cacheKey))) {
return bean;
} else if (!this.isInfrastructureClass(bean.getClass()) && !this.shouldSkip(bean.getClass(), beanName)) {
Object[] specificInterceptors = this.getAdvicesAndAdvisorsForBean(bean.getClass(), beanName, (TargetSource)null);
if (specificInterceptors != DO_NOT_PROXY) {
this.advisedBeans.put(cacheKey, Boolean.TRUE);
// 创建代理对象
Object proxy = this.createProxy(bean.getClass(), beanName, specificInterceptors, new SingletonTargetSource(bean));
this.proxyTypes.put(cacheKey, proxy.getClass());
return proxy;
} else {
this.advisedBeans.put(cacheKey, Boolean.FALSE);
return bean;
}
} else {
this.advisedBeans.put(cacheKey, Boolean.FALSE);
return bean;
}
}
再回头找到那个MemberService,他的beanPostProcessors列表中可没有那么多东西,可以看在他的processor列表中创建代理的处理器DefaultAdvisorAutoProxyCreator确实是没有的。
这个方法执行完之后,返回的就普通的对象了。我们都知道在Spring中,数据库事务都是通过AOP实现的,想要支持事务这个类必须被代理才行。至此本篇开头提到的MemberService中无法开启事务的真相找到了,因为Controller中注入的MemberService以@Bean
的方式配置到Spring中,导致被提前初始化而未能创建代理,所以不能开启事务。
捋一捋:
- 首先我们在项目整合Shiro的时候通过ShiroConfig做了一些配置,其中一项包括Shiro的授权认证器MemberAuthorizingRealm。
- 在MemberAuthorizingRealm中我们通过
@Autowired
注入了本篇的主角MemberService。 - Spring启动的时候,配置相关的都是优先初始化的,在初始化MemberAuthorizingRealm的时候发现需要注入一个MemberService对象,容器里肯定是没有的,那么就提前将其初始化了。此时如果在MemberService还有通过
@Autowired
注入的其他依赖,那么会一并初始化,依赖中要是还有依赖会继续递归初始化,这样下来会导致一系列的实例都是没有被代理的。 - 但是这时候Spring中创建代理的处理器是还没有的,导致MemberService的BeanPostProcessor中没有AbstractAutoProxyCreator这个对象,后面整个BeanPostProcessor列表执行的时候没有为其创建代理。
- Spring中的数据库事务都是需要代理支持的,所以MemberService中不能开启事务。
解决方案:既然MemberAuthorizingRealm中不能通过@Autowired
注入MemberService,那我们变通一下,不用第一时间注入,等需要用到的时候再向Spring索取就好了。
这里第一个想到的肯定就是ApplicationContext了,这好办,写一个ApplicationContext工具类:
@Component
public class ApplicationContextUtils implements ApplicationContextAware {
public static ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
ApplicationContextUtils.applicationContext = applicationContext;
}
public static Object getBean(String beanName) {
return applicationContext.getBean(beanName);
}
public static <T> T getBean(Class<T> type) {
return applicationContext.getBean(type);
}
}
通过实现ApplicationContextAware接口拿到ApplicationContext,后面就可以随心所以了,MemberAuthorizingRealm中需要用到MemberService的时候我们可以这么写:
MemberService memberService = ApplicationContextUtils.getBean(MemberService.class);
在其他类似的地方,如果何需要支持事务或者用到代理对象的地方,都可以通过这种方式获取。另外顺带提一下,如果需要用到对象原始的实例(非代理对象),我们可以通过在Bean名称前面加一个&
获取,还是以MemberService举栗:
MemberService memberService = ApplicationContextUtils.getBean("&memberService");
这样拿到的就是常规实例对象了,相关知识点:FactoryBean,之前写过一篇,请参考:
Spring中FactoryBean的作用和实现原理 https://www.guitu18.com/post/2019/04/28/33.html
本次排查记录总结:
- 在
@Configuration
注解的配置类中,通过@Bean
注册的对象是没有被创建代理的,如果你的业务需要使用到代理,请不要使用这种方式。 - 即便没有直接通过
@Bean
直接注入,在被@Bean
注册的对象直接依赖(@Autowired
注入等)也会导致该对象提前初始化,没有被创建代理。 - 如果必须要在通过
@Bean
注册的对象中用到代理对象,可以从ApplicationContext中获取到。
28 条评论
66666
今天遇到个类似的问题,读完大佬的文章醍醐灌顶,赞一个👍
确实是的,我也发现了这个问题,延迟加载确实可以解决,后来发现bean有点多。从容器里拿好点。
学习了
垃圾百度全是复制黏贴,谷歌第一条就是你的,完美解决|´・ω・)ノ
感谢你的评论支持,能够帮到你我也很高兴 ::paopao:tieba_emotion_02::
666 自己也发现了问题所在就是不知道具体的原因,原来是这样子OωO
学习了
写得好好哟,我要给你生猴子!::funny:04:: 作者写的不错,我也出现了这个问题,很好的解决了!
你这前缀加的,哥们我是个爷们儿
能帮到你我很荣幸,问题解决了就好。然后这个前缀是插件自动加的,你点赞会随机生成一句话到评论框 ::paopao:tieba_emotion_02::
挺好,虽然不是同一个问题,但是给了启发,问题得到了解决。
题外话:看UI和行文方式,像是个女程序猿?
哈哈并不是女程序猿,可能是主题配合插件色调比较梦幻吧 ::paopao:tieba_emotion_02:: ,插件作者是个妹子,我觉得星空背景效果挺好的就用了,下回抽空把颜色改改。 ::paopao:tieba_emotion_93::
的确挺梦幻,我同事看见了,以为我在浏览QQ空间 ::paopao:tieba_emotion_14::
还有,我回复的时候,居然还要输入姓名和邮箱,用户体验可以优化一下。以你调试bug的功底来看,这点儿活儿小case.
姓名和邮箱这个,是为了方便回复通知。个人博客本身就相对独立,若没有邮箱链接一下完全就是一座孤岛了,很可能某个游客一时兴起写了段评论,关了窗口之后就再也找不到地址了。
你看博客园等其他平台的评论都是需要注册的呢,相比于它们这种只需填写昵称和邮箱的评论方式已经轻便了太多了。
也是,没有登录认证很难识别用户,当然也可以在邮箱链接上做手脚,不过意义不大。看了你的博客,我也想自己整一个。对了,你的笔记页面用的是什么框架?我看见vue等好多技术网站都用的这套框架和样式,我喜欢这套。
笔记那个用的是 VuePress,也可以用来做博客,有不少主题可选。
https://www.vuepress.cn/
多谢,初步搭了一下,很好用。
嗯?已经不需要输入账号了?这么6?
这样别人是不是可以冒充我了?还有我的邮箱是不是暴露了?
sorry,职业病有点严重......
你第一次输入的昵称和邮箱会记录在Cookie中,只要你不换浏览器或者不清理缓存,下次打开页面会自动带上昵称和邮箱的,不用重新填写的。昵称可以冒充,邮箱是不可见的冒充不了。
哦哦,原来是cookie, 这个功能好。
大佬nbΣ(っ °Д °;)っ
博主细心啊,平时开发都没留意到,如果没有注意到事务失效问题确实是个隐患
@La, 嗯感谢支持。这次的事务失效也是在产生了问题之后我一路找过来才知道的,像这类问题一开始还很难被发现确实是个隐患。
涨姿势了
来来来,继续
我也是这个问题,终于解决了,感谢大佬
感觉很牛逼
的样子