SpringBoot 系列教程之事務隔離級別知識點小結


SpringBoot 系列教程之事務隔離級別知識點小結

上一篇博文介紹了聲明式事務@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,提供了事務支持

<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. 系列博文&源碼

系列博文

源碼

1. 一灰灰 Blog

盡信書則不如,以上內容,純屬一家之言,因個人能力有限,難免有疏漏和錯誤之處,如發現 bug 或者有更好的建議,歡迎批評指正,不吝感激

下面一灰灰的個人博客,記錄所有學習和工作中的博文,歡迎大家前去逛逛

一灰灰blog


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM