從事務的隔離級別談起
眾所周知,事務有四大特性,簡稱ACID:原子性、一致性、隔離性、持久性。
對于隔離性,簡單來說就是多個事務之間是彼此隔離的,互不影響。但想要做到完全的互不影響是很難的,因為數據的強一致性,很多時候需要犧牲性能去達成。比如如果我們能接受事務的串行執行,那一定是互不影響的。然而現實是,MySQL作為一個數據庫,必然是要支持一定程度的并行執行的,也就是多個事務同時去執行。
?
凡并行程序,往往是在性能和數據一致性上做取舍。較好的解決方案要么是最終一致,要么是盡量縮小串行執行的范圍。
?
如果多個事務同時并行執行,在沒有隔離的情況,可能會發生臟讀、不可重復讀、幻讀的問題。
案例數據(demo表):
id(主鍵) c(普通索引) d(無索引) 5 5 5 10 10 10 15 15 15 20 20 20 25 25 25
「臟讀」
一個事務讀取了另一個事務未提交的數據。
臟讀
「不可重復讀」
一個事務讀取同一行數據,多次讀取結果不同。
不可重復讀
「幻讀」
一個事務讀取到了別的事務插入的數據。
幻讀
但InnoDB因為使用了MVCC,讀取的是“快照”版本,有一些不同,但如果不上鎖,同樣可能會有幻讀問題。
InnoDB的幻讀
事務用了四種不同的隔離級別用來解決這些問題。
- Read uncommitted(未提交讀)
- Read Committed(已提交讀,簡稱RC)
- Repeatable Reads(可重復讀,簡稱RR)
- Serializable(串行化)
隔離級別越高,解決的問題越多,但并發性能也會越差。它們之間的關系如下表:
隔離級別 臟讀 不可重復讀 幻讀 Read uncommitted 是 是 是 Read Committed 是 是 Repeatable Reads 是 Serializable
?
但InnoDB有些許不同,InnoDB默認的隔離級別是RR,但是通過MVCC和間隙鎖來一定程度上的解決了幻讀的問題。這也是我們今天這篇文章后面會詳細介紹的。
?
無鎖思想:MVCC
MVCC即“多版本并發控制”,但是它在很多情況下避免了加鎖操作,因此開銷更低。
主流的關系型數據庫都實現了MVCC,但實現機制各有不同。實際上MVCC也沒有一個統一的標準。但大都實現了非阻塞的讀操作,寫操作也只是鎖定必要的行。本文以下內容所說的MVCC都指的是InnoDB實現的MVCC。
在Mysql的InnoDB引擎,是通過給每行記錄后面保存兩個隱藏的列來實現的。一個是保存行的創建時間,另一個保存了行的過期時間(或刪除時間)。
?
實際上存儲的并不是實際的一個時間戳,而是“系統版本號”。
?
每次開啟一個事務,系統版本號都會遞增。事務開始時,系統版本號會作為事務的版本號,用來和查詢到的行的版本號進行比較。
MVCC只在REPEATABLE READ和READ COMMITTED兩個隔離級別下工作,其它兩個隔離級別不能工作。因為READ UNCOMMITTED總是讀取最新的數據行,而不是符合當前事務版本的數據行。而SERIALIZABLE則會對所有讀取的行都加鎖。
在MySQL中,正常的SELECT語句,后面不加FOR UPDATE和LOCK IN SHARE MODE的,就是用的MVCC去讀。
?
MVCC和我們在應用層面去實現的“樂觀鎖”有一樣的思想:用版本號,在盡量無鎖的情況下實現一定程度的一致性。
?
InnoDB行鎖的概念
InnoDB的行鎖(也稱為臨鍵鎖) Next-Key Locks,「是MySQL對外暴露的鎖的基本單位,它會智能選擇記錄鎖或間隙鎖,鎖住一行或多行或一個間隙」。而記錄鎖又分為共享鎖和排他鎖,間隙鎖的概念下面有一個插入意向鎖。這些鎖的關系大概是這樣:
鎖的關系
記錄鎖
所謂記錄鎖 Record Locks,就是鎖住確定的一行行記錄。它分為共享鎖和排它鎖。分別對應不同的SQL寫法。
共享鎖
共享鎖 Shared Locks ,簡稱S鎖。使用以下SQL可能觸發:
SELECT ... LOCK IN SHARE MODE
之所以說“可能”觸發,是因為它查到了數據庫有確定的記錄才會鎖住這些記錄,否則會變成間隙鎖。這個其實很好理解,找到了數據,才鎖它。如果沒找到數據,就鎖這個間隙。
排他鎖
排他鎖 Exclusive locks ,簡稱X鎖。使用一下SQL可能觸發:
SELECT ... FOR UPDATE
這里的“可能”含義與上面同理,不贅述。
間隙鎖
間隙鎖 Gap - Lock,顧名思義,鎖住一個間隙。上文我們提到過,InnoDB默認的隔離級別是RR,但是通過間隙鎖來一定程度上的解決了幻讀的問題。它是怎么解決的呢?就是通過間隙鎖來解決的。
上面兩種SQL,如果沒有查找到確定的記錄,就會根據條件去鎖住一個間隙。間隙鎖是根據已有數據的一個左開右閉的區間。
還是這個案例數據(假設數據都是從1開始):
id(主鍵) c(普通索引) d(無索引) 5 5 5 10 10 10 15 15 15 20 20 20 25 25 25
對于下面這些區間的操作,會有對應的間隙鎖:(0, 5], (5, 10], (10, 15], (15, 20], (20, 25], (25, 正無窮)。
什么意思呢?假如你的SQL查詢的范圍不同,那它鎖住的區間就不同。比如:
-- 鎖住(0, 5]
SELECT * FROM demo where id = 3;
-- 鎖住(10, 15]
SELECT * FROM demo where id = 11;
間隙鎖其實是“共享”的。也就是說,多個事務可以獲取同一個區間的間隙鎖。
間隙鎖不互相阻塞
插入意向鎖
插入意向鎖 Insert Intention Locks,代表當前事務準備插入一行數據。使用INSERT/UPDATE/DELETE等語句會獲得插入意向鎖。
「插入意向鎖和插入意向鎖之間是兼容的,只要插入的鍵值不同,就不會相互阻塞」。比如以下兩個SQL,在不同的事務中,哪怕它們在同一個間隙,只要沒有間隙鎖,就不會阻塞:
插入兩條不同的記錄不阻塞
但如果兩個事務插入同一個key,那就會阻塞。
兩個插入意向鎖阻塞的情況
插入意向鎖可以保證兩個事務插入key不同的數據的時候不沖突,提升并發性。
「但是間隙鎖會阻塞插入意向鎖」!這也可以理解,因為InnoDB想在RR隔離級別就解決幻讀問題。所以A事務用SELECT語句獲取了一個間隙鎖,自然不希望B事務在這個期間往這個間隙插入一條新的記錄。
間隙鎖阻塞插入意向鎖
與索引的關系
不管哪種行級鎖,「行級鎖的其實都是索引」。所以在上面的demo中,如果對id(主鍵)或者column c(普通索引)操作,都會觸發相應的行級鎖,但如果對column d(無索引)做同樣的操作,InnoDB就會對表中所有數據加鎖,實際效果跟表級鎖一樣。
所以一定要注意,如果要上鎖,需要注意是否走了索引,不要弄成了表級鎖造成安全事故。
一個死鎖案例
最后給一個關于間隙鎖和插入意向鎖的死鎖案例吧,也是之前在項目上遇到過的真實案例。
過程
- 事務A select ... for update 查找一個不存在的數據,獲得間隙鎖;
- 事務B select ... for update 查找一個不存在的數據,獲得相同位置的間隙鎖;
- 事務A insert into ... 到這個間隙,嘗試獲得插入意向鎖,但被事務B持有的間隙鎖阻塞。
- 事務B insert into ... 到這個間隙,嘗試獲得插入意向鎖,但被事務A持有的間隙鎖阻塞,MySQL監測到死鎖,回滾事務B;
- 因為事務B回滾,所以事務B持有的間隙鎖被釋放,所以之前事務A的插入語句不再阻塞,順利執行插入操作。
?
報錯信息:Deadlock found when trying to get lock; try restarting transaction
?
用圖把這個過程描述出來:
死鎖過程
最終結果
事務A插入成功,事務B因為死鎖被回滾。
總結
間隙鎖主要還是用于防止幻讀的情況,所以多個事務能夠同時獲取同一段間隙鎖本身并沒有問題,間隙鎖能夠阻塞插入意向鎖也并沒有問題。
而插入意向鎖可以看成是一種特殊的間隙鎖,是用于在同一個間隙,插入不同的數據,不會互相阻塞。它比普通間隙鎖的數據一致性更低,但并發性能更好;
解決思路
普通間隙鎖還是主要用于讀操作防止幻讀。所以我們在想進行插入操作的時候,其實沒有必要對即將插入的間隙使用普通間隙鎖,直接使用insert語句產生的插入意向鎖就好了。
如果要保證數據的一致性,可以使用插入意向鎖配合主鍵、唯一鍵等約束。
附錄
那些在文章中用過的SQL:
-- 建表
create table demo
(
id int unsigned not null,
c int unsigned not null,
d int unsigned not null,
constraint demo_pk
primary key (id),
constraint idx_c
unique (c)
);
-- 插數據
INSERT INTO demo VALUES
(5, 5, 5),
(10, 10, 10),
(15, 15, 15),
(20, 20, 20),
(25, 25, 25);
-- 禁止自動提交事務
SET AUTOCOMMIT = 0;
-- 開啟事務
BEGIN;
-- 提交事務
COMMIT;
-- 會獲取鎖的查詢
SELECT * FROM demo WHERE id < 5 FOR UPDATE ;
關于作者
我是Yasin,一個不斷精進的菜雞。
微信公眾號:編了個程
個人網站:https://yasinshaw.com
關注我的公眾號,和我一起成長~