你的異常別被自己"吃"掉了都不知道
我們在開發企業應用時,由於資料操作在順序執行的過程中,線上可能有各種無法預知的問題,任何一步操作都有可能發生異常,異常則會導致後續的操作無法完成。此時由於業務邏輯並未正確的完成,所以在之前操作過資料庫的動作並不可靠,需要在這種情況下進行資料的回滾。
這叫事務。事務的作用就是為了保證使用者的每一個操作都是可靠的,事務中的每一步操作都必須成功執行,只要有發生異常就回退到事務開始未進行操作的狀態。這很好理解,轉賬、購票等等,必須整個事件流程全部執行完才能人為該事件執行成功,不能轉錢轉到一半,系統死了,轉賬人錢沒了,收款人錢還沒到。
在實際專案中,使用事務是很簡單的,例如在 Spring Boot 專案中,一個 @Transactional 註解就可以解決。但是事務有很多小坑在等著我們,這些小坑是我們在寫程式碼的時候沒有注意到,而且正常情況下不容易發現這些小坑,等專案寫大了,某一天突然出問題了,排查問題非常困難,到時候肯定是抓瞎,需要費很大的精力去排查問題。
本文我不教大家如何去使用事務,這個谷歌百度上有一大堆教程,我主要結合自己的經驗,給大家分享幾個實際中常見的問題。希望能給讀者帶來些啟發。
- 異常並沒有被 “捕獲” 到
這是個很常見的小坑,異常並沒有被 “捕獲” 到,導致事務並沒有回滾。我們在業務層程式碼中,也許已經考慮到了異常的存在,或者編輯器已經提示我們需要丟擲異常,但是這裡面有個需要注意的地方:並不是說我們把異常丟擲來了,有異常了事務就會回滾。我們來看一個例子:
@Service
public class UserServiceImpl implements UserService {
@Resource
private UserMapper userMapper;
我們看上面這個程式碼,其實並沒有什麼問題,手動丟擲一個 SQLException 來模擬實際中操作資料庫發生的異常,在這個方法中,既然丟擲了異常,那麼事務應該回滾,實際卻不如此,讀者可以自己測試一下就會發現,仍然是可以往資料庫插入一條使用者資料的。
那麼問題出在哪呢?因為 Spring Boot 預設的事務規則是遇到執行異常(RuntimeException)和程式錯誤(Error)才會回滾。比如上面我們的例子中如果丟擲的 RuntimeException 就沒有問題,但是丟擲 SQLException 就無法回滾了。
針對非執行時異常如果要進行事務回滾的話,可以在 @Transactional 註解中使用 rollbackFor 屬性來指定異常,比如:
@Transactional(rollbackFor = Exception.class)
這樣就沒有問題了,所以在實際專案中,一定要指定異常,這是大部分開發人員不注意的地方。
- 異常被 “吃” 掉了
就如我本文的標題一樣,異常怎麼會被吃掉呢?還是迴歸到現實專案中去,我們在處理異常時,有兩種方式,要麼丟擲去,讓上一層來捕獲處理;要麼把異常 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 程式碼的產生,一旦出現這種問題,往往排查起來比較費勁。這個就是很明顯的自己給自己挖坑,而且自己掉進去之後,還出不來。
那這種怎麼解決呢?直接往上拋,給上一層來處理即可,千萬不要在事務中把異常自己 ”吃“ 掉。
- 別忘了事務是有範圍的
事務範圍這個東西比上面兩個坑埋的更深!我之所以把這個也寫上,是因為這是我之前在實際專案中遇到的,該場景我就不模擬了,我寫一個 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 的地方加鎖,保證鎖的範圍比事務的範圍大即可。
每次有新的發現都會給自己做筆記!