1. 程式人生 > 實用技巧 >Mysql 中的事務與鎖

Mysql 中的事務與鎖

Mysql 中的事務與鎖

InnoDB與MyISAM的最大不同有兩點:一是支援事務(TRANSACTION);二是採用了行級鎖。

事務

事務是由一組SQL語句組成的邏輯處理單元,事務具有如下4個屬性,通常稱為事務的ACID屬性:
  • 原子性(Actomicity),事務是一個原子操作單元,其對資料的修改,要麼全都執行,要麼全都不執行;
  • 一致性(Consistent),在事務開始和完成時,資料都必須保持一致狀態;
  • 隔離性(Isolation),MySQL提供一定的隔離機制,保證事務在不受外部併發操作影響的“獨立”環境執行,這意味著事務處理過程中的中間狀態對外部是不可見的,反之亦然;
  • 永續性(Durable),事務完成之後,它對於資料的修改是永久性的,即使出現系統故障也能夠保持。

由於事務的併發執行,可能會引起一些問題,比如:

  • 更新丟失(Lost Update),當兩個或多個事務選擇同一行,然後基於最初選定的值更新該行時,由於每個事務都不知道彼此的存在,就會發生丟失更新問題——最後的更新覆蓋了其他事務所做的更新;
  • 髒讀(Dirty Reads),一個事務正在對一條記錄做修改,在這個事務並提交前,這條記錄的資料就處於不一致狀態;這時,另一個事務也來讀取同一條記錄,如果不加控制,第二個事務讀取了這些“髒”的資料,並據此做進一步的處理;
  • 幻讀(Phantom Reads),一個事務按相同的查詢條件重新讀取以前檢索過的資料,卻發現其他事務插入了滿足其查詢條件的新資料(通常對應於INSERT);
  • 不可重複讀(Non-Repeatable Reads),一個事務在讀取某些資料已經發生了改變、或某些記錄已經被刪除了(通常對應於UPDATE)。
舉例: “髒讀”,會話 2 更新 age 為 10,但是在 commit 之前就被 會話1 讀到了,此時 會話2 即使 rollback,會話1 讀到的 age還是10。 “幻讀”:由於在會話 1 之間插入了一個新的值,所以得到的兩次資料就不一樣了。 “不可重複讀”:由於在讀取中間變更了資料,所以會話 1 事務查詢期間的得到的結果就不一樣了。

注意,“更新丟失”不能單靠資料庫事務控制器來解決,而應該由使用者自己想辦法解決(比如加鎖);

但其它三個問題(髒讀、幻讀、不可重複讀)都是 讀一致性 問題,應該由資料庫提供一定的事務隔離機制來解決。資料庫實現事務隔離的方式,基本可以分為以下兩種:

  1. 加鎖,即在讀取資料前,對其加鎖,阻止其他事務對資料進行修改;
  2. 多版本併發控制(MultiVersion Concurrency Control, MVCC),通過生成一個數據請求時間點的一致性資料快照(Snapshot),並用這個快照來提供一定級別(語句級或事務級)的一致性讀取;從使用者的角度,好像是資料庫可以提供同一資料的多個版本。

資料庫的事務隔離級別越嚴格,事務併發的副作用越小,但付出的效能代價也就越大,因為事務隔離實質上就是使事務在一定程度上“序列化”進行,這顯然與“併發”是矛盾的,同時,不同的應用對讀一致性和事務隔離程度的要求也是不同的,比如許多應用對“不可重複讀”和“幻讀”並不敏感,可能更關心資料併發訪問的能力。

為了解決“隔離”與“併發”的矛盾,ISO/ANSI SQL92定義了4個事務隔離級別:

  • Read uncommitted(讀未提交,允許髒讀),如果一個事務已經開始寫資料,則不允許其它事務同時進行寫操作,但允許其他事務讀此行資料。該隔離級別可以通過“排他寫鎖”實現;
  • Read committed(讀已提交,允許不可重複讀),未提交的寫事務將會禁止其他事務訪問該行,這可以通過“瞬間共享讀鎖”和“排他寫鎖”實現;
  • Repeatable read(可重複讀,允許幻讀),讀取資料的事務將會禁止寫事務(但允許讀事務),寫事務則禁止任何其他事務;這可以通過“共享讀鎖”和“排他寫鎖”實現;
  • Serializable (序列化),事務最高隔離級別,在該級別下,事務序列化順序執行。僅僅通過“行級鎖”是無法實現事務序列化的,必須通過其他機制保證新插入的資料不會被剛執行查詢操作的事務訪問到。
