MySQL系列(10)— 事務隔離性之鎖

專欄系列文章:MySQL系列專欄mysql

InnoDB 中的鎖

在上一篇文章 事務隔離性之MVCC 中介紹了 MVCC 是如何保證一致性讀的,即一個事務中的修改不會影響另外一個事務中的讀取,MVCC 在REPEATABLE READ隔離級別下能夠避免 髒讀、不可重複讀、幻讀 的問題,保證了事務之間併發讀的隔離性。web

上篇文章末尾說到 MVCC 實際上是 快照讀,對普通的 SELECT 查詢能夠保證事務的隔離性,但 當前讀 仍是能讀到別的事務已提交的修改。除此以外,多個事務併發更新同一條數據,還須要保證併發寫的隔離性,避免髒寫的問題。這些狀況下就要用到鎖的機制了,鎖機制就是爲了支持對共享資源的併發訪問,保證數據的完整性和一致性。redis

鎖的類型

MySQL 有多種存儲引擎,MyISAM、MEMORY、MERGE 這些存儲引擎只支持表級鎖,並且這些引擎不支持事務,因此使用這些存儲引擎的鎖通常都是針對當前會話。算法

InnoDB 存儲引擎既支持表級鎖,也支持行級鎖。行級鎖就是針對行記錄加鎖,行級鎖粒度更細,併發性能比表級鎖更高。sql

InnoDB 有以下兩種類型的行級鎖:數據庫

  • 共享鎖(S Lock):簡稱 S鎖,後面就用SLock來表示。SLock 容許事務讀一行數據。
  • 排它鎖(X Lock):簡稱 X鎖(XLock),後面就用XLock來表示。XLock 容許事務刪除或更新一行數據。

若是一個事務T1獲取了一行記錄的 SLock,接着另外一個事務T2能夠當即獲取到這行記錄的 SLock,由於讀取並無改變這行記錄。若是另外一個事務T3想要獲取 XLock,就會被阻塞,必須等待 T一、T2 都釋放了 SLock 才能獲取到 XLock。編程

若是事務T1一開始就獲取了 XLock,那其它事務就沒法獲取 SLock 和 XLock 了,必須等待T1釋放了 XLock 後才能得到鎖。安全

它們的兼容性以下表所示:markdown

image.png

一致性非鎖定讀

前面說過基於 MVCC 的讀取是快照讀,也能夠稱爲一致性讀一致性非鎖定讀,所謂的非鎖定讀就是指讀操做不會對錶中的記錄作任何加鎖操做,其餘事務能夠對錶中的記錄作修改。數據結構

由於基於 MVCC 的讀取是讀的undo版本鏈上的快照版本,因此其它事務能夠對一樣的記錄加 SLock 或 XLock,一致性非鎖定讀不會去等待行上鎖的釋放,避免了頻繁的加鎖操做,大大提升了讀操做的性能。

可想而知,非鎖定讀機制能夠極大地提升數據庫的併發性。InnoDB 默認就是一致性非鎖定讀的讀取方式,即讀取不會佔用和等待表上的鎖。但須要注意的是,InnoDB 只在 READ COMMITTEDREPEATABLE READ 這兩個隔離級別下采用一致性非鎖定讀,也就是基於 MVCC 的讀取。

一致性鎖定讀

前面說 InnoDB 默認是一致性非鎖定讀,但有些場景下,咱們可能想要顯示的對讀取操做加鎖來保證數據邏輯的一致性,這種就是一致性鎖定讀。也能夠稱爲當前讀,由於經過加鎖操做來保證讀取的是最新的數據,得到鎖以後,別的事務就不能更新加鎖的記錄了。

InnoDB 中的當前讀:

  • SELECT ... LOCK IN SHARE MODE:獲取到 SLock,其它事務能夠獲取到 SLock,但不能獲取 XLock。
  • SELECT ... FOR UPDATE:獲取到 XLock,可能要對數據作更新,其它事務會阻塞等待。本質上和 UPDATE 語句的語意是一致的。
  • UPDATE:更新數據都是先讀後寫的,而這個讀,只能讀當前的值,就是當前讀。

須要注意的是,LOCK IN SHARE MODEFOR UPDATE必須在一個事務中使用,事務結束後,鎖就自動釋放了。

好比在查詢帳戶餘額來更新的時候,查詢時先對記錄顯示加 SLock,而不是默認的快照讀,這時其它的事務就只能讀取這條記錄,而沒法更新。但這可能會致使死鎖,好比事務T1先獲取了 SLock,事務T2也獲取了同一條記錄的 SLock,而後事務T1要更新這條記錄,就會一直阻塞住,由於更新要獲取記錄的 XLock,XLock 和 SLock 是不兼容的。

BEGIN;

SELECT * FROM account WHERE id = 1 LOCK IN SHARE MODE;

UPDATE account SET balance=100 WHERE id = 1;
複製代碼

若是一開始查詢就加 XLock,這樣別的事務就沒法再加 SLock 或者 XLock 了,這樣就能保證只有一個事務更新記錄。

BEGIN;

SELECT * FROM account WHERE id = 1 FOR UPDATE;

UPDATE account SET balance=100 WHERE id = 1;
複製代碼

通常來講,經過SQL加鎖來實現一致性是不太好的方式,這樣會致使將複雜的業務鎖機制隱藏到數據庫層面去,在業務代碼層面就很是很差維護。通常在分佈式系統的場景中,更推薦基於 redis、zookeeper 的分佈式鎖來實現複雜業務下的鎖機制。

表級鎖

InnoDB 支持對錶加鎖,表級鎖也分爲共享鎖(SLock)排他鎖(XLock)

在執行增刪改查的SQL語句時,InnoDB 並不會爲這個表添加表級別的 SLock 或者 XLock。對錶執行DDL操做時(如 ALTER TABLE、DROP TABLE),也不會用到 InnoDB 表級鎖,而是用的 MySQL Server 層面提供的一種元數據鎖(Metadata Lock,簡稱MDL)來實現的。

不過咱們能夠經過以下語句手動獲取表級別的 SLock 和 XLock:

  • LOCK TABLES t READ:InnoDB 會對錶加表級別的 SLock。
  • LOCK TABLES t WRITE:InnoDB 會對錶加表級別的 XLock。

表級鎖和行級鎖是互斥的,它們的兼容性以下表所示。

image.png

那如何實現表級別的鎖和行級別的鎖互斥呢?例如一個事務中在更新某些記錄,對這些記錄加了行級 XLock,另外一個併發事務要用 LOCK TABLES 對這個表加 SLOck 或者 XLock,這時確定就會阻塞。

InnoDB 提供了另外一種表級鎖,稱爲意向鎖,也分爲共享鎖和排它鎖:

  • 意向共享鎖:簡稱IS鎖,後面就稱ISLock。當事務準備在某條記錄上加 SLock 時,須要先在表級別加一個 ISLock。
  • 意向排他鎖:簡稱IX鎖,後面就稱IXLock。當事務準備在某條記錄上加 XLock 時,須要先在表級別加一個 IXLock。

ISLockIXLock僅僅爲了在以後加表級別的SLockXLock時能夠快速判斷表中的記錄是否被加鎖,因此 ISLock 和 IXLock 是兼容的,IXLock 和 IXLock 也是兼容的,ISLock、IXLock和表級別的 SLock、XLock 是有必定互斥性的。表級鎖和意向鎖的兼容性以下表所示。

image.png

因此在一個事物中更新記錄加行級XLock時,首先會在表上加IXLock,若是此時表已經加了 XLock 或 SLock,就會阻塞。若是沒有加表級鎖,IXLock 就會加鎖成功,此時另外的事務想要加表級鎖就會阻塞。

表級鎖的粒度比較粗,通常咱們也不會用到 LOCK TABLES 來手動加表級鎖,因此 InnoDB 的表級鎖和意向鎖是比較雞肋的。

鎖結構

不管是表級鎖仍是行級鎖,其實就是內存中的一個數據結構,對一條記錄加鎖的本質,其實就是在內存中建立一個鎖結構與之關聯。

鎖的內存結構

行鎖結構

首先看一下行鎖的結構,主要包含的一些信息以下圖所示:

image.png

① 鎖所在的事務信息:記錄事務ID(trx_id)等信息。

② 索引信息:行鎖是經過索引實現的,索引信息就是記錄加鎖的記錄是屬於哪一個索引的。

③ 行鎖信息:

  • 表空間ID:記錄所在表空間ID。
  • 頁號:記錄所在頁號。
  • n_bits:行鎖末尾放了一堆比特位,n_bits 表示使用了多少個比特位。

④ type_mode:這是一個32位的數,存儲了四個部分的信息。

  • lock_mode:鎖的模式,佔用低4位。可選的值有:

    • LOCK_IS:意向共享鎖
    • LOCK_IX:意向排它鎖
    • LOCK_S:共享鎖
    • LOCK_X:排它鎖
    • LOCK_AUTO_INC:主鍵自增鎖
  • lock_type:鎖的類型,佔用第5~8位。可選的值有:

    • LOCK_TABLE:表級鎖
    • LOCK_REC:行級鎖
  • is_waiting:鎖的狀態,佔用第9位。可選的值有:

    • 1:當這個比特位爲1時,表示當前事務未獲取到鎖,處在等待狀態
    • 0:當這個比特位爲0時,表示當前事務獲取鎖成功
  • rec_lock_type:行鎖的具體類型,使用其他的位來表示。只有在lock_type的值爲LOCK_REC時,也就是隻有在該鎖爲行級鎖時,纔會被細分爲更多的類型。可選的值有:

    • LOCK_ORDINARY:Next-Key Lock
    • LOCK_GAP:Gap Lock
    • LOCK_REC_NOT_GAP:Record Lock
    • LOCK_INSERT_INTENTION:插入意向鎖

⑤ 比特位:一個頁中有不少記錄,末尾的這一堆比特位就是用來表示哪條記錄被加鎖了。頁中的每條記錄的記錄頭中都有一個 heap_no 屬性表示記錄的排序號,每一個比特位就映射着一個 heap_no。

表鎖結構

相比行鎖結構,表鎖沒有末尾的比特位,以及只存儲了表鎖相關的一些表信息。

image.png

查看鎖信息

information_schema 數據庫下,咱們能夠經過 INNODB_TRX 查詢系統當前正在運行的事務信息,還能夠經過 INNODB_LOCKS 查詢事務持有的鎖信息。

INNODB_LOCKS 表中有以下字段:

  • lock_id:鎖的ID
  • lock_trx_id:事務ID
  • lock_mode:鎖的模式,主要的類型有:共享鎖(S)、排它鎖(X)、意向共享鎖(IS)、意向排它鎖(IX)。
  • lock_type:鎖的類型,RECORD 表明行級鎖,TABLE 表明表級鎖。
  • lock_table:要加鎖的表
  • lock_index:鎖住的索引
  • lock_space:表空間ID
  • lock_page:事務鎖定頁的數量。如果表鎖,則該值爲NULL
  • lock_rec:事務鎖定行的數量,如果表鎖,則該值爲NULL
  • lock_data:事務鎖定記錄的主鍵值,如果表鎖,則該值爲NULL

加鎖方式

InnoDB 在任何隔離級別下都不會發生髒寫的問題,多個併發事務對同一條記錄作修改時,只能排隊一個一個修改,這個排隊就是經過來實現的。在對某條記錄作 UPDATE、DELETE 操做時,都會先獲取這條記錄的 XLock 後再操做,獲取不到就會阻塞等待。下面就來看下 InnoDB 是如何經過鎖避免髒寫的問題的。

後面仍是以 account 這張表爲例,來作一些測試:注意 card 列是惟一索引,name 列是普通索引。

