javascript
Spring 声明式事务在业务开发中容易碰到的坑总结
Spring 聲明式事務(wù),在業(yè)務(wù)開(kāi)發(fā)使用上可能遇到的三類坑,包括:
第一,因?yàn)榕渲貌徽_,導(dǎo)致方法上的事務(wù)沒(méi)生效。我們務(wù)必確認(rèn)調(diào)用 @Transactional 注解標(biāo)記的方法是 public 的,并且是通過(guò) Spring 注入的 Bean 進(jìn)行調(diào)用的。
第二,因?yàn)楫惓L幚聿徽_,導(dǎo)致事務(wù)雖然生效但出現(xiàn)異常時(shí)沒(méi)回滾。Spring 默認(rèn)只會(huì)對(duì)標(biāo)記 @Transactional 注解的方法出現(xiàn)了 RuntimeException 和 Error 的時(shí)候回滾,如果我們的方法捕獲了異常,那么需要通過(guò)手動(dòng)編碼處理事務(wù)回滾。如果希望 Spring 針對(duì)其他異常也可以回滾,那么可以相應(yīng)配置 @Transactional 注解的 rollbackFor 和 noRollbackFor 屬性來(lái)覆蓋其默認(rèn)設(shè)置。
第三,如果方法涉及多次數(shù)據(jù)庫(kù)操作,并希望將它們作為獨(dú)立的事務(wù)進(jìn)行提交或回滾,那么我們需要考慮進(jìn)一步細(xì)化配置事務(wù)傳播方式,也就是 @Transactional 注解的 Propagation 屬性。
?
1、小心 Spring 的事務(wù)可能沒(méi)有生效
在使用 @Transactional 注解開(kāi)啟聲明式事務(wù)時(shí), 第一個(gè)最容易忽略的問(wèn)題是,很可能事務(wù)并沒(méi)有生效。
實(shí)現(xiàn)下面的 Demo 需要一些基礎(chǔ)類,首先定義一個(gè)具有 ID 和姓名屬性的 UserEntity,也就是一個(gè)包含兩個(gè)字段的用戶表:
@Entity @Data public class UserEntity {@Id@GeneratedValue(strategy = AUTO)private Long id;private String name; ?public UserEntity() { } ?public UserEntity(String name) {this.name = name;} }為了方便理解,我使用 Spring JPA 做數(shù)據(jù)庫(kù)訪問(wèn),實(shí)現(xiàn)這樣一個(gè) Repository,新增一個(gè)根據(jù)用戶名查詢所有數(shù)據(jù)的方法:
@Repository public interface UserRepository extends JpaRepository<UserEntity, Long> {List<UserEntity> findByName(String name); }定義一個(gè) UserService 類,負(fù)責(zé)業(yè)務(wù)邏輯處理。如果不清楚 @Transactional 的實(shí)現(xiàn)方式,只考慮代碼邏輯的話,這段代碼看起來(lái)沒(méi)有問(wèn)題。
定義一個(gè)入口方法 createUserWrong1 來(lái)調(diào)用另一個(gè)私有方法 createUserPrivate,私有方法上標(biāo)記了 @Transactional 注解。當(dāng)傳入的用戶名包含 test 關(guān)鍵字時(shí)判斷為用戶名不合法,拋出異常,讓用戶創(chuàng)建操作失敗,期望事務(wù)可以回滾:
@Service @Slf4j public class UserService {@Autowiredprivate UserRepository userRepository; ?//一個(gè)公共方法供Controller調(diào)用,內(nèi)部調(diào)用事務(wù)性的私有方法public int createUserWrong1(String name) {try {this.createUserPrivate(new UserEntity(name));} catch (Exception ex) {log.error("create user failed because {}", ex.getMessage());}return userRepository.findByName(name).size();} ?//標(biāo)記了@Transactional的private方法@Transactionalprivate void createUserPrivate(UserEntity entity) {userRepository.save(entity);if (entity.getName().contains("test"))throw new RuntimeException("invalid username!");} ?//根據(jù)用戶名查詢用戶數(shù)public int getUserCount(String name) {return userRepository.findByName(name).size();} }下面是 Controller 的實(shí)現(xiàn),只是調(diào)用一下剛才定義的 UserService 中的入口方法 createUserWrong1。
@Autowired private UserService userService; ? @GetMapping("wrong1") public int wrong1(@RequestParam("name") String name) {return userService.createUserWrong1(name); }調(diào)用接口后發(fā)現(xiàn),即便用戶名不合法,用戶也能創(chuàng)建成功。刷新瀏覽器,多次發(fā)現(xiàn)有十幾個(gè)的非法用戶注冊(cè)。
這里給出 @Transactional 生效原則 1,除非特殊配置(比如使用 AspectJ 靜態(tài)織入實(shí)現(xiàn) AOP),否則只有定義在 public 方法上的 @Transactional 才能生效。原因是,Spring 默認(rèn)通過(guò)動(dòng)態(tài)代理的方式實(shí)現(xiàn) AOP,對(duì)目標(biāo)方法進(jìn)行增強(qiáng),private 方法無(wú)法代理到,Spring 自然也無(wú)法動(dòng)態(tài)增強(qiáng)事務(wù)處理邏輯。
你可能會(huì)說(shuō),修復(fù)方式很簡(jiǎn)單,把標(biāo)記了事務(wù)注解的 createUserPrivate 方法改為 public 即可。在 UserService 中再建一個(gè)入口方法 createUserWrong2,來(lái)調(diào)用這個(gè) public 方法再次嘗試:
public int createUserWrong2(String name) {try {this.createUserPublic(new UserEntity(name));} catch (Exception ex) {log.error("create user failed because {}", ex.getMessage());}return userRepository.findByName(name).size(); } ? //標(biāo)記了@Transactional的public方法 @Transactional public void createUserPublic(UserEntity entity) {userRepository.save(entity);if (entity.getName().contains("test"))throw new RuntimeException("invalid username!"); }測(cè)試發(fā)現(xiàn),調(diào)用新的 createUserWrong2 方法事務(wù)同樣不生效。這里,我給出 @Transactional 生效原則 2,必須通過(guò)代理過(guò)的類從外部調(diào)用目標(biāo)方法才能生效。
Spring 通過(guò) AOP 技術(shù)對(duì)方法進(jìn)行增強(qiáng),要調(diào)用增強(qiáng)過(guò)的方法必然是調(diào)用代理后的對(duì)象。我們嘗試修改下 UserService 的代碼,注入一個(gè) self,然后再通過(guò) self 實(shí)例調(diào)用標(biāo)記有 @Transactional 注解的 createUserPublic 方法。
? ?public int createUserRight(String name) {try {self.createUserPublic(new UserEntity(name));} catch (Exception ex) {log.error("create user failed because {}", ex.getMessage());}return userRepository.findByName(name).size();}設(shè)置斷點(diǎn)可以看到,self 是由 Spring 通過(guò) CGLIB 方式增強(qiáng)過(guò)的類:
-
CGLIB 通過(guò)繼承方式實(shí)現(xiàn)代理類,private 方法在子類不可見(jiàn),自然也就無(wú)法進(jìn)行事務(wù)增強(qiáng);
-
this 指針代表對(duì)象自己,Spring 不可能注入 this,所以通過(guò) this 訪問(wèn)方法必然不是代理。
?
把 this 改為 self 后測(cè)試發(fā)現(xiàn),在 Controller 中調(diào)用 createUserRight 方法可以驗(yàn)證事務(wù)是生效的,非法的用戶注冊(cè)操作可以回滾。
雖然在 UserService 內(nèi)部注入自己調(diào)用自己的 createUserPublic 可以正確實(shí)現(xiàn)事務(wù),但更合理的實(shí)現(xiàn)方式是,讓 Controller 直接調(diào)用之前定義的 UserService 的 createUserPublic 方法,因?yàn)樽⑷胱约赫{(diào)用自己很奇怪,也不符合分層實(shí)現(xiàn)的規(guī)范:
@GetMapping("right2") public int right2(@RequestParam("name") String name) {try {userService.createUserPublic(new UserEntity(name));} catch (Exception ex) {log.error("create user failed because {}", ex.getMessage());}return userService.getUserCount(name); }我們?cè)偻ㄟ^(guò)一張圖來(lái)回顧下 this 自調(diào)用、通過(guò) self 調(diào)用,以及在 Controller 中調(diào)用 UserService 三種實(shí)現(xiàn)的區(qū)別:
?
通過(guò) this 自調(diào)用,沒(méi)有機(jī)會(huì)走到 Spring 的代理類;后兩種改進(jìn)方案調(diào)用的是 Spring 注入的 UserService,通過(guò)代理調(diào)用才有機(jī)會(huì)對(duì) createUserPublic 方法進(jìn)行動(dòng)態(tài)增強(qiáng)。
2、事務(wù)即便生效也不一定能回滾
通過(guò) AOP 實(shí)現(xiàn)事務(wù)處理可以理解為,使用 try…catch…來(lái)包裹標(biāo)記了 @Transactional 注解的方法,當(dāng)方法出現(xiàn)了異常并且滿足一定條件的時(shí)候,在 catch 里面我們可以設(shè)置事務(wù)回滾,沒(méi)有異常則直接提交事務(wù)。
這里的“一定條件”,主要包括兩點(diǎn)。
第一,只有異常傳播出了標(biāo)記了 @Transactional 注解的方法,事務(wù)才能回滾。在 Spring 的 TransactionAspectSupport 里有個(gè) invokeWithinTransaction 方法,里面就是處理事務(wù)的邏輯。可以看到,只有捕獲到異常才能進(jìn)行后續(xù)事務(wù)處理:
try {// This is an around advice: Invoke the next interceptor in the chain.// This will normally result in a target object being invoked.retVal = invocation.proceedWithInvocation(); } catch (Throwable ex) {// target invocation exceptioncompleteTransactionAfterThrowing(txInfo, ex);throw ex; } finally {cleanupTransactionInfo(txInfo); }第二,默認(rèn)情況下,出現(xiàn) RuntimeException(非受檢異常)或 Error 的時(shí)候,Spring 才會(huì)回滾事務(wù)。
打開(kāi) Spring 的 DefaultTransactionAttribute 類能看到如下代碼塊,可以發(fā)現(xiàn)相關(guān)證據(jù),通過(guò)注釋也能看到 Spring 這么做的原因,大概的意思是受檢異常一般是業(yè)務(wù)異常,或者說(shuō)是類似另一種方法的返回值,出現(xiàn)這樣的異常可能業(yè)務(wù)還能完成,所以不會(huì)主動(dòng)回滾;而 Error 或 RuntimeException 代表了非預(yù)期的結(jié)果,應(yīng)該回滾:
? ?public boolean rollbackOn(Throwable ex) {return ex instanceof RuntimeException || ex instanceof Error;}接下來(lái),分享 2 個(gè)反例。
重新實(shí)現(xiàn)一下 UserService 中的注冊(cè)用戶操作:
-
在 createUserWrong1 方法中會(huì)拋出一個(gè) RuntimeException,但由于方法內(nèi) catch 了所有異常,異常無(wú)法從方法傳播出去,事務(wù)自然無(wú)法回滾。
-
在 createUserWrong2 方法中,注冊(cè)用戶的同時(shí)會(huì)有一次 otherTask 文件讀取操作,如果文件讀取失敗,我們希望用戶注冊(cè)的數(shù)據(jù)庫(kù)操作回滾。雖然這里沒(méi)有捕獲異常,但因?yàn)?otherTask 方法拋出的是受檢異常,createUserWrong2 傳播出去的也是受檢異常,事務(wù)同樣不會(huì)回滾。
Controller 中的實(shí)現(xiàn),僅僅是調(diào)用 UserService 的 createUserWrong1 和 createUserWrong2 方法,這里就貼出實(shí)現(xiàn)了。這 2 個(gè)方法的實(shí)現(xiàn)和調(diào)用,雖然完全避開(kāi)了事務(wù)不生效的坑,但因?yàn)楫惓L幚聿划?dāng),導(dǎo)致程序沒(méi)有如我們期望的文件操作出現(xiàn)異常時(shí)回滾事務(wù)。
現(xiàn)在,我們來(lái)看下修復(fù)方式,以及如何通過(guò)日志來(lái)驗(yàn)證是否修復(fù)成功。針對(duì)這 2 種情況,對(duì)應(yīng)的修復(fù)方法如下。
第一,如果你希望自己捕獲異常進(jìn)行處理的話,也沒(méi)關(guān)系,可以手動(dòng)設(shè)置讓當(dāng)前事務(wù)處于回滾狀態(tài):
@Transactional public void createUserRight1(String name) {try {userRepository.save(new UserEntity(name));throw new RuntimeException("error");} catch (Exception ex) {log.error("create user failed", ex);TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();} }第二,在注解中聲明,期望遇到所有的 Exception 都回滾事務(wù)(來(lái)突破默認(rèn)不回滾受檢異常的限制):
@Transactional(rollbackFor = Exception.class) public void createUserRight2(String name) throws IOException {userRepository.save(new UserEntity(name));otherTask(); }在這個(gè)例子中,我們展現(xiàn)的是一個(gè)復(fù)雜的業(yè)務(wù)邏輯,其中有數(shù)據(jù)庫(kù)操作、IO 操作,在 IO 操作出現(xiàn)問(wèn)題時(shí)希望讓數(shù)據(jù)庫(kù)事務(wù)也回滾,以確保邏輯的一致性。在有些業(yè)務(wù)邏輯中,可能會(huì)包含多次數(shù)據(jù)庫(kù)操作,我們不一定希望將兩次操作作為一個(gè)事務(wù)來(lái)處理,這時(shí)候就需要仔細(xì)考慮事務(wù)傳播的配置了,否則也可能踩坑。
3、請(qǐng)確認(rèn)事務(wù)傳播配置是否符合自己的業(yè)務(wù)邏輯
這么一個(gè)場(chǎng)景:一個(gè)用戶注冊(cè)的操作,會(huì)插入一個(gè)主用戶到用戶表,還會(huì)注冊(cè)一個(gè)關(guān)聯(lián)的子用戶。我們希望將子用戶注冊(cè)的數(shù)據(jù)庫(kù)操作作為一個(gè)獨(dú)立事務(wù)來(lái)處理,即使失敗也不會(huì)影響主流程,即不影響主用戶的注冊(cè)。
接下來(lái),我們模擬一個(gè)實(shí)現(xiàn)類似業(yè)務(wù)邏輯的 UserService:
@Autowired private UserRepository userRepository; ? @Autowired private SubUserService subUserService; ? @Transactional public void createUserWrong(UserEntity entity) {createMainUser(entity);subUserService.createSubUserWithExceptionWrong(entity); } ? private void createMainUser(UserEntity entity) {userRepository.save(entity);log.info("createMainUser finish"); }SubUserService 的 createSubUserWithExceptionWrong 實(shí)現(xiàn)正如其名,因?yàn)樽詈笪覀儝伋隽艘粋€(gè)運(yùn)行時(shí)異常,錯(cuò)誤原因是用戶狀態(tài)無(wú)效,所以子用戶的注冊(cè)肯定是失敗的。我們期望子用戶的注冊(cè)作為一個(gè)事務(wù)單獨(dú)回滾,不影響主用戶的注冊(cè),這樣的邏輯可以實(shí)現(xiàn)嗎?
@Service @Slf4j public class SubUserService { ?@Autowiredprivate UserRepository userRepository; ?@Transactionalpublic void createSubUserWithExceptionWrong(UserEntity entity) {log.info("createSubUserWithExceptionWrong start");userRepository.save(entity);throw new RuntimeException("invalid status");} }我們?cè)?Controller 里實(shí)現(xiàn)一段測(cè)試代碼,調(diào)用 UserService:
@GetMapping("wrong") public int wrong(@RequestParam("name") String name) {try {userService.createUserWrong(new UserEntity(name));} catch (Exception ex) {log.error("createUserWrong failed, reason:{}", ex.getMessage());}return userService.getUserCount(name); }調(diào)用后可以在日志中發(fā)現(xiàn)如下信息,很明顯事務(wù)回滾了,最后 Controller 打出了創(chuàng)建子用戶拋出的運(yùn)行時(shí)異常:
[22:50:42.866] [http-nio-45678-exec-8] [DEBUG] [o.s.orm.jpa.JpaTransactionManager ? ? ? :555 ] - Rolling back JPA transaction on EntityManager [SessionImpl(103972212<open>)] [22:50:42.869] [http-nio-45678-exec-8] [DEBUG] [o.s.orm.jpa.JpaTransactionManager ? ? ? :620 ] - Closing JPA EntityManager [SessionImpl(103972212<open>)] after transaction [22:50:42.869] [http-nio-45678-exec-8] [ERROR] [t.d.TransactionPropagationController:23 ] - createUserWrong failed, reason:invalid status你馬上就會(huì)意識(shí)到,不對(duì)呀,因?yàn)檫\(yùn)行時(shí)異常逃出了 @Transactional 注解標(biāo)記的 createUserWrong 方法,Spring 當(dāng)然會(huì)回滾事務(wù)了。如果我們希望主方法不回滾,應(yīng)該把子方法拋出的異常捕獲了。
也就是這么改,把 subUserService.createSubUserWithExceptionWrong 包裹上 catch,這樣外層主方法就不會(huì)出現(xiàn)異常了:
@Transactional public void createUserWrong2(UserEntity entity) {createMainUser(entity);try{subUserService.createSubUserWithExceptionWrong(entity);} catch (Exception ex) {// 雖然捕獲了異常,但是因?yàn)闆](méi)有開(kāi)啟新事務(wù),而當(dāng)前事務(wù)因?yàn)楫惓R呀?jīng)被標(biāo)記為rollback了,所以最終還是會(huì)回滾。log.error("create sub user error:{}", ex.getMessage());} }運(yùn)行程序后可以看到如下日志:
[22:57:21.722] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :370 ] - Creating new transaction with name [org.geekbang.time.commonmistakes.transaction.demo3.UserService.createUserWrong2]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT [22:57:21.739] [http-nio-45678-exec-3] [INFO ] [t.c.transaction.demo3.SubUserService:19 ] - createSubUserWithExceptionWrong start [22:57:21.739] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :356 ] - Found thread-bound EntityManager [SessionImpl(1794007607<open>)] for JPA transaction [22:57:21.739] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :471 ] - Participating in existing transaction [22:57:21.740] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :843 ] - Participating transaction failed - marking existing transaction as rollback-only [22:57:21.740] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :580 ] - Setting JPA transaction on EntityManager [SessionImpl(1794007607<open>)] rollback-only [22:57:21.740] [http-nio-45678-exec-3] [ERROR] [.g.t.c.transaction.demo3.UserService:37 ] - create sub user error:invalid status [22:57:21.740] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :741 ] - Initiating transaction commit [22:57:21.740] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :529 ] - Committing JPA transaction on EntityManager [SessionImpl(1794007607<open>)] [22:57:21.743] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager :620 ] - Closing JPA EntityManager [SessionImpl(1794007607<open>)] after transaction [22:57:21.743] [http-nio-45678-exec-3] [ERROR] [t.d.TransactionPropagationController:33 ] - createUserWrong2 failed, reason:Transaction silently rolled back because it has been marked as rollback-only org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only ...需要注意以下幾點(diǎn):
-
如第 1 行所示,對(duì) createUserWrong2 方法開(kāi)啟了異常處理;
-
如第 5 行所示,子方法因?yàn)槌霈F(xiàn)了運(yùn)行時(shí)異常,標(biāo)記當(dāng)前事務(wù)為回滾;
-
如第 7 行所示,主方法的確捕獲了異常打印出了 create sub user error 字樣;如第 9 行所示,主方法提交了事務(wù);
-
奇怪的是,如第 11 行和 12 行所示,Controller 里出現(xiàn)了一個(gè) UnexpectedRollbackException,異常描述提示最終這個(gè)事務(wù)回滾了,而且是靜默回滾的。之所以說(shuō)是靜默,是因?yàn)?createUserWrong2 方法本身并沒(méi)有出異常,只不過(guò)提交后發(fā)現(xiàn)子方法已經(jīng)把當(dāng)前事務(wù)設(shè)置為了回滾,無(wú)法完成提交。
這挺反直覺(jué)的。我們之前說(shuō),出了異常事務(wù)不一定回滾,這里說(shuō)的卻是不出異常,事務(wù)也不一定可以提交。原因是,主方法注冊(cè)主用戶的邏輯和子方法注冊(cè)子用戶的邏輯是同一個(gè)事務(wù),子邏輯標(biāo)記了事務(wù)需要回滾,主邏輯自然也不能提交了。
看到這里,修復(fù)方式就很明確了,想辦法讓子邏輯在獨(dú)立事務(wù)中運(yùn)行,也就是改一下 SubUserService 注冊(cè)子用戶的方法,為注解加上 propagation = Propagation.REQUIRES_NEW 來(lái)設(shè)置 REQUIRES_NEW 方式的事務(wù)傳播策略,也就是執(zhí)行到這個(gè)方法時(shí)需要開(kāi)啟新的事務(wù),并掛起當(dāng)前事務(wù):
@Transactional(propagation = Propagation.REQUIRES_NEW) public void createSubUserWithExceptionRight(UserEntity entity) {log.info("createSubUserWithExceptionRight start");userRepository.save(entity);throw new RuntimeException("invalid status"); }主方法沒(méi)什么變化,同樣需要捕獲異常,防止異常漏出去導(dǎo)致主事務(wù)回滾,重新命名為 createUserRight:
@Transactional public void createUserRight(UserEntity entity) {createMainUser(entity);try{subUserService.createSubUserWithExceptionRight(entity);} catch (Exception ex) {// 捕獲異常,防止主方法回滾log.error("create sub user error:{}", ex.getMessage());} }運(yùn)行測(cè)試程序看到如下結(jié)果,getUserCount 得到的用戶數(shù)量為 1,代表只有一個(gè)用戶也就是主用戶注冊(cè)完成了,符合預(yù)期。
總結(jié)
以上是生活随笔為你收集整理的Spring 声明式事务在业务开发中容易碰到的坑总结的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: 如何在队列排队之前让ThreadPool
- 下一篇: LinkedList插入元素一定比Arr