每個級別的隔離程度不同,允許出現的副作用也不同,應用可以根據自己業務邏輯要求,通過選擇不同的隔離級別來平衡"隔離"與"併發"的矛盾

最後要說明的是:各具體資料庫並不一定完全實現了上述4個隔離級別,例如,Oracle只提供Read committed和Serializable兩個標準級別,另外還自己定義的Read only隔離級別:SQL Server除支援上述ISO/ANSI SQL92定義的4個級別外,還支援一個叫做"快照"的隔離級別,但嚴格來說它是一個用MVCC實現的Serializable隔離級別。MySQL支援全部4個隔離級別,但在具體實現時,有一些特點,比如在一些隔離級下是採用MVCC一致性讀,但某些情況又不是。

Mysql的預設隔離級別是Repeatable read。

MySQL中的鎖大致可分為以下3種:

  1. 表級鎖:主要是 MyISAM引擎使用,開銷小,加鎖快;不會出現死鎖;鎖定粒度大,發生鎖衝突的概率最高,併發度最低;
  2. 行級鎖:主要是 Innodb引擎使用,開銷大,加鎖慢;會出現死鎖;鎖定粒度最小,發生鎖衝突的概率最低,併發度也最高;
  3. 頁面鎖:開銷和加鎖時間界於表鎖和行鎖之間;會出現死鎖;鎖定粒度界於表鎖和行鎖之間,併發度一般。

可見,鎖的粒度越大,加鎖越快,但併發度越低。

表級鎖 (MyISAM引擎)

MySQL的表鎖有兩種模式:

  • 表共享讀鎖(Table Read Lock),主要由讀操作(SELECT)使用,不會阻塞其他使用者對同一表的讀請求,但會阻塞寫請求;
  • 表獨佔寫鎖(Table Write Lock),主要由寫操作(INSERT/UPDATE等)使用,會阻塞其他使用者對同一表的讀、寫請求。

可見,當一執行緒獲得對一個表的寫鎖後,只有持有鎖的執行緒可以對錶進行更新操作。其他執行緒的讀、寫操作都會等待,直到鎖被釋放為止。MyISAM表的讀和寫操作之間,以及寫和寫操作之間是序列的!

如下表,橫向title表示當前鎖模式,縱向表示請求鎖模式,是否相容的是表示不會阻塞,否表示要阻塞:

當前鎖模式/是否相容/請求鎖模式

None

讀鎖

寫鎖

讀鎖
寫鎖

MyISAM在執行查詢語句(SELECT)前,會自動給涉及的所有表加讀鎖,在執行更新操作(UPDATE、DELETE、INSERT等)前,會自動給涉及的表加寫鎖,這個過程並不需要使用者干預,因此使用者一般不需要直接用LOCK TABLE命令給MyISAM表顯式加鎖。但在涉及到多張表的操作時,可能會需要使用者顯式加鎖,例如有一個訂單表orders,其中記錄有訂單的總金額total,同時還有一個訂單明細表order_detail,其中記錄有訂單每一產品的金額小計subtotal,假設我們需要檢查這兩個表的金額合計是否相等,可能就需要執行如下兩條SQL:

SELECT SUM(total) FROM orders;
SELECT SUM(subtotal) FROM order_detail;

這時,如果不先給這兩個表加鎖,就可能產生錯誤的結果,因為第一條語句執行過程中,order_detail表可能已經發生了改變。因此,正確的方法應該是:

LOCK tables orders read local,order_detail read local;
SELECT SUM(total) FROM orders;
SELECT SUM(subtotal) FROM order_detail;
Unlock tables;

這裡要注意:

  1. 在用LOCKTABLES給表顯式加表鎖是時,必須同時取得所有涉及表的鎖,假如多個表加鎖有先後熟悉,則可能引發deadlock;
  2. 上面的例子在LOCK TABLES時加了‘local’選項,其作用就是在滿足MyISAM表併發插入條件的情況下,允許其他使用者在表尾插入記錄(表尾新增不會引起競爭條件)。

