一文說清 InnoDB 的事務機制

咱們從一個轉帳的故事開始。mysql

隔壁小王從美團上找到了一家水餃店,準備中午吃水餃。下單成功,支付20元。算法

商家這裏響了一下:叮叮,您有美團外賣新訂單啦,請及時處理。水餃一份,好嘞,下鍋。sql

很快小王吃到外賣了,吃完美美地躺下開始睡覺。數據庫

忽然手機一頓猛響。一個陌生的號碼打過來的,又是賣房的吧。小王想一想沒理他,繼續睡。數組

但是這哥麼持之以恆,一會又打過來了。小王忍無可忍準備接過電話罵他一頓。剛接電話聽到對面一陣急促的聲音傳來:你好你中午是否是點了一份咱們店的水餃?緩存

小王這才意識到感情是水餃店的。趕緊回覆到是的啊,咋了。安全

老闆說:你中午下單付款了嗎?服務器

小王:我確定付款了啊,否則怎麼下單。session

老闆說:我沒收到錢啊。你把付款的截圖發給我。併發

小王說:我吃飯還能不付錢嗎,你等着。

因而小王給老闆截圖了,老闆拿着截圖去找了美團技術,美團技術一查,轉帳失敗。跟老闆說很差意思,今天這代碼是實習生寫的,咱們立刻開除他,稍後轉給你。這時候老闆一顆懸着的心才放下,可不能一天就賣一份水餃還沒收到錢,這不虧大了呢!

以上純屬虛構,沒有詆譭美團實習生的意思。

從上面的問題看,付款成功了,轉帳失敗了,這時候用戶吃到了飯,可是老闆沒收到錢。放在正常的堂食,你不先付款,估計人兒就的趕你出去,一手交錢一手交貨買賣不變的道理。

咱們引伸出一個概念:最小操做單元。即咱們人爲定義了一個業務場景,這個場景中的操做要麼所有成功,要麼所有失敗。

英語原文中把這種最小操做單元定義爲:transaction ,在英語中的解釋是:

an occasion when someone buys or sells something, or when money is exchanged or the activity of buying or selling something:

  • a business transaction
  • Each transaction at the foreign exchange counter seems to take forever
  • We need to monitor the transaction of smaller deals.

通俗的說就是咱們作某事所發生的這個時機或這個場景,代指這整個的發生過程。在 MySQL 中咱們把 transaction 翻譯爲 事務,我的感受中文意思總和英文有點不搭。

上面這個例子中咱們能夠了解到 transaction 存在的主要意圖:

  1. 在最小操做單元中保持穩定的操做,即便在故障時也能恢復到操做以前的狀態保持數據一致性。
  2. 保持各個最小操做單元以前互相隔離,以防止互相交互產生的覆蓋性錯誤。

通常須要事務來控制的場景發生在:

更新--插入--選擇--插入--

即一個最小操做單元中保持兩個及以上的非查詢操做。

事務結束的兩種可能方式:

  • commit:提交最小操做單元中的全部操做。
  • terminate:操做終止,最小操做單元中全部修改無效。

數據庫操做的環境:

  • 共享-多用戶併發訪問
  • 不穩定-潛在的硬件/軟件故障

事務所需環境:

  • 不共享 - 一個事務內的操做不受其餘事務影響
  • 穩定 - 即便面對系統故障,當前事務的操做也能保留現場

一個事務一旦開始,則必須確保:

  • 全部操做必須可回溯
  • 全部操做對後續操做的影響必須是可見的

一個事務開始的過程當中必須確保:

在該事務結束以前其餘事務看不到它的結果。

若是事務停止:

必須確保當前事務全部可能影響數據一致性的操做都會被清理。

若是系統出現故障:

必須確保從新啓動時全部未提交的事務都會被清理。

針對以上事務操做過程當中可能會出現的問題,抽象出事務若是知足如下條件,則能夠保證數據完整性:

  • Automicity(原子性)

    要麼事務中的全部任務都必須發生,要麼都不發生。

  • Consistency(一致性)

    每一個事務都必須保留數據庫的完整性約束(已聲明的一致性規則)。它不能使數據處於矛盾狀態。在執行期間,一系列數據庫操做不會違反任何完整性約束。

  • Isolation(隔離性)

    兩個同時進行的事務不能互相干擾。交易中的中間結果必須對其餘交易不可見。其餘一系列數據庫操做沒法看到一系列數據庫操做的中間狀態。

  • Durability(持久性)

    已完成的事務之後不能停止或放棄其結果。它們必須在崩潰後經過(例如)從新啓動DBMS持續存在。保證已提交的一系列數據庫操做將永久保留。

特地查證了一下,關於事務四大特性的提出最先是在 1983 年由 Andreas Reuter 和 Theo Haerder 兩位關係型數據庫研發的鼻祖在論文:Principles of transaction-oriented database recovery 中提出。論文連接,感興趣的能夠下載來看看。

事務的 ACID 特性概念簡單,但不是很好理解,主要是由於這幾個特性不是一種平級關係:

  • 只有知足一致性,事務的執行結果纔是正確的。
  • 在無併發的狀況下,事務串行執行,隔離性必定可以知足。此時只要能知足原子性,就必定能知足一致性。 在併發的狀況下多個事務並行執行,事務不只要知足原子性,還須要知足隔離性,才能知足一致性。
  • 事務知足持久化是爲了能應對數據庫崩潰的狀況。

InnoDB 如何實現事務

鑑於 MyISAM 引擎不支持事務,支持事務的引擎只有 InnoDB,因此下面關於事務的講解都是基於 InnoDB引擎。

在 InnoDB引擎中實現事務最重要的東西就是日誌文件,保證事務的四大特性主要依靠這兩大日誌:

  • redo log :保證事務持久性
  • undo log:回滾日誌,保證事務原子性

