【DB系列】事务隔离级别知识点小结

文章目录
  1. I. 基础知识
    1. 1. 基本概念
    2. 2. 隔离级别
  2. II. 配置
    1. 1. 项目配置
    2. 3. 数据库
  3. III. 实例演示
    1. 1. 初始化数据
    2. 2. RU隔离级别
    3. 3. RC事务隔离级别
    4. 4. RR事务隔离级别
    5. 5. SERIALIZABLE事务隔离级别
    6. 6. 小结
  4. IV. 其他
    1. 0. 系列博文&源码
    2. 1. 一灰灰Blog

终于渡过漫长的自我隔离期,健康的活着真好;为武汉祈福,希望快点渡过,能早日回归大武汉 😭😭😭

上一篇博文介绍了声明式事务@Transactional的简单使用姿势,最文章的最后给出了这个注解的多个属性,本文将着重放在事务隔离级别的知识点上,并通过实例演示不同的事务隔离级别下,脏读、不可重复读、幻读的具体场景

I. 基础知识

在进入正文之前,先介绍一下事务隔离级别的一些基础知识点,详细内容,推荐参考博文

mysql之锁与事务

1. 基本概念

以下基本概念源于个人理解之后,通过简单的case进行描述,如有问题,欢迎拍砖

更新丢失

简单来讲,两个事务A,B分别更新一条记录的filedA, filedB字段,其中事务B异常,导致回滚,将这条记录的恢复为修改之前的状态,导致事务A的修改丢失了,这就是更新丢失

脏读

读取到另外一个事务未提交的修改,所以当另外一个事务是失败导致回滚的时候,这个读取的数据其实是不准确的,这就是脏读

不可重复读

简单来讲,就是一个事务内,多次查询同一个数据,返回的结果居然不一样,这就是不可重复度(重复读取的结果不一样)

幻读

同样是多次查询,但是后面查询时,发现多了或者少了一些记录

比如:查询id在[1,10]之间的记录,第一次返回了1,2,3三条记录;但是另外一个事务新增了一个id为4的记录,导致再次查询时,返回了1,2,3,4四条记录,第二次查询时多了一条记录,这就是幻读

幻读和不可重复读的主要区别在于:

  • 幻读针对的是查询结果为多个的场景,出现了数据的增加or减少
  • 不可重复度读对的是某些特定的记录,这些记录的数据与之前不一致

2. 隔离级别

后面测试的数据库为mysql,引擎为innodb,对应有四个隔离级别

隔离级别 说明 fix not fix
RU(read uncommitted) 未授权读,读事务允许其他读写事务;未提交写事务禁止其他写事务(读事务ok) 更新丢失 脏读,不可重复读,幻读
RC(read committed) 授权读,读事务允许其他读写事务;未提交写事务,禁止其他读写事务 更新丢失,脏读 不可重复读,幻读
RR(repeatable read) 可重复度,读事务禁止其他写事务;未提交写事务,禁止其他读写事务 更新丢失,脏读,不可重复度 幻读
serializable 序列化读,所有事务依次执行 更新丢失,脏读,不可重复度,幻读 -

说明,下面存为个人观点,不代表权威,谨慎理解和引用

  • 我个人的观点,rr级别在mysql的innodb引擎上,配合mvvc + gap锁,已经解决了幻读问题
  • 下面这个case是幻读问题么?
    • 从锁的角度来看,步骤1、2虽然开启事务,但是属于快照读;而9属于当前读;他们读取的源不同,应该不算在幻读定义中的同一查询条件中

II. 配置

接下来进入实例演示环节,首先需要准备环境,创建测试项目

创建一个SpringBoot项目,版本为2.2.1.RELEASE,使用mysql作为目标数据库,存储引擎选择Innodb,事务隔离级别为RR

1. 项目配置

在项目pom.xml文件中,加上spring-boot-starter-jdbc,会注入一个DataSourceTransactionManager的bean,提供了事务支持

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
```

### 2. 数据库配置

进入spring配置文件`application.properties`,设置一下db相关的信息

```properties
## DataSource
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/story?useUnicode=true&characterEncoding=UTF-8&useSSL=false
spring.datasource.username=root
spring.datasource.password=

3. 数据库

新建一个简单的表结构,用于测试

