Spring 声明式事务在业务开发中容易碰到的坑总结
Spring 聲明式事務,在業務開發使用上可能遇到的三類坑,包括:
第一,因為配置不正確,導致方法上的事務沒生效。我們務必確認調用 @Transactional 注解標記的方法是 public 的,并且是通過 Spring 注入的 Bean 進行調用的。
第二,因為異常處理不正確,導致事務雖然生效但出現異常時沒回滾。Spring 默認只會對標記 @Transactional 注解的方法出現了 RuntimeException 和 Error 的時候回滾,如果我們的方法捕獲了異常,那么需要通過手動編碼處理事務回滾。如果希望 Spring 針對其他異常也可以回滾,那么可以相應配置 @Transactional 注解的 rollbackFor 和 noRollbackFor 屬性來覆蓋其默認設置。
第三,如果方法涉及多次數據庫操作,并希望將它們作為獨立的事務進行提交或回滾,那么我們需要考慮進一步細化配置事務傳播方式,也就是 @Transactional 注解的 Propagation 屬性。
?
1、小心 Spring 的事務可能沒有生效
在使用 @Transactional 注解開啟聲明式事務時, 第一個最容易忽略的問題是,很可能事務并沒有生效。
實現下面的 Demo 需要一些基礎類,首先定義一個具有 ID 和姓名屬性的 UserEntity,也就是一個包含兩個字段的用戶表:
@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 做數據庫訪問,實現這樣一個 Repository,新增一個根據用戶名查詢所有數據的方法:
@Repository public interface UserRepository extends JpaRepository<UserEntity, Long> {List<UserEntity> findByName(String name); }定義一個 UserService 類,負責業務邏輯處理。如果不清楚 @Transactional 的實現方式,只考慮代碼邏輯的話,這段代碼看起來沒有問題。
定義一個入口方法 createUserWrong1 來調用另一個私有方法 createUserPrivate,私有方法上標記了 @Transactional 注解。當傳入的用戶名包含 test 關鍵字時判斷為用戶名不合法,拋出異常,讓用戶創建操作失敗,期望事務可以回滾:
@Service @Slf4j public class UserService {@Autowiredprivate UserRepository userRepository; ?//一個公共方法供Controller調用,內部調用事務性的私有方法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();} ?//標記了@Transactional的private方法@Transactionalprivate void createUserPrivate(UserEntity entity) {userRepository.save(entity);if (entity.getName().contains("test"))throw new RuntimeException("invalid username!");} ?//根據用戶名查詢用戶數public int getUserCount(String name) {return userRepository.findByName(name).size();} }下面是 Controller 的實現,只是調用一下剛才定義的 UserService 中的入口方法 createUserWrong1。
@Autowired private UserService userService; ? @GetMapping("wrong1") public int wrong1(@RequestParam("name") String name) {return userService.createUserWrong1(name); }調用接口后發現,即便用戶名不合法,用戶也能創建成功。刷新瀏覽器,多次發現有十幾個的非法用戶注冊。
這里給出 @Transactional 生效原則 1,除非特殊配置(比如使用 AspectJ 靜態織入實現 AOP),否則只有定義在 public 方法上的 @Transactional 才能生效。原因是,Spring 默認通過動態代理的方式實現 AOP,對目標方法進行增強,private 方法無法代理到,Spring 自然也無法動態增強事務處理邏輯。
你可能會說,修復方式很簡單,把標記了事務注解的 createUserPrivate 方法改為 public 即可。在 UserService 中再建一個入口方法 createUserWrong2,來調用這個 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(); } ? //標記了@Transactional的public方法 @Transactional public void createUserPublic(UserEntity entity) {userRepository.save(entity);if (entity.getName().contains("test"))throw new RuntimeException("invalid username!"); }測試發現,調用新的 createUserWrong2 方法事務同樣不生效。這里,我給出 @Transactional 生效原則 2,必須通過代理過的類從外部調用目標方法才能生效。
Spring 通過 AOP 技術對方法進行增強,要調用增強過的方法必然是調用代理后的對象。我們嘗試修改下 UserService 的代碼,注入一個 self,然后再通過 self 實例調用標記有 @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();}設置斷點可以看到,self 是由 Spring 通過 CGLIB 方式增強過的類:
-
CGLIB 通過繼承方式實現代理類,private 方法在子類不可見,自然也就無法進行事務增強;
-
this 指針代表對象自己,Spring 不可能注入 this,所以通過 this 訪問方法必然不是代理。
?
把 this 改為 self 后測試發現,在 Controller 中調用 createUserRight 方法可以驗證事務是生效的,非法的用戶注冊操作可以回滾。
雖然在 UserService 內部注入自己調用自己的 createUserPublic 可以正確實現事務,但更合理的實現方式是,讓 Controller 直接調用之前定義的 UserService 的 createUserPublic 方法,因為注入自己調用自己很奇怪,也不符合分層實現的規范:
@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); }我們再通過一張圖來回顧下 this 自調用、通過 self 調用,以及在 Controller 中調用 UserService 三種實現的區別:
?
通過 this 自調用,沒有機會走到 Spring 的代理類;后兩種改進方案調用的是 Spring 注入的 UserService,通過代理調用才有機會對 createUserPublic 方法進行動態增強。
2、事務即便生效也不一定能回滾
通過 AOP 實現事務處理可以理解為,使用 try…catch…來包裹標記了 @Transactional 注解的方法,當方法出現了異常并且滿足一定條件的時候,在 catch 里面我們可以設置事務回滾,沒有異常則直接提交事務。
這里的“一定條件”,主要包括兩點。
第一,只有異常傳播出了標記了 @Transactional 注解的方法,事務才能回滾。在 Spring 的 TransactionAspectSupport 里有個 invokeWithinTransaction 方法,里面就是處理事務的邏輯。可以看到,只有捕獲到異常才能進行后續事務處理:
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); }第二,默認情況下,出現 RuntimeException(非受檢異常)或 Error 的時候,Spring 才會回滾事務。
打開 Spring 的 DefaultTransactionAttribute 類能看到如下代碼塊,可以發現相關證據,通過注釋也能看到 Spring 這么做的原因,大概的意思是受檢異常一般是業務異常,或者說是類似另一種方法的返回值,出現這樣的異常可能業務還能完成,所以不會主動回滾;而 Error 或 RuntimeException 代表了非預期的結果,應該回滾:
? ?public boolean rollbackOn(Throwable ex) {return ex instanceof RuntimeException || ex instanceof Error;}接下來,分享 2 個反例。
重新實現一下 UserService 中的注冊用戶操作:
-
在 createUserWrong1 方法中會拋出一個 RuntimeException,但由于方法內 catch 了所有異常,異常無法從方法傳播出去,事務自然無法回滾。
-
在 createUserWrong2 方法中,注冊用戶的同時會有一次 otherTask 文件讀取操作,如果文件讀取失敗,我們希望用戶注冊的數據庫操作回滾。雖然這里沒有捕獲異常,但因為 otherTask 方法拋出的是受檢異常,createUserWrong2 傳播出去的也是受檢異常,事務同樣不會回滾。
Controller 中的實現,僅僅是調用 UserService 的 createUserWrong1 和 createUserWrong2 方法,這里就貼出實現了。這 2 個方法的實現和調用,雖然完全避開了事務不生效的坑,但因為異常處理不當,導致程序沒有如我們期望的文件操作出現異常時回滾事務。
現在,我們來看下修復方式,以及如何通過日志來驗證是否修復成功。針對這 2 種情況,對應的修復方法如下。
第一,如果你希望自己捕獲異常進行處理的話,也沒關系,可以手動設置讓當前事務處于回滾狀態:
@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 都回滾事務(來突破默認不回滾受檢異常的限制):
@Transactional(rollbackFor = Exception.class) public void createUserRight2(String name) throws IOException {userRepository.save(new UserEntity(name));otherTask(); }在這個例子中,我們展現的是一個復雜的業務邏輯,其中有數據庫操作、IO 操作,在 IO 操作出現問題時希望讓數據庫事務也回滾,以確保邏輯的一致性。在有些業務邏輯中,可能會包含多次數據庫操作,我們不一定希望將兩次操作作為一個事務來處理,這時候就需要仔細考慮事務傳播的配置了,否則也可能踩坑。
3、請確認事務傳播配置是否符合自己的業務邏輯
這么一個場景:一個用戶注冊的操作,會插入一個主用戶到用戶表,還會注冊一個關聯的子用戶。我們希望將子用戶注冊的數據庫操作作為一個獨立事務來處理,即使失敗也不會影響主流程,即不影響主用戶的注冊。
接下來,我們模擬一個實現類似業務邏輯的 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 實現正如其名,因為最后我們拋出了一個運行時異常,錯誤原因是用戶狀態無效,所以子用戶的注冊肯定是失敗的。我們期望子用戶的注冊作為一個事務單獨回滾,不影響主用戶的注冊,這樣的邏輯可以實現嗎?
@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");} }我們在 Controller 里實現一段測試代碼,調用 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); }調用后可以在日志中發現如下信息,很明顯事務回滾了,最后 Controller 打出了創建子用戶拋出的運行時異常:
[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你馬上就會意識到,不對呀,因為運行時異常逃出了 @Transactional 注解標記的 createUserWrong 方法,Spring 當然會回滾事務了。如果我們希望主方法不回滾,應該把子方法拋出的異常捕獲了。
也就是這么改,把 subUserService.createSubUserWithExceptionWrong 包裹上 catch,這樣外層主方法就不會出現異常了:
@Transactional public void createUserWrong2(UserEntity entity) {createMainUser(entity);try{subUserService.createSubUserWithExceptionWrong(entity);} catch (Exception ex) {// 雖然捕獲了異常,但是因為沒有開啟新事務,而當前事務因為異常已經被標記為rollback了,所以最終還是會回滾。log.error("create sub user error:{}", ex.getMessage());} }運行程序后可以看到如下日志:
[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 ...需要注意以下幾點:
-
如第 1 行所示,對 createUserWrong2 方法開啟了異常處理;
-
如第 5 行所示,子方法因為出現了運行時異常,標記當前事務為回滾;
-
如第 7 行所示,主方法的確捕獲了異常打印出了 create sub user error 字樣;如第 9 行所示,主方法提交了事務;
-
奇怪的是,如第 11 行和 12 行所示,Controller 里出現了一個 UnexpectedRollbackException,異常描述提示最終這個事務回滾了,而且是靜默回滾的。之所以說是靜默,是因為 createUserWrong2 方法本身并沒有出異常,只不過提交后發現子方法已經把當前事務設置為了回滾,無法完成提交。
這挺反直覺的。我們之前說,出了異常事務不一定回滾,這里說的卻是不出異常,事務也不一定可以提交。原因是,主方法注冊主用戶的邏輯和子方法注冊子用戶的邏輯是同一個事務,子邏輯標記了事務需要回滾,主邏輯自然也不能提交了。
看到這里,修復方式就很明確了,想辦法讓子邏輯在獨立事務中運行,也就是改一下 SubUserService 注冊子用戶的方法,為注解加上 propagation = Propagation.REQUIRES_NEW 來設置 REQUIRES_NEW 方式的事務傳播策略,也就是執行到這個方法時需要開啟新的事務,并掛起當前事務:
@Transactional(propagation = Propagation.REQUIRES_NEW) public void createSubUserWithExceptionRight(UserEntity entity) {log.info("createSubUserWithExceptionRight start");userRepository.save(entity);throw new RuntimeException("invalid status"); }主方法沒什么變化,同樣需要捕獲異常,防止異常漏出去導致主事務回滾,重新命名為 createUserRight:
@Transactional public void createUserRight(UserEntity entity) {createMainUser(entity);try{subUserService.createSubUserWithExceptionRight(entity);} catch (Exception ex) {// 捕獲異常,防止主方法回滾log.error("create sub user error:{}", ex.getMessage());} }運行測試程序看到如下結果,getUserCount 得到的用戶數量為 1,代表只有一個用戶也就是主用戶注冊完成了,符合預期。
總結
以上是生活随笔為你收集整理的Spring 声明式事务在业务开发中容易碰到的坑总结的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 如何在队列排队之前让ThreadPool
- 下一篇: Spring如何实现统一的基于请求头he