兩大日誌系統分別保證了持久性和原子性,另外還有兩大特性是經過什麼來保證的呢?

一致性 和 隔離性 是經過 MVCC 機制 和 鎖機制來一塊兒控制。先提早介紹,後面咱們詳解討論。

典型的事務操做會遵循以下流程:

start transaction;
...... # do your business
commit;

start transaction 標識事務的開始,直到遇到 commit 纔會提交事務。在該事務過程當中若是出現問題,會自動調用 rollback 邏輯回滾該事物已完成的 sql。

非顯式開啓事務

MySQL 中默認採用的是自動提交的模式:

mysql > show variables like 'autocommit';
+------------------+-------+
|   Variable_name  | Value |
+------------------+-------+
|   autocomment    | ON    |
+------------------+-------+

自動模式下,你無需顯式的輸入 start transaction 做爲開頭和使用 commit 做爲結尾來標識一個事務。每一個sql 語句都會被做爲一個事務提交。

固然你也能夠關閉自動提交事務機制:

mysql > set autocommit = 0;

須要注意的是:autocommit 參數的修改指只針對當前鏈接,在一個鏈接中修改該屬性並不會影響別的鏈接。

不被 autocommit 影響的操做

MySQL 中提供了一些不會被 autocommit 屬性值所影響的特殊指令,這些指定即便在事務中執行,他們也會馬上執行而不是等到 commit 語句以後再提交,這些特殊指令包括:DDL(create table / drop table / alter table)lock tables等等。

咱們探討事務到底在探討什麼?

事務的定義咱們已經瞭解,無非就是把幾個有上下文關聯的 sql 放在一塊兒操做要麼所有成功,要麼所有失敗。道理很簡單,那咱們分析這麼多到底在分析什麼呢?貌似難的點不在於打包執行,在於若是讓這些打包命中不互相影響,事務執行過程當中失敗如何回滾操做且不污染現有數據。這些纔是咱們討論事務應該關注的地方。

這些問題的根本其實又回到了事務的四大特性,不得不說 Theo Haerder 在 1983 年就能抽象出來如此高度凝練的總結實在是讓當下汗顏。

下面我就從 InnoDB 如何保證四大特性入手,逐一分析事務機制的實現。

保證原子性的關鍵技術 - undo log

對於事務的原子性來講,該事務內全部操做要麼所有成功要麼所有失敗就是事務的原子性。

所有成功這個毋庸置疑,若是中間忽然失敗,原子性該如何保證呢?是否該回滾當前已經執行成功的操做。

InnoDB 提供了一種日誌:undo log,它有兩個做用:提供 回滾 和 多個行版本控制(MVCC)。

好比一條 delete 操做在 undo log 中會對應一條 insert 記錄,反之亦然。當 update 操做時,它會記錄一條相反的 update 記錄。

當執行 rollback 時,就能夠從 undo log 中的邏輯記錄讀取到相應的內容並進行回滾。

有時候應用到行版本控制的時候,也是經過 undo log 來實現的:當讀取的某一行被其餘事務鎖定時,它能夠從 undo log 中分析出該行記錄之前的數據是什麼,從而提供該行版本信息,讓用戶實現非鎖定一致性讀取。

undo log 的存儲方式

InnoDB 存儲引擎對 undo log 的管理採用段的方式。rollback segment 稱爲回滾段,每一個回滾段中有 1024 個 undo log slot 。

在之前老版本,只支持 1 個 rollback segment,這樣就只能記錄 1024 個 undo log slot。後來 MySQL5.5 能夠支持 128 個 rollback slot,即支持 128 * 1024 個 undo log 操做。

MySQL5.6 以前,undo log 表空間位於共享表空間的回滾段中,共享表空間的默認的名稱是 ibdata,位於數據文件目錄中。
MySQL5.6 以後,undo log 表空間能夠配置成獨立的文件,可是提早須要在配置文件中配置,完成數據庫初始化後生效且不可改變 undo log 文件的個數。若是初始化數據庫以前沒有進行相關配置,那麼就沒法配置成獨立的表空間了。
MySQL5.7 以後的獨立 undo log 表空間配置參數以下:

innodb_undo_directory = /data/undospace/ #undo獨立表空間的存放目錄
innodb_undo_logs = 128 #回滾段爲128KB
innodb_undo_tablespaces = 4 #指定有4個undo log文件

undo log 的刪除時機

undo log 文件的個數是有限制的,因此不用無限堆積日誌文件。undo log 記錄的是當前事務操做的反向記錄,理論上當前事務結束,undo log 日誌就能夠廢棄。上面也提到過的多版本併發控制機制在隔離級別爲 repeatable read 的時候事務讀取的數據都是該事務最新提交的版本,那麼只要該事務不結束,行版本記錄就不能刪除。

另外不一樣的 sql 語句對應的 undo log 類型也不同,好比:

  • insert 語句:由於 insert 操做自己只對該事務可見,事務提交以前別的鏈接是看不到的,因此 insert 操做產生的 undo log 日誌在事務提交以後會立刻直接刪除,後續不會再被別的功能使用。
  • update / delete 語句:delete 操做在事務中並不會真的先刪除數據,而是將該條數據打上 「delete_bit」 標識,後續的刪除操做是在事務提交後的 purge 線程獨立操做。這兩種操做產生的 undo log 日誌均可以用反向的 update 來代替,這種操做上面說過 MVCC 機制可能會用上,因此就不能在事務結束以後直接刪除。

在事務提交以後,也不是立刻就刪除該事務對應的 undo log 日誌,而是將該事務對應的文件塊放入到刪除列表中,將來經過 purge 來刪除。而且提交事務時,還會判斷 undo log 分配的頁是否能夠重用,若是能夠重用,則會分配給後面來的事務,避免爲每一個獨立的事務分配獨立的 undo log 頁而浪費存儲空間和性能。

