太坑了!聊聊 Spring 事務失效的 12 種場景

前言

對於從事 java 開發工作的同學來說,spring 的事務肯定再熟悉不過了。

在某些業務場景下,如果一個請求中,需要同時寫入多張表的數據。爲了保證操作的原子性(要麼同時成功,要麼同時失敗),避免數據不一致的情況,我們一般都會用到 spring 事務。

確實,spring 事務用起來賊爽,就用一個簡單的註解:@Transactional,就能輕鬆搞定事務。我猜大部分小夥伴也是這樣用的,而且一直用一直爽。

但如果你使用不當,它也會坑你於無形。

今天我們就一起聊聊,事務失效的一些場景,說不定你已經中招了。不信,讓我們一起看看。

一 事務不生效

1. 訪問權限問題

衆所周知,java 的訪問權限主要有四種:private、default、protected、public,它們的權限從左到右,依次變大。

但如果我們在開發過程中,把有某些事務方法,定義了錯誤的訪問權限,就會導致事務功能出問題,例如:

@Service
public class UserService {
    
    @Transactional
    private void add(UserModel userModel) {
         saveData(userModel);
         updateData(userModel);
    }
}

我們可以看到 add 方法的訪問權限被定義成了private,這樣會導致事務失效,spring 要求被代理方法必須是public的。

說白了,在AbstractFallbackTransactionAttributeSource類的computeTransactionAttribute方法中有個判斷,如果目標方法不是 public,則TransactionAttribute返回 null,即不支持事務。

protected TransactionAttribute computeTransactionAttribute(Method method, @Nullable Class<?> targetClass) {
    // Don't allow no-public methods as required.
    if (allowPublicMethodsOnly() && !Modifier.isPublic(method.getModifiers())) {
      return null;
    }

    // The method may be on an interface, but we need attributes from the target class.
    // If the target class is null, the method will be unchanged.
    Method specificMethod = AopUtils.getMostSpecificMethod(method, targetClass);

    // First try is the method in the target class.
    TransactionAttribute txAttr = findTransactionAttribute(specificMethod);
    if (txAttr != null) {
      return txAttr;
    }

    // Second try is the transaction attribute on the target class.
    txAttr = findTransactionAttribute(specificMethod.getDeclaringClass());
    if (txAttr != null && ClassUtils.isUserLevelMethod(method)) {
      return txAttr;
    }

    if (specificMethod != method) {
      // Fallback is to look at the original method.
      txAttr = findTransactionAttribute(method);
      if (txAttr != null) {
        return txAttr;
      }
      // Last fallback is the class of the original method.
      txAttr = findTransactionAttribute(method.getDeclaringClass());
      if (txAttr != null && ClassUtils.isUserLevelMethod(method)) {
        return txAttr;
      }
    }
    return null;
  }

也就是說,如果我們自定義的事務方法(即目標方法),它的訪問權限不是public,而是 private、default 或 protected 的話,spring 則不會提供事務功能。

2. 方法用 final 修飾

有時候,某個方法不想被子類重新,這時可以將該方法定義成 final 的。普通方法這樣定義是沒問題的,但如果將事務方法定義成 final,例如:

@Service
public class UserService {

    @Transactional
    public final void add(UserModel userModel){
        saveData(userModel);
        updateData(userModel);
    }
}

我們可以看到 add 方法被定義成了final的,這樣會導致事務失效。

爲什麼?

如果你看過 spring 事務的源碼,可能會知道 spring 事務底層使用了 aop,也就是通過 jdk 動態代理或者 cglib,幫我們生成了代理類,在代理類中實現的事務功能。

但如果某個方法用 final 修飾了,那麼在它的代理類中,就無法重寫該方法,而添加事務功能。

注意:如果某個方法是 static 的,同樣無法通過動態代理,變成事務方法。

3. 方法內部調用

有時候我們需要在某個 Service 類的某個方法中,調用另外一個事務方法,比如:

@Service
public class UserService {

    @Autowired
    private UserMapper userMapper;

    @Transactional
    public void add(UserModel userModel) {
        userMapper.insertUser(userModel);
        updateStatus(userModel);
    }

    @Transactional
    public void updateStatus(UserModel userModel) {
        doSameThing();
    }
}

我們看到在事務方法 add 中,直接調用事務方法 updateStatus。從前面介紹的內容可以知道,updateStatus 方法擁有事務的能力是因爲 spring aop 生成代理了對象,但是這種方法直接調用了 this 對象的方法,所以 updateStatus 方法不會生成事務。

由此可見,在同一個類中的方法直接內部調用,會導致事務失效。

那麼問題來了,如果有些場景,確實想在同一個類的某個方法中,調用它自己的另外一個方法,該怎麼辦呢?

3.1 新加一個 Service 方法