例子中LOCK TABLE語句使用 local 關鍵字可以使得如果MyISAM允許在一個讀表的同時,另一個程序從表尾插入記錄。事實上,MyISAM儲存引擎有一個系統變數 concurrent_insert,專門用以控制其併發插入的行為,其值分別可以為0、1或2。

  • concurrent_insert = 0,不允許併發插入(此時讀和寫之間也是序列的);
  • concurrent_insert = 1,如果MyISAM允許在一個讀表的同時,且表文件沒有空洞(被刪除的行),另一個程序從表尾插入記錄(這也是MySQL的預設設定);
  • concurrent_insert = 2,無論MyISAM表中有沒有空洞,都允許在表尾插入記錄,都允許在表尾併發插入記錄。

注意,concurrent_insert 的併發是指在有SELECT的讀鎖時允許一個寫入操作(即讀和寫之間併發),而不是指多個寫入可以併發執行,如果有多個寫入操作之間仍然需要序列順序執行。

另外,對多數應用場景下,讀操作會遠多於寫操作,那這是否會導致頻繁的讀操作一直持有read鎖,而更新操作很難得到write鎖呢?答案是寫操作會優先獲得鎖,即使讀請求先進入鎖等待佇列,寫請求後到,寫鎖也會插到讀請求之前!這也正是MyISAM表不太適合於有大量更新操作和查詢操作應用的原因,因為,大量的更新操作會造成查詢操作很難獲得讀鎖,從而可能永遠阻塞。

我們可以通過一些設定來調節MyISAM的排程行為。
  • 通過指定啟動引數low-priority-updates,使MyISAM引擎預設給予讀請求以優先的權利(服務級);
  • 通過執行命令SET LOW_PRIORITY_UPDATES=1,使該連線發出的更新請求優先順序降低(連線級);
  • 通過指定INSERT、UPDATE、DELETE語句的LOW_PRIORITY屬性,降低該語句的優先順序(語句級);

以上幾個方法都是要麼更新優先,要麼查詢優先,MySQL也提供了一種折中的辦法來調節讀寫衝突,即給系統引數max_write_lock_count設定一個合適的值,當一個表的讀鎖達到這個值後,MySQL變暫時將寫請求的優先順序降低,給讀程序一定獲得鎖的機會。

行級鎖 (Innodb引擎)

MySQL的行級鎖也有兩種模式:

  • 共享鎖(S Lock),允許一個事務去讀一行,阻止其他事務獲得相同資料集的排他鎖;
  • 排它鎖(X Lock),允許獲取排它鎖的事務更新資料,阻止其他事務取得相同的資料集共享讀鎖和排他寫鎖。

可以想到這裡SS相互相容,XX、XS、SX不相容。

對於普通SELECT語句,InnoDB不會加任何鎖;對於UPDATE、DELETE和INSERT語句,InnoDB會自動給涉及及資料集加排他鎖(X);

事務可以通過以下語句顯式給記錄集加共享鎖或排它鎖:

SELECT * FROM table_name WHERE ... LOCK IN SHARE MODE
SELECT * FROM table_name WHERE ... FOR UPDATE

用SELECT .. IN SHARE MODE獲得共享鎖,主要用在需要資料依存關係時確認某行記錄是否存在,並確保沒有人對這個記錄進行UPDATE或者DELETE操作。但是如果當前事務也需要對該記錄進行更新操作,則很有可能造成死鎖。

對於鎖定行記錄後需要進行更新操作的應用,應該使用SELECT ... FOR UPDATE方式獲取排他鎖。

行鎖與索引

Innodb的行鎖到底鎖住的是什麼呢?我們先假設它鎖定的是一行資料或者記錄(Record)。

看個例子,事務A 對user表中 id = 1的記錄執行 select ... for update,如果鎖的是Record,那麼應該只對id=1的記錄加X鎖;

mysql> show create table user;
+-------+-------------------------------------------------------------------------------------------------------------------------------------------+
| Table | Create Table                                                                                                                              |
+-------+-------------------------------------------------------------------------------------------------------------------------------------------+
| user  | CREATE TABLE `user` (
  `id` bigint(20) unsigned NOT NULL DEFAULT '0',
  `name` varchar(32) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8 |
+-------+-------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)