持久性 - redo log

redo log 即重作日誌,重作日誌記錄每次操做的物理修改。

說 redo log 以前實際上是要先說一下 binlog,否則就不知道爲何要引入 redo log。

bin log = binary log,二進制日誌,它記錄了除了 select 以外全部的 DDL 和 DML 語句。以事件形式記錄,還包含語句所執行的消耗的時間,MySQL 的二進制日誌是事務安全型的。

binlog日誌有兩個最重要的使用場景:

  1. mysql 主從複製: mysql replication 在 master 端開啓 binlog,master 把它的二進制日誌傳遞給 slaves 來達到 master-slave 數據一致的目的。
  2. 數據恢復: 經過 mysqlbinlog 工具來恢復數據。

binlog 日誌包括兩類文件:

  1. 二進制日誌索引文件(文件名後綴爲 .index)用於記錄全部的二進制文件。
  2. 二進制日誌文件(文件名後綴爲 .00000*)記錄數據庫全部的 DDL 和 DML 語句事件。

binlog 文件是經過追加的方式寫入的,可經過配置參數max_binlog_size設置每一個 binlog 文件的大小,當文件大小大於給定值後,日誌會發生滾動,以後的日誌記錄到新的文件上。
binlog 有兩種記錄模式,statement 格式的話是記 sql 語句,row 格式會記錄行的內容。

持久性問題通常在發生故障的狀況纔會重視。在啓動 MySQL 以後不管上次是否正常關閉都會進行恢復操做,咱們假設如今沒有 redo log 只有 binlog,那麼數據文件的更新和寫入 binlog 只有兩種狀況:

  • 先更新數據文件,再寫入 binlog;
  • 先寫入 binlog,再更新數據文件。

若是先更新數據文件,接着服務器宕機,則致使 binlog 中缺乏最後的更新信息;若是先寫 binlog 再更新數據則可能致使數據文件未被更新。

因此在只有 binlog 的環境中 MySQL 是不具有 crash-safe 的能力。另一開始的 MySQL 使用 MyISAM 引擎,它只有 binlog,因此天然不支持事務。後面引入了 InnoDB 以後纔開始使用另一套日誌系統- redo log 來實現 crash-safe 功能。

redo log 和 binlog 的區別:

  • redo log 是 InnoDB 引擎特有的,binlog 是MySQL server 層實現的功能,與引擎無關。
  • redo log 是物理日誌,記錄 「在某個數據頁作了什麼修改」;binlog 是邏輯日誌,記錄 sql 語句的原始邏輯,好比 「給 ID = 1 這一行的 name value set ‘xiaoming’ 」。
  • redo log 空間是固定的,用完以後會覆蓋以前的數據;binlog 是追加寫,當前文件寫完以後會開啓一個新文件繼續寫。

redo log 由兩部分組成:

  • 內存中的重作日誌緩衝(redo log buffer)
  • 重作日誌文件(redo log file)

一個更新事務的總體流程

2

從一個事務的更新過程出發看看一個事務更新過程當中 redo log 處於什麼地位。

  1. 首先檢查 Buffer cache 中是否存在這條數據,若是存在直接返回,若是不存在則去索引樹中讀取這條數據並加載到 Buffer Cache。
  2. 執行器拿到這條行數據以後對它執行相應的更新操做。
  3. 將這條待更新的行數據調用執行引擎更新到 Buffer Cache 中,同時將這個記錄更新到 redo log 裏面,redo log 包含兩個部分的更新,更新完畢,此時 redo log 處於 prepare 的狀態,而後告訴執行器,你能夠提交事務。
  4. 執行器生成這個操做的 binlog 日誌,並把 binlog 寫入磁盤。
  5. 執行器調用引擎的提交事務接口,引擎把剛寫入的 redo log 改成 commit 狀態,整個事務提交完成。

這裏咱們注意到在 redo log 的提交過程當中引入了兩階段提交

兩階段提交

爲何必須有 「兩階段提交」 呢?這是爲了讓兩份日誌之間的邏輯一致。

前面咱們說過了,binlog 會記錄全部的邏輯操做,而且是採用 「追加寫」 的形式。若是你的 DBA 承諾說半個月內能夠恢復,那麼備份系統中必定會保存最近半個月的全部binlog,同時系統會按期作整庫備份。

因爲 redo log 和 binlog 是兩個獨立的邏輯,若是不用兩階段提交,要麼就是先寫完 redo log 再寫 binlog,或者採用反過來的順序,咱們看看這兩種方式會有什麼問題,用上面的 update 示例作假設:

  1. 先寫 redo log 後寫 binlog。假設在 redo log 寫完,binlog 尚未寫完的時候,MySQL 進程異常重啓。由於 redo log 已經寫完,系統即便崩潰仍然可以把數據恢復回來。可是 binlog 裏面就沒有記錄這個語句,所以備份日誌的時候 binlog 裏面就沒有這條語句。

    可是若是須要用這個 binlog 來恢復臨時庫的話,因爲這個語句的 binlog 丟失,恢復出來的值就與原庫值不一樣。

  2. 先寫 binlog 後寫 redo log。若是在 binlog 寫完以後宕機,因爲 redo log 還沒寫,崩潰恢復之後這個事務無效,因此這一行的值仍是未更新之前的值。可是 binlog 裏面已經記錄了崩潰前的更新記錄, binlog 來恢復的時候就多了一個事務出來與原庫的值不一樣。

能夠看到,兩階段提交就是爲了防止 binlog 和 redo log 不一致發生。同時咱們也注意到爲了這個崩潰恢復的一致性問題引入了不少新的東西,也讓系統複雜了不少,因此有得有失。