CREATE TABLE `account` (
  `id` bigint(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
  `card` varchar(60) NOT NULL COMMENT '卡號',
  `name` varchar(60) DEFAULT NULL COMMENT '姓名',
  `balance` int(11) NOT NULL DEFAULT '0' COMMENT '餘額',
  PRIMARY KEY (`id`),
  UNIQUE KEY `account_u1` (`card`) USING BTREE,
  KEY `account_n1` (`name`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='帳戶表';
複製代碼

例若有兩個事務T一、T2按以下順序更新 id=1 這條記錄:

image.png

事務T1更新 id=1 這條記錄時,首先會看內存中有沒有與這條記錄關聯的鎖結構,沒有的話就會生成一個鎖結構與之關聯,而且 is_waiting=false,表示獲取鎖成功(加鎖成功),而後事務T1就能夠執行更新操做了。

image.png

接着事務T2查詢id=1這行數據,此時是基於MVCC的快照讀,不會對記錄加任何鎖。

接着事務T2更新id=1這行數據,這時就會發現已經有一個鎖與這條記錄關聯了,而後事務T2也生成一個鎖結構與這條記錄關聯,可是 is_waiting=true,表示獲取鎖失敗(加鎖失敗),須要等待。

image.png

這時查看 INNODB_LOCKS 表,能夠看到有兩把鎖,咱們能夠獲得以下信息:

  • lock_trx_id:對應的事務ID分別是 56067七、560676。
  • lock_mode:鎖的模式是排它鎖(XLock)。
  • lock_type:RECORD 表示加的是行鎖。
  • lock_index:是針對主鍵索引加的鎖。
mysql> SELECT * FROM information_schema.INNODB_LOCKS;
+------------------+-------------+-----------+-----------+------------------+------------+------------+-----------+----------+-----------+
| lock_id          | lock_trx_id | lock_mode | lock_type | lock_table       | lock_index | lock_space | lock_page | lock_rec | lock_data |
+------------------+-------------+-----------+-----------+------------------+------------+------------+-----------+----------+-----------+
| 560677:13971:3:2 | 560677      | X         | RECORD    | `test`.`account` | PRIMARY    |      13971 |         3 |        2 | 1         |
| 560676:13971:3:2 | 560676      | X         | RECORD    | `test`.`account` | PRIMARY    |      13971 |         3 |        2 | 1         |
+------------------+-------------+-----------+-----------+------------------+------------+------------+-----------+----------+-----------+
複製代碼

接着能夠經過 INNODB_TRX 表查詢當前事務信息,能夠看到,560676 這個事務的運行狀態爲 RUNNING560677 這個事務的運行狀態爲 LOCK WAIT,也就是等待鎖。從這也能夠知道,T1事務的事務ID=560676,T2事務的事務ID=560677。

mysql> SELECT * FROM information_schema.INNODB_TRX;
+--------+-----------+---------------------+-----------------------+---------------------+------------+---------------------+---------------------------------------------+---------------------+-------------------+-------------------+------------------+-----------------------+-----------------+-------------------+-------------------------+---------------------+-------------------+------------------------+----------------------------+---------------------------+---------------------------+------------------+----------------------------+
| trx_id | trx_state | trx_started         | trx_requested_lock_id | trx_wait_started    | trx_weight | trx_mysql_thread_id | trx_query                                   | trx_operation_state | trx_tables_in_use | trx_tables_locked | trx_lock_structs | trx_lock_memory_bytes | trx_rows_locked | trx_rows_modified | trx_concurrency_tickets | trx_isolation_level | trx_unique_checks | trx_foreign_key_checks | trx_last_foreign_key_error | trx_adaptive_hash_latched | trx_adaptive_hash_timeout | trx_is_read_only | trx_autocommit_non_locking |
+--------+-----------+---------------------+-----------------------+---------------------+------------+---------------------+---------------------------------------------+---------------------+-------------------+-------------------+------------------+-----------------------+-----------------+-------------------+-------------------------+---------------------+-------------------+------------------------+----------------------------+---------------------------+---------------------------+------------------+----------------------------+
| 560677 | LOCK WAIT | 2021-05-31 17:41:03 | 560677:13971:3:2      | 2021-05-31 17:41:03 |          2 |                   4 | UPDATE account SET balance=200 WHERE id = 1 | starting index read |                 1 |                 1 |                2 |                  1136 |               1 |                 0 |                       0 | REPEATABLE READ     |                 1 |                      1 | NULL                       |                         0 |                         0 |                0 |                          0 |
| 560676 | RUNNING   | 2021-05-31 17:40:58 | NULL                  | NULL                |          3 |                   3 | NULL                                        | NULL                |                 0 |                 1 |                2 |                  1136 |               1 |                 1 |                       0 | REPEATABLE READ     |                 1 |                      1 | NULL                       |                         0 |                         0 |                0 |                          0 |
+--------+-----------+---------------------+-----------------------+---------------------+------------+---------------------+---------------------------------------------+---------------------+-------------------+-------------------+------------------+-----------------------+-----------------+-------------------+-------------------------+---------------------+-------------------+------------------------+----------------------------+---------------------------+---------------------------+------------------+----------------------------+
複製代碼

以後事務T1提交事務,就會把該事務生成的鎖結構釋放掉,而後看還有沒有別的事務在等待獲取鎖,發現事務T2還在等待獲取鎖,因此把事務T2對應的鎖結構的 is_waiting 屬性設置爲 false,而後把該事務對應的線程喚醒,讓它繼續執行,此時事務T2就獲取到鎖了。

image.png

行級鎖

參考:極客時間 《丁奇-MySQL實戰45講》

行鎖類型

InnoDB 既實現了行鎖,也實現了表鎖。行鎖是經過索引實現的,當SQL命中索引時,就會鎖住條件內的索引節點。若是沒有命中索引,那麼鎖的就是整個索引樹,其實就是升級爲表鎖了。

InnoDB 有3種行級鎖的算法實現,鎖結構中rec_lock_type屬性就表示行鎖類型。

  • Record Lock:記錄鎖,專門對索引項加鎖,就是鎖單條記錄。

  • Gap Lock:間隙鎖,對索引項之間的間隙加鎖,但不包含記錄自己,鎖住的是一個區間。

  • Next-Key Lock:臨鍵鎖,Gap Lock + Record Lock 的組合,對索引項之間的間隙加鎖,包含記錄自己,是一個左開右閉的區間。

Record Lock 老是會鎖住索引記錄,若是在建表的時候沒有設置任何一個索引,那麼就會使用隱式的主鍵(row_id)來進行鎖定。

Gap LockNext-key Lock 鎖住的是一段區間,即這個區間被鎖住後,不容許插入新的值,因此間隙鎖主要用於解決幻讀的問題。

還有一種鎖是 插入意向鎖(Insert Intention),插入意向鎖相似於 Gap Lock,不過插入意向鎖並不會阻止別的事務繼續獲取該記錄上任何類型的鎖。插入意向鎖的主要目的是,若是別的事務在這個間隙加了 Gap Lock,那麼要在這個間隙插入數據的這個事務就要生成一個插入意向鎖的結構,而後等待。因此插入意向鎖和 Gap Lock 是不兼容的。

關於間隙鎖,例如一個索引有 十、1五、20 這三個值:

  • 那麼該索引可能被 Next-Key Lock 鎖住的區間爲:

    (-∞, 10]
    (10, 15]
    (15, 20]
    (20, +Supremum]
    複製代碼
  • 能被 Gap Lock 鎖住的區間爲:

    (-∞, 10)
    (10, 15)
    (15, 20)
    (20, +Supremum)
    複製代碼

注意在 (20, +∞) 區間實際上的區間是 (20, +Supremum] 這個區間。前面介紹頁的結構時說過,InnoDB 每一個數據頁中有兩個虛擬的行記錄,用來限定記錄的邊界。Infimum 記錄是比該頁中任何主鍵值都要小的記錄,Supremum 記錄是比改頁中何主鍵值都要大的記錄。(20, +Supremum] 這裏的 Supremum 就是 20 這條記錄所在頁的最大記錄。

加鎖規則

何時加 Record Lock,何時又加 Gap Lock 或者 Next-Key Lock,總結起來加鎖規則有以下幾條。

  • 一、加鎖時會根據索引從左往右查找,加鎖的基本單位是 Next-Key Lock,就是說在對某條記錄加鎖時,默認用 Next-Key Lock 去鎖住一個左開右閉的區間。

  • 二、查找過程當中只有訪問到的對象纔會加鎖,好比查詢只用到了輔助索引,就不會對聚簇索引加鎖。

  • 三、惟一索引上的等值查詢,給惟一索引加鎖的時候,Next-Key Lock 退化爲 Record Lock

  • 四、普通索引上的等值查詢,會一直向右遍歷,最後一個值不知足等值條件的時候,Next-Key Lock 退化爲 Gap Lock。(就是說某個普通索引上可能有多個相同的值,所以就會有多個 Next-Key Lock 鎖住的左開右閉的區間,但最後一個區間會退化爲 Gap Lock)。

  • 五、惟一索引上的範圍查詢會訪問到不知足條件的第一個值爲止(包含這條記錄)。

注意只有在 REPEATABLE READ 或以上隔離級別下的特定操做纔會取得 Gap LockNext-key Lock,在 SELECT 、UPDATE 和 DELETE 時,除了基於惟一索引的查詢以外,其餘索引查詢時都會獲取 Gap LockNext-key Lock,即鎖住其掃描的範圍。

咱們往 account 表中初始化幾條數據,下面來作些測試驗證上面的規則。

TRUNCATE TABLE account;

INSERT INTO account ( id, card, NAME, balance )
VALUES
	( 1, 'A', 'A', 0 ),
	( 5, 'D', 'D', 5 ),
	( 10, 'H', 'H', 10 ),
	( 15, 'M', 'M', 15 ),
	( 20, 'R', 'R', 20 )
複製代碼

下面是這張表的初始數據,爲了測試,後續我全部的測試SQL都會默認使用 ROLLBACK 回滾掉,也就是每次測試執行SQL前的初始數據都是同樣的。

mysql> SELECT * FROM account;
+----+------+------+---------+
| id | card | name | balance |
+----+------+------+---------+
|  1 | A    | A    |       0 |
|  5 | D    | D    |       5 |
| 10 | H    | H    |      10 |
| 15 | M    | M    |      15 |
| 20 | R    | R    |      20 |
+----+------+------+---------+
複製代碼

這幾條數據在主鍵索引上就會產生以下幾個間隙:

(-∞, 1]
(1, 5]
(5, 10]
(10, 15]
(15, 20]
(20, +Supremum]
複製代碼

name 這個普通索引上會產生以下幾個間隙:

(-∞, A]
(A, D]
(D, H]
(H, M]
(M, R]
(R, +Supremum]
複製代碼

惟一索引等值查詢 - 行鎖

一、兩個事務按以下順序執行SQL語句:

T1 T2
BEGIN; BEGIN;
SELECT * FROM account WHERE id=1 LOCK IN SHARE MODE;
UPDATE account SET balance=200 WHERE id=1;
(blocked)
COMMIT;
ROLLBACK;

兩個事務都是使用的主鍵ID來查詢數據,T2事務執行時會阻塞住。

用加鎖規則分析下:

  • 根據規則1,加 Next-Key Lock 鎖住 (-∞, 1] 這個區間。
  • 根據規則3,因爲是惟一索引等值查詢,且記錄存在,所以 Next-Key Lock 退化爲 Record Lock,所以只鎖住 id=1 這條記錄。

由於T1鎖住了 id=1 這條記錄,T2 一樣也是更新 id=1 這條記錄,所以須要等待。

查看 INNODB_LOCKS 表,其中 lock_type 都爲 RECORD,表示加的是行級鎖;lock_index 列顯示鎖住的是聚簇索引(主鍵),lock_data 顯示鎖定的主鍵爲 1

mysql> SELECT lock_trx_id,lock_mode,lock_type,lock_table,lock_index,lock_data FROM information_schema.INNODB_LOCKS;
+-----------------+-----------+-----------+------------------+------------+-----------+
| lock_trx_id     | lock_mode | lock_type | lock_table       | lock_index | lock_data |
+-----------------+-----------+-----------+------------------+------------+-----------+
| 566301          | X         | RECORD    | `test`.`account` | PRIMARY    | 1         |
| 282768778791576 | S         | RECORD    | `test`.`account` | PRIMARY    | 1         |
+-----------------+-----------+-----------+------------------+------------+-----------+
複製代碼

兩個事務的 locke_mode 是不一樣的,一個是 X(XLock),一個是 S(SLock)。從這能夠看出,Record Lock 有 SLock 和 XLock 之分,互斥規則和前面說的是同樣的。

二、接着按以下的順序執行:

T1 T2
BEGIN; BEGIN;
SELECT * FROM account WHERE card='A'
LOCK IN SHARE MODE;
UPDATE account SET balance=200 WHERE card='A'
(blocked)

此次T一、T2是根據惟一索引列 card 來查詢,且 card=A 存在,因此在惟一索引card上對card=A這條記錄加 Record Lock。從 INNODB_LOCKS 查看 lock_index、lock_data 能夠獲得證明。

mysql> SELECT lock_trx_id,lock_mode,lock_type,lock_table,lock_index,lock_data FROM information_schema.INNODB_LOCKS;
+-----------------+-----------+-----------+------------------+------------+-----------+
| lock_trx_id     | lock_mode | lock_type | lock_table       | lock_index | lock_data |
+-----------------+-----------+-----------+------------------+------------+-----------+
| 566334          | X         | RECORD    | `test`.`account` | account_u1 | 'A'       |
| 282768778791576 | S         | RECORD    | `test`.`account` | account_u1 | 'A'       |
+-----------------+-----------+-----------+------------------+------------+-----------+
複製代碼

三、接着按以下順序執行:

T1 T2
BEGIN; BEGIN;
SELECT * FROM account WHERE card='A'
LOCK IN SHARE MODE;
UPDATE account SET balance=200 WHERE id=1;
(blocked)

T1 事務根據 card 列查詢,T2 事務根據 id 列查詢,推測 T1 事務應該是鎖住惟一索引 card=A,T2 鎖住聚簇索引 id=1。但查看 INNODB_LOCKS,會發現它們都是鎖住的聚簇索引 id=1 這條記錄。其實也很好理解,T2 要鎖住 id=1 這條記錄,T1 是想鎖定讀 card=A 這條記錄,T1 須要回表查詢聚簇索引上的數據,所以就直接鎖住聚簇索引 id=1 這條記錄了,阻塞 T2 事務更新這條記錄。

mysql> SELECT lock_trx_id,lock_mode,lock_type,lock_table,lock_index,lock_data FROM information_schema.INNODB_LOCKS;
+-----------------+-----------+-----------+------------------+------------+-----------+
| lock_trx_id     | lock_mode | lock_type | lock_table       | lock_index | lock_data |
+-----------------+-----------+-----------+------------------+------------+-----------+
| 566336          | X         | RECORD    | `test`.`account` | PRIMARY    | 1         |
| 282768778791576 | S         | RECORD    | `test`.`account` | PRIMARY    | 1         |
+-----------------+-----------+-----------+------------------+------------+-----------+
複製代碼

四、接着按以下順序執行:

T1 T2
BEGIN; BEGIN;
SELECT id FROM account WHERE card = 'A'
LOCK IN SHARE MODE;
UPDATE account SET balance=balance+200 WHERE id=1;
(Affected rows: 1)

T1 事務根據 card 查詢 id 字段,不須要回表,因此它只須要鎖住惟一索引 card=A 便可,T2 事務根據 id 列查詢更新,此次會發現 T2 事務能夠更新成功,不會阻塞住了。根據加鎖規則的第2條可知,只有訪問到的對象纔會加鎖,所以 T1 事務不會對聚簇索引 id=1 加鎖,所以T2能夠加鎖成功。

五、最後按照以下順序執行:

T1 T2
BEGIN; BEGIN;
SELECT id FROM account WHERE card = 'A'
FOR UPDATE;
UPDATE account SET balance=200 WHERE id=1;
(blocked)

與上一次的執行相比,區別在於 T1 事務的鎖定讀是用的 FOR UPDATE,此次 T2 事務會阻塞住。查看 INNODB_LOCKS 會發現兩個事務都是對聚簇索引 id=1 加 Record Lock(XLock)。

mysql> SELECT lock_trx_id,lock_mode,lock_type,lock_table,lock_index,lock_data FROM information_schema.INNODB_LOCKS;
+-------------+-----------+-----------+------------------+------------+-----------+
| lock_trx_id | lock_mode | lock_type | lock_table       | lock_index | lock_data |
+-------------+-----------+-----------+------------------+------------+-----------+
| 566341      | X         | RECORD    | `test`.`account` | PRIMARY    | 1         |
| 566339      | X         | RECORD    | `test`.`account` | PRIMARY    | 1         |
+-------------+-----------+-----------+------------------+------------+-----------+
複製代碼

這能夠驗證鎖定讀語句 LOCK IN SHARE MODEFOR UPDATE 在語義上的不一樣,FOR UPDATE 表示想要更新這條記錄,LOCK IN SHARE MODE 可能只是想讀取這條記錄,防止別的事務更新。所以 FOR UPDATE 的語義和 UPDATE 是相似的,認爲你接下來要去更新數據,所以會順便給聚簇索引上知足條件的行加上行鎖,防止其它事務併發更新。

惟一索引等值查詢 - 間隙鎖

在上一小節中根據惟一索引查詢的記錄在數據庫中都是存在的,因此都是加的行鎖(Record Lock),那若是記錄不存在又會加什麼鎖呢?

按以下順序執行:

T1 T2 T3
BEGIN; BEGIN; BEGIN;
SELECT * FROM account WHERE id=3
LOCK IN SHARE MODE;
(Query 0)
INSERT INTO account VALUES
(2, 'B', 'B', 0);
(blocked)
UPDATE account SET balance=balance+100 WHERE id = 5;
(Affected rows: 1)

T1 事務查詢 id=3 這條記錄不存在,接着事務 T2 要插入 id=2 這條記錄,被阻塞住了,接着 T3 更新了 id=5 這條記錄,能夠更新成功。

用加鎖規則分析下 T1 事務的加鎖:

  • 根據規則1,加鎖 Next-Key Lock,因爲 id=3 這條記錄不存在,因此鎖住的是 (1, 5] 這個區間。
  • 根據規則4,這是一個惟一索引等值查詢(id=3),因此 id=5 這條記錄不知足條件,Next-Key Lock 退化爲 Gap Lock,所以鎖住的範圍是 (1, 5)

事務 T1 鎖住了 (1, 5) 這個間隙,所以 T2 事務想在這個間隙插入一個 id=2 的記錄會被阻塞住,而 T3 事務就能夠成功更新 id=5 這條記錄。

執行 T一、T2,查看 INNODB_LOCKS 表,lock_mode 顯示加的是 Gap Lock,很明顯 T1 事務加的是 S Gap Lock,T2 事務加的是 X Gap Lock,說明 Gap Lock 也分 S、X 類型。lock_index 顯示鎖住的是主鍵索引,lock_data 表示在 id=5 這條記錄上加的 Gap Lock。

mysql> SELECT lock_trx_id,lock_mode,lock_type,lock_table,lock_index,lock_data FROM information_schema.INNODB_LOCKS;
+-------------+-----------+-----------+------------------+------------+-----------+
| lock_trx_id | lock_mode | lock_type | lock_table       | lock_index | lock_data |
+-------------+-----------+-----------+------------------+------------+-----------+
| 566789      | X,GAP     | RECORD    | `test`.`account` | PRIMARY    | 5         |
| 566788      | S,GAP     | RECORD    | `test`.`account` | PRIMARY    | 5         |
+-------------+-----------+-----------+------------------+------------+-----------+
複製代碼

接着按以下順序執行

T1 T2
BEGIN; BEGIN;
SELECT * FROM account WHERE id=3
LOCK IN SHARE MODE;
(Query 0)
SELECT * FROM account WHERE id=4
LOCK IN SHARE MODE;
(Query 0)

在上一步中,T3 事務是對 id=5 這條記錄加 Record Lock,能夠執行成功。此次執行事務 T2 一樣是對 (1,5) 這個區間加 Gap Lock,能夠執行成功。

從這能夠說明一個問題:雖然 Gap Lock 有 S、X 之分,可是它們起到的做用都是相同的。若是對一條記錄加了 Gap Lock,並不會限制其餘事務對繼續這條記錄加 Record Lock 或者 Gap Lock。

Gap Lock 只是用於保護這個間隙,防止插入新的記錄,Gap Lock 其實僅僅是爲了防止插入幻影記錄而提出的。

普通索引等值查詢

按以下順序執行SQL

T1 T2 T3 T4
BEGIN; BEGIN; BEGIN; BEGIN;
SELECT id FROM account WHERE name = 'D'
LOCK IN SHARE MODE;
INSERT INTO account VALUES
(4, 'C', 'C', 0);
(blocked)
INSERT INTO account VALUES
(6, 'E', 'E', 0);
(blocked)
UPDATE account SET balance=balance+100
WHERE name = 'H';
(Affected rows: 1)

事務T1查詢 name='D',name 列是普通索引,用加鎖規則來分析下:

  • 根據規則1,遍歷索引name列,鎖住的間隙是 (A, D],因爲是普通索引,還會向右繼續遍歷,所以還會鎖住 (D, H] 這個區間。
  • 根據規則4,索引上的等值查詢,向右遍歷到最後一個不知足等值條件的時候,Next-Key Lock 鎖住的 (D, H] 會退化爲 Gap Lock,只鎖住 (D, H)
  • 根據規則2,只有訪問到的對象纔會加鎖,這裏的查詢只用到了普通索引name,因此不會對聚簇索引加鎖(但注意若是是 FOR UPDATE 查詢,就會對聚簇索引加鎖)。

也就是說事務T1對name列普通索引加鎖,鎖住的間隙是 (A, D](D, H)

所以能夠看到事務T2想要往 (A, D] 這個間隙插入一個 'C' 會被阻塞住,事務T3想往 (D, H) 間插入一個 'E' 也會被阻塞。而事務T4更新的是 'H',不在T1鎖住的間隙內,所以能夠更新成功。

Limit 語句加鎖

仍是上面的例子,但此次T1中的查詢加了 LIMIT 1,由於知足 name='D' 的數據也只有一條。

T1 T2 T3
BEGIN; BEGIN; BEGIN;
SELECT id FROM account WHERE name = 'D' LIMIT 1
LOCK IN SHARE MODE;
INSERT INTO account VALUES
(4, 'C', 'C', 0);
(blocked)
INSERT INTO account VALUES
(6, 'E', 'E', 0);
(Affected rows: 1)

但此次的結果卻不同了,事務T3能夠向 (D, H) 這個間隙插入一個 'E' 了。

有了 LIMIT 語句以後,結果雖然同樣,但加鎖的效果是不同的。由於加了 LIMIT 1 以後,在遍歷到 name='D' 以後,已經有一條知足條件的數據了,就不會再日後遍歷了,所以鎖住的區間就只有 (A, D] 了,因此T3事務能夠執行成功。

咱們這裏雖然是用的 SELECT ... LOCK IN SHARE MODE(或 FOR UPDATE),但它和 DELETEUPDATE 的加鎖邏輯是相似的。若是咱們在根據普通索引來 DELETE/UPDATE,且知道記錄數時,那咱們就能夠在執行 DELETE/UPDATE 時加上 LIMIT,這樣不只能夠控制刪除/更新數據的條數,讓操做更安全,還能夠減少加鎖的範圍,提升數據庫併發性能。

例如我將T1事務的SELECT換成UPDATE後,能夠看到效果仍是同樣的。但若是去掉 LIMIT 1,事務T3仍是會阻塞住的。

T1 T2 T3
BEGIN; BEGIN; BEGIN;
UPDATE account SET balance=balance+100
WHERE name = 'D' LIMIT 1;
INSERT INTO account VALUES
(4, 'C', 'C', 0);
(blocked)
INSERT INTO account VALUES
(6, 'E', 'E', 0);
(Affected rows: 1)

主鍵索引範圍查詢

按以下順序執行SQL

T1 T2 T3
BEGIN; BEGIN; BEGIN;
SELECT id FROM account WHERE id >= 5 AND id < 6
LOCK IN SHARE MODE;
INSERT INTO account VALUES
(8, 'F', 'F', 0);
(blocked)
UPDATE account SET balance=balance+100
WHERE id = 10;
(blocked)

此次事務T1是在主鍵列上進行的範圍查詢,注意這裏的條件是 id >= 5 AND id < 6,它和直接查詢 id=5 是不一樣的,雖然查詢結果相同,但加鎖規則是不一樣的。查詢 id=5 很容易理解,加的是 Record Lock。使用加鎖規則來分析下 id >= 5 AND id < 6 是加的什麼鎖:

  • 查詢 id >= 5,要對 id=5 這條記錄加鎖,根據規則1,默認加 Next-Key Lock,鎖住 (1, 5] 這個區間。
  • 根據規則3,id=5 是惟一索引等值查詢,因此 Next-Key Lock 退化爲 Record Lock,只鎖住 id=5 這一條記錄。
  • 接着進行範圍查詢,根據規則5,惟一索引上的範圍查詢會訪問到不知足條件的第一個值爲止,會訪問到 id=10 這條記錄,所以加 Next-Key Lock 鎖住 (5, 10] 這個間隙。

所以T1事務鎖住的是 id=5 這條記錄以及 (5, 10] 這個區間。

能夠看到事務T2想要往 (5,10) 這個間隙插入 id=9 被阻塞住了,符合預期。但 T3 事務更新 id=10 這條記錄也被阻塞住了,所以須要注意,規則5所說的訪問到不知足條件的第一個值爲止,會包含不知足條件的這條記錄,也就是 (5,10]

接着按以下順序執行SQL

T1 T2 T3 T4
BEGIN; BEGIN; BEGIN; BEGIN;
SELECT * FROM account WHERE id > 5 AND id <= 15
LOCK IN SHARE MODE;
INSERT INTO account VALUES (14, 'L', 'L', 0);
(blocked)
INSERT INTO account VALUES (16, 'N', 'N', 0);
(blocked)
UPDATE account SET balance=balance+100 WHERE id = 20;
(blocked)

此次T1事務的查詢條件是 id > 5 AND id <= 15,按咱們的理解,應該是鎖住 (5, 10](10, 15] 這個區間,事務T2想往(10, 15]這個區間插入id=14,確實被阻塞住了。但事務T3想插入id=16,以及事務T4想更新id=20這條記錄也都被阻塞住了。

因此,這裏要注意理解原則5,惟一索引上的範圍查詢會向右訪問到不知足條件的第一個值爲止,範圍查詢 id <= 15 時,id=15 這條記錄是知足條件的,因此會接着訪問,訪問到 id=20 這條記錄時,纔不知足了。因此T1事務鎖住的範圍是 (5, 10], (10, 15](15, 20] 這三個區間。

不過,它這裏看起來更像是一個BUG,按照規則3,惟一索引上的等值查詢,即查詢 id<=15 在匹配到id=15這條記錄時就會中止,Next-Key Lock 會退化爲 Record Lock,加鎖的區間應該是 (5, 10], (10, 15) 以及id=15這條記錄,因此不會鎖住(15, 20]這個區間纔是,並且這明顯也是鎖得多餘了。

普通索引範圍查詢

按以下順序執行SQL

T1 T2 T3 T4
BEGIN; BEGIN; BEGIN; BEGIN;
SELECT id FROM account WHERE name >= 'D' AND name < 'E'
LOCK IN SHARE MODE;
INSERT INTO account VALUES (4, 'C', 'C', 100);
(blocked)
INSERT INTO account VALUES (8, 'F', 'F', 100);
(blocked)
UPDATE account SET balance=100
WHERE name = 'H';
(blocked)

此次T1事務是對普通索引 name 進行條件查詢,查詢條件是 name >= 'D' AND name < 'E',用加鎖規則來分析下:

  • name>='D' 會先找到 name='D' 這條記錄,根據規則1,加 Next-Key Lock,鎖住 (A, D] 這個區間,因爲是普通索引,繼續往右遍歷,所以還會加 Next-Key Lock 鎖住 (D, H]
  • 因爲 name 列不是惟一索引,不知足規則3,所以 Next-Key Lock 鎖住的 (A, D] 不會退化爲 Record Lock。
  • 因爲是範圍查詢,不是等值查詢,所以不知足規則4,Next-Key Lock 鎖住的 (D, H] 不會退化爲 Gap Lock。這是和 普通索引等值查詢 的區別。

所以事務T1鎖住的區間是 (A, D](D, H] 兩個區間。

事務T2想往 (A, D] 區間插入一個 'C',事務T3想往 (D, H] 區間插入一個 'F',而事務T4想更新 name='H' 這條記錄,T1事務鎖住了 (A, D](D, H] 兩個區間,所以 T二、T三、T4 都須要加鎖等待,被阻塞住。注意和普通索引等值查詢 的區別,普通索引等值查詢 name='D' 時,能夠更新 name='H' 這條記錄,而範圍查詢 name>='D' 時不行。

無索引查詢

按以下順序執行:

T1 T2
BEGIN; BEGIN;
SELECT * FROM account WHERE balance = 0 FOR UPDATE;
INSERT INTO account(card, name, balance) VALUES ('X', 'X', 100);
(blocked)

此次事務T1查詢 balance=0,balance 列上沒有任何索引,這時會鎖住全表。能夠看到事務T2想插入一個 balance=100 會被阻塞住,能夠驗證這個結論。

幻讀

幻讀是指在同一事務下,連續執行兩次一樣的SQL語句看到不一樣的結果,第二次的SQL語句可能會返回以前不存在的行,幻讀是專指讀到新插入的行

幻讀的問題

也許咱們會以爲既然是當前讀,能讀到別的事務新插入的行也是正常的,但幻讀有兩個問題。

首先從語義上來講,幻讀的問題在於,事務T1要鎖定的是一行數據,若是事務T2新插入了一行,而後事務T1再次鎖定讀時發現了兩行,這從語義上來講是有問題的。

其次,幻讀最大的問題在於可能致使數據和binlog日誌在邏輯上的不一致性。binlog 默認是 Statement 模式,記錄的是更新的SQL語句。

例如按下表執行,事務T1根據 name='D' 更新 balance=300,若是容許幻讀,事務T2就能夠再插入一行 name='D',balance=0 的數據,事務T1最後才提交,這時數據庫中新插入的那條 name='D' 的數據 balance=0binlog是在事務提交後才寫入的,所以binlog中的順序則是T2事務中的插入語句,接着纔是事務T1中的更新語句。若是將這個binlog應用到主從複製上,從庫中全部 name='D' 的數據 balance=300,這就出現了不一致。

T1 T2
BEGIN;
UPDATE account SET balance=300 WHERE name='D';
INSERT INTO account VALUES (6, 'D', 'D', 0);
COMMIT;

解決幻讀

前面的內容已經屢次提到過幻讀這個問題了,在上一篇文章的末尾,咱們提到在RR隔離級別下,基於MVCC快照讀是不會有幻讀的問題的,只在當前讀的狀況下才會出現幻讀。所以通常在讀取數據時,使用快照讀就能夠了。

在前面的多項測試中,很容易發現,Next-Key Lock 或者 Gap Lock 就是用於解決幻讀問題的,若是隻使用 Record Lock 鎖住已存在的行記錄,那麼行之間的間隙是沒辦法控制的,所以其它事務就可能往這些間隙插入數據,進而致使幻讀的問題。

間隙鎖是在 可重複讀(REPEATABLE READ) 隔離級別下才生效的,所以要避免幻讀的問題,須要設置數據庫隔離級別爲可重複讀

可是間隙鎖的引入,可能會致使一樣的語句鎖住更大的範圍,這其實對數據庫的併發性能會有必定影響。若是肯定業務不須要可重複讀的保證,能夠將數據庫隔離級別設置到讀已提交(READ COMMITTED),但這可能會致使數據和 binlog 日誌不一致,這時須要把 binlog 模式設置爲 row。ROW 模式下,binlog 記錄的是每行數據的修改,就不會有 Statement 模式下的那個問題了,但 ROW 模式會產生大量日誌內容。

死鎖

死鎖問題

前面咱們測試了那麼多鎖相關的內容,但這些測試中多個事務之間只是一個事務加鎖,而後阻塞其它事務,只要這個事務執行完釋放鎖,另外一個事務就能繼續執行。

死鎖就不同了,死鎖是指兩個或兩個以上的事務在執行過程當中,因爭奪鎖資源而形成的一種互相等待的現象。若無外力做用,事務都將沒法推動下去。

例若有下面這樣一個場景,根據 name 查詢,不存在則插入數據:

T1 T2
BEGIN; BEGIN;
SELECT * FROM account WHERE name = 'E' FOR UPDATE;
SELECT * FROM account WHERE name = 'F' FOR UPDATE;
INSERT INTO account(card, name) VALUES ('F', 'F');
(blocked)
INSERT INTO account(card, name) VALUES ('E', 'E');
(Deadlock found when trying to get lock; try restarting transaction)
(Affected rows: 1)

事務T1先鎖定讀 name='E',加的鎖是 Gap Lock,鎖住的是 (D, H) 這個間隙,事務T2也是同樣的,但間隙鎖之間是不會互相阻塞的。事務T2鎖住了 (D, H),但在插入數據時卻阻塞住了,它是被T1事務加的 Gap Lock 給阻塞住的。接着事務T1又來插入數據,這時數據庫就檢測到死鎖了,直接拋出死鎖異常並從新開始了事務。而後事務T2就得以繼續執行事務。

在上面這個示例中,兩個事務都持有 (D, H) 這個間隙的 Gap Lock,但接下來的插入操做都要獲取這個間隙的插入間隙鎖,插入間隙鎖和 Gap Lock 是衝突的,所以都要等待對方事務的 Gap Lock 釋放,因而就形成了循環等待,致使死鎖。

解決死鎖

解決死鎖問題最簡單的一種方法是超時,即當兩個事務互相等待時,當一個等待時間超過設置的某一閾值時,其中一個事務進行回滾,另外一個等待的事務就能繼續進行。在InnoDB存儲引擎中,能夠用參數innodb_lock_wait_timeout來設置超時的時間。

超時機制雖然簡單,可是其僅經過超時後對事務進行回滾的方式來處理,或者說其是根據FIFO的順序選擇回滾對象。但若超時的事務所佔權重比較大,如事務操做更新了不少行,佔用了較多的 undo log,這時採用FIFO的方式,就顯得不合適了,由於回滾這個事務的時間相對另外一個事務所佔用的時間可能會不少。

所以,除了超時機制,當前數據庫還都廣泛採用 wait-for graph(等待圖)的方式來進行死鎖檢測,當檢測到死鎖後會選擇一個最小(鎖定資源最少得事務)的事務進行回滾。較之超時的解決方案,這是一種更爲主動的死鎖檢測方式。能夠經過參數 innodb_deadlock_detect=on 開啓死鎖檢測,默認開啓。

不過,解決死鎖的最佳方式就是預防死鎖的發生,咱們平時編程中,能夠經過一些手段來預防死鎖的發生。

  • 在編程中儘可能按照固定的順序來處理數據庫記錄,好比有兩個更新操做,分別更新兩條相同的記錄,但更新順序不同,就有可能致使死鎖;

  • 在容許幻讀和不可重複讀的狀況下,儘可能使用 RC 事務隔離級別,能夠避免 Gap Lock 致使的死鎖問題;

  • 更新表時,儘可能使用主鍵更新;使用普通索引更新時,可能會鎖住不少間隙。若是不一樣時事務使用不一樣索引來更新,也可能致使死鎖。

  • 避免長事務,儘可能將長事務拆解,能夠下降與其它事務發生衝突的機率;

  • 設置鎖等待超時參數,經過 innodb_lock_wait_timeout 設置合理的等待超時閾值。在一些高併發的業務中,能夠將該值設置得小一些,避免大量事務等待,佔用系統資源,形成嚴重的性能開銷。

相關文章
相關標籤/搜索