上一篇博文介紹了聲明式事務@Transactional
的簡單使用姿勢,最文章的最后給出了這個注解的多個屬性,本文將着重放在事務隔離級別的知識點上,並通過實例演示不同的事務隔離級別下,臟讀、不可重復讀、幻讀的具體場景
I. 基礎知識
在進入正文之前,先介紹一下事務隔離級別的一些基礎知識點,詳細內容,推薦參考博文
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,提供了事務支持
<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 相關的信息
## 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. 數據庫
新建一個簡單的表結構,用於測試
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. 初始化數據
准備一些用於后續操作的數據
@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);
}
}
提供一些基本的查詢和修改方法
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,進行演示
事務一,修改數據
/**
* 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 使用)
@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,用兩個線程來調用只讀事務,和讀寫事務
@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;
}
輸出結果如下
============ 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 上,稍微改一改
// ---------- 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;
}
測試用例
/**
* 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);
}
輸出結果如下
============ 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 相對簡單
/**
* 只讀事務,主要目的是為了隔離其他事務的修改,對本次操作的影響;
*
* 比如在某些耗時的涉及多次表的讀取操作中,為了保證數據一致性,這個就有用了; 開啟只讀事務之后,不支持修改數據
*/
@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;
}
我們希望讀寫事務的執行周期在只讀事務的兩次查詢之內,所有測試代碼如下
/**
* 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);
}
輸出結果
============ 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 事務隔離級別
串行事務隔離級別,所有的事務串行執行,實際的業務場景中,我沒用過... 也不太能想像,什么場景下需要這種
@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
/**
* 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);
}
輸出結果如下
============ 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. 系列博文&源碼
系列博文
- 180926-SpringBoot 高級篇 DB 之基本使用
- 190407-SpringBoot 高級篇 JdbcTemplate 之數據插入使用姿勢詳解
- 190412-SpringBoot 高級篇 JdbcTemplate 之數據查詢上篇
- 190417-SpringBoot 高級篇 JdbcTemplate 之數據查詢下篇
- 190418-SpringBoot 高級篇 JdbcTemplate 之數據更新與刪除
- 200119-SpringBoot 系列教程之聲明式事務 Transactional
源碼
- 工程:https://github.com/liuyueyi/spring-boot-demo
- 實例源碼: https://github.com/liuyueyi/spring-boot-demo/blob/master/spring-boot/101-jdbctemplate-transaction
1. 一灰灰 Blog
盡信書則不如,以上內容,純屬一家之言,因個人能力有限,難免有疏漏和錯誤之處,如發現 bug 或者有更好的建議,歡迎批評指正,不吝感激
下面一灰灰的個人博客,記錄所有學習和工作中的博文,歡迎大家前去逛逛
- 一灰灰 Blog 個人博客 https://blog.hhui.top
- 一灰灰 Blog-Spring 專題博客 http://spring.hhui.top