InnoDB經過 Force Log at Commit 機制保證持久性:當事務提交(COMMIT)時,必須先將該事務的全部日誌緩衝寫入到重作日誌文件進行持久化,才能 COMMIT 成功。

爲了確保每第二天志都寫入 redo log 文件,在每次將 redo log buffer cache 寫入重作日誌文件後,InnoDB 引擎都須要調用一次 fsync 操做。所以磁盤的性能決定了事務提交的性能,也就是數據庫的性能。

innodb_flush_log_at_trx_commit 參數控制重作日誌刷新到磁盤的策略:

  • 0:事務提交時不進行寫入重作日誌操做,僅在 master thread 每秒進行一次。
  • 1:事務提交時必須調用一次fsync操做。
  • 2:僅寫入文件系統緩存,不進行fsync操做。

log buffer 根據以下規則寫入到磁盤重作日誌文件中:

  • 事務提交時。
  • 當 log buffer 中有一半的內存空間已經被使用。
  • log checkpoint 時,checkpoint在必定程度上表明瞭刷到磁盤時日誌所處的LSN位置。

一致性 和 隔離性實現 - 鎖機制 和 MVCC

實現一致性和隔離性是保證數據準確性的關鍵一環,前面兩個特性保證數據恢復不出問題,這兩個特性要保證數據插入和讀取不出問題。實現一致性和隔離性主要使用了兩個機制:

  • 鎖機制
  • 多版本併發控制

下面咱們就事務會產生哪些問題,MySQL 提出什麼方式來解決問題,這些方式的實現方案又是什麼來說解。

併發下事務會產生哪些問題

事務 A 和 事務 B 同時操做一個資源,根據不一樣的狀況可能會出現不一樣問題,總結下來有如下幾種:

  • 髒讀

    事務 A 讀到了事務 B 還未提交的數據。

  • 幻讀

    在當前事務中發現了不屬於當前事務操做的數據。幻讀是針對數據 insert 操做來講的。假設事務A對某些行的內容做了更改,可是還未提交,此時事務 B 插入了與事務 A 更改前的記錄相同的記錄行,而且在事務 A 提交以前先提交了,而這時,在事務A中查詢,會發現好像剛剛的更改對於某些數據未起做用,但實際上是事務 B 剛插入進來的,讓用戶感受出現了幻覺,這就叫幻讀。

  • 可重複讀

    可重複讀指的是在一個事務內,最開始讀到的數據和事務結束前的任意時刻讀到的同一批數據都是一致的。一般針對數據 update 操做。

  • 不可重複讀

    在同一個事務中兩次讀取一個數據的結果不同。對比可重複讀,不可重複讀指的是在同一事務內,不一樣的時刻讀到的同一批數據多是不同的,可能會受到其餘事務的影響,好比其餘事務改了這批數據並提交了。

爲何會提出隔離級別的概念

爲了解決事務併發過程當中可能會產生的這些問題,SQL 標準定義的四種隔離級別被 ANSI(美國國家標準學會)和 ISO/IEC(國際標準)採用,每種級別對事務的處理能力會有不一樣程度的影響。

SQL 標準定義了四種隔離級別,MySQL 全都支持。這四種隔離級別分別是:

  1. 讀未提交(READ UNCOMMITTED)
  2. 讀提交 (READ COMMITTED)
  3. 可重複讀 (REPEATABLE READ)
  4. 串行化 (SERIALIZABLE)

從上往下,隔離強度逐漸加強,性能逐漸變差。採用哪一種隔離級別要根據系統需求權衡決定,其中,可重複讀是 MySQL 的默認級別。

事務隔離其實就是爲了解決上面提到的髒讀、不可重複讀、幻讀這幾個問題,下面展現了 4 種隔離級別對這三個問題的解決程度。

隔離級別 髒讀 不可重複讀 幻讀
讀未提交 會發生 會發生 會發生
讀提交 不會發生 會發生 會發生
可重複讀 不會發生 不會發生 會發生
串行化 不會發生 不會發生 不會發生

只有串行化的隔離級別解決了所有這 3 個問題,其餘的 3 個隔離級別都有缺陷。

如何設置事務隔離級別

咱們能夠經過如下語句查看當前數據庫的隔離級別,經過下面語句能夠看出我使用的 MySQL 的隔離級別是 REPEATABLE-READ,也就是可重複讀,這也是 MySQL 的默認級別。

mysql> show variables like 'transaction_isolation';
+-----------------------+-----------------+
| Variable_name         | Value           |
+-----------------------+-----------------+
| transaction_isolation | REPEATABLE-READ |
+-----------------------+-----------------+
1 row in set (0.02 sec)

或者:

mysql> SELECT @@transaction_isolation;
+-------------------------+
| @@transaction_isolation |
+-------------------------+
| REPEATABLE-READ         |
+-------------------------+
1 row in set (0.00 sec)

固然咱們也能手動修改事務的隔離級別:

set [做用域] transaction isolation level [事務隔離級別];
做用域包含:
SESSION:SESSION 只針對當前回話窗口
GLOBAL:全局生效

隔離級別包含:
READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE

咱們來測試一下各個隔離級別對事務的影響。

新建表:

CREATE TABLE `test_db` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL DEFAULT '' COMMENT 'name',
  PRIMARY KEY (`id`),
  KEY `name_idx` (`name`(191))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=COMPACT COMMENT='測試表';

插入一些測試數據。

讀未提交(READ UNCOMMITTED)

首先設置事務隔離級別:

set global transaction isolation level READ UNCOMMITTED;

注意:設置徹底局隔離級別只對新打開的 session 有效,歷史打開的是不會受到影響的。

首先關閉事務自動提交:

set autocommit = 0;

開啓事務 A:

Query OK, 0 rows affected (0.00 sec)

mysql>
mysql> insert test_db (name) values ('xiaocee');
Query OK, 1 row affected (0.01 sec)

在事務A 中插入一條數據,並未提交事務。

接着開啓事務B:

mysql> select * from test_db;
+----+-----------+
| id | name      |
+----+-----------+
|  1 | xiaocee   |
+----+-----------+
9 rows in set (0.00 sec)

事務 B 中可以查到這條數據。即不一樣的事務能讀到對方未提交的數據。連髒讀都沒法解決,可重複讀和幻讀更無法解決。

讀已提交

讀已提交的數據確定能解決髒讀問題,可是對於幻讀和不可重複讀沒法將解決。

首先設置事務隔離級別:

set global transaction isolation level READ COMMITTED;

如今數據庫數據以下:

mysql> select * from test_db;
+----+-----------+
| id | name      |
+----+-----------+
|  1 | xiaoming2 |
|  2 | xiaohong  |
|  3 | xiaowei   |
|  4 | xiaowei1  |
|  5 | xiaoli    |
|  6 | xiaoche   |
|  8 | xiaoche   |
| 10 | xiaoche   |
| 12 | xiaocee   |
+----+-----------+
9 rows in set (0.00 sec)

開啓事務 A 將 id=1 的數據改成 「xiaoming3」:

mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

mysql> update test_db set name = 'xiaoming3' where id = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

這裏事務 A 未提交,接着開啓事務B 作第一次查詢:

mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from test_db where id = 1;
+----+-----------+
| id | name      |
+----+-----------+
|  1 | xiaoming2 |
+----+-----------+
9 rows in set (0.00 sec)

事務B查詢仍是原始值。

下面提交事務 A:

mysql> commit;
Query OK, 0 rows affected (0.00 sec)

接着在事務 B 中再查詢一次:

mysql> select * from test_db where id = 1;
+----+-----------+
| id | name      |
+----+-----------+
|  1 | xiaoming3 |
+----+-----------+
1 row in set (0.00 sec)

固然此次查到的確定是人家已提交的數據。這裏發生的問題就是不可重複讀:即同一個事務內每次讀取同一條數據的結果不同。

可重複讀

可重複讀隔離級別的含義就是重讀每次都同樣不會有問題。這就意味着一個事務不會讀取到別的事務未提交的修改。可是這裏就會有另外一個問題:在別的事務提交以前它讀到的數據不會發生變化,那麼另外一個事務若是將結果 a 改成 b,接着又改成了 a,對於當前事務來講直到另外一個事務提交以後它再讀纔會獲取到最新結果,可是它並不知道這期間別的事務對數據作了更新,這就是幻讀的問題

首先設置事務隔離級別:

set global transaction isolation level REPEATABLE READ;

如今數據庫數據以下:

如今數據庫數據以下:

mysql> select * from test_db;
+----+-----------+
| id | name      |
+----+-----------+
|  1 | xiaoming3 |
|  2 | xiaohong  |
|  3 | xiaowei   |
|  4 | xiaowei1  |
|  5 | xiaoli    |
|  6 | xiaoche   |
|  8 | xiaoche   |
| 10 | xiaoche   |
| 12 | xiaocee   |
+----+-----------+
9 rows in set (0.00 sec)

開啓事務 A 將 id=1 的數據改成 「xiaoming4」:

mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

mysql> update test_db set name = 'xiaoming3' where id = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

這裏事務 A 未提交,接着開啓事務B 作第一次查詢:

mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from test_db where id = 1;
+----+-----------+
| id | name      |
+----+-----------+
|  1 | xiaoming3 |
+----+-----------+
9 rows in set (0.00 sec)

事務B查詢仍是原始值。

下面提交事務 A:

mysql> commit;
Query OK, 0 rows affected (0.00 sec)

接着在事務 B 中再查詢一次:

mysql> select * from test_db where id = 1;
+----+-----------+
| id | name      |
+----+-----------+
|  1 | xiaoming3 |
+----+-----------+
1 row in set (0.00 sec)

查詢到仍是同樣的結果,下面提交事務B ,而後再查詢:

mysql> commit;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from test_db where id = 1;
+----+-----------+
| id | name      |
+----+-----------+
|  1 | xiaoming4 |
+----+-----------+
1 row in set (0.00 sec)

提交完以後再查就是 「xiaoming4」。

這也意味着在事務B未提交期間,事務A作任何操做對B來講都是盲視的。

串行化讀

串行化讀意味着將全部事務變爲順序執行,因此就不存在上述的四種問題,固然這也意味着效率是最低的。

有了隔離級別的概念,那隔離級別又是怎麼實現的呢?咱們接下來要講的鎖機制就是實現隔離級別的重要手段。

鎖的類型

從鎖定資源的角度看, MySQL 中的鎖分類:

  • 表級鎖
  • 行級鎖
  • 頁面鎖

表級鎖 的特色是每次都整張表加鎖,加鎖速度快,可是鎖的粒度太大,併發性就低,發生鎖衝突的機率大。

表鎖的種類主要包含兩種:

  • 讀鎖 (共享鎖):同一份數據多個讀操做同時進行不會互相影響,可是讀操做會阻塞寫操做。
  • 寫鎖(排他鎖):當前寫操做沒有完成以前會阻塞其餘讀和寫操做。

行級鎖 的特色是對一行數據加鎖,加鎖的開銷會大可是鎖粒度小發生鎖衝突的機率就低併發度提升了。

行鎖的種類包含:

  • 讀鎖(S 共享鎖):容許一個事務讀取某一行,其餘事務在讀取期間沒法修改該行數據但能夠讀。
  • 寫鎖(X 排他鎖):容許當前得到排它鎖的事務操做數據,其餘事務在操做期間沒法更改或者讀取。
  • 意向排它鎖(IX):一個事務給該數據行加排它鎖以前,必須先得到 IX 鎖。
  • 意向共享鎖(IS):一個事務給該數據行加共享鎖以前必須先得到 IS 鎖。

