前言
事務大家平時應該都有寫,之前寫事務的時候遇到一點坑,居然不生效,後來排查了一下,複習了一下各種事務失效的場景,想著不如來一個總結,這樣下次排查問題,就能有恃無恐了。那麼先來複習一下事務相關知識,事務是指操作的最小工作單位,作為一個單獨且不可切割的單元操作,要麼全部成功,要麼全部失敗。事務有四大特性(ACID
):
- 原子性(
Atomicity
):事務包含的操作,要麼全部成功,要麼全部失敗回滾,不會存在一半成功一半失敗的中間狀態。比如A
和B
一開始都有500
元,A
給B
轉賬100
,那麼A
的錢少了100
,B
的錢就必須多了100
,不能A
少了錢,B
也沒收到錢,那這個錢就不翼而飛了,不符合原子性了。 - 一致性(
Consistency
):一致性是指事務執行之前和之後,保持整體狀態的一致,比如A
和B
一開始都有500
元,加起來是1000
元,這個是之前的狀態,A
給B
轉賬100
,那麼最後A
是400
,B
是600
,兩者加起來還是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
所有的使用者看看:
在呼叫更新介面,頁面丟擲錯誤了:
控制檯也出現了異常,意思是除以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
支援的資料引擎:
可以看到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
那我們把該表的引擎修改成MyISAM
會怎麼樣呢?試試,在這裡我們只修改資料表的資料引擎:
mysql> ALTER TABLE user ENGINE=MyISAM;
Query OK, 2 rows affected (0.06 sec)
Records: 2 Duplicates: 0 Warnings: 0
然後再update
,不出意料,還是會報錯,看起來錯誤沒有什麼不同:
但是獲取全部資料的時候,第一個資料更新成功了,第二個資料沒有更新成功,說明事務沒有生效。
[{"id":1,"name":"李四","age":12},{"id":2,"name":"王五","age":11}]
結論:必須設定為InnoDB
引擎,事務才生效。
2. 方法不能是 private
事務必須是public
方法,如果用在了private
方法上,那麼事務會自動失效,但是在IDEA
中,只要我們寫了就會報錯:Methods annotated with '@Transactional' must be overrideable
,意思是事務的註解加上的方法,必須是可以重寫的,private
方法是不可以重寫的,所以報錯了。
同樣的final
修飾的方法,如果加上了註解,也會報錯,因為用final
就是不想被重寫:
Spring
中主要是用放射獲取Bean
的註解資訊,然後利用基於動態代理技術的AOP
來封裝了整個事務,理論上我想呼叫private
方法也是沒有問題的,在方法級別使用method.setAccessible(true);
就可以,但是可能Spring
團隊覺得private
方法就是開發人員意願上不願意公開的介面,沒有必要破壞封裝性,這樣容易導致混亂。
Protected
方法可不可以?不可以!
下面我們為了實現,魔改程式碼結構,因為介面不能用Portected
,如果用了介面,就不可能用protected
方法,會直接報錯,而且必須在同一個包裡面使用,我們把controller
和service
放到同一個包下:
測試後發現事務不生效,結果依然是一個更新了,另外一個沒有更新:
[{"id":1,"name":"李四","age":12},{"id":2,"name":"王五","age":11}]
結論:必須使用在public
方法上,不能用在private
,final
,static
方法上,否則不會生效。
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. 配置不對導致
- 方法上需要使用
@Transactional
才能開啟事務 - 多個資料來源配置或者多個事務管理器的時候,注意如果運算元據庫
A
,不能使用B
的事務,雖然這個問題很幼稚,但是有時候用錯難查詢問題。 - 如果在
Spring
中,需要配置@EnableTransactionManagement
來開啟事務,等同於配置xml
檔案*<tx:annotation-driven/>*
,但是在Springboot
中已經不需要了,在springboot
中SpringBootApplication
註解包含了@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
用切面對方法進行包裝,只對外部呼叫方法進行攔截,內部方法沒有進行攔截。
看原始碼:實際上我們呼叫事務方法的時候,會進入DynamicAdvisedInterceptor
的public Object intercept(Object proxy, Method method, Object[] args, MethodProxy methodProxy)()
方法:
裡面呼叫了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
報錯說:這個事務被標識了必須回滾掉,最終還是回滾掉了。
怎麼處理呢?
- 外層主動丟擲錯誤,
throw new RuntimeException()
- 外層主動丟擲錯誤,
- 使用
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
主動標識回滾
- 使用
@Transactional
public void updateUserAge() {
try {
userMapper.updateUserAge(1);
userServiceImpl2.updateUserAge();
}catch (Exception ex){
ex.printStackTrace();
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}
}
8. 依賴外部網路請求回滾需要考慮
有些時候,我們不僅操作自己的資料庫,還需要同時考慮外部的請求,比如同步資料,同步失敗,需要回滾掉自己的狀態,在這種場景下,必須考慮網路請求是否會出錯,出錯如何處理,錯誤碼是哪一個的時候才成功。
如果網路超時了,實際上成功了,但是我們判定為沒有成功,回滾掉了,可能會導致資料不一致。這種需要被呼叫方支援重試,重試的時候,需要支援冪等,多次呼叫儲存狀態的一致,雖然整個主流程很簡單,裡面的細節還是比較多的。
總結
事務被Spring
包裹了複雜性,很多東西可能原始碼很深,我們用的時候注意模擬測試一下呼叫是不是能正常回滾,不能理所當然,人是會出錯的,而很多時候黑盒測試根本測試這種異常資料,如果沒有正常回滾,後面需要手動處理,考慮到系統之間同步的問題,會造成很多不必要的麻煩,手動改資料庫這流程就必須走。
【作者簡介】:
秦懷,公眾號【秦懷雜貨店】作者,技術之路不在一時,山高水長,縱使緩慢,馳而不息。個人寫作方向:Java原始碼解析
,JDBC
,Mybatis
,Spring
,redis
,分散式
,劍指Offer
,LeetCode
等,認真寫好每一篇文章,不喜歡標題黨,不喜歡花裡胡哨,大多寫系列文章,不能保證我寫的都完全正確,但是我保證所寫的均經過實踐或者查詢資料。遺漏或者錯誤之處,還望指正。