mysql> select * from user;
+----+-------+
| id | name  |
+----+-------+
|  1 | Green |
|  6 | Bush  |
+----+-------+
2 rows in set (0.00 sec)

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

mysql> select * from user where id=1 for update;
+----+-------+
| id | name  |
+----+-------+
|  1 | Green |
+----+-------+
1 row in set (0.00 sec)

mysql> 

另外一個 事務B 對記錄id=3加X鎖,它和事務A 操作的記錄並非同一條,此時似乎不應該被阻塞,但事實上它被阻塞了!

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

mysql> select * from user where id=6 for update;  // blocked

至此,我們可以確定前面的假設是錯誤的,行鎖住的不是資料行(或者叫Record),否則不會出現整張表都被鎖住的情況。

如果我們對上面例子中的user表執行如下語句,增加主鍵id

mysql> alter table user add primary key (id);
Query OK, 0 rows affected (12.97 sec)
Records: 0  Duplicates: 0  Warnings: 0
mysql> show create table user;
+-------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Table | Create Table                                                                                                                                                    |
+-------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------+
| user  | CREATE TABLE `user` (
  `id` bigint(20) unsigned NOT NULL DEFAULT '0',
  `name` varchar(32) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 |
+-------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)

然後重複上述例子,事務A

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

mysql> select * from user where id=1 for update;
+----+-------+
| id | name  |
+----+-------+
|  1 | Green |
+----+-------+
1 row in set (0.00 sec)

事務B

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

mysql> select * from user where id=6 for update; // ok
+----+-------+
| id | name  |
+----+-------+
|  1 | Green |
+----+-------+
1 row in set (0.00 sec)

結論:如果InnoDB的行鎖不是通過鎖定記錄實現的,那麼可能和索引有關?

事實上,InnoDB行鎖是通過索引上的索引項來實現的,這一點MySQL與Oracle不同,後者是通過在資料中對相應資料行加鎖來實現的。InnoDB這種行鎖實現特點意味者:只有通過索引條件檢索資料,InnoDB才會使用行級鎖,否則,InnoDB將使用表鎖 (行鎖升級為表鎖)

意向鎖(Intention Locks)

需要注意的是,當給某一行增加共享鎖、排他鎖時,資料庫會自動給這一行所處的表新增意向共享鎖(IS Lock)、意向排他鎖(IX Lock)也就是說,如果想給 r行 增加鎖,需要給 r行 所在的表先增加意向排他鎖。

  • 意向共享鎖(IS Lock),事務打算給資料行共享鎖,事務在給一個數據行加共享鎖前必須先取得該表的IS鎖;
  • 意向排他鎖(IX Lock),事務打算給資料行加排他鎖,事務在給一個數據行加排他鎖前必須先取得該表的IX鎖。

意向鎖是InnoDB自動加的,不需使用者干預。

下面來看一下意向鎖的作用,假設事務A對 r行 加了S鎖,之後事務B申請整個表的寫鎖,那麼資料庫需要做的事情包括:

step1:判斷該表是否已被其他事務用表鎖鎖表;
step2:發現表上有意向共享鎖,說明表中有些行被共享行鎖鎖住了,因此,事務B申請表的寫鎖會被阻塞。

如果沒有意向鎖,那麼在進行step2的時候,需要遍歷整個表判斷是否有行鎖的存在,以免發生衝突;但如果有了意向鎖,則只需要判斷該意向鎖與即將申請的表鎖是否相容即可。因為意向鎖的存在,代表了有(或即將有)行級鎖的存在。

引用官方的話
Intention locks do not block anything except full table requests (for example, LOCK TABLES ... WRITE). 
The main purpose of intention locks is to show that someone is locking a row, or going to lock a row in the table.

可見意向鎖的作用主要是協調行鎖與表鎖的關係,如下表,注意,該表裡的X和S都是表級鎖,而非行鎖!

綠色部分表示意向鎖之間可以相容,例如如果表上已經被加了 IX鎖,證明此時有 事務A 在修改表中的具體某行的資料(對應行的資料這時可能被加了X鎖),此時如果又有 事務B 要對錶加 IS鎖(或IX鎖),仍可以成功,但事務B後續查詢或者修改的是某行的資料,這行和事務A修改的資料可能會有衝突、也可能沒有衝突。 紅色部分表示當有事務對錶加寫鎖(X)時,其他事務將無法獲得任何意向鎖;如果有事務對錶加共享鎖(S)時,其它事務可以獲得IS鎖,但無法獲得IX鎖。
當前鎖模式/是否相容/請求鎖模式 X IX S IS
X
IX
S
IS

