创作内容丰富的干货文章很费心力,感谢点过此文章的读者,点一个关注鼓励一下作者,激励他分享更多的精彩好文,谢谢大家!
什么是数据库事务
数据库事务(Database Transaction) ,是指作为单个逻辑工作单元执行的一系列操作,要么完全地执行,要么完全地不执行。
简单来说: 事务是逻辑上的一组操作,要么都执行,要么都不执行。
通过事务,至少可以实现2点:
(1)操作的原子性
(2)数据一致性。
我们系统的每个业务方法可能包括了多个原子性的数据库操作,比如下面的 savePerson() 方法中就有两个原子性的数据库操作。
这些原子性的数据库操作是有依赖的,它们要么都执行,要不就都不执行。
public void savePerson() {
personDao.save(person);
personDetailDao.save(personDetail);
}
事务就是保证这两个关键操作要么都成功,要么都要失败。 最经典也经常被拿出来说例子就是转账了。
假如小明要给小红转账 1000 元,这个转账会涉及到两个关键操作就是:
1. 将小明的余额减少 1000 元。2. 将小红的余额增加 1000 元。
万一在这两个操作之间突然出现错误比如银行系统崩溃或者网络故障,导致小明余额减少而小红的余额没有增加,这样就不对了。
public class OrdersService {
private AccountDao accountDao;
public void setOrdersDao(AccountDao accountDao) {
this.accountDao = accountDao;
}
@Transactional(propagation = Propagation.REQUIRED,
isolation = Isolation.DEFAULT, readOnly = false, timeout = -1)
public void accountMoney() {
//小红账户多1000
accountDao.addMoney(1000,xiaohong);
//模拟突然出现的异常,比如银行中可能为突然停电等等
//如果没有配置事务管理的话会造成,小红账户多了1000而小明账户没有少钱
int i = 10 / 0;
//小明账户少1000
accountDao.reduceMoney(1000,xiaoming);
}
}
数据库事务的 ACID 四大特性
数据库事务的 ACID 四大特性是事务的基础,下面简单来了解一下。
事务的执行具备四大特征:
1、Atomic 原子性
事务必须是一个原子的操作序列单元,事务中包含的各项操作在一次执行过程中,要么全部执行成功,要么全部不执行,任何一项失败,整个事务回滚,只有全部都执行成功,整个事务才算成功。
2、Consistency 一致性
事务的执行不能破坏数据库数据的完整性和一致性,事务在执行之前和之后,数据库都必须处于一致性状态。
3、Isolation 隔离性
在并发环境中,并发的事务是相互隔离的,一个事务的执行不能被其他事务干扰。
即不同的事务并发操纵相同的数据时,每个事务都有各自完整的数据空间,即一个事务内部的操作及使用的数据对其他并发事务是隔离的,并发执行的各个事务之间不能相互干扰。
4、Durability 持久性
持久性(durability):持久性也称永久性(permanence),指一个事务一旦提交,它对数据库中对应 数据的状态变更就应该是永久性的。
即使发生系统崩溃或机器宕机,只要数据库能够重新启动,那么一定能够将其恢复到事务成功结束时的状态。
比方说:一个人买东西的时候需要记录在账本上,即使老板忘记了那也有据可查。
以上的事务特性是由Innodb引擎提供,为实现这些特性,使用到了数据库锁、WAL、MVCC等技术。
事务的并发执行
并发场景、高并发场景下, 事务都是并发执行的。
多个事务,如果都是一个一个串行,想想数据库的性能会有多低下。
但是,事务的并发执行,可能会带来很多问题, 比如 脏读、不可重复读、幻读等问题。
如果不对事务进行并发控制,我们看看数据库并发操作是会有那些异常情形
(1)一类丢失更新:两个事物读同一数据,一个修改字段1,一个修改字段2,后提交的恢复了先
提交修改的字段。
(2)二类丢失更新:两个事物读同一数据,都修改同一字段,后提交的覆盖了先提交的修改。
(3)脏读:读到了未提交的值,万一该事物回滚,则产生脏读。
(4)不可重复读:两个查询之间,被另外一个事务修改(update)了数据的内容,产生内容的不
一致。
(5)幻读:两个查询之间,被另外一个事务插入或删除了(insert、delete)记录,产生结果集
的不一致。
在数据库操作中,为了有效保证并发读取数据的正确性,提出的事务隔离级别。我们的数据库锁,也是为了构建这些隔离级别存在的。
隔离级别 |
脏读(
Dirty
Read
)
|
不可重复读
(
NonRepeatable Read
)
|
幻读(
Phantom
Read
)
|
读未提交
(Read
uncommitted
)
|
可能
| 可能 | 可能 |
读已提交(
Read
committed
)
| 不可能 | 可能 | 可能 |
可重复读
(
Repeatable
read
)
| 不可能 | 不可能 | 可能 |
可串行化(
Serializable
)
| 不可能 | 不可能 | 不可能 |
(1)读未提交
如果一个事务正在处理某一数据,并对其进行了更新, 但同时尚未完成事务,或者说事务没有提交。与此同时,允许另一个事务也能够访问该数据。 例如A将变量n从0累加到10才提交事务,此时B可能读到变量n从0到10之间的所有中间值。
允许脏读。在读未提交隔离级别下,允许脏读的情况发生。
脏读指的是读到了其他事务未提交的数据。未提交意味着这些数据可能会回滚,也就是可能最终不会存到数据库中,也就是不存在的数据。
读到了并不一定最终存在的数据,这就是脏读。
脏读最大的问题就是可能会读到不存在的数据。
比如在上图中,事务B的更新数据被事务A读取,但是事务B回滚了,更新数据全部还原,也就是说事务A刚刚读到的数据并没有存在于数据库中。
(2)读已提交
只允许读到已经提交的数据。
即事务A在将n从0累加到10的过程中,B无法看到n的中间值,最终只能看到10。
在读已提交隔离级别下,禁止了脏读,但是可能出现不可重复读的情况发生。
事务A在将n从0累加到10的过程中,B无法看到n的中间值,最终只能看到10。
同时,有事务C进行从10到20的累加,此时B在同一个事务内再次读时,读到的是20。
不可重复读指的是在一个事务内,最开始读到的数据和事务结束前的任意时刻读到的同一批数据出现不一致的情况。
事务A多次读取同一数据,但事务B在事务A多次读取的过程中,对数据作了更新并提交,导致事务A多次读取同一数据时,结果不一致。
不可重复读一词,有点反人类,不好记忆。是从Nonrepeatable read翻译过来的,感觉英文的,好记忆一点。
(3)可重复读
保证在事务处理过程中,多次读取同一个数据时,其值都和事务开始时刻时是一致的。
在可重复读隔离级别下,禁止了:脏读、不可重复读。
但是,允许幻读。
在可重复读中,该sql第一次读取到数据后,就将这些数据加锁(悲观锁),其它事务无法修改这些数据,就可以实现可重复读了。
但这种方法却无法锁住insert的数据,所以当事务A先前读取了数据,或者修改了全部数据,事务B还是可以insert数据提交,这时事务A就会发现莫名其妙多了一条之前没有的数据,这就是幻读,不能通过行锁来避免。
(4)串行化
最严格的事务,要求所有事务被串行执行,不能并发执行。
事务的隔离级别总结
隔离级别有串行化读、可重复读、读已提交、读未提交四种级别。
- 串行化读级别下的事务并发度太低,原因是锁的粒度太大,基本没有场景可以被使用。
- 读未提交级别允许脏读,可以使用的场景并不多。
- 读已提交和可重复读是大多数数据库也是大多数项目会采用的数据库隔离级别。
读已提交隔离级别下由于读到其他事务已提交的数据,所以不会出现脏读,在普通读取时,使用到
MVCC的快照读机制解决幻读问题;若在查询语句后增加for update,标识当前查询是当前读,当前读并不能解决幻读问题;允许不可重复读。
读已提交隔离级别并发度比较高,互联网行业需要数据库较高的事务并发度,一般会选择此种隔离级别。也是Oracle数据库的默认隔离级别。在使用锁方面,查询时对结果中数据的索引加共享锁,数据读取结束就会释放,更新时对操作的数据索引加排他锁,需要等到事务结束才会释放。
可重复读隔离级别是Mysql的默认隔离级别,事务并发度仅次于读已提交,普通读取的幻读问题与读已提交的解决方式一致,不过当前读可以使用 Gap Lock + Record Lock 解决。不可重复读的问题解决办法就是查询时对结果中数据的索引加锁共享锁,再事务结束时才会释放锁。解决不可重读的代价会牺牲掉并发度。
JDBC的转账事务案例
为了深入Spring 事务源码,先看一下在 JDBC 中对事务的操作处理.
还是以经典的转账事务为例。
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;
public class TransactionExample {
public static void main(String[] args) {
Connection conn = null;
PreparedStatement pstmt1 = null, pstmt2 = null;
try {
// 加载数据库驱动并建立连接
Class.forName("com.mysql.jdbc.Driver");
conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/test", "root",
"password");
// 关闭自动提交,开启事务
conn.setAutoCommit(false);
// 创建SQL语句
String sql1 = "UPDATE account SET balance = balance - ? WHERE id = ?";
String sql2 = "UPDATE account SET balance = balance + ? WHERE id = ?";
// 创建PreparedStatement对象
pstmt1 = conn.prepareStatement(sql1);
pstmt2 = conn.prepareStatement(sql2);
// 设置参数
pstmt1.setDouble(1, 1000);
pstmt1.setInt(2, 1);
pstmt2.setDouble(1, 1000);
pstmt2.setInt(2, 2);
// 执行更新操作
int count1 = pstmt1.executeUpdate();
int count2 = pstmt2.executeUpdate();
if (count1 > 0 && count2 > 0) {
System.out.println("转账成功");
// 提交事务
conn.commit();
} else {
System.out.println("转账失败");
// 回滚事务
conn.rollback();
}
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (SQLException e) {
try {
if (conn != null) {
// 回滚事务
conn.rollback();
}
} catch (SQLException e1) {
e1.printStackTrace();
}
e.printStackTrace();
} finally {
try {
if (pstmt1 != null) {
pstmt1.close();
}
if (pstmt2 != null) {
pstmt2.close();
}
if (conn != null) {
conn.close();
}
} catch (SQLException e) {
e.printStackTrace();
}
}
}
}
java 代码中要使用事务,就是三个步骤:
start transaction 开启一个事务
commit 事务提交
rollback 事务回滚
mysql中事务默认是自动提交,一条sql语句就是一个事务。
所以,上面的代码,第一步,首先关闭自动提交,开启手动事务方式。
//关闭自动提交,开启事务
conn.setAutoCommit(false)
然后,第2步,如果顺利的话,就提交事务
//提交事务
conn.commit()
如果发生异常的话,第3步,就回滚事务
//回滚事务
conn.rollback()
这里总结一下,上面代码中的JDBC的转账事务案例几个重点步骤:
- 获取连接:
Connection conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/test", "root", "password")
- 关闭自动提交,开启事务:conn.setAutoCommit(false)
- 提交事务:conn.commit()
- 回滚事务:conn.rollback()