完蛋,我的事務怎麼不生效?

秦怀杂货店發表於2021-12-28

前言

事務大家平時應該都有寫,之前寫事務的時候遇到一點坑,居然不生效,後來排查了一下,複習了一下各種事務失效的場景,想著不如來一個總結,這樣下次排查問題,就能有恃無恐了。那麼先來複習一下事務相關知識,事務是指操作的最小工作單位,作為一個單獨且不可切割的單元操作,要麼全部成功,要麼全部失敗。事務有四大特性(ACID):

  • 原子性(Atomicity):事務包含的操作,要麼全部成功,要麼全部失敗回滾,不會存在一半成功一半失敗的中間狀態。比如AB一開始都有500元,AB轉賬100,那麼A的錢少了100B的錢就必須多了100,不能A少了錢,B也沒收到錢,那這個錢就不翼而飛了,不符合原子性了。
  • 一致性(Consistency):一致性是指事務執行之前和之後,保持整體狀態的一致,比如AB一開始都有500元,加起來是1000元,這個是之前的狀態,AB轉賬100,那麼最後A400B600,兩者加起來還是1000,這個整體狀態需要保證。
  • 隔離性(Isolation):前面兩個特性都是針對同一個事務的,而隔離性指的是不同的事務,當多個事務同時在操作同一個資料的時候,需要隔離不同事務之間的影響,併發執行的事務之間不能相互干擾。
  • 永續性(Durability):指事務如果一旦被提交了,那麼對資料庫的修改就是永久性的,就算是資料庫發生故障了,已經發生的修改也必然存在。

事務的幾個特性並不是資料庫事務專屬的,廣義上的事務是一種工作機制,是併發控制的基本單位,保證操作的結果,還會包括分散式事務之類的,但是一般我們談論事務,不特指的話,說的就是與資料庫相關的,因為我們平時說的事務基本都基於資料庫來完成。

事務不僅是適用於資料庫。我們可以將此概念擴充套件到其他元件,類似佇列服務或外部系統狀態。因此,“一系列資料操作語句必須完全完成或完全失敗,以一致的狀態離開系統”

測試環境

前面我們已經部署過了一些demo專案,以及用docker快速搭建環境,本文基於的也是之前的環境:

  • JDK 1.8
  • Maven 3.6
  • Docker
  • Mysql

事務正常回滾的樣例

正常的事務樣例,包含兩個介面,一個是獲取所有的使用者中的資料,另外一個更新的,是update使用者資料,其實就是每個使用者的年齡+1,我們讓一次操作完第一個之後,丟擲異常,看看最後的結果:

@Service("userService")
public class UserServiceImpl implements UserService {

    @Resource
    UserMapper userMapper;

    @Autowired
    RedisUtil redisUtil;

    @Override
    public List<User> getAllUsers() {
        List<User> users = userMapper.getAllUsers();
        return users;
    }

    @Override
    @Transactional
    public void updateUserAge() {
        userMapper.updateUserAge(1);
        int i= 1/0;
        userMapper.updateUserAge(2);
    }
}

資料庫操作:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.aphysia.springdocker.mapper.UserMapper">
    <select id="getAllUsers" resultType="com.aphysia.springdocker.model.User">
        SELECT * FROM user
    </select>

    <update id="updateUserAge" parameterType="java.lang.Integer">
        update user set age=age+1 where id =#{id}
    </update>
</mapper>

先獲取http://localhost:8081/getUserList所有的使用者看看:

image-20211124233731699

在呼叫更新介面,頁面丟擲錯誤了:

image-20211124233938596

控制檯也出現了異常,意思是除以0,異常:

java.lang.ArithmeticException: / by zero
    at com.aphysia.springdocker.service.impl.UserServiceImpl.updateUserAge(UserServiceImpl.java:35) ~[classes/:na]
    at com.aphysia.springdocker.service.impl.UserServiceImpl$$FastClassBySpringCGLIB$$c8cc4526.invoke(<generated>) ~[classes/:na]
    at org.springframework.cglib.proxy.MethodProxy.invoke(MethodProxy.java:218) ~[spring-core-5.3.12.jar:5.3.12]
    at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.invokeJoinpoint(CglibAopProxy.java:783) ~[spring-aop-5.3.12.jar:5.3.12]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163) ~[spring-aop-5.3.12.jar:5.3.12]
    at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:753) ~[spring-aop-5.3.12.jar:5.3.12]
    at org.springframework.transaction.interceptor.TransactionInterceptor$1.proceedWithInvocation(TransactionInterceptor.java:123) ~[spring-tx-5.3.12.jar:5.3.12]
    at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:388) ~[spring-tx-5.3.12.jar:5.3.12]
    at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119) ~[spring-tx-5.3.12.jar:5.3.12]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) ~[spring-aop-5.3.12.jar:5.3.12]
    at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:753) ~[spring-aop-5.3.12.jar:5.3.12]
    at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:698) ~[spring-aop-5.3.12.jar:5.3.12]
    at com.aphysia.springdocker.service.impl.UserServiceImpl$$EnhancerBySpringCGLIB$$25070cf0.updateUserAge(<generated>) ~[classes/:na]

然後我們再次請求http://localhost:8081/getUserList,看到資料兩個都是11說明資料都沒有發生變化,第一個操作完之後,異常,回滾成功了:

[{"id":1,"name":"李四","age":11},{"id":2,"name":"王五","age":11}]

那什麼時候事務不正常回滾呢?且聽我細細道來:

實驗

1. 引擎設定不對

我們知道,Mysql其實有一個資料庫引擎的概念,我們可以用show engines來檢視Mysql支援的資料引擎:

image-20211124234913121

可以看到Transactions那一列,也就是事務支援,只有InnoDB,那就是隻有InnoDB支援事務,所以要是引擎設定成其他的事務會無效。

我們可以用show variables like 'default_storage_engine'看預設的資料庫引擎,可以看到預設是InnoDB:

mysql> show variables like 'default_storage_engine';
+------------------------+--------+
| Variable_name          | Value  |
+------------------------+--------+
| default_storage_engine | InnoDB |
+------------------------+--------+

那我們看看我們演示的資料表是不是也是用了InnoDB,可以看到確實是使用InnoDB

image-20211124235353205

那我們把該表的引擎修改成MyISAM會怎麼樣呢?試試,在這裡我們只修改資料表的資料引擎:

mysql> ALTER TABLE user ENGINE=MyISAM;
Query OK, 2 rows affected (0.06 sec)
Records: 2  Duplicates: 0  Warnings: 0

然後再update,不出意料,還是會報錯,看起來錯誤沒有什麼不同:

image-20211125000554928

但是獲取全部資料的時候,第一個資料更新成功了,第二個資料沒有更新成功,說明事務沒有生效。

[{"id":1,"name":"李四","age":12},{"id":2,"name":"王五","age":11}]

結論:必須設定為InnoDB引擎,事務才生效。

2. 方法不能是 private

事務必須是public方法,如果用在了private方法上,那麼事務會自動失效,但是在IDEA中,只要我們寫了就會報錯:Methods annotated with '@Transactional' must be overrideable,意思是事務的註解加上的方法,必須是可以重寫的,private方法是不可以重寫的,所以報錯了。

image-20211125083648166

同樣的final修飾的方法,如果加上了註解,也會報錯,因為用final就是不想被重寫:

image-20211126084347611

Spring中主要是用放射獲取Bean的註解資訊,然後利用基於動態代理技術的AOP來封裝了整個事務,理論上我想呼叫private方法也是沒有問題的,在方法級別使用method.setAccessible(true);就可以,但是可能Spring團隊覺得private方法就是開發人員意願上不願意公開的介面,沒有必要破壞封裝性,這樣容易導致混亂。

Protected方法可不可以?不可以!

下面我們為了實現,魔改程式碼結構,因為介面不能用Portected,如果用了介面,就不可能用protected方法,會直接報錯,而且必須在同一個包裡面使用,我們把controllerservice放到同一個包下:

image-20211125090358299

測試後發現事務不生效,結果依然是一個更新了,另外一個沒有更新:

[{"id":1,"name":"李四","age":12},{"id":2,"name":"王五","age":11}]

結論:必須使用在public方法上,不能用在private,finalstatic方法上,否則不會生效。

3. 異常必須是執行期的異常

Springboot管理異常的時候,只會對執行時的異常(RuntimeException 以及它的子類) 進行回滾,比如我們前面寫的i=1/0;,就會產生執行時的異常。

從原始碼來看也可以看到,rollbackOn(ex)方法會判斷異常是RuntimeException或者Error

    public boolean rollbackOn(Throwable ex) {
        return (ex instanceof RuntimeException || ex instanceof Error);
    }

異常主要分為以下型別:

所有的異常都是Throwable,而Error是錯誤資訊,一般是程式發生了一些不可控的錯誤,比如沒有這個檔案,記憶體溢位,IO突然錯誤了。而Exception下,除了RuntimeException,其他的都是CheckException,也就是可以處理的異常,Java程式在編寫的時候就必須處理這個異常,否則編譯是通不過去的。

由下面的圖我們可以看出,CheckedException,我列舉了幾個常見的IOException IO異常,NoSuchMethodException沒有找到這個方法,ClassNotFoundException 沒找到這個類,而RunTimeException有常見的幾種:

  • 陣列越界異常:IndexOutOfBoundsException
  • 型別轉換異常:ClassCastException
  • 空指標異常:NullPointerException

事務預設回滾的是:執行時異常,也就是RunTimeException,如果丟擲其他的異常是無法回滾的,比如下面的程式碼,事務就會失效:

    @Transactional
     public void updateUserAge() throws Exception{
        userMapper.updateUserAge(1);
        try{
            int i = 1/0;
        }catch (Exception ex){
            throw new IOException("IO異常");
        }
        userMapper.updateUserAge(2);
    }

4. 配置不對導致

  1. 方法上需要使用@Transactional才能開啟事務
  2. 多個資料來源配置或者多個事務管理器的時候,注意如果運算元據庫A,不能使用B的事務,雖然這個問題很幼稚,但是有時候用錯難查詢問題。
  3. 如果在Spring中,需要配置@EnableTransactionManagement來開啟事務,等同於配置xml檔案*<tx:annotation-driven/>*,但是在Springboot中已經不需要了,在springbootSpringBootApplication註解包含了@EnableAutoConfiguration註解,會自動注入。

@EnableAutoConfiguration自動注入了哪些東西呢?在jetbrains://idea/navigate/reference?project=springDocker&path=~/.m2/repository/org/springframework/boot/spring-boot-autoconfigure/2.5.6/spring-boot-autoconfigure-2.5.6.jar!/META-INF/spring.factories下有自動注入的配置:

# Auto Configure
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
org.springframework.boot.autoconfigure.admin.SpringApplicationAdminJmxAutoConfiguration,\
org.springframework.boot.autoconfigure.aop.AopAutoConfiguration,\
org.springframework.boot.autoconfigure.amqp.RabbitAutoConfiguration,\
...
org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration,\
...

裡面配置了一個TransactionAutoConfiguration,這是事務自動配置類:

@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(PlatformTransactionManager.class)
@AutoConfigureAfter({ JtaAutoConfiguration.class, HibernateJpaAutoConfiguration.class,
        DataSourceTransactionManagerAutoConfiguration.class, Neo4jDataAutoConfiguration.class })
@EnableConfigurationProperties(TransactionProperties.class)
public class TransactionAutoConfiguration {
  ...
    @Configuration(proxyBeanMethods = false)
    @ConditionalOnBean(TransactionManager.class)
    @ConditionalOnMissingBean(AbstractTransactionManagementConfiguration.class)
    public static class EnableTransactionManagementConfiguration {

        @Configuration(proxyBeanMethods = false)
        @EnableTransactionManagement(proxyTargetClass = false)   // 這裡開啟了事務
        @ConditionalOnProperty(prefix = "spring.aop", name = "proxy-target-class", havingValue = "false")
        public static class JdkDynamicAutoProxyConfiguration {

        }
    ...

    }

}

