本篇文章主要介绍的是 MySQL / JDBC 中的事务,为了方便读者浏览,这里默认需要读者已经掌握 SQL基础 以及 JDBC 数据库连接基础。这部分的基础也可以参考下面的链接进行简单的快速入门。
1.概述
MySQL 事务主要用于处理操作量大,复杂度高的数据。比如说,在银行转账系统中,A -> B 转账 1000 元,这时就需要将 A 账户余额 -1000,对应的 B 账户余额 +1000。这两个操作过程必须同时执行成功才能完成此操作,这样,这些数据库操作语句就构成了一个事务。
- 在上面的举例中如果 A 的账户余额 -1000 执行完毕后,程序被中断了(抛出异常、服务器宕机等),而 B 账户没有 +1000 元,这肯定是有问题的。
- 现在对事务应该有一个了解了吧🤔事务中的多个操作,或者全部执行完毕,或者全不执行,不存在只执行了一部分的情况。
2.事务的四大特性(ACID)
- 原子性(Atomicity):事务中的所有操作是不可再分割的原子单位,事务中的所有操作是一个整体,或者整体执行成功,亦或者整体执行失败。
- 一致性(Consistency):事务执行后,数据库状态与其他业务规则保持一致。如转账业务,无论执行成功与否,参与转账的两个帐号余额值和应该是不变的。
- 隔离性(Isolation):在并发操作中,不同事务之间应该隔离开来,每个并发中的事务的执行不会相互干扰。
- 持久性(Durability):一旦事务提交成功,事务中的所有数据更新必须被持久化到数据库中,即使提交事务后,数据库马上崩溃,在数据库重新启动时,也必须能保证通过某种机制恢复数据。
3.MySQL 中的事务
在默认情况下,MySQL 每执行一条 SQL 语句,都是一个单独的事务。如果需要在每一个事务中包含多条 SQL 语句的执行,那么就需要开启事务和结束事务。
- 开启事务:
START TRANSACTION
- 结束事务:
COMMIT
或ROLLBACK
- 在执行 SQL 语句之前,先执行
START TRANSACTION
,则代表开启了一个事务,然后执行多条 SQL 语句,最后需要结束事务,COMMIT
表示提交,即事务中的多条 SQL 语句所更改的数据会持久化到数据库中。或者ROLLBACK
表示回滚,即回滚到事务的起点,将之前所做的所有操作撤销。
Reiminder 💁♂️
ROLLBACK
可以结束事务,但不代表会将数据持久化到数据库中,而只有COMMIT
提交才可以将数据持久化到数据库中。
- 测试表:
# 创建 Account 表
CREATE TABLE `Account` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(20) NOT NULL,
`balance` decimal(10,0) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `Account_id_uindex` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
# 插入数据
INSERT INTO Account (name, balance) VALUES ('A', 10000);
INSERT INTO Account (name, balance) VALUES ('B', 10000);
INSERT INTO Account (name, balance) VALUES ('C', 10000);
3.1 - COMMIT 测试
# 开启事务
START TRANSACTION;
# 执行事务 SQL 语句
# SQL1
UPDATE Account
SET balance = balance - 1000
WHERE id = 1;
# SQL2
UPDATE Account
SET balance = balance + 1000
WHERE id = 2;
# 提交事务
COMMIT;
# 结果分析
1 A 9000
2 B 11000
3 C 10000
分析:提交事务后,更新的数据将被持久化到数据库中。
3.2 - ROOLBACK 测试
# 开启事务
START TRANSACTION;
# 执行事务 SQL 语句
# SQL1
UPDATE Account
SET balance = balance - 1000
WHERE id = 1;
# SQL2
UPDATE Account
SET balance = balance + 1000
WHERE id = 2;
# 回滚事务
ROLLBACK;
# 提交事务
COMMIT;
# 结果分析
1 A 10000
2 B 10000
3 C 10000
分析:事务提交前执行 ROLLBACK 回滚事务至 START TRANSACTION 时的状态,所以持久化后数据库中数据没有被改变。
3.3 - 事务不提交测试
# 开启事务
START TRANSACTION;
# 执行事务 SQL 语句
# SQL1
UPDATE Account
SET balance = balance - 1000
WHERE id = 1;
# SQL2
UPDATE Account
SET balance = balance + 1000
WHERE id = 2;
# 输出结果
SELECT *
FROM Account;
# 控制台打印数据
1 A 9000
2 B 11000
3 C 10000
# 结果分析(数据库数据)
1 A 10000
2 B 10000
3 C 10000
分析:在执行了 SQL 语句后,在内存中的数据表数据已经被修改了,但是由于没有提交事务,所以数据没有被持久化到数据库中。
4.并发事务问题
- 脏读(Dirty Read):在事务的执行过程中,读取到了其他事务的 未提交 的数据,即读到了脏数据。
- 不可重复读(Unrepeatable Read):在事务的执行过程中,读到了其他事务 修改后 的数据,换句话说在该事务中的不同时间点读取到了不一致的数据,即不可重复读。
- 幻读/虚读(Phantom Read):在事务的执行过程中,读取到了其他事务对 记录数 修改后的数据,对同一张表的两次查询的
COUNT(*)
不一致。 - 不可重复读与幻读的区别:
- 不可重复读:强调的是数据 内容 的不一致,主要针对
UPDATE
的修改。 - 幻读:强调的是 记录数 的不一致,主要针对
INSERT
/DELETE
的修改。
- 不可重复读:强调的是数据 内容 的不一致,主要针对
5.四大隔离级别
刚刚我们介绍了事务并发时可能出现的各种问题,其实可以发现是违背了事务的 隔离性 的要求所引起的,所以我们需要通过事务的隔离来解决这个问题,下面我们就来介绍一下事务的四大隔离级别。
- 串行化(SERIALIZABLE):
- 概述:对数据串行的访问,非并发访问。
- 特点:不会出现任何并发问题,性能最差。
- 理解:在当前串行化事务中,如果有其他事务对数据进行了增删改操作,当前事务读取数据会被阻塞,需要等到其他事务结束后(ROLLBACK/COMMIT)才能执行数据读取。
- 可重复读(REPEATABLE READ):
- 概述:在一个事务的执行过程中,能保证读取到数据的一致性,是 MySQL 中使用的 InnoDB 存储引擎默认的隔离级别。
- 特点:可避免脏读和不可重复读,不能避免幻读问题,并发式读取访问,性能比串行化好。
- 理解:在一个事务内,锁定读取,通过保存第一次读取的快照(snapshot),保证每次读取的数据一致。
- 读已提交(READ COMMITTED):
- 概述:在一个事务内,可以读取到其他事务已经提交的数据,性能优于可重复读(REPEATABLE READ),Oracle 数据库中的默认隔离级别。
- 特点:可避免脏读,不能避免不可重复读和幻读问题,并发式访问读取,性能比可重复读好。
- 理解:MySQL中与 Oracle 的该隔离级别,通过读取新鲜的快照(fresh snapshot)来读取其他事务已提交的更新内容。
- 读未提交(READ UNCOMMITTED):
- 概述:在一个事务内,可以读取到其他事务没有提交的修改内容,即脏读(Dirty Read)。
- 特点:不能避免任何并发事务的问题,性能最好。
- 理解:在 SERIALIZBLE 的事务隔离级别,InnoDB 存储引擎会对每个 SELECT 语句后自动加上 LOCK IN SHARE MODE,即给每个读取操作加一个共享锁,因此在这个事务隔离级别下,读占用锁了,一致性的非锁定读不再予以支持,一般不会在本地事务中使用 SERIALIZBLE 的隔离级别,SERIALIZABLE 的事务隔离级别主要用于 InnoDB 存储引擎的分布式事务。
隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
串行化(SERIALIZABLE) | ✔️ | ✔️ | ✔️ |
可重复读(REPEATABLE READ) | ✔️ | ✔️ | - |
读已提交(READ COMMITTED) | ✔️ | − | − |
读未提交(READ UNCOMMITTED) | − | − | − |
6.MySQL 各隔离级别的并发事务测试
查看隔离级别:MySQL 默认隔离级别是
REPEATABLE-READ
,可以通过SELECT @@TX_ISOLATION;
查看隔离级别。设置隔离级别:
SET SESSION TRANSACTION ISOLATION LEVEL xxx;
测试表及数据
id | name | balance |
---|---|---|
1 | A | 10000 |
2 | B | 10000 |
6.1 - 串行化测试
- 测试版本:
MySQL Server 5.7
- 测试环境:
# 设置窗口 2 隔离级别为 串行化
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
# 特别的 -> 窗口 1 的隔离级别不需要特别设置。
# 我们演示是通过窗口 1 进行修改数据值,在窗口 2 来观察结果的。
值得注意💡在第 3 步,窗口 1 执行 INSERT 插入了一条数据,而后第 4 步窗口 2 执行 SELECT 操作会被阻塞(避免幻读),直到窗口 1 事务结束(COLLBACK/COMMIT)后才会被执行。
特别的💡当窗口 2 一旦执行过 SELECT 操作后,如果有其他事务对数据进行增删改操作都将被阻塞(可重复读的保证),直到该串行化事务结束后才会被执行。
6.2 - 可重复读测试
与串行化类似的是,当窗口 2 执行步骤 3 读操作后,查询的结果将被锁定。当其他事务要对该锁定数据执行更改操作时都将会被阻塞,所以当窗口 1 执行步骤 4 时将会被阻塞,从而保证了可重复读。
6.3 - 读已提交测试
当步骤 4 修改了
balance
值时,此时还未提交,所以步骤 5 查询到的结果并没有改变(读已提交),而在步骤 7 查询到了窗口 1 改变的结果,因为此时窗口 1 的事务已经提交。
特别的💡在窗口 2 事务的执行过程中,步骤 3 与步骤 7 查询到了不同的结果,由此可以看出这是与可重复读的重要区别。
6.4 - 读未提交测试
步骤 4 中窗口 1 事务修改了数据,步骤 5 中窗口 2 事务读取到了修改后的数据,此时窗口 1 事务还未提交,因此读取到的是 脏数据,该隔离级别不能避免任何的并发事务问题。
7.JDBC 事务
刚刚我们介绍了在 MySQL 中对事务进行的操作,而 JDBC 中 也必然有与对应的方式进行事务控制,下面我们介绍一下 JDBC 中对事务的控制。
- 在 JDBC 中处理事务都是通过 Connection 完成的。
- 同一个事务中的所有的操作,都是使用同一个 Connection 对象。
7.1 - 开启事务
- 方法:
void setAutoCommit(boolean autoCommit)
读读 API 📖
If a connection is in auto-commit mode, then all its SQL statements will be executed and committed as individual transactions. Otherwise, its SQL statements are grouped into transactions that are terminated by a call to either the method commit or the method rollback. By default, new connections are in auto-commit mode.
- 如果 connection 处于自动提交模式,会将每一条 SQL 语句作为一个单独的事务提交(commit);否则,其 SQL 语句可以通过调用
commit()
方法或rollback()
方法终止事务。默认是自动提交模式。
Reminder 💁♂️
Java 还特别指出:对于 DML 语句,例如插入、 更新或删除和 DDL 语句,该语句是完整的尽快它执行完。
Select 语句,该语句完成时关闭关联的 ResultSet。
7.2 - 提交事务
- 方法:
commit()
读读 API 📖
Makes all changes made since the previous commit/rollback permanent and releases any database locks currently held by this Connection object. This method should be used only when auto-commit mode has been disabled.
- 提交自上次提交后的所有更改,并释放目前此连接对象的任何 数据库锁。
- 只有当禁用了自动提交时此方法有效。
7.3 - 回滚事务
- 方法:
rollback()
读读 API 📖
Undoes all changes made in the current transaction and releases any database locks currently held by this Connection object. This method should be used only when auto-commit mode has been disabled.
- 撤销对当前事务中所做的所有更改,并释放目前此连接对象持有的任何 数据库锁。
- 只有当禁用了自动提交时此方法有效。
7.4 - 设置保存点
- 方法:
Savepoint setSavepoint(String name)
读读 API 📖
Creates a savepoint with the given name in the current transaction and returns the new Savepoint object that represents it.
if setSavepoint is invoked outside of an active transaction, a transaction will be started at this newly created savepoint.
- 在当前事务中创建一个指定名称的保存点,并返回一个用来表示它的新的保存点对象。
- 如果该方法在一个事务外被调用时,将在这个新创建的保存点启动事务。
7.5 - 事务回滚
- 不带保存点的 JDBC 事务的基本格式:
try {
connection.setAutoCommit(false); // 禁用自动提交
...
...
connection.commit(); // 在 try 的末尾提交
} catch() {
connection.rollback(); // 事务执行中断则回滚
}
- 代码示例:
public static void transfer(boolean b) throws Throwable {
Connection connection = null;
PreparedStatement preparedStatement = null;
try {
connection = JdbcUtils.getConnection();
// 禁用自动提交
connection.setAutoCommit(false);
String sql = "UPDATE Account SET balance = balance + ? WHERE id = ?";
preparedStatement = connection.prepareStatement(sql);
// 操作 1
preparedStatement.setDouble(1, -10000);
preparedStatement.setInt(2, 1);
preparedStatement.executeUpdate();
// 在事务的两个操作中抛出异常,中断事务内务的执行
if (b) {
throw new Exception();
}
// 操作 2
preparedStatement.setDouble(1, 10000);
preparedStatement.setInt(2, 2);
preparedStatement.executeUpdate();
// 提交事务
connection.commit();
} catch (Exception e) {
try {
if (connection != null) {
connection.rollback();
}
} catch (SQLException e1) {
e1.printStackTrace();
}
throw new RuntimeException();
} finally {
JdbcUtils.release(connection, preparedStatement);
}
}
7.6 - 回滚到保存点
- 概述:保存点(savePoint) 是 JDBC 3.0 的 API,其要求数据库支持以保存点方式的的回滚。
- 检查方法:
boolean b = connection.getMetaData().supportsSavepoints();
- 回滚到保存点方法:
void rollback(Savepoint savepoint)
- 作用:保存点的作用是将事务回滚到指定的保存点。需要在事务中先设置好保存点,然后回滚时通过
Savepoint
回滚到指定的保存点,而不是回滚整个事务。
Reminder 💁♂️
回滚到指定的保存点并没有结束事务,只有回滚了整个事务才会结束事务。
- 代码示例:
/*
* 李四对张三说,如果你给我转1W,我就给你转100W。
* ==========================================
*
* 张三给李四转1W(张三减去1W,李四加上1W)
* 设置保存点!
* 李四给张三转100W(李四减去100W,张三加上100W)
* 查看李四余额为负数,那么回滚到保存点。
* 提交事务
*/
private static void savepoint() throws RuntimeException {
Connection connection = null;
PreparedStatement preparedStatement = null;
try {
connection = JdbcUtils.getConnection();
// 禁用自动提交
connection.setAutoCommit(false);
String sql = "UPDATE Account SET balance = balance + ? WHERE name = ?";
preparedStatement = connection.prepareStatement(sql);
// 操作1(张三减去1W)
preparedStatement.setDouble(1, -10000);
preparedStatement.setString(2, "zs");
preparedStatement.executeUpdate();
// 操作2(李四加上1W)
preparedStatement.setDouble(1, 10000);
preparedStatement.setString(2, "ls");
preparedStatement.executeUpdate();
// 设置表存点
Savepoint savepoint = connection.setSavepoint();
// 操作3(李四减去100W)
preparedStatement.setDouble(1, -1000000);
preparedStatement.setString(2, "ls");
preparedStatement.executeUpdate();
// 操作4(张三加上100W)
preparedStatement.setDouble(1, 1000000);
preparedStatement.setString(2, "zs");
preparedStatement.executeUpdate();
// 操作5(查看李四余额)
sql = "SELECT balance FROM Account WHERE name = ?";
preparedStatement = connection.prepareStatement(sql);
preparedStatement.setString(1, "ls");
ResultSet resultSet = preparedStatement.executeQuery();
double balance = 0;
if (resultSet.next()) {
balance = resultSet.getDouble("balance");
}
// 如果李四的余额为负数,那么回滚到指定保存点
if (balance < 0) {
connection.rollback(savepoint);
System.out.println("张三你上当了");
}
// 提交事务
connection.commit();
} catch (SQLException e) {
// 回滚事务
if (connection != null) {
try {
connection.rollback();
} catch (SQLException e1) {
e1.printStackTrace();
}
}
throw new RuntimeException();
} finally {
JdbcUtils.release(connection, preparedStatement);
}
}
悄悄话 🌈
- 年后的学习节奏变得非常之快,导致最近也很久没有与大家分享技术笔记了,最近学习了 JavaWeb 的 HTML、CSS、JS、MySQL、Tomcat、Servlet、JSP 等等内容,哪一个技术拿出来都应该可以让我来研究一阵子了,怎奈进度太快也只能是抓大放小。最近在数据库阶段的事务控制的部分我比较感兴趣并做了一些小实验,觉得有一些意义,所以来与大家分享一下。
- 后面的 Cookie、Session 技术也是我觉得理解的比较深入的一个技术点,我会留在下次的更新中进行分享。
彩蛋 🐣
-
最近在整理一些 JavaWeb 成长之路 的一些学习笔记,本篇是 Database 系列中的一篇,今后还会与大家分享 JavaWeb 中的一系列的技术,有兴趣的朋友可以关注我的专题,一同学习。
如果你觉得我的分享对你有帮助的话,请在下面👇随手点个喜欢 💖,你的肯定才是我最大的动力,感谢。