實戰分析:事務的隔離級别和傳播屬性
什麼是事務?
要麼全部都要執行,要麼就都不執行。
事務所具有的四種特性
原子性,一緻性,隔離性,持久性
原子性
個人了解,就是事務執行不可分割,要麼全部完成,要麼全部拉倒不幹。
一緻性
關于一緻性這個概念我們來舉個例子說明吧,假設張三給李四轉了100元,那麼需要先從張三那邊扣除100,然後李四那邊增加100,這個轉賬的過程對于其他事務而言是無法看到的,這種狀态始終都在保持一緻,這個過程我們稱之為一緻性。
隔離性
并發通路資料庫時,一個使用者的事務不被其他事務所幹擾,各并發事務之間資料是獨立的;
持久性
一個事務被送出之後。它對資料庫中資料的改變是持久的,即使資料庫發生故障也不應該對其有任何影響。
為什麼會出現事務的隔離級别?
我們都知道,資料庫都是有相應的事物隔離級别。之是以需要分成不同級别的事務,這個是因為在并發的場景下,讀取資料可能會有出現髒讀,不可重複讀以及幻讀的情況,是以需要設定相應的事物隔離級别。
![](https://img.laitimes.com/img/9ZDMuAjOiMmIsIjOiQnIsISM9AnYldnJwAzN9c3Pn5Gcu4WNHNWdZRUT5FkaOFTSq5UMFRFT3VEVZJjRtFmdjRUT2tGVNdXS6xkeSdVW2h3RjFTODRWdWdEZ1ljMZRXQzQmdwIjYqVTaiBHbYF2bwhVWyYUbhV3YzQ2M5kHT20ESjBjUIF2Lc12bj5SYphXa5VWen5WY35iclN3Ztl2Lc9CX6MHc0RHaiojIsJye.png)
為了友善了解,我們将使用java程式代碼來示範并發讀取資料時候會産生的相應場景:
環境準備:
- jdk8
- mysql資料
建立測試使用表:
CREATE TABLE `money` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`money` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
一個友善于操作mysql的簡單JdbcUtil工具類:
import java.io.IOException;
import java.sql.*;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Properties;
/**
* Jdbc操作資料庫工具類
*
* @author idea
* @version 1.0
*/
public class JdbcUtil {
public static final String DRIVER;
public static final String URL;
public static final String USERNAME;
public static final String PASSWORD;
private static Properties prop = null;
private static PreparedStatement ps = null;
/**
* 加載配置檔案中的資訊
*/
static {
prop = new Properties();
try {
prop.load(JdbcUtil.class.getClassLoader().getResourceAsStream("db.properties"));
} catch (IOException e) {
e.printStackTrace();
}
DRIVER = prop.getProperty("driver");
URL = prop.getProperty("url");
USERNAME = prop.getProperty("username");
PASSWORD = prop.getProperty("password");
}
/**
* 擷取連接配接
*
* @return void
* @author blindeagle
*/
public static Connection getConnection() {
try {
Class.forName(DRIVER);
Connection conn = DriverManager.getConnection(URL, USERNAME, PASSWORD);
return conn;
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (SQLException e) {
e.printStackTrace();
}
return null;
}
/**
* 資料轉換為list類型
*
* @param rs
* @return
* @throws SQLException
*/
public static List convertList(ResultSet rs) throws SQLException {
List list = new ArrayList();
//擷取鍵名
ResultSetMetaData md = rs.getMetaData();
//擷取行的數量
int columnCount = md.getColumnCount();
while (rs.next()) {
//聲明Map
HashMap<String,Object> rowData = new HashMap();
for (int i = 1; i <= columnCount; i++) {
//擷取鍵名及值
rowData.put(md.getColumnName(i), rs.getObject(i));
}
list.add(rowData);
}
return list;
}
}
髒讀
所謂的髒讀是指讀取到沒有送出的資料資訊。
模拟場景:兩個線程a,b同時通路資料庫進行操作,a線程需要插入資料到庫裡面,但是沒有送出事務,這個時候b線程需要讀取資料庫的資訊,将a裡面所要插入的資料(但是沒有送出)給讀取了進來,造成了髒讀現象。
代碼如下所示:
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
/**
* @author idea
* @date 2019/7/2
* @Version V1.0
*/
public class DirtyReadDemo {
public static final String READ_SQL = "SELECT * FROM money";
public static final String WRITE_SQL = "INSERT INTO `money` (`id`, `money`) VALUES ('3', '350')";
public Object lock = new Object();
/**
* 髒讀模拟(注意:需要設定表的存儲引擎為innodb類型)
*/
public static void dirtyRead() {
try {
Connection conn = JdbcUtil.getConnection();
conn.setAutoCommit(false);
PreparedStatement writePs = conn.prepareStatement(WRITE_SQL);
writePs.executeUpdate();
System.out.println("執行寫取資料操作----");
Thread.sleep(500);
//需要保證連接配接不同
Connection readConn = JdbcUtil.getConnection();
//注意這裡面需要保證送出的事物等級為:未送出讀
readConn.setTransactionIsolation(Connection.TRANSACTION_READ_UNCOMMITTED);
PreparedStatement readPs = readConn.prepareStatement(READ_SQL);
ResultSet rs = readPs.executeQuery();
System.out.println("執行讀取資料操作----");
List list = JdbcUtil.convertList(rs);
for (Object o : list) {
System.out.println(o);
}
readConn.close();
} catch (SQLException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
dirtyRead();
}
}
由于這個案例裡面的事物隔離級别知識設定在了TRANSACTION_READ_UNCOMMITTED層級,是以對于沒有送出事務的資料也會被讀取進來。造成了髒資料讀取的情況。
是以程式運作之後的結果如下:
為了預防髒讀的情況發生,我們通常需要提升事務的隔離級别,從原先的TRANSACTION_READ_UNCOMMITTED提升到TRANSACTION_READ_COMMITTED,這個時候我們再來運作一下程式,會發現原先有的髒資料讀取消失了:
不可重複讀
所謂的不可重複讀,我的了解是,多個線程a,b同時讀取資料庫裡面的資料,a線程負責插入資料,b線程負責寫入資料,b線程裡面有兩次讀取資料庫的操作,分别是select1和select2,由于事務的隔離級别設定在了TRANSACTION_READ_COMMITTED,是以當select1執行了之後,a線程插入了新的資料,再去執行select2操作的時候會讀取出新的資料資訊,導緻出現了不可重複讀問題。
示範代碼:
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
/**
* 不可重複讀案例
* @author idea
* @date 2019/7/2
* @Version V1.0
*/
public class NotRepeatReadDemo {
public static final String READ_SQL = "SELECT * FROM money";
public static final String WRITE_SQL = "INSERT INTO `money` (`id`, `money`) VALUES ('3', '350')";
public Object lock = new Object();
/**
* 不可重複讀模拟
*/
public void notRepeatRead() {
Thread writeThread = new Thread(new Runnable() {
@Override
public void run() {
try (Connection conn = JdbcUtil.getConnection();) {
//堵塞等待喚醒
synchronized (lock) {
lock.wait();
}
conn.setAutoCommit(true);
PreparedStatement ps = conn.prepareStatement(WRITE_SQL);
ps.executeUpdate();
System.out.println("執行寫取資料操作----");
ps.close();
} catch (SQLException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
Thread readThread = new Thread(new Runnable() {
@Override
public void run() {
try {
Connection readConn = JdbcUtil.getConnection();
readConn.setAutoCommit(false);
readConn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);
PreparedStatement readPs = readConn.prepareStatement(READ_SQL);
ResultSet rs = readPs.executeQuery();
System.out.println("執行讀取資料操作1----");
List list = JdbcUtil.convertList(rs);
for (Object obj : list) {
System.out.println(obj);
}
synchronized (lock){
lock.notify();
}
Thread.sleep(1000);
ResultSet rs2 = readPs.executeQuery();
System.out.println("執行讀取資料操作2----");
List list2 = JdbcUtil.convertList(rs2);
for (Object obj : list2) {
System.out.println(obj);
}
readConn.commit();
readConn.close();
} catch (SQLException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
writeThread.start();
readThread.start();
}
public static void main(String[] args) {
NotRepeatReadDemo notRepeatReadDemo=new NotRepeatReadDemo();
notRepeatReadDemo.notRepeatRead();
}
}
在設定了TRANSACTION_READ_COMMITTED隔離級别的情況下,上述程式的運作結果為:
為了避免這種情況的發生,需要保證在同一個事務裡面,多次重複讀取的資料都是一緻的,是以需要将事務的隔離級别從TRANSACTION_READ_COMMITTED提升到TRANSACTION_REPEATABLE_READ級别,這種情況下,上述程式的運作結果為:
幻讀
官方文檔對于幻讀的定義如下:
The so-called phantom problem occurs within a transaction when the same query produces different sets of rows at different times. For example, if a SELECT is executed twice, but returns a row the second time that was not returned the first time, the row is a “phantom” row.
讀到上一次沒有傳回的記錄,看起來是幻影一般。
幻讀與不可重複讀類似。它發生在一個事務(T1)讀取了幾行資料,接着另一個并發事務(T2)插入了一些資料時。在随後的查詢中,第一個事務(T1)就會發現多了一些原本不存在的記錄,就好像發生了幻覺一樣,是以稱為幻讀。為了解決這種情況,可以選擇将事務的隔離級别提升到TRANSACTION_SERIALIZABLE。
什麼是TRANSACTION_SERIALIZABLE?
TRANSACTION_SERIALIZABLE是目前事務隔離級别中最高等級的設定,可以完全服從ACID的規則,通過加入行鎖的方式(innodb存儲引擎中)來防止出現資料并發導緻的資料不一緻性問題。為了友善了解,可以看看下方的程式:
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.concurrent.CountDownLatch;
/**
* @author idea
* @date 2019/7/2
* @Version V1.0
*/
public class FantasyReadDemo {
public static final String READ_SQL = "SELECT * FROM money";
public static final String UPDATE_SQL = "UPDATE `money` SET `money` = ? WHERE `id` = 3;n";
public CountDownLatch countDownLatch=new CountDownLatch(2);
public void readAndUpdate1() {
try (Connection conn = JdbcUtil.getConnection();) {
conn.setAutoCommit(false);
PreparedStatement ps = conn.prepareStatement(READ_SQL);
conn.setTransactionIsolation(Connection.TRANSACTION_SERIALIZABLE);
ResultSet rs = ps.executeQuery();
rs.next();
int currentMoney = (int) rs.getObject(2);
System.out.println("執行寫取資料操作----" + currentMoney);
//堵塞等待喚醒
countDownLatch.countDown();
PreparedStatement writePs = conn.prepareStatement(UPDATE_SQL);
writePs.setInt(1, currentMoney - 1);
writePs.execute();
conn.commit();
writePs.close();
ps.close();
System.out.println("執行寫操作結束---1");
} catch (Exception e) {
e.printStackTrace();
readAndUpdate1();
}
}
public void readAndUpdate2() {
try (Connection conn = JdbcUtil.getConnection();) {
conn.setAutoCommit(false);
PreparedStatement ps = conn.prepareStatement(READ_SQL);
conn.setTransactionIsolation(Connection.TRANSACTION_SERIALIZABLE);
ResultSet rs = ps.executeQuery();
rs.next();
int currentMoney = (int) rs.getObject(2);
System.out.println("執行寫取資料操作----" + currentMoney);
//堵塞喚醒
countDownLatch.countDown();
PreparedStatement writePs = conn.prepareStatement(UPDATE_SQL);
writePs.setInt(1, currentMoney - 1);
writePs.execute();
conn.commit();
writePs.close();
ps.close();
System.out.println("執行寫操作結束---2");
} catch (Exception e) {
//使用串行化事務級别能夠較好的保證資料的一緻性,可串行化事務 serializable 是事務的***别,在每個讀資料上加上鎖
//innodb裡面是加入了行鎖,是以出現了異常的時候,隻需要重新執行一遍事務即可。
e.printStackTrace();
readAndUpdate2();
}
}
public void fantasyRead() {
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
readAndUpdate1();
}
});
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
readAndUpdate2();
}
});
try {
thread1.start();
// Thread.sleep(500);
thread2.start();
} catch (Exception e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
FantasyReadDemo fantasyReadDemo = new FantasyReadDemo();
fantasyReadDemo.fantasyRead();
}
}
這裡面将事務的隔離級别設定到了TRANSACTION_SERIALIZABLE,但是在運作過程中為了保證資料的一緻性,串行化級别的事物會給相應的行資料加入行鎖,是以在執行的過程中會抛出下面的相關異常:
com.mysql.jdbc.exceptions.jdbc4.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction
at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)
at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
at java.lang.reflect.Constructor.newInstance(Constructor.java:423)
at com.mysql.jdbc.Util.handleNewInstance(Util.java:377)
.......
這裡為了友善示範,在抛出異常的時候重新再次執行了一遍事務的方法,進而完成多次事務并發執行。
但是實際應用場景中,我們對于這種并發狀态造成的問題都會交給業務層面加入鎖來解決沖突,是以TRANSACTION_SERIALIZABLE隔離級别一般在應用場景中比較少見。
七種事務的傳播機制
事務的七種傳播機制分别為:
REQUIRED(預設) 預設的事務傳播機制,如果目前不支援事務,那麼就建立一個新的事務。
SUPPORTS 表示支援目前的事務,如果目前沒有事務,則不會單獨建立事務
以上的這兩種事務傳播機制比較好了解,接下來的幾種事務傳播機制就比上邊的這幾類稍微複雜一些了。
REQUIRES_NEW
定義: 建立一個新事務,如果目前事務已經存在,把目前事務挂起。
為了更好的了解REQUIRES_NEW的含義,我們通過下邊的這個執行個體來進一步了解:
有這麼一個業務場景,需要往資料插入一個account賬戶資訊,然後同時再插入一條userAccount的流水資訊。(隻是模拟場景,是以對象的命名有點簡陋)
直接來看代碼實作,内容如下所示:
/**
* @author idea
* @data 2019/7/6
*/
@Service
public class AccountService {
@Autowired
private AccountDao accountDao;
@Autowired
private UserAccountService userAccountService;
/**
* 外層定義事務, userAccountService.saveOne單獨定義事務
*
* @param accountId
* @param money
*/
@Transactional(propagation = Propagation.REQUIRED)
public void saveOne(Integer accountId, Double money) {
accountDao.insert(new Account(accountId, money));
userAccountService.saveOne("idea", 1001);
//這裡模拟抛出異常
int j=1/0;
}
}
再來看userAccountService.saveOne函數:
/**
* @author idea
* @data 2019/7/6
*/
@Service
public class UserAccountService {
@Autowired
private UserAccountDao userAccountDao;
/**
* @param username
* @param accountId
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveOne(String username,Integer accountId){
userAccountDao.insert(new UserAccount(username,accountId));
}
}
執行程式的時候,AccountService.saveOne裡面的 userAccountService.saveOne函數為單獨定義的一個事務,而且傳播屬性為REQUIRES_NEW。是以在執行外層函數的時候,即使後邊抛出了異常,也并不會影響到内部 userAccountService.saveOne的函數執行。
REQUIRES_NEW 總是新啟一個事務,這個傳播機制适用于不受父方法事物影響的操作,比如某些業務場景下需要記錄業務日志,用于異步反查,那麼不管主體業務邏輯是否完成,日志都需要記錄下來,不能因為主體業務邏輯報錯而丢失日志;但是本身是一個單獨的事物,會受到復原的影響,也就是說 userAccountService.saveOne裡面要是抛了異常,子事務内容一起復原。
NOT_SUPPORTED
定義:無事務執行,如果目前事務不存在,把已存在的目前事務挂起。
還是接上邊的代碼來進行試驗:
賬戶的轉賬操作:
userAccountService内部的saveOne操作:
在執行的過程中,userAccountService.saveOne抛出了異常,但是由于該方法申明的事物傳播屬性為NOT_SUPPORTED級别,是以當子事務内部抛出異常的時候,子事務本身不會復原,而且也不會影響父類事務的執行。
NOT_SUPPORTED可以用于發送提示消息,站内信、短信、郵件提示等。不屬于并且不應當影響主體業務邏輯,即使發送失敗也不應該對主體業務邏輯復原,并且執行過程中,如果父事務出現了異常,進行復原,也不會影響子類的事務。
NESTED
定義:嵌套事務,如果目前事務存在,那麼在嵌套的事務中執行。如果目前事務不存在,則表現跟REQUIRED一樣。
關于Nested的定義,我個人感覺網上寫的比較含糊,是以自己通過搭建Demo來強化了解,還是原來的例子,假設說父類事務執行的過程中抛出了異常如下,那麼子類也要跟着復原:
當父事務出現了異常之後,進行復原,子事務也會被牽扯進來一起復原。
MANDATORY
定義:MANDATORY單詞中文翻譯為強制,支援使用目前事務,如果目前事務不存在,則抛出Exception。
這個比較好了解
當子方法定義了事務,且事務的傳播屬性為MANDATORY級别的時候,如果父方法沒有定義事務操作的話,就會抛出異常。(此時的子方法會将資料記錄到資料庫裡面)
NEVER
定義:目前如果存在事務則抛出異常
在執行userAccountService.saveOne函數的時候,發現父類的方法定義了事務,是以會抛出異常資訊,并且userAccountService.saveOne會復原。
傳播屬性小結:
PROPAGATION_NOT_SUPPORTED
不會受到父類事務影響而復原,自己也不會影響父類函數,出現異常後會自動復原。
PROPAGATION_REQUIRES_NEW
不會受到父類事務影響而復原,自己也不會影響父類函數,出現異常後會自動復原。
NESTED
會受到父類事務影響而復原,出現異常後自身也復原。如果不希望影響父類函數,那麼可以通過使用try catch來控制操作。
MANDATORY
強制使用當期的事物,如果目前的父類方法沒有事務,那麼在處理資料的時候就會抛出異常
NEVER
目前如果存在事務則抛出異常
REQUIRED(預設) 預設的事務傳播機制,如果目前不支援事務,那麼就建立一個新的事務。
SUPPORTS 表示支援目前的事務,如果目前沒有事務,則不會單獨建立事務
本文的全部相關代碼都已經上傳到gitee上邊了,歡迎感興趣的朋友前往進行代碼下載下傳:
https://gitee.com/IdeaHome_admin/wfw
posted on 2019-07-08 12:42 Java知音* 閱讀(...) 評論(...) 編輯 收藏