頁面鎖 由於MySQL 數據文件存儲是按照頁去劃分的,因此這個鎖是 MySQL 特有的。開銷和加鎖時間界於表鎖和行鎖之間,鎖定粒度界於表鎖和行鎖之間,併發度通常。

在 InnoDB 引擎中默認使用行級鎖,咱們重點就行級鎖的加鎖、解鎖來作一些說明。

行級鎖上鎖分爲 隱式上鎖 和 顯式上鎖。

隱式上鎖是默認的上鎖方式,select不會自動上鎖,insertupdatedelete 都會自動加排它鎖。在語句執行完畢會釋放。

顯式上鎖即經過手動的方式給 sql 語句加鎖,好比:

共享鎖:

select * from tableName lock in share mode;

排他鎖:

select * from tableName for update;
行級鎖的實現方式

在 InnoDB 中行級鎖的具體實現分爲三種類型:

  • 鎖定單個行記錄:Record Lock。
  • 鎖定一個範圍,不包含記錄自己:Gap Lock。
  • 同時鎖住一行數據 + 該數據上下浮動的間隙 :next-Key Lock。

接下來咱們經過一個示例來測試 InnoDB 中這三種鎖的實現。

先建立一個測試表:

CREATE TABLE `test_db` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL DEFAULT '' COMMENT 'name',
  PRIMARY KEY (`id`),
  KEY `name_idx` (`name`(191))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=COMPACT COMMENT='測試表';

插入兩條數據:

3

還記得咱們上面說過 MySQL 是自動提交事務,爲了測試鎖咱們須要關閉自動提交:

set autocommit = 0;

這個設置只在當前鏈接中生效,記得每開一個鏈接都要設置一下。

Record Lock 測試

開啓一個事務:

mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

mysql> update test_db set name = 'xiaoming1' where id = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

查看事務狀態:

mysql> show engine innodb status;
------------
TRANSACTIONS
------------
Trx id counter 25355
Purge done for trx's n:o < 0 undo n:o < 0 state: running but idle
History list length 0
LIST OF TRANSACTIONS FOR EACH SESSION:
---TRANSACTION 283540073944880, not started
0 lock struct(s), heap size 1136, 0 row lock(s)
---TRANSACTION 25354, ACTIVE 40 sec
2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 5, OS thread handle 12524, query id 757 localhost ::1 root starting
show engine innodb status
--------

事務狀態顯示有一行在被鎖定。

下面咱們在當前鏈接中查詢一下如今的數據庫:

mysql> select * from test_db;
+----+-----------+
| id | name      |
+----+-----------+
|  1 | xiaoming1 |
|  2 | xiaohong  |
+----+-----------+
2 rows in set (0.00 sec)

發現當前數據庫已經被修改了,是事務並無提交。別急咱們繼續看看。

下面在一個新的鏈接開啓第二個事務:

mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

mysql> mysql>  update test_db set name = 'xiaoming2' where id = 1;

這時候發現這一條語句卡住了沒法執行。

查看事務狀態:

mysql> show engine innodb status;
......

------- TRX HAS BEEN WAITING 6 SEC FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 2 page no 4 n bits 72 index PRIMARY of table `test_db`.`test_db` trx id 2072 lock_mode X locks rec but not gap waiting
Record lock, heap no 4 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
 0: len 4; hex 80000001; asc     ;;
 1: len 6; hex 000000000817; asc       ;;
 2: len 7; hex 02000001080422; asc       ";;
 3: len 9; hex 7869616f6d696e6732; asc xiaoming2;;

------------------
---TRANSACTION 2071, ACTIVE 50318 sec
2 lock struct(s), heap size 1136, 1 row lock(s), undo log entries 2
MySQL thread id 10, OS thread handle 123145423929344, query id 96 localhost 127.0.0.1 root starting
show engine innodb status
Trx read view will not see trx with id >= 2073, sees < 2072

從事務狀態上能夠看到對 id = 1 的這一行加了 record lock。

再看這一句:

trx id 2072 lock_mode X locks rec but not gap waiting

X 鎖就是咱們上面說的排它鎖,只對當前記錄加鎖,並不對間隙加鎖。

Gap Lock 測試

測試 Gap Lock 我發現若是 where 條件是主鍵的時候,只會有 record lock 不會有gap lock。

因此 gap lock 的條件是 where 條件必須是非惟一鍵。

首先查詢一下當前的數據:

mysql> set autocommit = 0;
Query OK, 0 rows affected (0.00 sec)

mysql>
mysql> select * from test_db;
+----+-----------+
| id | name      |
+----+-----------+
|  1 | xiaoming4 |
|  2 | xiaohong  |
|  3 | xiaowei   |
|  4 | xiaowei1  |
|  5 | xiaoli    |
|  6 | xiaoche   |
| 10 | xiaohai   |
| 12 | xiaocee   |
+----+-----------+
8 rows in set (0.00 sec)

開啓事務A:

mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from test_db where name ='xiaohai' for update;
+----+---------+
| id | name    |
+----+---------+
| 10 | xiaohai |
+----+---------+
1 row in set (0.00 sec)

這裏咱們作的事情是對 name 列作查詢條件,它是非惟一索引能夠被間隙鎖命中。如今的 id=10name=xiaohai,若是被間隙鎖命中的話,xiaoc* -- xiaoh*中間的字符應該都是不能插入的。因此咱們就用這種方式來試試。

開啓事務B:

mysql> set autocommit = 0;
Query OK, 0 rows affected (0.00 sec)

mysql> insert test_db (id, name) values (8, 'xiaodai');

插入「xiaodai」,能夠發現「卡住了」,查詢一下事務狀態:

mysql> show engine innodb status;
------------
TRANSACTIONS
------------
......
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s), undo log entries 1
MySQL thread id 32, OS thread handle 123145425444864, query id 385 localhost 127.0.0.1 root update
insert test_db (id, name) values (8, 'xiaodai')
------- TRX HAS BEEN WAITING 24 SEC FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 2 page no 5 n bits 80 index name_idx of table `test_db`.`test_db` trx id 2133 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
 0: len 7; hex 7869616f686169; asc xiaohai;;
 1: len 4; hex 8000000a; asc     ;;
......
------------------

這裏的事務日誌說了在插入以前這個索引已經被gap lock 鎖住了,因此咱們的測試是有效的。

那麼 gap lock 的邊界是多少呢?這裏我實測是當前記錄往前找到一個邊界和日後找到一個邊界,對於上面的測試數據來講就是:往前到 "xiaoche" ,日後到 「xiaohong」, 且你再插入一個等於當前鎖定記錄 「xiaohai」 的值也是能夠的,這個就留給你們動手試試。

Gap Lock 解決了什麼問題呢?上面咱們說到 讀已提交級別有不可重複讀的問題。Gap Lock 就是爲了防止在本事務還未提交以前,別的事務在當前事務周邊插入或修改了數據形成讀不一致。

Next-key Lock 測試

Next-key Lock 其實是 Record Lock 和 gap Lock 的組合。

Next-key Lock 是在下一個索引記錄自己和索引以前的 gap Lock 加上 S 鎖或是 X 鎖 ( 若是是讀就加上 S 鎖,若是是寫就加 X 鎖)。

默認狀況下,InnoDB 的事務隔離級別爲 RR,系統參數 innodb_locks_unsafe_for_binlog=false。InnoDB 使用 next-key Lock 對索引進行掃描和搜索,這樣就讀取不到幻象行,避免了幻讀的發生。

這就至關於對當前數據和當前數據周圍的數據都作了保護,當前數據不會發生幻讀,當前數據周圍的數據不會出現修改或新增從而致使讀不一致。

可是須要注意的是,上面測試 Gap Lock 也說過,Gap Lock 只對非惟一索引列生效,一樣 Next-key Lock若是也是做用於非惟一索引那麼會自動降級爲 Record Lock。

MVCC機制

什麼是 MVCC?

MVCC,Multi-Version Concurrency Control,多版本併發控制。同一份數據臨時保留多版本的一種方式,進而實現併發控制,簡稱一致性非鎖定讀。

上面咱們討論過在多個事務的場景下,經過鎖機制能夠保證當前事務讀不到未提交的事務。可是加鎖也會帶來壞處,那就是阻塞,只有讀讀之間能夠併發,讀寫,寫讀,寫寫都不能併發操做。引入多版本機制就是爲了解決這個問題,減小阻塞時間,經過這個機制,只有寫寫是會阻塞,其他狀況都不會阻塞操做。

好比咱們還用 RR 隔離級別下的例子來講,事務A寫了一個數據未提交,事務B讀取數據,這時候是讀不到A事務未提交的記錄。B事務只能讀到A事務未提交以前的版本。這裏就使用了版本管理機制,每一個鏈接在某個瞬間看到的是是數據庫在當前的一個快照,每一個事務在提交以前對其餘的讀者來講是不可見的。

通常來講 MVCC 只在 Read Committed 和 Repeatable Read 兩個隔離級別下工做。Read Uncommitted 老是能讀取到未提交的記錄,不須要版本控制;Serializable 對全部的讀取都對加鎖,單獨靠 MVCC 沒法完成。

MVCC 的實現,是經過保存數據在某一個時間點的快照來實現的。所以每個事務不管執行多長時間看到的數據,都是同樣的。因此 MVCC 實現可重複讀。

MVCC 的實現

隱藏字段

爲了實現多版本控制,InnoDB 引擎在每一行數據中都添加了幾個隱藏字段:

  • DB_TRX_ID:記錄最近一次對本記錄作(insert/upadte)的事務 ID,大小爲 6 字節;
  • DB_ROLL_PTR:回滾指針,指向回滾段的 undo log,大小爲 7 字節;
  • DB_ROW_ID:單調遞增的行 ID,大小爲 6 字節,當表沒有主鍵索引或者非空惟一索引的時候 InnoDB 就用這個字段創聚簇索引,這個字段跟MVCC的實現沒有關係。

MVCC 在 InnoDB 的實現依賴 undo log 和 read view。undo log 中記錄的是數據表記錄行的多個版本,也就是事務執行過程當中的回滾段,其實就是MVCC 中的一行原始數據的多個版本鏡像數據。read view 主要用來判斷當前版本數據的可見性。

undo log

undo log 上面講解的時候說go會用於 MVCC 機制。由於 undo log 中存儲的是老版本的數據,若是一個事務讀取當前行,可是當前行記錄不可見,那麼能夠順着 undo log 鏈表找到知足其可見性的版本。

版本鏈

每條 undo log 也都有一個 old_trx_id 屬性和一個 old_roll_pointer 屬性(INSERT 操做對應的 undo log 沒有這些屬性,由於該記錄沒有更早的版本)用於記錄上一個 undo log。最終這些 undo log 就鏈接起來造成了一個鏈表,這個鏈表稱之爲版本鏈,版本鏈的頭節點就是當前記錄的最新值。

Read View(讀視圖)

若是一個事務修改了記錄但還沒有提交,其餘事務是不能讀取記錄的最新版本的。此時就須要判斷版本鏈中的哪一個版本是能夠被當前事務訪問的,爲此 InnoDB 提出了 ReadView 的概念。 Read View 裏面保存了「對本事務不可見的其餘活躍事務」,主要是用來作可見性判斷。

Read View 底層定義了一些關鍵字段:

ReadView 字段 描述
trx_ids 在生成 ReadView 時當前系統中活躍的讀寫事務,即Read View初始化時當前未提交的事務列表。因此當進行RR讀的時候,trx_ids中的事務對於本事務是不可見的(除了自身事務,自身事務對於表的修改對於本身固然是可見的)。理解起來就是建立RV時,將當前活躍事務ID記錄下來,後續即便他們提交對於本事務也是不可見的。
low_limit_id 在生成 ReadView 時當前系統中活躍的讀寫事務中最小的事務 ID,事務號 >= low_limit_id 的記錄,對於當前 Read View 都是不可見的
up_limit_id 系統應該給下一個事務分配的 ID 值,事務號 < up_limit_id ,對於當前Read View都是可見的
creator_trx_id 生成該 ReadView 的事務 ID

一旦一個 Read View 被建立,這三個參數將再也不發生變化,理解這點很重要,其中 min_trx_id 和 max_trx_id 分別是 trx_Ids 數組的上下界。

記錄行修改的具體流程

  1. 首先當前事務對記錄行加排他鎖;
  2. 而後把該行數據拷貝到 undo lo g中,做爲舊版本;
  3. 拷貝完畢後,修改該行的數據,而且修改記錄行最新的修改事務 id ,也就是 DB_TRX_ID 爲當前事務 id;
  4. 事務提交,提交前用 CAS 機制判斷記錄行當前最新修改的事務 id 是否發生了變化,若是沒變,則提交成功;若是變了,說明存在其餘事務修改了這個記錄行,那麼就應該回滾這個事務。也就是當前事務沒有生效。

記錄行查詢時的可見性判斷算法

在 InnoDB 中建立一個新事務後,執行第一個 select 語句的時候,InnoDB 會建立一個快(readView),快照中會保存系統當前不該該被本事務看到的其餘活躍事務 id 列表(即trx_ids)。當用戶在這個事務中要讀取某個記錄行的時候,InnoDB 會將該記錄行的 DB_TRX_ID 與該 ReadView 中的一些變量進行比較,判斷是否知足可見性條件。

假設當前事務要讀取某一個記錄行,該記錄行的 DB_TRX_ID(即最新修改該行的事務ID)爲 trx_id,ReadView 的活躍事務列表 trx_ids 的上下界分別爲 min_trx_id 和 max_trx_id。

具體的比較算法以下:

  1. 若是 trx_id < up_limit_id, 那麼代表 「最新修改該行的事務」 在 「當前事務」 建立快照以前就提交了,因此該記錄行的值對當前事務是可見的。直接標識爲可見,返回true;
  2. 若是 trx_id >= low_limit_id, 那麼代表 「最新修改該行的事務」 在 「當前事務」 建立快照以後才被建立且修改該行的,因此該記錄行的值對當前事務不可見。應該經過回滾指針找到上個記錄行版本,判斷是否可見。循環往復,直到可見;
  3. 若是 up_limit_id <= trx_id < low_limit_id, 那就得經過二分查找判斷 trx_id 是否在 trx_ids 列表出現過。
    1. 若是出現過,說明是當前read view 中某個活躍的事務提交了,那固然是不可見的,應該經過回滾指針找到上個記錄行版本,判斷是否可見,循環往復,直到可見;
    2. 若是沒有出現過,說明這個事務是已經提交了的,表示爲可見,返回 true。

須要注意的是,新建事務(當前事務)與正在內存中 commit 的事務不在活躍事務鏈表中。

不一樣隔離級別下 read view 生成原則

RC 級別

每一個快照讀操做都會生成最新的 read view,因此在 RC 級別中能看到別的事務提交的記錄。

RR 級別

同一個事務中的第一個快照讀纔會建立 Read View, 以後的快照讀獲取的都是同一個Read View。

關於MVCC 的總結

上面介紹了 MVCC 在 innoDB 中的實現,咱們回顧一下理想中的 MVCC 應該是什麼樣的:

  • 每行數據都有一個版本,每次更新都更新該版本
  • 每一個事務只在當前版本上更新,各個事務無干擾
  • 多個事務提交時比較版本號,若是成功則覆蓋原紀錄,不然放棄

MVCC 的理論聽起來和 樂觀鎖一致。可是反觀 InnoDB 中的實現,事務修改數據首先借助排它鎖,事務失敗還藉助到 undo log 來實現回滾。理論上若是一個完整的 MVCC 實現應該藉助版本號就能夠,若是使用上了 X 鎖那何須還浪費時間再使用 樂觀鎖呢?

事實上理想的 MVCC 可能會引起bug,單純依靠版本控制沒法完成一致性非鎖定讀。任何一個複雜的系統在摻雜各類變量的狀況總會引起一些旁支問題。

好比,在理想的MVCC 模式下,TX1執行修改 Row1成功,修改Row2失敗,此時須要回滾Row1;

但由於Row1沒有被鎖定,其數據可能又被 TX2 修改,若是此時回滾 Row1的內容,則會破壞 TX2 的修改結果。

MVCC 機制提供了讀的非阻塞能力,對於寫來講若是不用鎖,確定會出錯。可是對於數據庫系統來講,讀纔是大頭,這已經解決了生產力的要求。

總結

以上從數據庫多事務併發可能會產生什麼問題分析,數據庫奠定者總結出事務的四大特性,爲了性能和數據準確性的協調總結出不一樣的隔離級別,爲了實現不一樣的隔離級別分別使用了什麼技術。這些問題環環相扣,從問題出發尋找解決思路,鎖機制,MVCC機制,都是爲了解決數據一致性問題同時兼顧讀效率而存在。爲了持久性提出了兩階段提交弄出了 redo log,爲了實現 原子性 和 MVCC 又多出了 undo log。全部的實現都是基於特定的場景和需求,站在需求場景下去理解這些概念就會更容易感覺到設計者的初衷。

相關文章
相關標籤/搜索