這個方法非常簡單,只需要新加一個 Service 方法,把 @Transactional 註解加到新 Service 方法上,把需要事務執行的代碼移到新方法中。具體代碼如下:

@Servcie
public class ServiceA {
   @Autowired
   prvate ServiceB serviceB;

   public void save(User user) {
         queryData1();
         queryData2();
         serviceB.doSave(user);
   }
 }

 @Servcie
 public class ServiceB {

    @Transactional(rollbackFor=Exception.class)
    public void doSave(User user) {
       addData1();
       updateData2();
    }

 }

3.2 在該 Service 類中注入自己

如果不想再新加一個 Service 類,在該 Service 類中注入自己也是一種選擇。具體代碼如下:

@Servcie
public class ServiceA {
   @Autowired
   prvate ServiceA serviceA;

   public void save(User user) {
         queryData1();
         queryData2();
         serviceA.doSave(user);
   }

   @Transactional(rollbackFor=Exception.class)
   public void doSave(User user) {
       addData1();
       updateData2();
    }
 }

可能有些人可能會有這樣的疑問:這種做法會不會出現循環依賴問題?

答案:不會。

其實 spring ioc 內部的三級緩存保證了它,不會出現循環依賴問題。。

3.3 通過 AopContent 類

在該 Service 類中使用 AopContext.currentProxy() 獲取代理對象

@Servcie
public class ServiceA {

   public void save(User user) {
         queryData1();
         queryData2();
         ((ServiceA)AopContext.currentProxy()).doSave(user);
   }

   @Transactional(rollbackFor=Exception.class)
   public void doSave(User user) {
       addData1();
       updateData2();
    }
 }

4. 未被 spring 管理

在我們平時開發過程中,有個細節很容易被忽略。即使用 spring 事務的前提是:對象要被 spring 管理,需要創建 bean 實例。

通常情況下,我們通過 @Controller、@Service、@Component、@Repository 等註解,可以自動實現 bean 實例化和依賴注入的功能。

//@Service
public class UserService {

    @Transactional
    public void add(UserModel userModel) {
         saveData(userModel);
         updateData(userModel);
    }    
}

5. 多線程調用

在實際項目開發中,多線程的使用場景還是挺多的。如果 spring 事務用在多線程場景中,會有問題嗎?

@Slf4j
@Service
public class UserService {

    @Autowired
    private UserMapper userMapper;
    @Autowired
    private RoleService roleService;

    @Transactional
    public void add(UserModel userModel) throws Exception {
        userMapper.insertUser(userModel);
        new Thread(() -> {
            roleService.doOtherThing();
        }).start();
    }
}

@Service
public class RoleService {

    @Transactional
    public void doOtherThing() {
        System.out.println("保存role表數據");
    }
}

從上面的例子中,我們可以看到事務方法 add 中,調用了事務方法 doOtherThing,但是事務方法 doOtherThing 是在另外一個線程中調用的。

這樣會導致兩個方法不在同一個線程中,獲取到的數據庫連接不一樣,從而是兩個不同的事務。如果想 doOtherThing 方法中拋了異常,add 方法也回滾是不可能的。

如果看過 spring 事務源碼的朋友,可能會知道 spring 的事務是通過數據庫連接來實現的。當前線程中保存了一個 map,key 是數據源,value 是數據庫連接。

private static final ThreadLocal<Map<Object, Object>> resources =

  new NamedThreadLocal<>("Transactional resources");

我們說的同一個事務,其實是指同一個數據庫連接,只有擁有同一個數據庫連接才能同時提交和回滾。如果在不同的線程,拿到的數據庫連接肯定是不一樣的,所以是不同的事務。

6. 表不支持事務

周所周知,在 mysql5 之前,默認的數據庫引擎是myisam

它的好處就不用多說了:索引文件和數據文件是分開存儲的,對於查多寫少的單表操作,性能比 innodb 更好。

有些老項目中,可能還在用它。

在創建表的時候,只需要把ENGINE參數設置成MyISAM即可:

CREATE TABLE `category` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `one_category` varchar(20) COLLATE utf8mb4_bin DEFAULT NULL,
  `two_category` varchar(20) COLLATE utf8mb4_bin DEFAULT NULL,
  `three_category` varchar(20) COLLATE utf8mb4_bin DEFAULT NULL,
  `four_category` varchar(20) COLLATE utf8mb4_bin DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=MyISAM AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin

myisam 好用,但有個很致命的問題是:不支持事務

如果只是單表操作還好,不會出現太大的問題。但如果需要跨多張表操作,由於其不支持事務,數據極有可能會出現不完整的情況。

此外,myisam 還不支持行鎖和外鍵。

所以在實際業務場景中,myisam 使用的並不多。在 mysql5 以後,myisam 已經逐漸退出了歷史的舞臺,取而代之的是 innodb。