行鎖可以再分為記錄鎖、臨健鎖、間隙鎖,假如表中某欄位值有1、4、7、10幾條記錄,如下圖

圖中間隙鎖的記錄都是不存在的,而臨健鎖是一個左開右閉區間。

記錄鎖(Record Locks)

上面在講到 行鎖與索引 關係的時候,提到行鎖實際鎖的是index,而非record,也就是說 Record locks 其實是鎖索引資料,那麼當表中沒有index怎麼辦呢?

這種情況下Innodb會建立一個隱藏的clustered index,並對該聚集索引加鎖。


臨鍵鎖(Next-key Locks)

臨健鎖 是記錄鎖和間隙鎖的組合,它的封鎖範圍,既包含索引記錄(record),又包含索引區間(gap),所以它是一個左開右閉區間。

臨健鎖 只有在事務隔離級別RR(Repeated Read)下才生效,用於避免幻讀。

間隙鎖(Gap Locks)

當我們用範圍條件而不是相等條件檢索資料,並請求共享或排他鎖時,InnoDB會給符合條件的已有資料的索引項加鎖;對於鍵值在條件範圍內但並不存在的記錄,叫做“間隙(GAP)”,InnoDB也會對這個“間隙”加鎖。

舉個例子,有一個表user,事務A執行如下語句,啟動一個讀事務,where子句檢索 id > 6 的範圍 (這裡利用for update加X鎖),
mysql> select * from user;
+----+------+
| id | name |
+----+------+
|  6 | Bush |
+----+------+
1 row in set (0.00 sec)

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

mysql> select * from user where id > 6 for update;
Empty set (0.00 sec)

此時事務A還未執行commit,然後另一個事物B先插入一條 id = 1 的記錄,可以成功;然後再插入一條 id = 7 的記錄,發現被夯住,這是因為 id > 6 的記錄被事務A 加上了間隙鎖。

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

mysql> insert into user values (1, "Green");
Query OK, 1 row affected (0.00 sec)

mysql> insert into user values (7, "Brown");

再舉個例子,唯一索引有值1、5、7、11,那麼該表隱藏的next-key lock包括(左開右閉區間)

(-infinity, 1]
(1, 5]
(5, 7]
(7, 11]
(11, +infinity]

如果 事務A 執行如下範圍檢索

begin;
SELECT * FROM `user` WHERE `id` BETWEEN 5 AND 7 FOR UPDATE;

此時,另一個事務B 執行

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

mysql> insert into user values (4, "4");
Query OK, 1 row affected (0.01 sec)

mysql> insert into user values (6, "6");
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

mysql> insert into user values (11, "11");
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

mysql> insert into user values (12, "12"); 
Query OK, 1 row affected (0.00 sec)

可見,事務A產生的間隙鎖會鎖住 (5, 7] 和 (7, 11] 兩個區間。

再比如,若 事務A 執行如下檢索(不存在的記錄)

BEGIN;
/* 查詢 id = 3 這一條不存在的資料並加記錄鎖 */
SELECT * FROM `user` WHERE `id` = 3 FOR UPDATE;

此時,另一個事務B執行

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

mysql> INSERT INTO `user` (`id`, `name`) VALUES (2, '2');
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
mysql
> INSERT INTO `user` (`id`, `name`) VALUES (6, '6'); Query OK, 1 row affected (0.00 sec)

可見,事務A產生的間隙鎖會鎖住 (1, 5] 。當然,如果事務A 檢索的條件能夠命中記錄(比如where id=5),就不會產生間隙鎖,而只會產生記錄鎖。

綜上對行鎖的一些結論:

  1. 加鎖的基本單位是 next-key lock;
  2. 加鎖是基於索引的,查詢過程中訪問到的物件才會加鎖(如果沒有索引會退化為表鎖);
  3. 在唯一索引上的等值查詢,如果該記錄不存在,會產生gap lock,如果記錄存在,則只會產生record lock;
  4. 對於查詢某一範圍內的查詢語句,會產生間隙鎖,如:WHERE `id` BETWEEN 5 AND 7 FOR UPDATE;