1
2
3
4
5
6
7
8
9
10
CREATE TABLE `money` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(20) NOT NULL DEFAULT '' COMMENT '用户名',
`money` int(26) NOT NULL DEFAULT '0' COMMENT '钱',
`is_deleted` tinyint(1) NOT NULL DEFAULT '0',
`create_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `name` (`name`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;

III. 实例演示

1. 初始化数据

准备一些用于后续操作的数据

1
2
3
4
5
6
7
8
9
10
11
12
@Component
public class DetailDemo {
@Autowired
private JdbcTemplate jdbcTemplate;

@PostConstruct
public void init() {
String sql = "replace into money (id, name, money) values (320, '初始化', 200)," + "(330, '初始化', 200)," +
"(340, '初始化', 200)," + "(350, '初始化', 200)";
jdbcTemplate.execute(sql);
}
}

提供一些基本的查询和修改方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private boolean updateName(int id) {
String sql = "update money set `name`='更新' where id=" + id;
jdbcTemplate.execute(sql);
return true;
}

public void query(String tag, int id) {
String sql = "select * from money where id=" + id;
Map map = jdbcTemplate.queryForMap(sql);
System.out.println(tag + " >>>> " + map);
}

private boolean updateMoney(int id) {
String sql = "update money set `money`= `money` + 10 where id=" + id;
jdbcTemplate.execute(sql);
return false;
}

2. RU隔离级别

我们先来测试RU隔离级别,通过指定@Transactional注解的isolation属性来设置事务的隔离级别

通过前面的描述,我们知道RU会有脏读问题,接下来设计一个case,进行演示

事务一,修改数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* ru隔离级别的事务,可能出现脏读,不可避免不可重复读,幻读
*
* @param id
*/
@Transactional(isolation = Isolation.READ_UNCOMMITTED, rollbackFor = Exception.class)
public boolean ruTransaction(int id) throws InterruptedException {
if (this.updateName(id)) {
this.query("ru: after updateMoney name", id);
Thread.sleep(2000);
if (this.updateMoney(id)) {
return true;
}
}
this.query("ru: after updateMoney money", id);
return false;
}

只读事务二(设置readOnly为true,则事务为只读)多次读取相同的数据,我们希望在事务二的第一次读取中,能获取到事务一的中间修改结果(所以请注意两个方法中的sleep使用)

1
2
3
4
5
6
7
@Transactional(readOnly = true, isolation = Isolation.READ_UNCOMMITTED, rollbackFor = Exception.class)
public boolean readRuTransaction(int id) throws InterruptedException {
this.query("ru read only", id);
Thread.sleep(1000);
this.query("ru read only", id);
return true;
}

接下来属于测试的case,用两个线程来调用只读事务,和读写事务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Component
public class DetailTransactionalSample {
@Autowired
private DetailDemo detailDemo;

/**
* ru 隔离级别
*/
public void testRuIsolation() throws InterruptedException {
int id = 330;
new Thread(new Runnable() {
@Override
public void run() {
call("ru: 只读事务 - read", id, detailDemo::readRuTransaction);
}
}).start();

call("ru 读写事务", id, detailDemo::ruTransaction);
}
}

private void call(String tag, int id, CallFunc<Integer, Boolean> func) {
System.out.println("============ " + tag + " start ========== ");
try {
func.apply(id);
} catch (Exception e) {
}
System.out.println("============ " + tag + " end ========== \n");
}


@FunctionalInterface
public interface CallFunc<T, R> {
R apply(T t) throws Exception;
}

输出结果如下

1
2
3
4
5
6
7
8
9
============ ru 读写事务 start ========== 
============ ru: 只读事务 - read start ==========
ru read only >>>> {id=330, name=初始化, money=200, is_deleted=false, create_at=2020-01-20 11:37:51.0, update_at=2020-01-20 11:37:51.0}
ru: after updateMoney name >>>> {id=330, name=更新, money=200, is_deleted=false, create_at=2020-01-20 11:37:51.0, update_at=2020-01-20 11:37:52.0}
ru read only >>>> {id=330, name=更新, money=200, is_deleted=false, create_at=2020-01-20 11:37:51.0, update_at=2020-01-20 11:37:52.0}
============ ru: 只读事务 - read end ==========

ru: after updateMoney money >>>> {id=330, name=更新, money=210, is_deleted=false, create_at=2020-01-20 11:37:51.0, update_at=2020-01-20 11:37:54.0}
============ ru 读写事务 end ==========

关注一下上面结果中ru read only >>>>开头的记录,首先两次输出结果不一致,所以不可重复读问题是存在的

其次,第二次读取的数据与读写事务中的中间结果一致,即读取到了未提交的结果,即为脏读

3. RC事务隔离级别

rc隔离级别,可以解决脏读,但是不可重复读问题无法避免,所以我们需要设计一个case,看一下是否可以读取另外一个事务提交后的结果

在前面的测试case上,稍微改一改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// ---------- rc 事物隔离级别
// 测试不可重复读,一个事务内,两次读取的结果不一样


@Transactional(readOnly = true, isolation = Isolation.READ_COMMITTED, rollbackFor = Exception.class)
public boolean readRcTransaction(int id) throws InterruptedException {
this.query("rc read only", id);
Thread.sleep(1000);
this.query("rc read only", id);
Thread.sleep(3000);
this.query("rc read only", id);
return true;
}

/**
* rc隔离级别事务,未提交的写事务,会挂起其他的读写事务;可避免脏读,更新丢失;但不能防止不可重复读、幻读
*
* @param id
* @return
*/
@Transactional(isolation = Isolation.READ_COMMITTED, rollbackFor = Exception.class)
public boolean rcTranaction(int id) throws InterruptedException {
if (this.updateName(id)) {
this.query("rc: after updateMoney name", id);
Thread.sleep(2000);
if (this.updateMoney(id)) {
return true;
}
}

return false;
}

测试用例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* rc 隔离级别
*/
private void testRcIsolation() throws InterruptedException {
int id = 340;
new Thread(new Runnable() {
@Override
public void run() {
call("rc: 只读事务 - read", id, detailDemo::readRcTransaction);
}
}).start();

Thread.sleep(1000);

call("rc 读写事务 - read", id, detailDemo::rcTranaction);
}

输出结果如下

1
2
3
4
5
6
7
8
9
============ rc: 只读事务 - read start ========== 
rc read only >>>> {id=340, name=初始化, money=200, is_deleted=false, create_at=2020-01-20 11:46:17.0, update_at=2020-01-20 11:46:17.0}
============ rc 读写事务 - read start ==========
rc: after updateMoney name >>>> {id=340, name=更新, money=200, is_deleted=false, create_at=2020-01-20 11:46:17.0, update_at=2020-01-20 11:46:23.0}
rc read only >>>> {id=340, name=初始化, money=200, is_deleted=false, create_at=2020-01-20 11:46:17.0, update_at=2020-01-20 11:46:17.0}
============ rc 读写事务 - read end ==========

rc read only >>>> {id=340, name=更新, money=210, is_deleted=false, create_at=2020-01-20 11:46:17.0, update_at=2020-01-20 11:46:25.0}
============ rc: 只读事务 - read end ==========

从上面的输出中,在只读事务,前面两次查询,结果一致,虽然第二次查询时,读写事务修改了这个记录,但是并没有读取到这个中间记录状态,所以这里没有脏读问题;

当读写事务完毕之后,只读事务的第三次查询中,返回的是读写事务提交之后的结果,导致了不可重复读

4. RR事务隔离级别

针对rr,我们主要测试一下不可重复读的解决情况,设计case相对简单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* 只读事务,主要目的是为了隔离其他事务的修改,对本次操作的影响;
*
* 比如在某些耗时的涉及多次表的读取操作中,为了保证数据一致性,这个就有用了; 开启只读事务之后,不支持修改数据
*/
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ, rollbackFor = Exception.class)
public boolean readRrTransaction(int id) throws InterruptedException {
this.query("rr read only", id);
Thread.sleep(3000);
this.query("rr read only", id);
return true;
}

/**
* rr隔离级别事务,读事务禁止其他的写事务,未提交写事务,会挂起其他读写事务;可避免脏读,不可重复读,(我个人认为,innodb引擎可通过mvvc+gap锁避免幻读)
*
* @param id
* @return
*/
@Transactional(isolation = Isolation.REPEATABLE_READ, rollbackFor = Exception.class)
public boolean rrTransaction(int id) {
if (this.updateName(id)) {
this.query("rr: after updateMoney name", id);
if (this.updateMoney(id)) {
return true;
}
}

return false;
}

我们希望读写事务的执行周期在只读事务的两次查询之内,所有测试代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* rr
* 测试只读事务
*/
private void testReadOnlyCase() throws InterruptedException {
// 子线程开启只读事务,主线程执行修改
int id = 320;
new Thread(new Runnable() {
@Override
public void run() {
call("rr 只读事务 - read", id, detailDemo::readRrTransaction);
}
}).start();

Thread.sleep(1000);

call("rr 读写事务", id, detailDemo::rrTransaction);
}

输出结果

1
2
3
4
5
6
7
8
============ rr 只读事务 - read start ========== 
rr read only >>>> {id=320, name=初始化, money=200, is_deleted=false, create_at=2020-01-20 11:46:17.0, update_at=2020-01-20 11:46:17.0}
============ rr 读写事务 start ==========
rr: after updateMoney name >>>> {id=320, name=更新, money=200, is_deleted=false, create_at=2020-01-20 11:46:17.0, update_at=2020-01-20 11:46:28.0}
============ rr 读写事务 end ==========

rr read only >>>> {id=320, name=初始化, money=200, is_deleted=false, create_at=2020-01-20 11:46:17.0, update_at=2020-01-20 11:46:17.0}
============ rr 只读事务 - read end ==========

两次只读事务的输出一致,并没有出现上面的不可重复读问题

说明

  • @Transactional注解的默认隔离级别为Isolation#DEFAULT,也就是采用数据源的隔离级别,mysql innodb引擎默认隔离级别为RR(所有不额外指定时,相当于RR)

5. SERIALIZABLE事务隔离级别

串行事务隔离级别,所有的事务串行执行,实际的业务场景中,我没用过… 也不太能想像,什么场景下需要这种

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Transactional(readOnly = true, isolation = Isolation.SERIALIZABLE, rollbackFor = Exception.class)
public boolean readSerializeTransaction(int id) throws InterruptedException {
this.query("serialize read only", id);
Thread.sleep(3000);
this.query("serialize read only", id);
return true;
}

/**
* serialize,事务串行执行,fix所有问题,但是性能低
*
* @param id
* @return
*/
@Transactional(isolation = Isolation.SERIALIZABLE, rollbackFor = Exception.class)
public boolean serializeTransaction(int id) {
if (this.updateName(id)) {
this.query("serialize: after updateMoney name", id);
if (this.updateMoney(id)) {
return true;
}
}

return false;
}

测试case

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* Serialize 隔离级别
*/
private void testSerializeIsolation() throws InterruptedException {
int id = 350;
new Thread(new Runnable() {
@Override
public void run() {
call("Serialize: 只读事务 - read", id, detailDemo::readSerializeTransaction);
}
}).start();

Thread.sleep(1000);

call("Serialize 读写事务 - read", id, detailDemo::serializeTransaction);
}

输出结果如下

1
2
3
4
5
6
7
8
============ Serialize: 只读事务 - read start ========== 
serialize read only >>>> {id=350, name=初始化, money=200, is_deleted=false, create_at=2020-01-20 12:10:23.0, update_at=2020-01-20 12:10:23.0}
============ Serialize 读写事务 - read start ==========
serialize read only >>>> {id=350, name=初始化, money=200, is_deleted=false, create_at=2020-01-20 12:10:23.0, update_at=2020-01-20 12:10:23.0}
============ Serialize: 只读事务 - read end ==========

serialize: after updateMoney name >>>> {id=350, name=更新, money=200, is_deleted=false, create_at=2020-01-20 12:10:23.0, update_at=2020-01-20 12:10:39.0}
============ Serialize 读写事务 - read end ==========

只读事务的查询输出之后,才输出读写事务的日志,简单来讲就是读写事务中的操作被delay了

6. 小结

本文主要介绍了事务的几种隔离级别,已经不同干的隔离级别对应的场景,可能出现的问题;

隔离级别说明

级别 fix not fix
RU 更新丢失 脏读,不可重复读,幻读
RC 更新丢失 脏读 不可重复读,幻读
RR 更新丢、脏读,不可重复读,幻读 -
serialze 更新丢失、 脏读,不可重复读,幻读 -

使用说明

  • mysql innodb引擎默认为RR隔离级别;@Transactinoal注解使用数据库的隔离级别,即RR
  • 通过指定Transactional#isolation来设置事务的事务级别

IV. 其他

0. 系列博文&源码

系列博文

源码

1. 一灰灰Blog

尽信书则不如,以上内容,纯属一家之言,因个人能力有限,难免有疏漏和错误之处,如发现bug或者有更好的建议,欢迎批评指正,不吝感激

下面一灰灰的个人博客,记录所有学习和工作中的博文,欢迎大家前去逛逛

一灰灰blog


打赏 如果觉得我的文章对您有帮助,请随意打赏。
分享到