实战分析:事务的隔离级别和传播属性
什么是事务?
要么全部都要执行,要么就都不执行。
事务所具有的四种特性
原子性,一致性,隔离性,持久性
原子性
个人理解,就是事务执行不可分割,要么全部完成,要么全部拉倒不干。
一致性
关于一致性这个概念我们来举个例子说明吧,假设张三给李四转了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
![](https://www.bmabk.com/wp-content/uploads/2022/12/qrcode_for_gh_114a06b58d37_258.jpg)
《《--扫描二维码关注他!
【Java知音】公众号,每天早上8:30为您准时推送一篇技术文章
在Java知音公众号内回复“面试题聚合”,送你一份Java面试题宝典。