有時候我們在開發的過程中,發現某張表的事務一直都沒有生效,那不一定是 spring 事務的鍋,最好確認一下你使用的那張表,是否支持事務。

7. 未開啓事務

有時候,事務沒有生效的根本原因是沒有開啓事務。

你看到這句話可能會覺得好笑。

開啓事務不是一個項目中,最最最基本的功能嗎?

爲什麼還會沒有開啓事務?

沒錯,如果項目已經搭建好了,事務功能肯定是有的。

但如果你是在搭建項目 demo 的時候,只有一張表,而這張表的事務沒有生效。那麼會是什麼原因造成的呢?

當然原因有很多,但沒有開啓事務,這個原因極其容易被忽略。

如果你使用的是 springboot 項目,那麼你很幸運。因爲 springboot 通過DataSourceTransactionManagerAutoConfiguration類,已經默默的幫你開啓了事務。

你所要做的事情很簡單,只需要配置spring.datasource相關參數即可。

但如果你使用的還是傳統的 spring 項目,則需要在 applicationContext.xml 文件中,手動配置事務相關參數。如果忘了配置,事務肯定是不會生效的。

具體配置如下信息:

   
<!-- 配置事務管理器 --> 
<bean class="org.springframework.jdbc.datasource.DataSourceTransactionManager" id="transactionManager"> 
    <property ></property> 
</bean> 
<tx:advice id="advice" transaction-manager="transactionManager"> 
    <tx:attributes> 
        <tx:method />
    </tx:attributes> 
</tx:advice> 
<!-- 用切點把事務切進去 --> 
<aop:config> 
    <aop:pointcut expression="execution(* com.susan.*.*(..))" id="pointcut"/> 
    <aop:advisor advice-ref="advice" pointcut-ref="pointcut"/> 
</aop:config>

默默的說一句,如果在 pointcut 標籤中的切入點匹配規則,配錯了的話,有些類的事務也不會生效。

二 事務不回滾

1. 錯誤的傳播特性

其實,我們在使用@Transactional註解時,是可以指定propagation參數的。

該參數的作用是指定事務的傳播特性,spring 目前支持 7 種傳播特性:

如果我們在手動設置 propagation 參數的時候,把傳播特性設置錯了,比如:

@Service
public class UserService {

    @Transactional(propagation = Propagation.NEVER)
    public void add(UserModel userModel) {
        saveData(userModel);
        updateData(userModel);
    }
}

我們可以看到 add 方法的事務傳播特性定義成了 Propagation.NEVER,這種類型的傳播特性不支持事務,如果有事務則會拋異常。

目前只有這三種傳播特性纔會創建新事務:REQUIRED,REQUIRES_NEW,NESTED。

2. 自己吞了異常

事務不會回滾,最常見的問題是:開發者在代碼中手動 try...catch 了異常。比如:

@Slf4j
@Service
public class UserService {
    
    @Transactional
    public void add(UserModel userModel) {
        try {
            saveData(userModel);
            updateData(userModel);
        } catch (Exception e) {
            log.error(e.getMessage(), e);
        }
    }
}

這種情況下 spring 事務當然不會回滾,因爲開發者自己捕獲了異常,又沒有手動拋出,換句話說就是把異常吞掉了。

如果想要 spring 事務能夠正常回滾,必須拋出它能夠處理的異常。如果沒有拋異常,則 spring 認爲程序是正常的。

3. 手動拋了別的異常

即使開發者沒有手動捕獲異常,但如果拋的異常不正確,spring 事務也不會回滾。

@Slf4j
@Service
public class UserService {
    
    @Transactional
    public void add(UserModel userModel) throws Exception {
        try {
             saveData(userModel);
             updateData(userModel);
        } catch (Exception e) {
            log.error(e.getMessage(), e);
            throw new Exception(e);
        }
    }
}

上面的這種情況,開發人員自己捕獲了異常,又手動拋出了異常:Exception,事務同樣不會回滾。

因爲 spring 事務,默認情況下只會回滾RuntimeException(運行時異常)和Error(錯誤),對於普通的 Exception(非運行時異常),它不會回滾。

4. 自定義了回滾異常

在使用 @Transactional 註解聲明事務時,有時我們想自定義回滾的異常,spring 也是支持的。可以通過設置rollbackFor參數,來完成這個功能。

但如果這個參數的值設置錯了,就會引出一些莫名其妙的問題,例如:

@Slf4j
@Service
public class UserService {
    
    @Transactional(rollbackFor = BusinessException.class)
    public void add(UserModel userModel) throws Exception {
       saveData(userModel);
       updateData(userModel);
    }
}

如果在執行上面這段代碼,保存和更新數據時,程序報錯了,拋了 SqlException、DuplicateKeyException 等異常。而 BusinessException 是我們自定義的異常,報錯的異常不屬於 BusinessException,所以事務也不會回滾。