值得注意的是,@Transactional除了可以用於方法,還可以用於類,表示這個類所有的public方法都會配置事務。

5. 事務方法不能在同個類裡面呼叫

想要進行事務管理的方法只能在其他類裡面被呼叫,不能在當前類被呼叫,否則會失效,為了實現這個目的,如果同一個類有不少事務方法,還有其他方法,這個時候有必要抽取出一個事務類,這樣分層會比較清晰,避免後繼者寫的時候在同一個類呼叫事務方法,造成混亂。

事務失效的例子:

比如我們將service事務方法改成:

    public void testTransaction(){
        updateUserAge();
    }

    @Transactional
     public void updateUserAge(){
        userMapper.updateUserAge(1);
        int i = 1/0;
        userMapper.updateUserAge(2);
    }

controller裡面呼叫的是沒有事務註解的方法,再間接呼叫事務方法:

    @RequestMapping("/update")
    @ResponseBody
    public int update() throws Exception{
        userService.testTransaction();
        return 1;
    }

呼叫之後,發現事務失效,一個更新另外一個沒有更新:

[{"id":1,"name":"李四","age":12},{"id":2,"name":"王五","age":11}]

為什麼會這樣呢?

Spring用切面對方法進行包裝,只對外部呼叫方法進行攔截,內部方法沒有進行攔截。

看原始碼:實際上我們呼叫事務方法的時候,會進入DynamicAdvisedInterceptorpublic Object intercept(Object proxy, Method method, Object[] args, MethodProxy methodProxy)()方法:

image-20211128125711187

裡面呼叫了AdvisedSupport.getInterceptorsAndDynamicInterceptionAdvice(),這裡是獲取呼叫呼叫鏈。而沒有@Transactional註解的方法userService.testTransaction(),根本獲取不到代理呼叫鏈,呼叫的還是原來的類的方法。

spring裡面要想對一個方法進行代理,用的就是aop,肯定需要一個標識,標識哪一個方法或者類需要被代理,spring裡面定義了@Transactional作為切點,我們定義這個標識,就會被代理。

代理的時機是什麼時候呢?

Spring統一管理了我們的bean,代理的時機自然就是建立bean的過程,看看哪一個類帶了這個標識,就生成代理物件。

SpringTransactionAnnotationParser這個類有一個方法是用來判斷TransactionAttribute註解的:

    @Override
    @Nullable
    public TransactionAttribute parseTransactionAnnotation(AnnotatedElement element) {
        AnnotationAttributes attributes = AnnotatedElementUtils.findMergedAnnotationAttributes(
                element, Transactional.class, false, false);
        if (attributes != null) {
            return parseTransactionAnnotation(attributes);
        }
        else {
            return null;
        }
  }

6.多執行緒下事務失效

假設我們在多執行緒裡面像以下方式使用事務,那麼事務是不能正常回滾的:

    @Transactional
    public void updateUserAge() {
        new Thread(
                new Runnable() {
                    @Override
                    public void run() {
                        userMapper.updateUserAge(1);
                    }
                }
        ).start();
        int i = 1 / 0;
        userMapper.updateUserAge(2);
    }

因為不同的執行緒使用的是不同SqlSession,相當於另外一個連線,根本不會用到同一個事務:

2021-11-28 14:06:59.852 DEBUG 52764 --- [       Thread-2] org.mybatis.spring.SqlSessionUtils       : Creating a new SqlSession
2021-11-28 14:06:59.930 DEBUG 52764 --- [       Thread-2] c.a.s.mapper.UserMapper.updateUserAge    : <==    Updates: 1
2021-11-28 14:06:59.931 DEBUG 52764 --- [       Thread-2] org.mybatis.spring.SqlSessionUtils       : Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@2e956409]

7. 注意合理使用事務巢狀

