別在 MyBatis-Plus 里瞎用 @Transactional!這鍋背到我差點被踢出項目組
兄弟們,咱今天來嘮嘮 MyBatis-Plus 里那個讓人又愛又恨的 @Transactional 注解。先給大家講個真實的 "血淚教訓":上個月我在項目里一頓操作猛如虎,對著 Service 層狂甩 @Transactional 注解,自以為事務管理穩如老狗,結果線上接連爆雷 —— 訂單創建成功但庫存沒扣,用戶退款申請提交了但財務系統沒同步,最慘的是某天凌晨收到運維大哥的奪命連環 call,說數據庫連接池被撐爆了。當時項目經理看我的眼神,恨不得把我連人帶電腦一起踢出項目組...
痛定思痛之后,我抱著 MyBatis-Plus 官方文檔和 Spring 事務源碼啃了三天三夜,終于摸清了這個注解在 MyBatis-Plus 里的各種坑。今兒個咱就掰開揉碎了聊,保證讓你看完再也不會在事務管理上栽跟頭,說不定還能反向指導新人避坑,妥妥的升職加薪小技巧啊!
一、先搞明白:MyBatis-Plus 的事務和 Spring 到底啥關系?
好多小伙伴可能跟我當初一樣犯迷糊:MyBatis-Plus 不是 ORM 框架嗎,為啥要用 Spring 的 @Transactional 注解?這就得從 MyBatis-Plus 的出身說起了 —— 它本質上是 MyBatis 的增強工具,本身并不提供事務管理功能,而是完全依賴 Spring 的事務管理機制。所以咱們聊的 @Transactional,本質上還是 Spring 的注解,只不過在 MyBatis-Plus 的使用場景里,有些細節需要特別注意。
1.1 底層原理:Spring 是怎么玩轉事務的?
這里咱用個接地氣的比喻:Spring 的事務管理就像一場舞臺劇,@Transactional 注解就是導演給演員(方法)貼的標簽,告訴幕后的 AOP 代理(替身演員):"這一段戲需要開啟事務,要是演砸了(拋異常)得回滾??!" 具體來說:
- 代理模式:Spring 會給加了 @Transactional 的類生成動態代理(JDK 代理或 CGLIB 代理),當調用目標方法時,實際執行的是代理類中的事務增強邏輯。
- 事務攔截器:org.springframework.transaction.interceptor.TransactionInterceptor 是核心攔截器,它會在方法執行前開啟事務,執行過程中監控異常,執行完畢后根據情況提交或回滾事務。
- 數據源綁定:通過 ThreadLocal 將數據庫連接與當前線程綁定,確保同一個事務內使用的是同一個數據庫連接。
這里有個關鍵知識點:MyBatis-Plus 的 Mapper 方法在執行時,必須通過 Spring 容器獲取的 Mapper 代理對象調用,才能保證處于 Spring 的事務管理范圍內。如果你在代碼里自己 new 了一個 Mapper 實例(雖然正常人不會這么干,但咱得防著新手踩坑),那事務肯定不會生效。
1.2 MyBatis-Plus 的特殊點:這些操作會影響事務嗎?
咱都知道 MyBatis-Plus 有很多貼心的增強功能,比如自動填充、樂觀鎖、邏輯刪除等,這些功能在事務中會不會搞事情呢?
- 自動填充(MetaObjectHandler):放心,自動填充是在 Mapper 執行 SQL 之前完成的,屬于業務邏輯的一部分,只要在事務方法內調用 Mapper,填充的數據會跟著事務一起提交或回滾。
- 樂觀鎖(@Version):重點來了!當使用樂觀鎖時,MyBatis-Plus 會在更新語句中添加版本號校驗條件。如果在事務中多個線程同時更新同一條數據,后提交的線程會因為版本號不一致導致更新失敗,此時事務會回滾,這是正?,F象,不是 bug 哦。
- 邏輯刪除(@TableLogic):邏輯刪除本質上是執行 update 語句,將 deleted 字段標記為 1,和普通的 update 操作一樣,受事務管理控制,刪除操作會在事務提交時生效。
二、這些 "想當然" 的操作,分分鐘讓事務失效!
2.1 同類方法調用:別以為加了注解就萬事大吉
先看一段讓我栽跟頭的代碼:
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private StockService stockService;
// 外層方法加了事務
@Transactional(rollbackFor = Exception.class)
public void createOrder(OrderDTO orderDTO) {
OrderEntity orderEntity = convertToEntity(orderDTO);
orderMapper.insert(orderEntity); // 插入訂單
updateStock(orderDTO.getProductId(), orderDTO.getQuantity()); // 調用內部方法扣庫存
}
// 內部方法沒加注解,以為會跟著外層事務走
private void updateStock(Long productId, Integer quantity) {
StockEntity stock = stockMapper.selectById(productId);
if (stock.getStockQuantity() < quantity) {
throw new BusinessException("庫存不足");
}
stock.setStockQuantity(stock.getStockQuantity() - quantity);
stockMapper.updateById(stock); // 這里出問題了!
}
}
看起來挺合理吧?外層方法有事務,內部方法應該跟著一起回滾。但實際情況是:當 updateStock 拋異常時,訂單數據居然已經插入數據庫了!為啥呢?原因解析:Spring 的 AOP 代理是基于接口或類的,當在同一個類中調用另一個方法時,實際上是通過 this 引用調用的,而不是通過代理對象調用。這時候,事務增強邏輯就不會生效,內部方法相當于在事務之外執行。
解決方案:
- 方法上移:把 updateStock 的邏輯直接寫在外層方法里,別搞內部調用。
- 自我注入:在類中注入自己,通過代理對象調用方法:
@Service
public class OrderService {
@Autowired
private OrderService self; // 注入自己
@Transactional(rollbackFor = Exception.class)
public void createOrder(OrderDTO orderDTO) {
OrderEntity orderEntity = convertToEntity(orderDTO);
orderMapper.insert(orderEntity);
self.updateStock(orderDTO.getProductId(), orderDTO.getQuantity()); // 通過代理對象調用
}
private void updateStock(Long productId, Integer quantity) {
// ... 邏輯不變
}
}
不過這種方法有點反直覺,建議還是盡量避免同類內的方法調用,保持事務方法的原子性。
2.2 不同數據源:多數據源場景下事務會 "迷路"
現在微服務架構里,多數據源場景很常見,比如主庫寫、從庫讀,或者分庫分表。這時候如果在一個事務里操作多個數據源,@Transactional 還能生效嗎?
先看配置:
@Configuration
public class DataSourceConfig {
@Bean("masterDataSource")
@Primary
public DataSource masterDataSource() {
// 主數據源配置
}
@Bean("slaveDataSource")
public DataSource slaveDataSource() {
// 從數據源配置
}
@Bean("masterTransactionManager")
@Primary
public DataSourceTransactionManager masterTransactionManager(
@Qualifier("masterDataSource") DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
@Bean("slaveTransactionManager")
public DataSourceTransactionManager slaveTransactionManager(
@Qualifier("slaveDataSource") DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
}
然后在 Service 里:
@Service
public class MultiDataSourceService {
@Autowired
private MasterOrderMapper masterOrderMapper;
@Autowired
private SlaveStockMapper slaveStockMapper;
// 以為加了@Transactional就能跨庫事務
@Transactional(rollbackFor = Exception.class)
public void updateBoth(String orderNo, Integer stockId) {
MasterOrderEntity order = masterOrderMapper.selectByOrderNo(orderNo);
order.setStatus("已支付");
masterOrderMapper.updateById(order);
SlaveStockEntity stock = slaveStockMapper.selectById(stockId);
stock.setStockStatus("已扣除");
slaveStockMapper.updateById(stock);
if (orderNo.equals("異常訂單")) {
throw new RuntimeException("模擬異常");
}
}
}
實際運行結果:主庫的訂單狀態更新了,但從庫的庫存狀態沒回滾!為啥呢?因為 @Transactional 默認使用的是主數據源的事務管理器,而跨數據源的事務需要分布式事務解決方案(如 Seata、XA 協議等),單純的 @Transactional 搞不定。解決方案:
- 明確指定事務管理器:如果只是操作同一個數據源內的不同庫(比如分庫),可以通過 @Transactional (transactionManager = "masterTransactionManager") 指定正確的事務管理器。
- 分布式事務方案:涉及多個獨立數據源時,必須使用分布式事務框架,別指望 Spring 的本地事務能搞定。
2.3 枚舉類型坑:數據庫類型和 Java 枚舉對不上,事務不回滾
MyBatis-Plus 支持將 Java 枚舉映射到數據庫字段,比如:
public enum OrderStatus {
NEW(0, "新建"),
PAID(1, "已支付"),
CANCELLED(2, "已取消");
private Integer code;
private String desc;
// 構造器和getter省略
}
@TableField(typeHandler = OrderStatusTypeHandler.class)
private OrderStatus status;
然后在 Service 里:
@Transactional(rollbackFor = Exception.class)
public void updateOrderStatus(Long orderId, Integer statusCode) {
OrderEntity order = orderMapper.selectById(orderId);
order.setStatus(OrderStatus.valueOf(statusCode)); // 這里可能拋IllegalArgumentException
orderMapper.updateById(order);
// 假設后面還有其他操作
int i = 1 / 0; // 模擬除零異常
}
當 statusCode 傳了一個不存在的枚舉值時,OrderStatus.valueOf 會拋 IllegalArgumentException,按理說整個事務應該回滾。但實際情況是:訂單狀態可能已經更新了,后面的除零異常導致方法報錯,但事務沒回滾!原因解析:Spring 默認只對 RuntimeException 及其子類回滾,CheckedException 不回滾。而 IllegalArgumentException 是 RuntimeException,按道理應該回滾啊?問題出在 MyBatis-Plus 的 TypeHandler 上,當枚舉映射錯誤時,MyBatis 會在執行 SQL 之前就拋出異常,此時事務可能還沒真正開啟,或者說異常發生在事務增強邏輯之外。
解決方案:
- 顯式指定回滾異常:@Transactional (rollbackFor = {Exception.class, IllegalArgumentException.class}),雖然有點粗暴,但能確保所有異常都回滾。
- 提前校驗參數:在設置枚舉值之前,先檢查 statusCode 是否合法,避免在 MyBatis 處理時拋異常。
2.4 序列化陷阱:事務方法參數或返回值不能序列化
在分布式環境下(比如 Spring Cloud),如果 @Transactional 方法的參數或返回值包含無法序列化的對象,會導致事務失效,甚至應用啟動報錯。比如:
@Service
public class UserService {
@Transactional(rollbackFor = Exception.class)
public UserEntity updateUser(UserEntity user) {
// 假設UserEntity里有一個ThreadLocal類型的字段
userMapper.updateById(user);
return user;
}
}
如果 UserEntity 包含 ThreadLocal、Socket 等無法序列化的字段,當通過 RMI、Feign 等遠程調用時,就會報錯。雖然這不是 MyBatis-Plus 特有的問題,但在分布式場景下經常和 MyBatis-Plus 一起出現,必須注意。解決方案:
- 確保實體類可序列化:讓實體類實現 Serializable 接口,并且所有字段都是可序列化的。
- 避免在遠程接口中使用事務方法:事務方法盡量只在本地調用,遠程接口專注于業務邏輯,別加 @Transactional。
三、深度解析:@Transactional 的核心參數,你真的懂嗎?
3.1 propagation:事務傳播行為,組隊打副本的策略
這是最容易搞錯的參數,決定了多個事務方法嵌套調用時的行為。類比組隊打副本:
- REQUIRED(默認值):如果當前有事務,就加入;沒有就創建新事務。就像組隊打 BOSS,你要是已經在隊伍里,就跟著一起打;沒隊伍就自己建一個。
- REQUIRES_NEW:不管有沒有事務,都創建新事務,掛起當前事務。相當于自己開個新隊伍,不管原來有沒有隊伍。
- SUPPORTS:支持當前事務,沒有就以非事務方式執行。就是能組隊就組隊,組不了就單刷。
- NOT_SUPPORTED:不支持事務,掛起當前事務,以非事務方式執行。就是拒絕組隊,自己單刷。
- NEVER:必須沒有事務,否則拋異常。就是堅決不組隊,看到隊伍就跑。
- NESTED:嵌套事務,在一個事務中開啟子事務,子事務回滾不影響父事務,父事務回滾子事務跟著回滾。相當于副本里的小 BOSS 戰,小 BOSS 滅了可以重來,整個副本失敗就全完。
舉個栗子:
@Transactional(propagation = Propagation.REQUIRED)
public void parentMethod() {
childMethod();
// 這里拋異常,parent和child都回滾
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void childMethod() {
// 這里拋異常,child回滾,parent不受影響(如果parent在child之后拋異常,child還是會回滾,因為parent的事務包含child)
}
注意:NESTED 需要數據庫支持,比如 MySQL 的 InnoDB 引擎支持 Savepoint 實現嵌套事務,而 REQUIRES_NEW 是通過新的事務連接實現的,性能上會有差異。
3.2 isolation:事務隔離級別,解決并發問題的關鍵
默認值是 Isolation.DEFAULT(使用數據庫默認隔離級別,MySQL 是 REPEATABLE_READ,Oracle 是 READ_COMMITTED)。常見的隔離級別:
- READ_UNCOMMITTED:讀未提交,可能出現臟讀、不可重復讀、幻讀。
- READ_COMMITTED:讀已提交,避免臟讀,可能出現不可重復讀、幻讀。
- REPEATABLE_READ:可重復讀,避免臟讀、不可重復讀,可能出現幻讀(MySQL 通過 MVCC 解決了幻讀,Oracle 沒有)。
- SERIALIZABLE:串行化,最高隔離級別,避免所有并發問題,但性能最差。
在 MyBatis-Plus 中使用時要注意:
- 高隔離級別會影響性能,別一拍腦袋就設為 SERIALIZABLE。
- 如果業務中存在幻讀風險(比如統計數據時插入新數據),MySQL 可以用 REPEATABLE_READ,Oracle 需要用 SERIALIZABLE 或應用層加鎖。
3.3 timeout:事務超時時間,別讓事務一直 "掛機"
默認值是 - 1(永不超時),但在實際項目中,必須設置合理的超時時間,避免長事務占用數據庫連接,導致連接池耗盡(這就是我之前踩的坑!)。比如:
@Transactional(timeout = 30) // 30秒超時
public void longRunningTransaction() {
// 長時間運行的操作,比如批量插入、復雜計算
}
設置時要根據業務邏輯估算最長執行時間,預留一定緩沖,比如批量插入 10 萬條數據,測試發現平均 20 秒完成,超時時間可以設 30 秒。
3.4 readOnly:只讀事務,提升性能的小技巧
如果方法只是查詢數據,沒有寫操作,建議設置 readOnly=true:
@Transactional(readOnly = true)
public List<OrderEntity> listOrders() {
return orderMapper.selectList(null);
}
這樣做有兩個好處:
- 數據庫可以做優化,比如 MySQL 在只讀事務中不記錄 binlog(如果開啟了 binlog_format=ROW)。
- 提醒開發者這個方法是只讀的,避免誤操作添加寫邏輯。
四、MyBatis-Plus 事務最佳實踐:這樣寫才規范
4.1 事務范圍:能小則小,別搞 "大包圍"
很多新手喜歡在 Service 層的入口方法上加 @Transactional,把整個方法都包在事務里,包括日志記錄、參數校驗、遠程調用等和數據庫無關的操作。這會導致事務持續時間過長,增加鎖競爭和超時風險。正確的做法是:
- 事務只包裹真正的數據庫操作,無關邏輯放在事務外。
- 批量操作時,合理拆分批次,避免單次事務處理太多數據(比如每次處理 1000 條數據,提交一次事務)。
4.2 異常處理:別吞掉回滾的 "信號"
@Transactional(rollbackFor = Exception.class)
public void processOrder() {
try {
// 數據庫操作
orderMapper.insert(order);
// 模擬異常
int i = 1 / 0;
} catch (Exception e) {
// 錯誤做法:吞掉異常,不拋出去
log.error("處理訂單失敗", e);
}
}
這樣寫的后果是:事務不會回滾,因為 Spring 是根據方法是否拋異常來決定是否回滾的,異常被捕獲且沒有重新拋出,事務會認為執行成功,正常提交。正確的做法是:
@Transactional(rollbackFor = Exception.class)
public void processOrder() {
try {
orderMapper.insert(order);
int i = 1 / 0;
} catch (Exception e) {
log.error("處理訂單失敗", e);
// 必須重新拋出異常,或者調用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
throw new RuntimeException("處理訂單失敗", e);
}
}
或者在不需要處理異常的情況下,直接讓異常往上拋,別在事務方法內捕獲后不處理。
4.3 字段更新:注意 MyBatis-Plus 的更新策略
MyBatis-Plus 的 updateById、update 等方法,默認會更新所有非 null 字段,這在事務中可能導致意外結果。比如:
@Transactional(rollbackFor = Exception.class)
public void updateUserPartial(Long userId, UserEntity user) {
// 假設user只有name字段有值,其他字段為null
user.setId(userId);
userMapper.updateById(user); // 會更新所有非null字段,包括name,其他字段不會被更新
}
這本來是正常行為,但如果在事務中,多個線程同時更新同一個用戶的不同字段,可能會出現更新順序問題。建議明確使用 updateWrapper,指定需要更新的字段:
userMapper.update(user, Wrappers.<UserEntity>update().set("name", user.getName()).eq("id", userId));
這樣更清晰,也避免因實體類字段變化導致的意外更新。
4.4 分布式場景:別把本地事務當萬能藥
前面提到的多數據源問題,本質上是分布式事務范疇。在微服務架構中,如果涉及跨服務、跨數據源的事務,必須使用分布式事務解決方案。這里簡單提一下常見方案:
- TCC 模式(Try-Confirm-Cancel):適合強一致性場景,比如資金轉賬,實現復雜度高。
- 可靠消息最終一致性:通過消息中間件保證事務最終一致,適合異步場景,比如訂單創建后通知庫存服務扣庫存。
- Seata 框架:阿里巴巴開源的分布式事務解決方案,支持 AT 模式(自動生成回滾日志)、TCC 模式等,和 Spring Cloud 集成良好。
4.5 監控和日志:讓事務問題無處遁形
- 開啟事務日志:在 application.properties 中配置:
# Spring事務日志
logging.level.org.springframework.transaction=DEBUG
# MyBatis-Plus執行日志
mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
這樣可以在控制臺看到事務的開啟、提交、回滾過程,以及執行的 SQL 語句,方便排查問題。
- 自定義注解和切面:可以寫一個 @TransactionalLog 注解,結合 AOP 記錄事務方法的執行時間、參數、異常信息等,幫助監控事務性能和異常情況。
五、總結:用好 @Transactional 的 "三板斧"
- 搞懂原理:知道 Spring 事務是怎么通過 AOP 代理和數據庫連接綁定實現的,明白 MyBatis-Plus 只是 ORM 工具,事務管理靠 Spring。
- 避開陷阱:記住同類方法調用、多數據源、枚舉類型、序列化這些常見坑,寫代碼時多檢查。
- 規范使用:合理設置事務傳播行為、隔離級別、超時時間,控制事務范圍,做好異常處理,分布式場景用對方案。