即使 rollbackFor 有默認值,但阿里巴巴開發者規範中,還是要求開發者重新指定該參數。

這是爲什麼呢?

因爲如果使用默認值,一旦程序拋出了 Exception,事務不會回滾,這會出現很大的 bug。所以,建議一般情況下,將該參數設置成:Exception 或 Throwable。

5. 嵌套事務回滾多了

public class UserService {

    @Autowired
    private UserMapper userMapper;

    @Autowired
    private RoleService roleService;

    @Transactional
    public void add(UserModel userModel) throws Exception {
        userMapper.insertUser(userModel);
        roleService.doOtherThing();
    }
}

@Service
public class RoleService {

    @Transactional(propagation = Propagation.NESTED)
    public void doOtherThing() {
        System.out.println("保存role表數據");
    }
}

這種情況使用了嵌套的內部事務,原本是希望調用 roleService.doOtherThing 方法時,如果出現了異常,只回滾 doOtherThing 方法裏的內容,不回滾 userMapper.insertUser 裏的內容,即回滾保存點。。但事實是,insertUser 也回滾了。

why?

因爲 doOtherThing 方法出現了異常,沒有手動捕獲,會繼續往上拋,到外層 add 方法的代理方法中捕獲了異常。所以,這種情況是直接回滾了整個事務,不只回滾單個保存點。

怎麼樣才能只回滾保存點呢?

@Slf4j
@Service
public class UserService {

    @Autowired
    private UserMapper userMapper;

    @Autowired
    private RoleService roleService;

    @Transactional
    public void add(UserModel userModel) throws Exception {

        userMapper.insertUser(userModel);
        try {
            roleService.doOtherThing();
        } catch (Exception e) {
            log.error(e.getMessage(), e);
        }
    }
}

可以將內部嵌套事務放在 try/catch 中,並且不繼續往上拋異常。這樣就能保證,如果內部嵌套事務中出現異常,只回滾內部事務,而不影響外部事務。

三 其他

1 大事務問題

在使用 spring 事務時,有個讓人非常頭疼的問題,就是大事務問題。

通常情況下,我們會在方法上@Transactional註解,填加事務功能,比如:

@Service
public class UserService {
    
    @Autowired 
    private RoleService roleService;
    
    @Transactional
    public void add(UserModel userModel) throws Exception {
       query1();
       query2();
       query3();
       roleService.save(userModel);
       update(userModel);
    }
}


@Service
public class RoleService {
    
    @Autowired 
    private RoleService roleService;
    
    @Transactional
    public void save(UserModel userModel) throws Exception {
       query4();
       query5();
       query6();
       saveData(userModel);
    }
}

@Transactional註解,如果被加到方法上,有個缺點就是整個方法都包含在事務當中了。

上面的這個例子中,在 UserService 類中,其實只有這兩行才需要事務:

roleService.save(userModel);
update(userModel);

在 RoleService 類中,只有這一行需要事務:

saveData(userModel);

現在的這種寫法,會導致所有的 query 方法也被包含在同一個事務當中。

如果 query 方法非常多,調用層級很深,而且有部分查詢方法比較耗時的話,會造成整個事務非常耗時,而從造成大事務問題。

關於大事務問題的危害,可以閱讀一下我的另一篇文章《讓人頭痛的大事務問題到底要如何解決?》,上面有詳細的講解。

2. 編程式事務

上面聊的這些內容都是基於@Transactional註解的,主要說的是它的事務問題,我們把這種事務叫做:聲明式事務

其實,spring 還提供了另外一種創建事務的方式,即通過手動編寫代碼實現的事務,我們把這種事務叫做:編程式事務。例如:

   @Autowired
   private TransactionTemplate transactionTemplate;
   
   ...
   
   public void save(final User user) {
         queryData1();
         queryData2();
         transactionTemplate.execute((status) ={
            addData1();
            updateData2();
            return Boolean.TRUE;
         })
   }

在 spring 中爲了支持編程式事務,專門提供了一個類:TransactionTemplate,在它的 execute 方法中,就實現了事務的功能。

相較於@Transactional註解聲明式事務,我更建議大家使用,基於TransactionTemplate的編程式事務。主要原因如下:

  1. 避免由於 spring aop 問題,導致事務失效的問題。

  2. 能夠更小粒度的控制事務的範圍,更直觀。

建議在項目中少使用 @Transactional 註解開啓事務。但並不是說一定不能用它,如果項目中有些業務邏輯比較簡單,而且不經常變動,使用 @Transactional 註解開啓事務開啓事務也無妨,因爲它更簡單,開發效率更高,但是千萬要小心事務失效的問題。

推薦關注「Linux 愛好者」,提升 Linux 技能

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/kXfrQJvgn_hQZGwqcS9k2A