我们先来看一句话:
同一个应用程序中的多个事务或不同应用程序中的多个事务在同一个数据集上并发执行时, 可能会出现许多意外的问题
这句话的意思就好比说 你的电脑被其他人远程操控了,而你自己也可以操控你的电脑,这种现象就是并发执行,数据库中的事务也存在这样的现象,这就有可能出问题。
这种现象通常会导致三类问题:
脏读(Drity Read):
已知有两个事务A和B,A读取了已经被B更新但还没有被提交的数据,之后,B回滚事务,A读的就是脏数据(说白了脏读就是读取了还未提交的数据)。
举个例子吧:
老板发工资,刚开始老板把5000块钱达到Tom的账号上,但还没有提交commit
事务,Tom这时候去查自己的工资卡(假设Tom工资卡的余额为1000块钱),发现多了5000块钱(即现在余额变成了6000块钱);但过了一会儿,老板突然发现这5000块钱打错了,应该付给Tom 2000块钱,于是赶紧回滚rollback
了事务,又向Tom的工资卡里转了2000块钱,并提交commit
了事务,这时Tom又查了自己的工资卡,发现余额只剩3000了,于是大呼了一声:我去…
演示:
由于MySQL数据库默认为Repeatable read(重复读),所以需要先配置以下(这里提供一种方案):
- 在my.ini配置文件最后(mysqld下面)加上如下配置:
transaction-isolation = READ-UNCOMMITTED
其中READ-UNCOMMITTED为读取未提交(即数据未提交也可以被读取),还有另外三种:READ-COMMITTED为读取以提交(即只能读取已提交了的数据)、REPEATABLE-READ为重复读(只能读取已提交的数据,不明白了先记着,下面就会讲),Serializable(序列化)。
- 配置好后我们测试一下(在服务里重启一下MySQL数据库):
- 在MySQL数据库里执行以下语句:
# 此时不可避免脏读(即可以读取未提交的数据)
select @@global.tx_isolation,@@tx_isolation;
先创建一个表(Tom的工资卡)
create table account(id int(36) primary key comment '主键',card_id varchar(16) unique comment '卡号',name varchar(8) not null comment '姓名',balance float(10,2) default 0 comment '余额'
)engine=innodb;#然后向表中添加一条数据(相当于Tom的工资卡的余额为1000块钱)
insert into account (id,card_id,name,balance) values (1,'6226090219290000','Tom',1000);
我们先执行一下Boss类中的Main方法:
public class Boss {public static void main(String[] args) {Connection connection = null;Statement statement = null;try {Class.forName("com.mysql.jdbc.Driver");String url = "jdbc:mysql://127.0.0.1:3306/test";connection = DriverManager.getConnection(url, "root", "root");//关闭自动提交connection.setAutoCommit(false);statement = connection.createStatement();//向Tom的工资卡里先打5000块钱,但并未提交String sql = "update account set balance=balance+5000 where card_id='6226090219290000'";statement.executeUpdate(sql);//让程序在此处停止30s后再往下执行,30秒后老板发现工资发错了Thread.sleep(30000);//于是老板赶紧回滚了刚才的事务connection.rollback();//重新向Tom的工资卡里打了2000块钱sql = "update account set balance=balance+2000 where card_id='6226090219290000'";statement.executeUpdate(sql);//并提交了事务connection.commit();} catch (Exception e) {e.printStackTrace();} finally {//释放资源try {if (statement!=null) {statement.close();}} catch (SQLException e) {e.printStackTrace();}try {if (connection!=null) {connection.close();}} catch (SQLException e) {e.printStackTrace();}}}
}
然后执行Employee(相当于Tom查看自己的工资卡余额)(这里在30s之前执行一次,在30s执行之后再执行一次,会发现两次的结果不一样,即前后两次Tom的工资卡余额不一样):
public class Employee {public static void main(String[] args) {Connection connection = null;Statement statement = null;ResultSet resultSet = null;try {Class.forName("com.mysql.jdbc.Driver");String url = "jdbc:mysql://127.0.0.1:3306/test";connection = DriverManager.getConnection(url, "root", "root");statement = connection.createStatement();String sql = "select balance from account where card_id='6226090219290000'";resultSet = statement.executeQuery(sql);if(resultSet.next()) {System.out.println(resultSet.getDouble("balance"));}} catch (Exception e) {e.printStackTrace();} finally {//释放资源try {if (statement!=null) {statement.close();}} catch (SQLException e) {e.printStackTrace();}try {if (connection!=null) {connection.close();}} catch (SQLException e) {e.printStackTrace();}}}
}
我们在Boss类Main方法执行后的前30s之前执行一次Employee类中Main方法,会发现余额为6000,但当30s之后再次执行,会发现余额变成了3000,这就是所谓的脏读。
解决脏读的方法:
将该处的配置换为transaction-isolation = READ-COMMITTED
(读取已提交),重新启动MySQL数据库:
这时再次执行Boss类Main方法,在30s之前执行一次Employee类中Main方法,发现余额为1000块钱,30s之后再次执行,发现余额变成了3000块钱,即避免了脏读。
不可重复读(Non-Repeatable Read):
已知有两个事务A和B,A多次读取同一数据,B在A多次读取的过程中对数据作了修改并提交,导致了多次读取同一数据时,结果不一致
场景:
Tom的工资卡里有3000块钱余额。一天Tom请朋友们聚餐(没对老婆说)消费了1000块钱。Tom去柜台支付,掏出银行卡去刷pos机,pos机提示余额3000块钱,可以扣款成功。但Tom刚和了四两闷倒驴,头有点犯晕,想了30s,但就在这30s之内,他老婆以迅雷不及掩耳的速度将Tom的工资卡里的3000余额赚到了她自己的账户里。Tom想了30s,灵光一闪,记起了密码,赶紧向pos机里输密码,输完密码,pos机却提示余额不足,扣款失败。于是Tom心中泛起了嘀咕:莫非这pos机…
演示:
我们先创建一个表account(向里面存两个账号,分别是Tom和他老婆Lily的)
create table account(id int(36) primary key comment '主键',card_id varchar(16) unique comment '卡号',name varchar(8) not null comment '姓名',balance float(10,2) default 0 comment '余额'
)engine=innodb;# 假设刚开始Tom的余额为3000,她老婆的余额为0
insert into account (id,card_id,name,balance) values (1,'6226090219290000','Tom',3000);
insert into account (id,card_id,name,balance) values (2,'6226090219299999','LilY',0);
我们先执行Machine类(相当于POS机的操作):
public class Machine {public static void main(String[] args) {Connection connection = null;Statement statement = null;ResultSet resultSet = null;try {//Tom的消费金额double sum=1000;Class.forName("com.mysql.jdbc.Driver");String url = "jdbc:mysql://127.0.0.1:3306/test";connection = DriverManager.getConnection(url, "root", "root");//关闭自动提交事务connection.setAutoCommit(false);statement = connection.createStatement();String sql = "select balance from account where card_id='6226090219290000'";resultSet = statement.executeQuery(sql);if(resultSet.next()) {System.out.println("余额:"+resultSet.getDouble("balance"));}System.out.println("请输入支付密码:");//Tom想密码想了30sThread.sleep(30000);//30秒后密码输入成功resultSet = statement.executeQuery(sql);if(resultSet.next()) {double balance = resultSet.getDouble("balance");System.out.println("余额:"+balance);if(balance<sum) {System.out.println("余额不足,扣款失败!");return;}}sql = "update account set balance=balance-"+sum+" where card_id='6226090219290000'";statement.executeUpdate(sql);connection.commit();System.out.println("扣款成功!");} catch (Exception e) {e.printStackTrace();} finally {//释放资源try {if (resultSet != null) {resultSet.close();}} catch (SQLException e) {e.printStackTrace();}try {if (statement != null) {statement.close();}} catch (SQLException e) {e.printStackTrace();}try {if (connection != null) {connection.close();}} catch (SQLException e) {e.printStackTrace();}}}
}
然后执行Wife类(相当于Tom的老婆):
public class Wife {public static void main(String[] args) {Connection connection = null;Statement statement = null;try {//转账金额double money=3000;Class.forName("com.mysql.jdbc.Driver");String url = "jdbc:mysql://127.0.0.1:3306/test";connection = DriverManager.getConnection(url, "root", "root");connection.setAutoCommit(false);statement = connection.createStatement();String sql = "update account set balance=balance-"+money+" where card_id='6226090219290000'";statement.executeUpdate(sql);sql = "update account set balance=balance+"+money+" where card_id='6226090219299999'";statement.executeUpdate(sql);connection.commit();System.out.println("转账成功");} catch (Exception e) {e.printStackTrace();} finally {//释放资源try {if (statement != null) {statement.close();}} catch (SQLException e) {e.printStackTrace();}try {if (connection != null) {connection.close();}} catch (SQLException e) {e.printStackTrace();}}}
}
在执行Machine类后的前30s内,执行Wife类,提示转账成功,然后30s后,Tom输入密码成功,却发现余额不足,扣款失败,这就是不可重复读。
解决不可重复读的方法(表面上解决了):
在my.ini配置文件最后(mysqld下面)加上如下配置:
transaction-isolation = Repeatable read
但我们会发现这时,POS机可以扣款成功,她老婆也可以转账成功,而此时再查两人的余额,发现Tom的余额为-1000块钱,他老婆的余额为3000块钱。
(这是由于MVCC机制,这里暂且不赘述了,有兴趣可以百度一下。)
幻读(Phantom Read):
已知有两个事务A和B,A从一个表中读取了数据,然后B在该表中插入了一些数据,导致了A再次读取同一个表时,就会多出几行,简单地说,一个事务中先后读取一个范围的记录,但每次读取的记录数不同,称之为幻像读。
场景
Tom的老婆把Tom管的很严,时常查Tom的消费记录。这一天又查了Tom的消费记录,发现这个月Tom的消费总额为80块钱,老婆觉得自己的老公好节俭啊,就准备看一下具体的消费记录,看看把前都花在了哪里?但就在Tom老婆准备查消费细则时,Tom在外面消费了1000块钱,Tom的老婆打开消费细则一看,“嗯,怎么是1080块钱,刚还是80块钱呢?”…
这就是幻读。
但幻读一般危害不大,所以在开发中就不会管它。
最后总结一下事务隔离的级别:
事务隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
READ-UNCOMMITTED(读未提交) | √ | √ | √ |
READ-COMMITTED(读提交) | × | √ | √ |
REPEATABLE-READ(重复读) | × | × | √ |
SERIALIZABLE(序列化) | × | × | × |
注意:√表示可能会出现,×表示不会出现;这四种事务隔离级别从上到下依次提高。
一般在开发中SERIALIZABLE(序列化)很少用到,因为它的效率太低了,就好比单行道,虽然可以避免很多问题(上面的三种现象)的发生,但每次只能过一辆车(只能执行一个事务),效率低,花费时间长。你可以想象一下在双十一晚上,几万个人同时点击提交,结果却要一个一个执行,可能到你提交订单成功时已到了两三天后了。
用的最多的还是REPEATABLE-READ(重复读),既可以防止脏读、不可重复读,又可并发执行,效率提高了很多,虽然不可避免有幻读,但幻读本身危害不大,所以不足为虑。
其中MySQL数据库支持四种事务隔离级别,默认为REPEATABLE-READ(重复读),而Oracle数据库支持READ-COMMITTED(读提交)和SERIALIZABLE(序列化)两种事务隔离级别,默认为READ-COMMITTED(读提交)。