数据库事务和@Transaction注解
数据库事务(Database Transaction) ,是指作为单个逻辑工作单元执行的一系列操作,要么完全地执行,要么完全地不执行。 事务处理可以确保除非事务性单元内的所有操作都成功完成,否则不会永久更新面向数据的资源。通过将一组相关操作组合为一个要么全部成功要么全部失败的单元,可以简化错误恢复并使应用程序更加可靠。
事务的基本要素(ACID)
- 原子性(Atomicity):事务开始后所有操作,要么全部做完,要么全部不做,不可能停滞在中间环节。事务执行过程中出错,会回滚到事务开始前的状态,所有的操作就像没有发生一样。也就是说事务是一个不可分割的整体,就像化学中学过的原子,是物质构成的基本单位。
- 一致性(Consistency):事务开始前和结束后,数据库的完整性约束没有被破坏 。比如A向B转账,不可能A扣了钱,B却没收到。
- 隔离性(Isolation):同一时间,只允许一个事务请求同一数据,不同的事务之间彼此没有任何干扰。比如A正在从一张银行卡中取钱,在A取钱的过程结束前,B不能向这张卡转账。
- 持久性(Durability):事务完成后,事务对数据库的所有更新将被保存到数据库,不能回滚。
当单进程执行时,很容易保证事务的4中基本要素,并且不会引发问题,然而当多个并发进程共同执行事务时候,可能会引发不同的问题。
事务隔离级别
隔离级别 | 脏读 | 不可重复读 | 幻读 | 特点 |
---|---|---|---|---|
未提交读(read-uncommitted) | √ | √ | √ | 优点在于并发能力高,适合那些对数据一致性没有要求而追求高并发的场景,缺点是脏读 |
读写提交(read-committed) | × | √ | √ | 出现不可重复读 |
可重复读(repeatable-read) | × | × | √ | mysql的默认事务隔离级别。会出现幻读 |
串行化(serializable) | × | × | × | 并行性低,一般不用。通常消除幻读使用数据库锁的方式 |
- 脏读:事务A读取了事务B更新的数据,然后B回滚操作,那么A读取到的数据是脏数据
- 不可重复读:事务 A 多次读取同一数据,事务 B 在事务A多次读取的过程中,对数据作了更新并提交,导致事务A多次读取同一数据时,结果不一致。
- 幻读:幻读是针对多条数据库记录的。例如,系统管理员A将数据库中所有学生的成绩从具体分数改为ABCDE等级,但是系统管理员B就在这个时候插入了一条具体分数的记录,当系统管理员A改结束后发现还有一条记录没有改过来,就好像发生了幻觉一样,这就叫幻读。
首先,我们先设置MySQL事务隔离级别为Read committed
- 在my.ini配置文件最后加上如下配置
#可选参数有:READ-UNCOMMITTED, READ-COMMITTED, REPEATABLE-READ, SERIALIZABLE.
[mysqld]
transaction-isolation = READ-COMMITTED
- 重启MySQL服务
脏读
提出问题
同一个应用程序中的多个事务或不同应用程序中的多个事务在同一个数据集上并发执行时, 可能会出现许多意外的问题。
例如: 已知有两个事务A和B, B读取了已经被A更新但还没有被提交的数据,之后,A回滚事务,B读取的数据就是脏数据。
场景:
Tom的账户money=0,公司发工资把5000元打到Tom的账户上,Tom的money=money+5000元,但是该事务并未提交,而Tom正好去查看账户,发现工资已经到账,账户money=5000元,非常高兴,可是不幸的是,公司发现发给Tom的工资金额不对,应该是2000元,于是迅速回滚了事务,修改金额后,将事务提交,Tom再次查看账户时发现账户money=2000元,Tom空欢喜一场,从此郁郁寡欢,走上了不归路……
当我们设置事务隔离级别为READ-COMMITTED(读已提交)时事务流程如下:
事务A(代表公司) | 事务B(代表Tom) |
---|---|
read(money); | |
money=money+5000; | |
write(money) | |
read(money);(读已提交数据) | |
… | |
rollback;(money=0) | |
money=money+2000 | |
submit ; | |
read(money);(读已提交数据) |
分析:上述情况即为脏读,两个并发的事务:“事务A:公司给Tom发工资”、“事务B:Tom查询工资账户”,事务隔离级别为READ-COMMITTED(读已提交)时事务B只会读取已提交的数据。
实验
我们在java代码中观察这种情况:
public class Boss {//公司给Tom发工资
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();
String sql = "update account set money=money+5000 where card_id='6226090219290000'";
statement.executeUpdate(sql);
Thread.sleep(10000);//10秒后发现工资发错了
connection.rollback();
sql = "update account set money=money+2000 where card_id='6226090219290000'";
statement.executeUpdate(sql);
connection.commit();
} catch (Exception e) {
e.printStackTrace();
} finally {
//释放资源
}
}
}
public class Employee {//Tom查询余额
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 {
//释放资源
}
}
}
在执行Boss中main方法后立即执行Employee中的main方法得:
在执行Boss中main方法后等待10秒,执行Employee中的main方法得:
得出结论
事务隔离级别为READ-COMMITTED(读已提交)时不会出现“脏读”
不可重复读
提出问题
场景:Tom拿着工资卡去消费,酒足饭饱后在收银台买单,服务员告诉他本次消费1000元,Tom将银行卡给服务员,服务员将银行卡插入POS机,POS机读到卡里余额为3000元,就在Tom磨磨蹭蹭输入密码时,他老婆以迅雷不及掩耳盗铃之势把Tom工资卡的3000元转到自己账户并提交了事务,当Tom输完密码并点击“确认”按钮后,POS机检查到Tom的工资卡已经没有钱,扣款失败,Tom十分纳闷,明明卡里有钱,于是怀疑POS有鬼,和收银小姐姐大打出手,300回合之后终因伤势过重而与世长辞,Tom老婆痛不欲生,郁郁寡欢,从此走上了不归路…
当我们设置事务隔离级别为READ-COMMITTED(读已提交)时事务流程如下:
事务A(代表POS机) | 事务B(代表老婆) |
---|---|
read(money); | |
输入密码 | read(money); |
… | money=money-3000;(转账) |
… | write(money);submit ; |
read(money); | |
余额不足! |
分析:上述情况即为不可重复读,两个并发的事务,“事务A:POS机扣款”、“事务B:Tom的老婆网上转账”,事务A事先读取了数据,事务B紧接了更新数据并提交了事务,而事务A再次读取该数据扣款时,数据已经发生了改变。
实验
我们在java代码中观察这种情况:
public class Machine {//POS机扣款
public static void main(String[] args) {
Connection connection = null;
Statement statement = null;
ResultSet resultSet = null;
try {
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 money from account where card_id='6226090219290000'";
resultSet = statement.executeQuery(sql);
if(resultSet.next()) {
System.out.println("余额:"+resultSet.getDouble("money"));
}
System.out.println("请输入支付密码:");
Thread.sleep(10000);//10秒后密码输入成功
resultSet = statement.executeQuery(sql);
if(resultSet.next()) {
double money = resultSet.getDouble("money");
System.out.println("余额:"+money);
if(money<sum) {
System.out.println("余额不足,扣款失败!");
return;
}
}
sql = "update account set money=money-"+sum+" where card_id='6226090219290000'";
statement.executeUpdate(sql);
connection.commit();
System.out.println("扣款成功!");
} catch (Exception e) {
e.printStackTrace();
} finally {
//释放资源
}
}
}
public class Wife {//Tom的老婆网上转账
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 money=money-"+money+" where card_id='6226090219290000'";
statement.executeUpdate(sql);
sql = "update account set money=money+"+money+" where card_id='6226090219299999'";
statement.executeUpdate(sql);
connection.commit();
System.out.println("转账成功");
} catch (Exception e) {
e.printStackTrace();
} finally {
//释放资源
}
}
}
在执行Machine中main方法后立即执行Wife中的main方法得:
等待10秒后Machine中main方法执行结果:
得出结论
事务隔离级别为READ-COMMITTED(读已提交)时会出现“不可重复读”。
幻读(phantom read)
幻读(Phantom Read): 已知有两个事务A和B,A从一个表中读取了数据,然后B在该表中插入了一些新数据,导致A再次读取同一个表, 就会多出几行。
- 前提条件:可重复读隔离级别,使用当前读时(REPEATABLE_READ)。
- 表现:一个事务(同一个read view)在前后两次查询同一范围的时候,后一次查询看到了前一次查询没有看到的行。两点需要说明:
1、在可重复读隔离级别下,普通查询是快照读,是不会看到别的事务插入的数据的,幻读只在当前读下才会出现。
2、幻读专指新插入的行,读到原本存在行的更新结果不算。因为当前读的作用就是能读到所有已经提交记录的最新值。
提出问题
场景:Tom的老婆工作在银行部门,她时常通过银行内部系统查看Tom的工资卡消费记录。2019年5月的某一天,她查询到Tom当月工资卡的总消费额为80元,Tom的老婆非常吃惊,心想“老公真是太节俭了,嫁给他真好!”,而Tom此时正好在外面胡吃海塞后在收银台买单,消费1000元,即新增了一条1000元的消费记录并提交了事务,沉浸在幸福中的老婆查询了Tom当月工资卡消费明细一探究竟,可查出的结果竟然发现有一笔1000元的消费,Tom的老婆瞬间怒气冲天,外卖订购了一个大号的榴莲,傍晚降临,Tom生活在了水深火热之中,只感到膝盖针扎的痛…
当我们设置事务隔离级别为READ-COMMITTED(读已提交)时事务流程如下:
事务A(代表老婆) | 事务B(代表Tom消费) |
---|---|
read(消费记录); | |
消费金额80元 | read(money); |
… | money=money-1000;(消费) |
… | write(money);submit ; |
read(消费记录); | |
消费金额1080元 |
分析:上述情况即为幻读,两个并发的事务,“事务A:获取事务B消费记录”、“事务B:添加了新的消费记录”,事务A获取事务B消费记录时数据多出了一条。
实验
我们在java代码中观察这种情况:
public class Bank {//老婆查看消费记录
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");
connection.setAutoCommit(false);
statement = connection.createStatement();
String sql = "select sum(amount) total from record where card_id='6226090219290000' and date_format(create_time,'%Y-%m')='2019-05'";
resultSet = statement.executeQuery(sql);
if(resultSet.next()) {
System.out.println("总额:"+resultSet.getDouble("total"));
}
Thread.sleep(10000);//30秒后查询2019年5月消费明细
sql="select amount from record where card_id='6226090219290000' and date_format(create_time,'%Y-%m')='2019-05'";
resultSet = statement.executeQuery(sql);
System.out.println("消费明细:");
while(resultSet.next()) {
double amount = resultSet.getDouble("amount");
System.out.println(amount);
}
connection.commit();
} catch (Exception e) {
e.printStackTrace();
} finally {
//释放资源
}
}
}
public class Husband {//Tom消费1000元
public static void main(String[] args) {
Connection connection = null;
Statement statement = null;
try {
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 = "update account set money=money-"+sum+" where card_id='6226090219290000'";
statement.executeUpdate(sql);
sql = "insert into record (id,card_id,amount,create_time) values (3,'6226090219290000',"+sum+",'2019-05-19');";
statement.executeUpdate(sql);
connection.commit();
} catch (Exception e) {
e.printStackTrace();
} finally {
//释放资源
}
}
}
在执行Bank中main方法后立即执行Wife中的Husband方法得:
等待10秒后控制台输出:
得出结论
事务隔离级别为READ-COMMITTED(读已提交)时会出现“幻读”
幻读的影响
- 会造成一个事务中先产生的锁,无法锁住后加入的满足条件的行。
- 产生数据一致性问题,在一个事务中,先对符合条件的目标行做变更,而在事务提交前有新的符合目标条件的行加入。这样通过binlog恢复的数据是会将所有符合条件的目标行都进行变更的。
幻读产生的原因
- 行锁只能锁住行,即使把所有的行记录都上锁,也阻止不了新插入的记录。
如何解决幻读
- 将两行记录间的空隙加上锁,阻止新记录的插入;这个锁称为间隙锁。
- 间隙锁与间隙锁之间没有冲突关系。跟间隙锁存在冲突关系的,是往这个间隙中插入一个记录这个操作。
所用表
create table account(
id int(36) primary key comment '主键',
card_id varchar(16) unique comment '卡号',
name varchar(8) not null comment '姓名',
money float(10,2) default 0 comment '余额'
)engine=innodb;
insert into account (id,card_id,name,money) values (1,'6226090219290000','Tom',3000);
create table record(
id int(36) primary key comment '主键',
card_id varchar(16) comment '卡号',
amount float(10,2) comment '金额',
create_time date comment '消费时间'
)engine=innodb;
insert into record (id,card_id,amount,create_time) values (1,'6226090219290000',37,'2019-05-01');
insert into record (id,card_id,amount,create_time) values (2,'6226090219290000',43,'2019-05-07');
数据库锁
乐观锁
相对悲观锁而言,乐观锁假设认为数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则让返回用户错误的信息,让用户决定如何去做。一般的实现乐观锁的方式就是记录数据版本,并且不会造成线程的阻塞。
但是,版本的冲突会造成请求失败的概率剧增,这时往往需要通过重入的机制将请求失败的概率降低。而多次的重入又会带来过多执行SQL的问题,为了克服这个问题,可以考虑使用按时间戳或者限制重入次数的办法。
悲观锁
正如其名,它指的是对数据被外界修改持保守态度。因此在整个数据处理过程中,将数据处于锁定状态。 悲观锁的实现,往往依靠数据库提供的锁机制 (也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)。
数据库两种锁可以实现悲观锁,排他锁(Exclusive Lock,也叫X锁)和共享锁(Shared Lock,也叫S锁)。排他锁表示对数据进行写操作,如果一个事务对对象加了排他锁,其他事务就不能再给它加任何锁了;共享锁会为操作对象加共享锁,可以允许其他共享锁的加锁操作,但是不允许排它锁加锁操作。
锁粒度
MySQL锁的粒度几种,从小到大分别是,行锁(Record Lock)、间隙锁(Gap Lock)、Next-Key Lock、表锁。
1、 行锁:锁定一行记录
2、 间隙锁:锁定一个范围,但是不包含记录本身
3、Next-Key Lock:Record Lock和Gap Lock的组合,锁定一个区间以及对应的记录
4、 表锁:锁定整张表
MySQL innoDB加锁是通过索引项判断加锁的范围,即如果操作的对象上没有索引或者没有使用索引,则会导致锁的粒度扩大。因为InnoDB索引采用的BTree结构,所以,如果不是唯一索引,则如果使用查询值索引不存在,或者使用的条件查询则会引发Next-Key Lock。
意向锁
有了共享锁(S锁)和排它锁(X锁),能够实现数据库操作不同粒度的加锁,但是在一个操作需要给整个表加X锁的时候,需要确定整个表以及表的每一行没有被加S锁或者X锁,那么如何确定表中每一行没有被加锁,需要扫描整个表吗?如果表中有海量记录需要长时间等待,此时需要有意向锁。
Spring的@Transaction注解
在Spring中,为了 “擦除”令人厌烦的 try...catch...finally..语句,减少代码中数据库连接开闭和事务回滚提交的代码, Spring利用其 AOP 为我们提供了一个数据库事务的约定流程,这样开发的代码可读性就更高,也更好维护。
Spring数据库事务约定
@Transactional源码分析:
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface Transactional {
// 通过 bean name 指定事务管理器
@AliasFor("transactionManager")
String value() default "";
// 同 value 属性
@AliasFor("value")
String transactionManager() default "";
// 传播行为
Propagation propagation() default Propagation.REQUIRED;
// 隔离级别
Isolation isolation() default Isolation.DEFAULT;
// 超时时间
int timeout() default TransactionDefinition.TIMEOUT_DEFAULT;
// 是否只读事务
boolean readOnly() default false;
// 方法在发生指定异常时回滚,默认是所有异常都囚滚
Class<? extends Throwable>[] rollbackFor() default {};
// 方法在发生指定异常名称时回滚,默认是所有异常都回滚
String[] rollbackForClassName() default {};
// 方法在发生指定异常时不回滚,默认是所有异常都回滚
Class<? extends Throwable>[] noRollbackFor() default {};
// 方法在发生指定异常名称时不回滚,默认是所有异常都回滚
String[] noRollbackForClassName() default {};
}
事务管理器
事务的打开、回滚和提交是由事务管理器来完成的。在Spring中,事务管理器的顶层接口为PlatformTransactionManager,Spring也定义了一些其他的接口和类。
在Spring Boot中,当你依赖于mybatis-spring-boot-starter之后,它会自动创建一个DataSource TransactionManager对象作为事务管理器;如果依赖于spring-boot-starter-data-jpa,则它会自动创建JpaTransactionManager对象作为事务管理器,所以我们一般不需要自己创建事务管理器而直接使用它们即可。
传播行为
传播行为是方法之间调用事务采取的策略。在大部分的情况下,我们会认为数据库事务要么全部成功,要么全部失败。但当执行批量任务时,我们有时不希望因为极少数的任务不能完成而回滚所有的批量任务 。
在Spring中,当一个方法调用另外一个方法时,可以让事务采取不同的策略工作,如新建事务或者挂起当前事务等,这便是事务的传播行为。例如,批量任务我们称之为当前方法,当它调用单个任务时,称单个任务为子方法。当前方法调用子方法的时候,让每一个子方法不在当前事务中执行,而是创建一个新的事务,我们就说当前方法调用子方法的传播行为为新建事务。此外,还可能让方法在无事务、独立事务中执行,这些完全取决于业务需求。
源码分析:
public enum Propagation {
// 需要事务。它是默认传播行为,如果当前存在事务,就沿用当前事务,否则新建一个事务运行子方法
REQUIRED(TransactionDefinition.PROPAGATION_REQUIRED),
// 支持事务,如果当前存在事务,就沿用当前事务,如果不存在 ,则继续采用无事务的方式运行子方法
SUPPORTS(TransactionDefinition.PROPAGATION_SUPPORTS),
// 必须使用事务,如果当前没有事务,则会抛出异常,如果存在当前事务 ,就沿用当前事务
MANDATORY(TransactionDefinition.PROPAGATION_MANDATORY),
// 无论当前事务是否存在,都会创建新事务运行方法,这样新事务就可以拥有新的锁和隔离级别等特性,与当前事务相互独立
REQUIRES_NEW(TransactionDefinition.PROPAGATION_REQUIRES_NEW),
// 不支持事务,当前存在事务时,将挂起事务,运行方法
NOT_SUPPORTED(TransactionDefinition.PROPAGATION_NOT_SUPPORTED),
// 不支持事务,如果当前方法存在事务,则抛出异常,否则继续使用无事务机制运行
NEVER(TransactionDefinition.PROPAGATION_NEVER),
// 在当前方法调用子方法时,如果子方法发生异常,只因滚子方法执行过的 SQL,而不回滚当前方法的事务
NESTED(TransactionDefinition.PROPAGATION_NESTED);
private final int value;
Propagation(int value) { this.value = value; }
public int value() { return this.value; }
}
常用的传播行为是REQUIRED、REQUIRES_NEW、NESTED三种。
@Transactional自调用失效问题
Spring数据库事务的实现原理是AOP,而AOP的原理是动态代理。在事务自调用的过程中,是类自身的调用,而不是代理对象去调用,那么就不会产生AOP,这样Spring就不能把你的代码织入到约定的流程中,于是就产生了@Transactional自调用失效的场景。
有两种解决方法:用一个Service去调用另一个Service,这样就是代理对象的调用,Spring就会将代码织入事务流程;或者从Spring IoC容器中获取代理对象去启用AOP。