首先事務是有傳播機制的:

  • REQUIRED(預設):支援使用當前事務,如果當前事務不存在,建立一個新事務,如果有直接使用當前的事務。
  • SUPPORTS:支援使用當前事務,如果當前事務不存在,就不會使用事務。
  • MANDATORY:支援使用當前事務,如果當前事務不存在,則丟擲Exception,也就是必須當前處於事務裡面。
  • REQUIRES_NEW:建立新事務,如果當前事務存在,把當前事務掛起。
  • NOT_SUPPORTED:沒有事務執行,如果當前事務存在,把當前事務掛起。
  • NEVER:沒有事務執行,如果當前有事務則丟擲Exception
  • NESTED:巢狀事務,如果當前事務存在,那麼在巢狀的事務中執行。如果當前事務不存在,則表現跟`REQUIRED

查不多。

預設的是REQUIRED,也就是事務裡面呼叫另外的事務,實際上不會重新建立事務,而是會重用當前的事務。那如果我們這樣來寫巢狀事務:

@Service("userService")
public class UserServiceImpl {
    @Autowired
    UserServiceImpl2 userServiceImpl2;
  
    @Resource
    UserMapper userMapper;
  
      @Transactional
    public void updateUserAge() {
        try {
            userMapper.updateUserAge(1);
            userServiceImpl2.updateUserAge();
        }catch (Exception ex){
            ex.printStackTrace();
        }
    }
}

呼叫的另外一個事務:

@Service("userService2")
public class UserServiceImpl2 {

    @Resource
    UserMapper userMapper;

    @Transactional
    public void updateUserAge() {
        userMapper.updateUserAge(2);
        int i = 1 / 0;
    }
}

會丟擲以下錯誤:

org.springframework.transaction.UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only

我們但是實際事務是正常回滾掉了,結果是對的,之所以出現這個問題,是因為裡面到方法丟擲了異常,用的是同一個事務,說明事務必須被回滾掉的,但是外層被catch住了,本來就是同一個事務,一個說回滾,一個catch住不讓spring感知到Exception,那不是自相矛盾麼?所以spring報錯說:這個事務被標識了必須回滾掉,最終還是回滾掉了

怎麼處理呢?

    1. 外層主動丟擲錯誤,throw new RuntimeException()
    1. 使用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();主動標識回滾
    @Transactional
    public void updateUserAge() {
        try {
            userMapper.updateUserAge(1);
            userServiceImpl2.updateUserAge();
        }catch (Exception ex){
            ex.printStackTrace();
            TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
        }
    }

8. 依賴外部網路請求回滾需要考慮

有些時候,我們不僅操作自己的資料庫,還需要同時考慮外部的請求,比如同步資料,同步失敗,需要回滾掉自己的狀態,在這種場景下,必須考慮網路請求是否會出錯,出錯如何處理,錯誤碼是哪一個的時候才成功。

如果網路超時了,實際上成功了,但是我們判定為沒有成功,回滾掉了,可能會導致資料不一致。這種需要被呼叫方支援重試,重試的時候,需要支援冪等,多次呼叫儲存狀態的一致,雖然整個主流程很簡單,裡面的細節還是比較多的。

image-20211128153822791

總結

事務被Spring包裹了複雜性,很多東西可能原始碼很深,我們用的時候注意模擬測試一下呼叫是不是能正常回滾,不能理所當然,人是會出錯的,而很多時候黑盒測試根本測試這種異常資料,如果沒有正常回滾,後面需要手動處理,考慮到系統之間同步的問題,會造成很多不必要的麻煩,手動改資料庫這流程就必須走。

image-20211128154248397

【作者簡介】
秦懷,公眾號【秦懷雜貨店】作者,技術之路不在一時,山高水長,縱使緩慢,馳而不息。個人寫作方向:Java原始碼解析JDBCMybatisSpringredis分散式劍指OfferLeetCode等,認真寫好每一篇文章,不喜歡標題黨,不喜歡花裡胡哨,大多寫系列文章,不能保證我寫的都完全正確,但是我保證所寫的均經過實踐或者查詢資料。遺漏或者錯誤之處,還望指正。

劍指Offer全部題解PDF

2020年我寫了什麼?

開源程式設計筆記

相關文章