1. 程式人生 > >你的異常別被自己 “吃” 掉了都不知道!

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

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

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

在實際專案中,使用事務是很簡單的,例如在 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 的地方加鎖,保證鎖的範圍比事務的範圍大即可。

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

--------------------------------------------------------------------------------------------------------------------

我的微信公眾號:【程式設計師私房菜】
關注後回覆“架構”、“資源”等指定關鍵字獲取海量免費學習視訊。我們一同進步!

ç¨åºåç§æ¿è