以下的分析和总结都是针对InnoDb引擎.
事务的四大特性
这四个特性并不是平级的关系. 准确的说, 一致性是最基本的属性, 其他的属性都是为了保证一致性而存在的
Atomicity 原子性
原子性是指整个事务是一个整体,不可分割的最小工作单位。一个事务中的所有操作要么全部执行成功,要么全部都不执行。其中任何一条语句执行失败,都会导致事务回滚。
Consistency 一致性
指数据库的记录总是从一个一致性状态转变成另一个一致性状态。这里的一致性是语义上的一致性, 并不是语法上的一致性.
比如经典的银行转账例子,若A转账200给B,则需要执行如下三步:
- 检查A账户余额是否大于200
- 从A账户减去200
- 将B账户增加200
在事务的概念中,以上三个步骤必须处于同一个事务中,若不是处于同一事务中,当程序执行到3的时候系统异常了,就会导致用户的钱被扣.在真正的银行事务中,不仅需要强事务来保证一致性,而且会有一个日终对账系统,来计算每天所有的资金流向,保证整个资金流动值为0。
Isolation 隔离性
通常来说,一个事务所做的修改在事务未提交之前,对其他事务是不可见的。
还有一点需要注意,如果在同一个事务中如果执行相同的sql但是得到的结果不一样,此时也是违反隔离性的,比如把mysql的隔离级别设为read commited时就有可能会出现这种情况。
Durability 持久性
持久性 指的是数据一旦提交,结果就是永久性的。并不应为宕机等情况丢失。一般理解就是写入硬盘保存成功。
事务的实现方式
- 原子性和持久性利用redo log(重做日志) 实现.
- 一致性利用undo log(回滚日志)实现
- 隔离性利用锁来实现
具体日志的实现方式见另一篇关于日志的文章中提到的.
事务传播级别
以下例子中用到的表结构:
create table user1 (
id int unsigned not null auto increment,
name varchar(32) not null,
primary key(id)
) engine=InnoDB
create table user2 (
id int unsigned not null auto increment,
name varchar(32) not null,
primary key(id)
) engine=InnoDB
在Spring中定义的事务的传播级主要有如下几种,现就其中几个主要的说明如下
PROPAGATION_REQUIRED
Spring默认的传播级别。按需加载。当上下文中已经存在事务,则加入到该事务执行。若上下文不存在事务,则新建一个事务执行。
此处上下文可以理解为@Transactional注解作用的地方,当该注解作用于方法时,上下文从方法最先开始的地方开启,也就是说在刚进入方法时,首先就会开启一个事务。
1.当外部方法未开启事务时, 若外部方法抛出异常, 如何运行
User1Service代码
@Service
public class User1Service extends ServiceImpl<User1Mapper, User1Entity> {
@Resource
private User1Mapper user1Mapper;
@Transactional(propagation = Propagation.REQUIRED)
public void addUser1(User1Entity user1Entity) {
user1Mapper.insert(user1Entity);
}
}
User2Service代码
@Service
public class User2Service extends ServiceImpl<User2Mapper, User2Entity> {
@Resource
private User2Mapper user2Mapper;
@Transactional(propagation = Propagation.REQUIRED)
public void addUser2(User2Entity user2Entity) {
user2Mapper.insert(user2Entity);
}
}
测试代码:
public void t1() {
User1Entity user1Entity = new User1Entity();
user1Entity.setId(100);
user1Entity.setName("sju");
user1Service.addUser1(user1Entity);
User2Entity user2Entity = new User2Entity();
user2Entity.setId(200);
user2Entity.setName("fdf");
user2Service.addUser2(user2Entity);
throw new RuntimeException();
}
结果: user1, user2均插入成功.
分析: 因为外围方法未开启事务,所以程序执行到两个addUser方法时都会开启一个新的事务,这两个事务是相互独立的,分别执行自己的逻辑,插入成功,外部方法异常不影响内部.
2.当外部方法未开启事务时, 若内部方法抛出异常, 如何运行
User1Service代码:
@Service
public class User1Service extends ServiceImpl<User1Mapper, User1Entity> {
@Resource
private User1Mapper user1Mapper;
@Transactional(propagation = Propagation.REQUIRED)
public void addUser1(User1Entity user1Entity) {
user1Mapper.insert(user1Entity);
}
}
User2Service代码:
@Service
public class User2Service extends ServiceImpl<User2Mapper, User2Entity> {
@Resource
private User2Mapper user2Mapper;
@Transactional(propagation = Propagation.REQUIRED)
public void addUser2(User2Entity user2Entity) {
user2Mapper.insert(user2Entity);
throw new RuntimeException();
}
}
测试代码:
public void t1() {
User1Entity user1Entity = new User1Entity();
user1Entity.setId(100);
user1Entity.setName("sju");
user1Service.addUser1(user1Entity);
User2Entity user2Entity = new User2Entity();
user2Entity.setId(200);
user2Entity.setName("fdf");
user2Service.addUser2(user2Entity);
}
结果: user1插入成功, user2插入失败
分析: 还是因为外部方法未开启事务, 所以user1, user2会开启两个独立的事务, 互不影响. 所以user1插入成功, user2插入失败.
3.当外部方法开启事务时, 若内部方法或者外部方法抛出异常, 如何运行
User1Service和User2Service代码同之前, 只是在测试方法上也加上@Transactional注解. 测试方法代码如下:
@Transactional
public void t1() {
User1Entity user1Entity = new User1Entity();
user1Entity.setId(100);
user1Entity.setName("sju");
user1Service.addUser1(user1Entity);
User2Entity user2Entity = new User2Entity();
user2Entity.setId(200);
user2Entity.setName("fdf");
user2Service.addUser2(user2Entity);
throw new RuntimeException();
}
结果: user1, user2都未插入
分析: 外部方法开启事务,内部两个方法都会加入到该事务中成为一个事务,外部方法回滚,则所有回滚
PROPAGATION_NESTED
事务之间是嵌套的。若上下文存在事务,则嵌套执行,若不存在事务,则新建事务执行。
原理:子事务是父事务的一部分,当进入子事务之前,会先在父事务建立一个回滚点save point,然后执行子事务。待子事务执行结束,再执行父事务。
总结起来就是: 子事务的执行不会影响父事务,但父事务的执行会影响子事务.
举个例子, 若methodA以PROPAGATION_REQUIRED修饰, methodB以PROPAGATION_NESTED修饰, 并且在methodA中调用methodB, 此时A为父事务, B为子事务。 当出现以下几种情况时, 其执行情况如下:
异常情况 | 执行结果 |
---|---|
A抛异常,B正常 | A, B 都回滚 |
A正常, B异常 | B先回滚, A再正常提交 |
A,B都抛异常 | A , B 都回滚 |
几个问题:
问题1: 若子事务回滚,会发生什么?
若子事务回滚,则父事务会回滚到之前建立的save point,然后执行其他业务逻辑,父事务之前的操作不受影响,更不会自动回滚。所以父事务不受子事务的影响。
问题2:若父事务回滚,子事务会回滚吗?
答:子事务会回滚
问题3:子事务和父事务谁先提交?
答:子事务先提交
PROPAGATION_SUPPORTS
支持当前事务,若没有当前事务,则以非事务执行
PROPAGATION_MANDATORY
使用当前事务,注意这是强制性的(mandatory),因为当发现当前没有事务时,直接抛出异常
**PROPAGATION_**REQUIRES_NEW
不管上下文中是否存在事务,每次都新建一个独立事务.
若上下文中存在事务,则先会将上下文事务挂起,然后执行本方法事务,待本方法事务执行完毕之后,继续执行上下文事务。
若上下文未开启事务,其行为逻辑与requires级别一致,即新建一个自己独立的事务.
当外部方法开启事务时,requires_new修饰的内部方法依然会单独开启独立事务,且与外部方法的事务相互独立,内部方法之间,内部方法和外部方法的事务均相互独立,互不影响。
PROPAGATION_NOT_SUPPORTED
以非事务方式执行代码,若当前存在事务,则将当前事务挂起
PROPAGATION_NEVER
以非事务执行,该处语气比上一个更强,因为当发现当前存在事务时,则直接抛出异常
事务隔离级别
定义事务在数据库当中读写的控制范围。总共有四大级别
隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
未提交读 | 可能 | 可能 | 可能 |
提交读 | 不可能 | 可能 | 可能 |
可重复读 | 不可能 | 不可能 | 可能 |
串行读 | 不可能 | 不可能 | 不可能 |
未提交读 Read Uncommited
一个事务所做的修改在该事务未提交前对其他事务可见。
此种隔离级别一般不采用,因为可能会产生脏读.
如下表所示,事务A在时刻5读取到了事务B在时刻4还未提交的操作,导致出现了脏读。按照实际情况,应该最后余额1500,现在只有500
时间 | 事务A | 事务B |
---|---|---|
1 | 开始事务 | - |
2 | - | 开始事务 |
3 | - | 查询余额有1000 |
4 | - | 取出1000,余额0 |
5 | 查询余额0 | - |
6 | - | 撤销掉事务 |
7 | 存入500,余额500 | - |
8 | 提交事务 | - |
提交读 Read Commited
只能读取到其他事务已经提交的数据。
一个事务所做的修改在该事务未提交之前对其他事务不可见,即在未提交之前,实际上并没有对数据库进行更改,只有在真正提交的时候才会去更改数据。此级别能够解决脏读的问题,但是有可能会产生不可重复读的问题。Orcale默认数据隔离级别是该级别。
下图展示的是在该级别下产生的不可重复读的问题,表A原先数据只有1条, 事务A在同一个事务中前后两次读取到的余额是不一致的,这是因为事务B在这过程当中修改了该条记录并提交
时间 | 事务A | 事务B |
---|---|---|
1 | begin | - |
2 | begin | |
3 | select * from t; 返回结果: 1行 | |
4 | insert into t select 2; | |
5 | commit; | |
6 | select * from t; 返回结果: 2行 |
可重复读 Repetable Read
保证在同一个事务中,两次读取到的数据是一致的。该级别能解决不可重复读的问题,但是又可能会出现幻读(注意此处指的是一般意义上的数据库,MYSQL InnoDB在该级别下是不存在幻读的,具体原因见后面)
幻读针对的是数据的新增,而不可重复读针对的是数据的修改.
下图是出现幻读的情景,前后两次读取的数据不一致。
时间点 | 事务A | 事务B |
---|---|---|
1 | set autocommit = false | set autocommit=false |
2 | select count(*) from user 结果为 1 | - |
3 | - | insert into user(name) values(‘jki’) |
4 | select count(*) from user 结果为 2 | - |
5 | - | commit |
6 | commit | - |
7 | - | - |
8 | select count(*) from user 结果为2 | - |
user表在刚开始操作之前只有一条数据.可以看到在同一个事务A中, 前后两个时间点2和4得到的结果是不一样的, 这就是幻读, 原因是事务B在这期间新插入了一条数据.
Mysql InnoDb默认数据库隔离级别是该级别,即可重复读。
一般意义上的不可重复读是无法解决幻读的,但是在测试MySQL的时候发现,是不存在幻读的,这是怎么回事?
如下图是Mysql的不可重复读对应的操作,我们发现是不存在幻读的
时间点 | 事务A | 事务B |
---|---|---|
1 | set autocommit = false | set autocommit=false |
2 | select count(*) from user 结果为 1 | - |
3 | - | insert into user(name) values(‘jki’) |
4 | select count(*) from user 结果仍然为1 | - |
5 | - | commit |
6 | select count(*) from user 结果仍然为1 | - |
7 | commit | - |
8 | select count(*) from user 结果为2 | - |
我们可以看到在同一个事务A中, 即使事务B同时插入了一条记录, 事务A在事务B未提交的时间点4和事务B已经提交的时间点6统计的结果仍然和之前的保持一致.
那么InnoDb的可重复读隔离级别是如何解决幻读的?
答: InnoDB的行锁总共有三种算法,分别如下:
-
Record lock 单个行记录上的锁
-
Gap lock 间隙锁, 锁定一个范围,但不包括记录本身
-
Next-Key lock: 上述两个锁的组合,锁定一个范围,同时包括锁定记录本身.
Record Lock总是会去锁住索引记录, 如果InnoDb在建表的时候没有设置任何一个索引,则存储引擎会使用隐式的主键来进行锁定.
InnoDB对于行的查询默认使用Next-key lock算法(此处的查询指的是显示指定加锁的查询,如for update,否则的话普通查询是通过一致性非锁定读获取数据),例如一个索引有如下三条记录: 1, 3, 7, 则Next-key lock表示的区间为:
(-∞, 1] (1, 3] (3, 7] (3, +∞)
注意,当索引是唯一索引且是等值查询时(例如常见的主键等值查找id =1), Next-Key lock会降级为record lock,只会锁住某行索引记录,而不是一个区间.
通过使用next-key lock, InnoDb在可重复读的级别下,就可以防止出现幻读.因为在其他事务对于该区间范围内插入或删除数据时,该区域已经有一个next-key lock, 必须等待该锁释放掉,才能执行插入.
串行读 Serialize
保证事务串行执行。其原理是通过对读取的每一行数据都加锁来保证的。它不会出现上述错误,但是会导致大量的锁竞争,影响性能。
几个概念
1.脏读
一个事务读取到了另外一个事务未提交的数据。此处会违反事务的隔离性原则。
2.不可重复读
在同一个事务中两次读取到的数据不一致。主要原因是当事务隔离级别是Read Commited(提交读)的时候,一个事务所做的修改在事务未提交之前对其他事务是不可见的。该隔离级别能满足事务隔离性的一般定义。但是会导致不可重复读产生。
3.幻读
当某个事务在读取某个范围内的数据时,另外一个事务也对该范围内的数据进行新增或删除,此时两次读取到的结果不一致,导致出现幻读。
幻读和不可重复读的区别是不可重复读是对于数据的修改操作, 如果用锁来实现,在可重复读中,当sql第一次读取到数据时,就将数据加锁,这样其他事务就无法修改这些数据,就可以实现可重复读。但是该方式却无法锁住insert的数据,这就是幻读。
分布式事务
未完待定
@Transactional注解
该注解是Spring中用来控制事务的注解.在使用时需要注意以下几点:
-
@Transactional注解默认回滚RuntimeExcepption异常,即当方法被Transactional注解时,若在期间发生RuntimeException异常,则Spring会自动回滚该事务,注意此处即使异常被处理(即catch捕获了该类型的RuntimeException异常),Spring仍然会回滚事务
-
@Transactional注解有一个参数rollbackFor可用来指定当自定义的非RuntimeException发生时,也会进行事务回滚(Spring默认只回滚RuntimeException异常,对于其他非Runtime异常是不会自动回滚的)
-
@Transactional注解只能作用于public方法上,虽然也能写在非public上而不报错,但实际上是不起作用的