本篇博客介绍数据库事务相关的概念。
什么是事务?
事务就是一组DML(数据操纵语言,Data Manipulation Language)语句组成,这些语句在逻辑上存在相关性,这一组DML语句要么全部成功,要么全部失败,是一个整体。MySQL提供一种机制,保证我们达到这样的效果。事务还规定不同的客户端看到的数据是不相同的。
为什么需要事务?
我们创建一个测试表:
create table account(
id int primary key,
name varchar(20) not null default '',
balance decimal(10, 2) not null default 0.0
);
我们向其中插入两条数据:
insert into account(name, money) values
('范庄元', 5000),
('林英新', 3000);
此时,范庄元向林英新转账4000元,我们通过下面的操作来完成:
-- 范庄元的账户扣除4000元
update account set money = money - 4000 where name = '范庄元';
-- 林英新的账户到账4000元
update account set money = money + 4000 where name = '林英新';
我们执行一下来看一下效果:
可以看到并没有问题,但是如果第一条语句执行完之后,网络就断开了连接或者数据库服务器宕机了,此时的结果如下:
可以看到,此时银行账户整体丢失了4000元,这显然是不合适的。
所以,为了解决上述问题,我们可以使用事务,保证以上两句SQL要么全部执行成功,要么全部执行失败。
事务基本操作
我们还是使用前面创建的测试表:
开始一个事务。
start transaction;
创建一个保存点。
savepoint 保存点名;
回滚到保存点(根据具体情况)。
rollback to 保存点名;
事务提交。
commit;
代码演示:
- 开启事务。
- 设置保存点sp1。
- 添加一条记录。
- 设置保存点sp2。
- 再添加一条记录。
- 查看表中的数据。
- 插入的第二条数据不小心是有问题的,回滚到sp2状态。
- 这时候查看表中的内容,第二条数据就没有了。
- 提交事务。
事务操作的注意事项
- 如果没有设置保存点,也可以回滚,只能回滚到事务的开始。直接使用rollback(前提是事务还没有提交)。
- 如果一个事务被提交了(commit),则不可以回滚(rollback)。
- 可以选择回滚到哪个保存点。
- InnoDB支持事务,MyISAM不支持事务。
- 开始事务可以使用start tansaction。
在开启事务的时候,相当于是创建了一个快照,事务提交之前的操作相当于都是在对快照进行操作。commit之后才会对数据进行持久化操作。所以在commit之前的操作,其他客户端是看不到的。我们实际来演示一下:
- 首先,我们开启事务。
- 然后,我们向account表中插入一条数据。
- 此时,我们新开一个客户端,来查看一下account表中的数据。
- 我们将前面的事务提交了,然后在当前客户端再来查看一下。
事务的隔离级别
当我们有多个客户端同时操作数据库的某张表,如何进行隔离操作?MySQL提供了隔离级别。
当MySQL表被多个线程或者客户端开启各自事务操作数据库中的数据时,MySQL提供了一种机制,可以让不同的事务在操作数据时,具有隔离性。从而保证数据的一致性。
无隔离性的问题
我们使用前面的测试表来演示以下三个问题,首先我们将表中数据清空,然后将事务的隔离级别设置为读未提交,这样可以观察到三个问题的效果。
脏读
是指当一个事务正在访问数据,并且对数据进行了修改,而这种修改还没有提交到数据库中,这时,另外一个事务也访问这个数据,然后使用了这个数据。
我们来具体演示一下脏读实际效果:
- 首先,我们插入一条数据。
- 新开一个客户端代表财务人员,开启事务,将范庄元的工资修改为8000,不提交。
- 此时,范庄元读取自己的工资,发现自己的工资变为8000。
- 财务人员发现操作有误,回滚了事务,此时范庄元再来查看自己的工资又变成了1000。
不可重复读
是指在一个事务内,多次读同一个数据。在这个事务还没有结束时,另外一个事务也访问该数据。那么,在第一个事务中的两次读数据之间,由于第二个事务的修改,那么第一个事务两次读到的数据可能是不一样的。这样就发生了在一个事务内两次读到的数据是不一样的,因此称为是不可重复读(即不能读到相同的数据内容)。
我们来具体演示一下不可重复读的效果:
- 首先,范庄元开启了一个事务,读取了自己的工资为1000,操作并没有完成。
- 此时新开一个客户端代表财务人员,财务人员也开启了事务,然后修改范庄元的工资为2000,并提交了事务。
- 范庄元再次读取自己的工资,发现工资变成了2000。
解决方案:只有在修改事务完全提交之后才可以读取数据,就可以避免该问题。
幻读
是指当事务不是独立执行时发生的一种现象,例如第一个事务对一个表中的数据进行了修改,这种修改涉及到表中的全部数据行。同时,第二个事务也修改这个表中的数据,这种修改是向表中插入一行新数据。那么,以后就会发生操作第一个事务的用户发现表中还有没有修改的数据行,就好像发生了幻觉一样。
下面,我们来演示一下幻读的效果:
- 首先,我们创建一个员工表,插入10条数据,工资皆为1000。
- 然后,我们在当前客户端开启事务一,读取所有工资为1000的员工。
- 此时,我们新开一个客户端,在新客户端开启事务二,插入一条工资为1000的数据。
- 我们在事务一再次查询工资为1000的员工,就会查到11条数据。
解决方法:如果在操作事务完成数据处理之前,任何其他事务都不可以添加新数据,则可避免该问题。
注意:不可重复读的重点是修改:同样的条件,你读取过的数据,再次读取出来发现值不一样了;幻读的重点在于新增或者删除:同样的条件,第1次和第2次读出来的记录数不一样。
事务隔离级别的设置
隔离级别 | 脏读 | 不可重复读 | 幻读 | 加锁读 |
读未提交(read uncommited) | √ | √ | √ | 不加锁 |
读已提交(read commited) | × | √ | √ | 不加锁 |
可重复读(repeatable read) | × | × | × | 不加锁 |
可串行化(serializable) | × | × | × | 加锁 |
√:会发生该问题。
×:不会发生该问题。
设置事务的隔离级别。
-- 语法
set session transaction isolation level read uncommitted;
查看当前的隔离级别:
MariaDB [bank]> select @@tx_isolation;
+------------------+
| @@tx_isolation |
+------------------+
| READ-UNCOMMITTED |
+------------------+
1 row in set (0.00 sec)
隔离级别:可串行化。示例:
当客户端A在执行select过程中,DBMS会对库加锁,如果这时客户端B执行插入操作,只要还没释放锁,就插入不进去,会将B的insert语句放入等待队列,直到释放了锁或超时。
注意:MySQL默认的隔离级别是可重复读,一般情况下不要修改。
事务的ACID特性
- 原子性(Atomicity)
事务是应用中最小的执行单位,就如原子是自然界中的最小颗粒。具有不可再分的特性。事务是应用中不可再分的最小逻辑执行体。 - 一致性(Consistency)
事务执行的结果,必须使数据库从一个一致性状态变到另一个一致性状态。当数据库只包含事务成功提交的结果时,数据库处于一致性状态。如果系统运行发生中断,某个事务尚未完成而被迫中断,未完成事务对数据库所做的修改已被写入数据库,此时数据库就处于一种不正确(不一致)的状态。因此一致性是通过原子性来保证的。 - 隔离性(Isolation)
各个事务的执行互不干扰,任意一个事务的内部操作对其他并发事务都是隔离的。也就是说,并发执行的事务之间不能看到对方的中间状态,并发执行的事务之间不能互相影响。 - 持久性(Durability)
一个事务一旦被提交,它对数据库所做的修改都会存储到磁盘中。
JDBC中事务的使用
JDBC中使用事务,只需要三个接口,如下:
// 开启事务
connection.setAutoCommit(false);
// 提交事务
connection.commit();
// 回滚
connection.rollback();
我们来看一下这三个接口在代码中的实际使用:
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
public class Insert {
public static void insert(
String empName, int empMoney
) {
Connection connection = null;
PreparedStatement statement = null;
ResultSet resultSet = null;
try {
try {
connection = DBUtil.getConnection2();
// 开启事务
connection.setAutoCommit(false);
String sql = "insert into emp(name, money) " +
"values (?, ?)";
statement = connection.prepareStatement(sql);
statement.setString(1, empName);
statement.setInt(2, empMoney);
int ret = statement.executeUpdate();
if (ret == 1) {
System.out.printf("Insert Successful, %d row affected!", ret);
}
// 提交事务
connection.commit();
} finally {
DBUtil.Close(connection, statement, resultSet);
}
} catch (SQLException e) {
e.printStackTrace();
if (connection != null) {
try {
// 回滚事务
connection.rollback();
} catch (SQLException ex) {
ex.printStackTrace();
}
}
}
}
}