你的異常別被自己 “吃” 掉了都不知道!

轉載出處:https://blog.csdn.net/eson_15/article/details/84504059

 

我們在開發企業應用時,由於數據操作在順序執行的過程中,線上可能有各種無法預知的問題,任何一步操作都有可能發生異常,異常則會導致後續的操作無法完成。此時由於業務邏輯並未正確的完成,所以在之前操作過數據庫的動作並不可靠,需要在這種情況下進行數據的回滾。

這叫事務。事務的作用就是爲了保證用戶的每一個操作都是可靠的,事務中的每一步操作都必須成功執行,只要有發生異常就回退到事務開始未進行操作的狀態。這很好理解,轉賬、購票等等,必須整個事件流程全部執行完才能人爲該事件執行成功,不能轉錢轉到一半,系統死了,轉賬人錢沒了,收款人錢還沒到。

在實際項目中,使用事務是很簡單的,例如在 Spring Boot 項目中,一個 @Transactional 註解就可以解決。但是事務有很多小坑在等着我們,這些小坑是我們在寫代碼的時候沒有注意到,而且正常情況下不容易發現這些小坑,等項目寫大了,某一天突然出問題了,排查問題非常困難,到時候肯定是抓瞎,需要費很大的精力去排查問題。

本文我不教大家如何去使用事務,這個谷歌百度上有一大堆教程,我主要結合自己的經驗,給大家分享幾個實際中常見的問題。希望能給讀者帶來些啓發。

1. 異常並沒有被 “捕獲” 到

這是個很常見的小坑,異常並沒有被 “捕獲” 到,導致事務並沒有回滾。我們在業務層代碼中,也許已經考慮到了異常的存在,或者編輯器已經提示我們需要拋出異常,但是這裏面有個需要注意的地方:並不是說我們把異常拋出來了,有異常了事務就會回滾。我們來看一個例子:

 

   @Service
    public class UserServiceImpl implements UserService {
     
       @Resource
       private UserMapper userMapper;
       
       @Override
       @Transactional
       public void isertUser(User user) throws Exception {
           // 插入用戶信息
           userMapper.insertUser(user);
           // 手動拋出異常
           throw new SQLException("數據庫異常");
       }
    }

我們看上面這個代碼,其實並沒有什麼問題,手動拋出一個SQLException 來模擬實際中操作數據庫發生的異常,在這個方法中,既然拋出了異常,那麼事務應該回滾,實際卻不如此,讀者可以自己測試一下就會發現,仍然是可以往數據庫插入一條用戶數據的。

那麼問題出在哪呢?因爲 Spring Boot 默認的事務規則是遇到運行異常(RuntimeException)和程序錯誤(Error)纔會回滾。比如上面我們的例子中如果拋出的 RuntimeException 就沒有問題,但是拋出 SQLException 就無法回滾了。

針對非檢測異常,如果要進行事務回滾的話,可以在 @Transactional 註解中使用 rollbackFor 屬性來指定異常,比如:

@Transactional(rollbackFor = Exception.class)

這樣就沒有問題了,所以在實際項目中,一定要指定異常,這是大部分開發人員不注意的地方。

2. 異常被 “吃” 掉了

就如我本文的標題一樣,異常怎麼會被吃掉呢?還是迴歸到現實項目中去,我們在處理異常時,有兩種方式,要麼拋出去,讓上一層來捕獲處理;要麼把異常 try...catch 掉,在異常出現的地方給處理掉。就因爲有這個 try...catch,所以導致異常被 “吃” 掉,事務無法回滾。我們還是看上面那個例子,只不過簡單修改一下代碼:

   

 @Service
    public class UserServiceImpl implements UserService {
     
       @Resource
       private UserMapper userMapper;
     
       @Override
       @Transactional(rollbackFor = Exception.class)
       public void isertUser(User user) {
           try {
               // 插入用戶信息
               userMapper.insertUser(user);
               // 手動拋出異常
               throw new SQLException("數據庫異常");
           } catch (Exception e) {
               // 異常處理邏輯
           }
       }
    }

讀者也可以自己測試一下,仍然是可以插入一條用戶數據,說明事務並沒有因爲拋出異常而回滾。這就是 try...catch 把異 “吃” 掉了,這個細節往往比上面那個坑更難以發現,因爲我們的思維方式很容易導致 try...catch 代碼的產生,一旦出現這種問題,往往排查起來比較費勁。這個就是很明顯的自己給自己挖坑,而且自己掉進去之後,還出不來。

那這種怎麼解決呢?直接往上拋,給上一層來處理即可,千萬不要在事務中把異常自己 ”吃“ 掉。

3. 別忘了事務是有範圍的

事務範圍這個東西比上面兩個坑埋的更深!我之所以把這個也寫上,是因爲這是我之前在實際項目中遇到的,該場景我就不模擬了,我寫一個 demo 讓大家看一下,把這個坑記住即可,以後在寫代碼時,遇到併發問題,如果能想到這個坑,那麼這篇文章也就有價值了。

   

@Service
    public class UserServiceImpl implements UserService {
     
       @Resource
       private UserMapper userMapper;
     
       @Override
       @Transactional(rollbackFor = Exception.class)
       public synchronized void isertUser4(User user) {
           // 實際中的具體業務……
           userMapper.insertUser(user);
       }
    }

可以看到,因爲要考慮併發問題,我在業務層代碼的方法上加了個 synchronized 關鍵字。我舉個實際的場景,比如一個數據庫中,針對某個用戶,只有一條記錄,下一個插入動作過來,會先判斷該數據庫中有沒有相同的用戶,如果有就不插入,就更新,沒有才插入,所以理論上,數據庫中永遠就一條同一用戶信息,不會出現同一數據庫中插入了兩條相同用戶的信息。

但是在壓測時,就會出現上面的問題,數據庫中確實有兩條同一用戶的信息,那說明 synchronized 並沒有起到作用。分析其原因,在於事務的範圍和鎖的範圍問題。

從上面方法中可以看到,方法上是加了事務的,那麼也就是說,在執行該方法開始時,事務啓動,執行完了後,事務關閉。但是 synchronized 沒有起作用,其實根本原因是因爲事務的範圍比鎖的範圍大。也就是說,在加鎖的那部分代碼執行完之後,鎖釋放掉了,但是事務還沒結束,就在此時另一個線程進來了,事務沒結束的話,第二個線程進來時,數據庫的狀態和第一個線程剛進來是一樣的。即由於mysql Innodb引擎的默認隔離級別是可重複讀(在同一個事務裏,SELECT的結果是事務開始時時間點的狀態),線程二事務開始的時候,線程一還沒提交完成,導致讀取的數據還沒更新。第二個線程也做了插入動作,導致了髒數據。

這個問題可以避免,第一,把事務去掉即可(不推薦);第二,在調用該 service 的地方加鎖,保證鎖的範圍比事務的範圍大即可。

寫在後面:這三個小坑在實際開發中經常遇到,希望能給讀者一些啓發,如果你覺得有用,請轉發給更多的人。
 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章