實戰分析:事務的隔離級別和傳播屬性
什麼是事務?
要麼全部都要執行,要麼就都不執行。
事務所具有的四種特性
原子性,一致性,隔離性,永續性
原子性
個人理解,就是事務執行不可分割,要麼全部完成,要麼全部拉倒不幹。
一致性
關於一致性這個概念我們來舉個例子說明吧,假設張三給李四轉了100元,那麼需要先從張三那邊扣除100,然後李四那邊增加100,這個轉賬的過程對於其他事務而言是無法看到的,這種狀態始終都在保持一致,這個過程我們稱之為一致性。
隔離性
併發訪問資料庫時,一個使用者的事務不被其他事務所幹擾,各併發事務之間資料是獨立的;
永續性
一個事務被提交之後。它對資料庫中資料的改變是持久的,即使資料庫發生故障也不應該對其有任何影響。
為什麼會出現事務的隔離級別?
我們都知道,資料庫都是有相應的事物隔離級別。之所以需要分成不同級別的事務,這個是因為在併發的場景下,讀取資料可能會有出現髒讀,不可重複讀以及幻讀的情況,因此需要設定相應的事物隔離級別。
為了方便理解,我們將使用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