事務
什么是事務?
維基百科的定義:事務是數據庫管理系統(DBMS)執行過程中的一個邏輯單位,由 一個有限的數據庫操作序列構成。
這里面有兩個關鍵點,第一個,它是數據庫最小的工作單元,是不可以再分的。第 二個,它可能包含了一個或者一系列的 DML 語句,包括 insert delete update
(單條 DDL(create drop)和 DCL(grant revoke)也會有事務)
事務的四大特性
事務的四大特性:ACID。
第一個,原子性,Atomicity,也就是我們剛才說的不可再分,也就意味著我們對數 據庫的一系列的操作,要么都是成功,要么都是失敗,不可能出現部分成功或者部分失 敗的情況。以轉賬的場景為例,一個賬戶的余額減少,對應一個賬戶的增加,這兩個一 定是同時成功或者同時失敗的。
全部成功比較簡單,問題是如果前面一個操作已經成功了,后面的操作失敗了,怎 么讓它全部失敗呢?這個時候我們必須要回滾。
原子性,在 InnoDB 里面是通過 undo log 來實現的,它記錄了數據修改之前的值(邏 輯日志),一旦發生異常,就可以用 undo log 來實現回滾操作
第二個,一致性,consistent,指的是數據庫的完整性約束沒有被破壞,事務執行的 前后都是合法的數據狀態。比如主鍵必須是唯一的,字段長度符合要求。
除了數據庫自身的完整性約束,還有一個是用戶自定義的完整性。
比如說轉賬的這個場景,A 賬戶余額減少 1000,B 賬戶余額只增加了 500,這個時 候因為兩個操作都成功了,按照我們對原子性的定義,它是滿足原子性的, 但是它沒有 滿足一致性,因為它導致了會計科目的不平衡
還有一種情況,A 賬戶余額為 0,如果這個時候轉賬成功了,A 賬戶的余額會變成 -1000,雖然它滿足了原子性的,但是我們知道,借記卡的余額是不能夠小于 0 的,所以 也違反了一致性。用戶自定義的完整性通常要在代碼中控制。
第三個,隔離性,Isolation,我們有了事務的定義以后,在數據庫里面會有很多的 事務同時去操作我們的同一張表或者同一行數據,必然會產生一些并發或者干擾的操作, 那么我們對隔離性的定義,就是這些很多個的事務,對表或者行的并發操作,應該是透 明的,互相不干擾的。通過這種方式,我們最終也是保證業務數據的一致性。
最后一個叫做持久性,Durable,事務的持久性是什么意思呢?我們對數據庫的任意 的操作,增刪改,只要事務提交成功,那么結果就是永久性的,不可能因為我們系統宕 機或者重啟了數據庫的服務器,它又恢復到原來的狀態了。這個就是事務的持久性。
持久性怎么實現呢?數據庫崩潰恢復(crash-safe)是通過什么實現的?
持久性是通過 redo log 和 double write 雙寫緩沖來實現的,我們操作數據的時候,會先寫到內存的 buffer pool 里面,同時記錄 redo log,如果在刷盤之前出現異常,在 重啟后就可以讀取 redo log 的內容,寫入到磁盤,保證數據的持久性。
當然,恢復成功的前提是數據頁本身沒有被破壞,是完整的,這個通過雙寫緩沖 (double write)保證。
原子性,隔離性,持久性,最后都是為了實現一致性。
數據庫什么時候會出現事務
無論是我們在 Navicat 的這種工具里面去操作,還是在我們的 Java 代碼里面通過 API 去操作,還是加上@Transactional 的注解或者 AOP 配置,其實最終都是發送一個 指令到數據庫去執行,Java 的 JDBC 只不過是把這些命令封裝起來了。
我們先來看一下我們的操作環境。版本(5.7),存儲引擎(InnnoDB),事務隔離 級別(RR)。
select version();
show variables like '%engine%';
show global variables like "tx_isolation";
執行這樣一條更新語句的時候,它有事務嗎?
update student set sname = '貓老公 111' where id=1;
實際上,它自動開啟了一個事務,并且提交了,所以最終寫入了磁盤。
這個是開啟事務的第一種方式,自動開啟和自動提交。
InnoDB 里面有一個 autocommit 的參數(分成兩個級別, session 級別和 global 級別)。
show variables like 'autocommit';
它的默認值是 ON。autocommit 這個參數是什么意思呢?是否自動提交。如果它的 值是 true/on 的話,我們在操作數據的時候,會自動開啟一個事務,和自動提交事務。
否則,如果我們把 autocommit 設置成 false/off,那么數據庫的事務就需要我們手 動地去開啟和手動地去結束。
手動開啟事務也有幾種方式,一種是用 begin;一種是用 start transaction。
那么怎么結束一個事務呢?我們結束也有兩種方式,第一種就是提交一個事務, commit;還有一種就是 rollback,回滾的時候,事務也會結束。還有一種情況,客戶端 的連接斷開的時候,事務也會結束。
后面我們會講到,當我們結束一個事務的時候,事務持有的鎖就會被釋放,無論是 提交還是回滾。
我們用 begin 手工開啟一個事務,執行第二個 update,但是數據沒有寫入磁盤,因 為事務還沒有提交,這個時候 commit 一下,再刷新一下,OK,寫入了
這個就是我們開啟和結束事務的兩種方式。
事務并發會帶來什么問題?
當很多事務并發地去操作數據庫的表或者行的時候,如果沒有我們剛才講的事務的 Isolation 隔離性的時候,會帶來哪些問題呢?
我們有兩個事務,一個是 Transaction A,一個是 Transaction B,在第一個事務里 面,它首先通過一個 where id=1 的條件查詢一條數據,返回 name=Ada,age=16 的 這條數據。然后第二個事務,它同樣地是去操作 id=1 的這行數據,它通過一個 update 的語句,把這行 id=1 的數據的 age 改成了 18,但是注意,它沒有提交。
這個時候,在第一個事務里面,它再次去執行相同的查詢操作,發現數據發生了變 化,獲取到的數據 age 變成了 18。那么,這種在一個事務里面,由于其他的時候修改了 數據并且沒有提交,而導致了前后兩次讀取數據不一致的情況,這種事務并發的問題, 我們把它定義成什么?
這個叫做臟讀
。
如果在轉賬的案例里面,我們第一個事務基于讀取到的第二個事務未提交的余額進 行了操作,但是第二個事務進行了回滾,這個時候就會導致數據不一致。
這種讀取到其他事務未提交的數據的情況,我們把它叫做臟讀。
我們再來看第二個
同樣是兩個事務,第一個事務通過 id=1 查詢到了一條數據。然后在第二個事務里面 執行了一個 update 操作,這里大家注意一下,執行了 update 以后它通過一個 commit 提交了修改。然后第一個事務讀取到了其他事務已提交的數據導致前后兩次讀取數據不 一致的情況,就像這里,age 到底是等于 16 還是 18,那么這種事務并發帶來的問題, 我們把它叫做什么?
這種一個事務讀取到了其他事務已提交的數據導致前后兩次讀取數據不一致的情 況,我們把它叫做不可重復讀
。
在第一個事務里面我們執行了一個范圍查詢,這個時候滿足條件的數據只有一條。 在第二個事務里面,它插入了一行數據,并且提交了。重點:插入了一行數據。在第一 個事務里面再去查詢的時候,它發現多了一行數據。這種情況,我們把它叫做什么呢?
一個事務前后兩次讀取數據數據不一致,是由于其他事務插入數據造成的,這種情 況我們把它叫做幻讀
。
不可重復讀和幻讀,的區別在那里呢?
不可重復讀是修改或者刪除,幻讀是插入。
小結:我們剛才講了事務并發帶來的三大問題,現在來給大家總結一下。無論是臟 讀,還是不可重復讀,還是幻讀,它們都是數據庫的讀一致性
的問題,都是在一個事務里面前后兩次讀取出現了不一致的情況。
讀一致性的問題,必須要由數據庫提供一定的事務隔離機制來解決。就像我們去飯 店吃飯,基本的設施和衛生保證都是飯店提供的。那么我們使用數據庫,隔離性的問題也必須由數據庫幫助我們來解決。
SQL92 標準
所以,就有很多的數據庫專家聯合制定了一個標準,也就是說建議數據庫廠商都按 照這個標準,提供一定的事務隔離級別,來解決事務并發的問題,這個就是 SQL92 標準。
我們來看一下 SQL92 標準的官網。
http://www.contrib.andrew.cmu.edu/~shadow/sql/sql1992.txt
這里面有一張表格(搜索_iso
),里面定義了四個隔離級別,右邊的 P1 P2 P3 就是 代表事務并發的 3 個問題,臟讀,不可重復讀,幻讀。Possible 代表在這個隔離級別下, 這個問題有可能發生,換句話說,沒有解決這個問題。Not Possible 就是解決了這個問題。
我們詳細地分析一下這 4 個隔離級別是怎么定義的。
- Read Uncommitted(未提交讀)
一個事務可以讀取到其 他事務未提交的數據,會出現臟讀,所以叫做 RU,它沒有解決任何的問題。 - Read Committed(已提交讀)
也就是一個事務只能讀取 到其他事務已提交的數據,不能讀取到其他事務未提交的數據,它解決了臟讀的問題, 但是會出現不可重復讀的問題。 - Repeatable Read (可重復讀)
它解決了不可重復讀的問題, 也就是在同一個事務里面多次讀取同樣的數據結果是一樣的,但是在這個級別下,沒有定義解決幻讀的問題
。 - Serializable(串行化)
在這個隔離級別里面,所有的事務都是串 行執行的,也就是對數據的操作需要排隊,已經不存在事務的并發操作了,所以它解決 了所有的問題。
這個是 SQL92 的標準,但是不同的數據庫廠商或者存儲引擎的實現有一定的差異, 比如 Oracle 里面就只有兩種 RC(已提交讀)和 Serializable(串行化)。那么 InnoDB 的實現又是怎么樣的呢?
MySQL InnoDB 對隔離級別的支持
在 MySQL InnoDB 里面,不需要使用串行化的隔離級別去解決所有問題。那我們來 看一下 MySQL InnoDB 里面對數據庫事務隔離級別的支持程度是什么樣的。
InnoDB 支持的四個隔離級別和 SQL92 定義的基本一致,隔離級別越高,事務的并 發度就越低。唯一的區別就在于,InnoDB 在 RR 的級別就解決了幻讀的問題。這個也是 InnoDB 默認使用 RR 作為事務隔離級別的原因,既保證了數據的一致性,又支持較高的 并發度。
兩大實現方案
那么大家想一下,如果要解決讀一致性的問題,保證一個事務中前后兩次讀取數據 結果一致,實現事務隔離,應該怎么做?我們有哪一些方法呢?你的思路是什么樣的呢? 總體上來說,我們有兩大類的方案。
1.LBCC
第一種,我既然要保證前后兩次讀取數據一致,那么我讀取數據的時候,鎖定我要 操作的數據,不允許其他的事務修改就行了。這種方案我們叫做基于鎖的并發控制 Lock Based Concurrency Control(LBCC)。
如果僅僅是基于鎖來實現事務隔離,一個事務讀取的時候不允許其他時候修改,那 就意味著不支持并發的讀寫操作,而我們的大多數應用都是讀多寫少的,這樣會極大地 影響操作數據的效率
2.MVCC
所以我們還有另一種解決方案,如果要讓一個事務前后兩次讀取的數據保持一致, 那么我們可以在修改數據的時候給它建立一個備份或者叫快照,后面再來讀取這個快照 就行了。這種方案我們叫做多版本的并發控制 Multi Version Concurrency Control (MVCC)。
MVCC 的核心思想是: 我可以查到在我這個事務開始之前已經存在的數據,即使它 在后面被修改或者刪除了。在我這個事務之后新增的數據,我是查不到的
問題:這個快照什么時候創建?讀取數據的時候,怎么保證能讀取到這個快照而不 是最新的數據?這個怎么實現呢
InnoDB 為每行記錄都實現了兩個隱藏字段:
DB_TRX_ID,6 字節:插入或更新行的最后一個事務的事務 ID,事務編號是自動遞 增的(我們把它理解為創建版本號
,在數據新增或者修改為新數據的時候,記錄當前事 務 ID)
DB_ROLL_PTR,7 字節:回滾指針(我們把它理解為刪除版本號,數據被刪除或記 錄為舊數據的時候,記錄當前事務 ID)。
我們把這兩個事務 ID 理解為版本號。
https://www.processon.com/view/link/5d29999ee4b07917e2e09298 MVCC 演示圖
其實這兩個字段大多數翻譯出來是時間,但是存儲是版本號:
在InnoDB中,會在每行數據后添加兩個額外的隱藏的值來實現MVCC,這兩個值一個記錄這行數據何時被創建,另外一個記錄這行數據何時過期(或者被刪除)。 在實際操作中,存儲的并不是時間,而是事務的版本號,每開啟一個新事務,事務的版本號就會遞增。
來自美團技術文章
第一個事務,初始化數據(檢查初始數據)
此時的數據,創建版本是當前事務 ID,刪除版本為空:
第二個事務,執行第 1 次查詢,讀取到兩條原始數據,這個時候事務 ID 是 2:
第三個事務,插入數據:
此時的數據,多了一條 tom,它的創建版本號是當前事務編號,3:
第二個事務,執行第 2 次查詢:
MVCC 的查找規則:只能查找創建版本小于等于當前事務 ID 的數據,和刪除版本大 于當前事務 ID 的行(或未刪除)。
也就是不能查到在我的事務開始之后插入的數據,tom 的創建 ID 大于 2,所以還是 只能查到兩條數據。
第四個事務,刪除數據,刪除了 id=2 jack 這條記錄:
此時的數據,jack 的刪除版本被記錄為當前事務 ID,4,其他數據不變:
在第二個事務中,執行第 3 次查詢:
查找規則:只能查找創建版本小于等于當前事務 ID 的數據,和刪除版本大于當前事 務 ID 的行(或未刪除)。
也就是,在我事務開始之后刪除的數據,所以 jack 依然可以查出來。所以還是這兩 條數據。
第五個事務,執行更新操作,這個事務事務 ID 是 5:
此時的數據,更新數據的時候,舊數據的刪除版本被記錄為當前事務 ID 5(undo), 產生了一條新數據,創建 ID 為當前事務 ID 5:
第二個事務,執行第 4 次查詢:
查找規則:只能查找創建版本小于等于當前事務 ID 的數據,和刪除版本大于當前事 務 ID 的行(或未刪除)。
因為更新后的數據 penyuyan 創建版本大于 2,代表是在事務之后增加的,查不出 來。
而舊數據 qingshan 的刪除版本大于 2,代表是在事務之后刪除的,可以查出來。
通過以上演示我們能看到,通過版本號的控制,無論其他事務是插入、修改、刪除, 第一個事務查詢到的數據都沒有變化
在 InnoDB 中,MVCC 是通過 Undo log 實現的。
Oracle、Postgres 等等其他數據庫都有 MVCC 的實現
需要注意,在 InnoDB 中,MVCC 和鎖是協同使用的,這兩種方案并不是互斥的。
第一大類解決方案是鎖,鎖又是怎么實現讀一致性的呢?
MySQL InnoDB 鎖的基本類型
https://dev.mysql.com/doc/refman/5.7/en/innodb-locking.html
官網把鎖分成了 8 類。所以我們把前面的兩個行級別的鎖(Shared and Exclusive Locks),和兩個表級別的鎖(Intention Locks)稱為鎖的基本模式。
后面三個 Record Locks、Gap Locks、Next-Key Locks,我們把它們叫做鎖的算法, 也就是分別在什么情況下鎖定什么范圍
鎖的粒度
我們講到 InnoDB 里面既有行級別的鎖,又有表級別的鎖,我們先來分析一下這兩種鎖定粒度的一些差異。
表鎖,顧名思義,是鎖住一張表;行鎖就是鎖住表里面的一行數據。鎖定粒度,表 鎖肯定是大于行鎖的。
那么加鎖效率,表鎖應該是大于行鎖還是小于行鎖呢?大于。為什么?表鎖只需要 直接鎖住這張表就行了,而行鎖,還需要在表里面去檢索這一行數據,所以表鎖的加鎖 效率更高。
第二個沖突的概率?表鎖的沖突概率比行鎖大,還是小?
大于,因為當我們鎖住一張表的時候,其他任何一個事務都不能操作這張表。但是 我們鎖住了表里面的一行數據的時候,其他的事務還可以來操作表里面的其他沒有被鎖 定的行,所以表鎖的沖突概率更大。
表鎖的沖突概率更大,所以并發性能更低,這里并發性能就是小于。
InnoDB 里面我們知道它既支持表鎖又支持行鎖,另一個常用的存儲引擎 MyISAM 支 持什么粒度的鎖?這是第一個問題。第二個就是 InnoDB 已經支持行鎖了,那么它也可 以通過把表里面的每一行都鎖住來實現表鎖,為什么還要提供表鎖呢?
要搞清楚這個問題,我們就要來了解一下 InnoDB 里面的基本的鎖的模式(lock mode),這里面有兩個行鎖和兩個表鎖。
共享鎖
第一個行級別的鎖就是我們在官網看到的 Shared Locks (共享鎖),我們獲取了 一行數據的讀鎖以后,可以用來讀取數據,所以它也叫做讀鎖,注意不要在加上了讀鎖 以后去寫數據,不然的話可能會出現死鎖的情況。而且多個事務可以共享一把讀鎖。那怎么給一行數據加上讀鎖呢?
我們可以用 select …… lock in share mode; 的方式手工加上一把讀鎖
釋放鎖有兩種方式,只要事務結束,鎖就會自動事務,包括提交事務和結束事務。
我們也來驗證一下,看看共享鎖是不是可以重復獲取。
排它鎖
第二個行級別的鎖叫做 Exclusive Locks(排它鎖),它是用來操作數據的,所以又 叫做寫鎖。只要一個事務獲取了一行數據的排它鎖,其他的事務就不能再獲取這一行數 據的共享鎖和排它鎖。
排它鎖的加鎖方式有兩種,第一種是自動加排他鎖。我們在操作數據的時候,包括 增刪改,都會默認加上一個排它鎖。
還有一種是手工加鎖,我們用一個 FOR UPDATE 給一行數據加上一個排它鎖,這個 無論是在我們的代碼里面還是操作數據的工具里面,都比較常用。
釋放鎖的方式跟前面是一樣的。
排他鎖的驗證:
這個是兩個行鎖,接下來就是兩個表鎖。
意向鎖
意向鎖是什么呢?我們好像從來沒有聽過,也從來沒有使用過,其實他們是由數據 庫自己維護的
也就是說,當我們給一行數據加上共享鎖之前,數據庫會自動在這張表上面加一個 意向共享鎖。
當我們給一行數據加上排他鎖之前,數據庫會自動在這張表上面加一個意向排他鎖。
反過來說:
如果一張表上面至少有一個意向共享鎖,說明有其他的事務給其中的某些數據行加 上了共享鎖。
如果一張表上面至少有一個意向排他鎖,說明有其他的事務給其中的某些數據行加 上了排他鎖。
select * from t2 where id =4 for update;
TABLE LOCK table gupao
.t2
trx id 24467lock mode IX
RECORD LOCKS space id 64 page no 3 n bits 72 index PRIMARY of table gupao
.t2
trx id 24467 lock_mode X locks rec but not gap
那么這兩個表級別的鎖存在的意義是什么呢?第一個,我們有了表級別的鎖,在 InnoDB 里面就可以支持更多粒度的鎖。它的第二個作用,我們想一下,如果說沒有意向鎖的話,當我們準備給一張表加上表鎖的時候,我們首先要做什么?是不是必須先要去 判斷有沒其他的事務鎖定了其中了某些行?如果有的話,肯定不能加上表鎖。那么這個 時候我們就要去掃描整張表才能確定能不能成功加上一個表鎖,如果數據量特別大,比如有上千萬的數據的時候,加表鎖的效率是不是很低?
但是我們引入了意向鎖之后就不一樣了。我只要判斷這張表上面有沒有意向鎖,如 果有,就直接返回失敗。如果沒有,就可以加鎖成功。所以 InnoDB 里面的表鎖,我們 可以把它理解成一個標志。就像火車上廁所有沒有人使用的燈,是用來提高加鎖的效率 的。
以上就是 MySQL 里面的 4 種基本的鎖的模式,或者叫做鎖的類型。
到這里我們要思考兩個問題,首先,鎖的作用是什么?它跟 Java 里面的鎖是一樣的, 是為了解決資源競爭的問題,Java 里面的資源是對象,數據庫的資源就是數據表或者數 據行。
所以鎖是用來解決事務對數據的并發訪問的問題的。
那么,鎖到底鎖住了什么呢?
當一個事務鎖住了一行數據的時候,其他的事務不能操作這一行數據,那它到底是 鎖住了這一行數據,還是鎖住了這一個字段,還是鎖住了別的什么東西呢?
行鎖的原理
沒有索引的表(假設鎖住記錄)
首先我們有三張表,一張沒有索引的 t1,一張有主鍵索引的 t2,一張有唯一索引的 t3。
CREATE TABLE `t1` (
`id` int(11) DEFAULT NULL,
`name` varchar(255) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO `t1` (`id`, `name`) VALUES (1, '1');
INSERT INTO `t1` (`id`, `name`) VALUES (2, '2');
INSERT INTO `t1` (`id`, `name`) VALUES (3, '3');
INSERT INTO `t1` (`id`, `name`) VALUES (4, '4');
CREATE TABLE `t2` (
`id` int(11) NOT NULL,
`name` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO `t2` (`id`, `name`) VALUES (1, '1');
INSERT INTO `t2` (`id`, `name`) VALUES (4, '4');
INSERT INTO `t2` (`id`, `name`) VALUES (7, '7');
INSERT INTO `t2` (`id`, `name`) VALUES (10, '10');
CREATE TABLE `t3` (
`id` int(11) ,
`name` varchar(255) ,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO `t3` (`id`, `name`) VALUES (1, '1');
INSERT INTO `t3` (`id`, `name`) VALUES (4, '4');
INSERT INTO `t3` (`id`, `name`) VALUES (7, '7');
INSERT INTO `t3` (`id`, `name`) VALUES (10, '10');
我們先假設 InnoDB 的鎖鎖住了是一行數據或者一條記錄。
我們先來看一下 t1 的表結構,它有兩個字段,int 類型的 id 和 varchar 類型的 name。 里面有 4 條數據,1、2、3、4。
現在我們在兩個會話里面手工開啟兩個事務。
在第一個事務里面,我們通過 where id =1 鎖住第一行數據。
在第二個事務里面,我們嘗試給 id=3 的這一行數據加鎖,大家覺得能成功嗎?
很遺憾,我們看到紅燈亮起,這個加鎖的操作被阻塞了。這就有點奇怪了,第一個 事務鎖住了 id=1 的這行數據,為什么我不能操作 id=3 的數據呢?
我們再來操作一條不存在的數據,插入 id=5。它也被阻塞了。實際上這里整張表都 被鎖住了。所以,我們的第一個猜想被推翻了,InnoDB 的鎖鎖住的應該不是 Record
那為什么在沒有索引或者沒有用到索引的情況下,會鎖住整張表?這個問題我們先 留在這里。
我們繼續看第二個演示。
有主鍵索引的表
我們看一下 t2 的表結構。字段是一樣的,不同的地方是 id 上創建了一個主鍵索引。 里面的數據是 1、4、7、10
第一種情況,使用相同的 id 值去加鎖,沖突;使用不同的 id 加鎖,可以加鎖成功。 那么,既然不是鎖定一行數據,
有沒有可能是鎖住了 id 的這個字段呢
?
唯一索引(假設鎖住字段)
我們看一下 t3 的表結構。字段還是一樣的, id 上創建了一個主鍵索引,name 上 創建了一個唯一索引。里面的數據是 1、4、7、10
在第一個事務里面,我們通過 name 字段去鎖定值是 4 的這行數據。
在第二個事務里面,嘗試獲取一樣的排它鎖,肯定是失敗的,這個不用懷疑。
在這里我們懷疑 InnoDB 鎖住的是字段,所以這次我換一個字段,用 id=4 去給這行 數據加鎖,大家覺得能成功嗎?
很遺憾,又被阻塞了,說明鎖住的是字段的這個推測也是錯的,否則就不會出現第 一個事務鎖住了 name,第二個字段鎖住 id 失敗的情況。
既然
鎖住的不是 record,也不是 column
,InnoDB 里面鎖住的到底是什么呢?在這 三個案例里面,我們要去分析一下他們的差異在哪里,也就是這三張表的結構,是什么 區別導致了加鎖的行為的差異?其實答案就是索引
。InnoDB 的行鎖,就是通過鎖住索引來實現的。
那么我們還有兩個問題沒有解決:
1、為什么表里面沒有索引的時候,鎖住一行數據會導致鎖表?
或者說,如果鎖住的是索引,一張表沒有索引怎么辦? 所以,一張表有沒有可能沒有索引?
1)如果我們定義了主鍵(PRIMARY KEY),那么 InnoDB 會選擇主鍵作為聚集索引。
2)如果沒有顯式定義主鍵,則 InnoDB 會選擇第一個不包含有 NULL 值的唯一索 引作為主鍵索引。
3)如果也沒有這樣的唯一索引,則 InnoDB 會選擇內置 6 字節長的 ROWID 作 為隱藏的聚集索引,它會隨著行記錄的寫入而主鍵遞增。
所以,為什么鎖表,是因為查詢沒有使用索引,會進行全表掃描,然后把每一個隱 藏的聚集索引都鎖住了
2、為什么通過唯一索引給數據行加鎖,主鍵索引也會被鎖住?
大家還記得在 InnoDB 里面,當我們使用輔助索引的時候,它是怎么檢索數據的嗎? 輔助索引的葉子節點存儲的是什么內容?
在輔助索引里面,索引存儲的是二級索引和主鍵的值。比如name=4,存儲的是name 的索引和主鍵 id 的值 4
而主鍵索引里面除了索引之外,還存儲了完整的數據。所以我們通過輔助索引鎖定 一行數據的時候,它跟我們檢索數據的步驟是一樣的,會通過主鍵值找到主鍵索引,然后也鎖定。
現在我們已經搞清楚 4 個鎖的基本類型和鎖的原理了,在官網上,還有 3 種鎖,我 們把它理解為鎖的算法。我們也來看下 InnoDB 在什么時候分別鎖住什么范圍。
鎖的算法
我們先來看一下我們測試用的表,t2,這張表有一個主鍵索引。
我們插入了 4 行數據,主鍵值分別是 1、4、7、10。
為了讓大家真正理解這三種行鎖算法的區別,我們需要了解一下三種范圍的概念。
因為我們用主鍵索引加鎖,我們這里的劃分標準就是主鍵索引的值。
這些數據庫里面存在的主鍵值,我們把它叫做 Record,記錄,那么這里我們就有 4 個 Record。
根據主鍵,這些存在的 Record 隔開的數據不存在的區間,我們把它叫做 Gap,間 隙,它是一個
左開右開
的區間。最后一個,間隙(Gap)連同它左邊的記錄(Record),我們把它叫做臨鍵的區間, 它是一個
左開右閉
的區間。t2 的主鍵索引,它是整型的,可以排序,所以才有這種區間。如果我的主鍵索引不 是整形,是字符怎么辦呢?字符可以排序嗎? 用 ASCII 碼來排序。
我們已經弄清楚了三個范圍的概念,下面我們就來看一下在不同的范圍下,行鎖是 怎么表現的。
記錄鎖(Record Lock)
第一種情況,當我們對于唯一性的索引(包括唯一索引和主鍵索引)使用等值查詢,精準匹配到一條記錄的時候,這個時候使用的就是記錄鎖。
比如 where id = 1 4 7 10 。
這個演示我們在前面已經看過了。我們使用不同的 key 去加鎖,不會沖突,它只鎖 住這個 record。
間隙鎖(Gap Lock)
第二種情況,當我們查詢的記錄不存在,沒有命中任何一個 record,無論是用等值 查詢還是范圍查詢的時候,它使用的都是間隙鎖。
舉個例子,where id >4 and id <7,where id = 6。
重復一遍,當查詢的記錄不存在的時候,使用間隙鎖。
注意,間隙鎖主要是阻塞插入 insert。相同的間隙鎖之間不沖突。
Gap Lock 只在 RR 中存在。如果要關閉間隙鎖,就是把事務隔離級別設置成 RC, 并且把 innodb_locks_unsafe_for_binlog 設置為 ON。
這種情況下除了外鍵約束和唯一性檢查會加間隙鎖,其他情況都不會用間隙鎖。
臨鍵鎖(Next_Key Lock)
第三種情況,當我們使用了范圍查詢,不僅僅命中了 Record 記錄,還包含了 Gap 間隙,在這種情況下我們使用的就是臨鍵鎖,它是 MySQL 里面默認的行鎖算法,相當于 記錄鎖+間隙鎖
組合加一起。
其他兩種退化的情況:
唯一性索引,等值查詢匹配到一條記錄的時候,退化成記錄鎖。
沒有匹配到任何記錄的時候,退化成間隙鎖。
比如我們使用>5 <9, 它包含了記錄不存在的區間,也包含了一個 Record 7。
臨鍵鎖,鎖住最后一個 key 的下一個左開右閉的區間。
select * from t2 where id >5 and id <=7 for update; -- 鎖住(4,7]和(7,10]
select * from t2 where id >8 and id <=10 for update; -- 鎖住 (7,10],(10,+∞)
為什么要鎖住下一個左開右閉的區間?——就是為了解決幻讀的問題
隔離級別的實現
所以,我們再回過頭來看下這張圖片,為什么 InnoDB 的 RR 級別能夠解決幻讀的 問題,就是用臨鍵鎖實現的。
我們再回過頭來看下這張圖片,這個就是MySQL InnoDB里面事務隔離級別的實現。
最后我們來總結一下四個事務隔離級別的實現:
- Read Uncommited
RU 隔離級別:不加鎖。 - Serializable
Serializable 所有的 select 語句都會被隱式的轉化為 select ... in share mode,會 和 update、delete 互斥。
這兩個很好理解,主要是 RR 和 RC 的區別?
- Repeatable Read
RR 隔離級別下,普通的 select 使用快照讀(snapshot read)
,底層使用MVCC
來實 現。
加鎖的 select(select ... in share mode / select ... for update)以及更新操作 update, delete 等語句使用當前讀(current read)
,底層使用記錄鎖
、或者間隙鎖
、臨鍵鎖
。 - Read Commited
RC 隔離級別下,普通的 select 都是快照讀,使用MVCC
實現。
加鎖的 select 都使用記錄鎖
,因為沒有 Gap Lock。
除了兩種特殊情況——外鍵約束檢查(foreign-key constraint checking)以及重復 鍵檢查(duplicate-key checking)時會使用間隙鎖
封鎖區間。 所以 RC 會出現幻讀
的問題。
事務隔離級別怎么選?
https://dev.mysql.com/doc/refman/5.7/en/innodb-transaction-isolation-levels.html
RU 和 Serializable 肯定不能用。為什么有些公司要用 RC,或者說網上有些文章推 薦有 RC?
RC 和 RR 主要有幾個區別:
1、 RR 的間隙鎖會導致鎖定范圍的擴大。
2、 條件列未使用到索引,RR 鎖表,RC 鎖行。
3、 RC 的“半一致性”(semi-consistent)讀可以增加 update 操作的并發性。
在 RC 中,一個 update 語句,如果讀到一行已經加鎖的記錄,此時 InnoDB 返回記 錄最近提交的版本,由 MySQL 上層判斷此版本是否滿足 update 的 where 條件。若滿 足(需要更新),則 MySQL 會重新發起一次讀操作,此時會讀取行的最新版本(并加鎖)。
實際上,如果能夠正確地使用鎖(避免不使用索引去枷鎖),只鎖定需要的數據, 用默認的 RR 級別就可以了。
在我們使用鎖的時候,有一個問題是需要注意和避免的,我們知道,排它鎖有互斥 的特性。一個事務或者說一個線程持有鎖的時候,會阻止其他的線程獲取鎖,這個時候 會造成阻塞等待,如果循環等待,會有可能造成死鎖。
這個問題我們需要從幾個方面來分析,一個是鎖為什么不釋放,第二個是被阻塞了 怎么辦,第三個死鎖是怎么發生的,怎么避免。
死鎖
鎖的釋放與阻塞
回顧:鎖什么時候釋放?
事務結束(commit,rollback);客戶端連接斷開。
如果一個事務一直未釋放鎖,其他事務會被阻塞多久?會不會永遠等待下去?如果 是,在并發訪問比較高的情況下,如果大量事務因無法立即獲得所需的鎖而掛起,會占 用大量計算機資源,造成嚴重性能問題,甚至拖跨數據庫。
[Err] 1205 - Lock wait timeout exceeded; try restarting transaction
MySQL 有一個參數來控制獲取鎖的等待時間,默認是 50 秒。
show VARIABLES like 'innodb_lock_wait_timeout';
對于死鎖,是無論等多久都不能獲取到鎖的,這種情況,也需要等待 50 秒鐘嗎?那 不是白白浪費了 50 秒鐘的時間嗎?
我們先來看一下什么時候會發生死鎖
死鎖的發生和檢測
在第一個事務中,檢測到了死鎖,馬上退出了,第二個事務獲得了鎖,不需要等待 50 秒:
[Err] 1213 - Deadlock found when trying to get lock; try restarting transaction
為什么可以直接檢測到呢?是因為死鎖的發生需要滿足一定的條件,所以在發生死 鎖時,InnoDB 一般都能通過算法(wait-for graph)自動檢測到。
那么死鎖需要滿足什么條件?死鎖的產生條件:
因為鎖本身是互斥的,(1)同一時刻只能有一個事務持有這把鎖,(2)其他的事 務需要在這個事務釋放鎖之后才能獲取鎖,而不可以強行剝奪,(3)當多個事務形成等 待環路的時候,即發生死鎖。
舉例:
理發店有兩個總監。一個負責剪頭的 Tony 總監,一個負責洗頭的 Kelvin 總監。
Tony 不能同時給兩個人剪頭,這個就叫互斥
。
Tony 在給別人在剪頭的時候,你不能讓他停下來幫你剪頭,這個叫不能強行剝奪
。
如果Tony的客戶對Kelvin總監說:你不幫我洗頭我怎么剪頭?Kelvin的客戶對Tony 總監說:你不幫我剪頭我怎么洗頭?這個就叫形成等待環路
如果鎖一直沒有釋放,就有可能造成大量阻塞或者發生死鎖,造成系統吞吐量下降, 這時候就要查看是哪些事務持有了鎖
查看鎖信息(日志)
SHOW STATUS 命令中,包括了一些行鎖的信息:
show status like 'innodb_row_lock_%';
Innodb_row_lock_current_waits:當前正在等待鎖定的數量;
Innodb_row_lock_time :從系統啟動到現在鎖定的總時間長度,單位 ms;
Innodb_row_lock_time_avg :每次等待所花平均時間;
Innodb_row_lock_time_max:從系統啟動到現在等待最長的一次所花的時間;
Innodb_row_lock_waits :從系統啟動到現在總共等待的次數。
SHOW 命令是一個概要信息。InnoDB 還提供了三張表來分析事務與鎖的情況:
select * from information_schema.INNODB_TRX; -- 當前運行的所有事務 ,還有具體的語句
select * from information_schema.INNODB_LOCKS; -- 當前出現的鎖
select * from information_schema.INNODB_LOCK_WAITS; -- 鎖等待的對應關系
找出持有鎖的事務之后呢?
如果一個事務長時間持有鎖不釋放,可以 kill 事務對應的線程 ID,也就是 INNODB_TRX 表中的 trx_mysql_thread_id,例如執行 kill 4,kill 7,kill 8。
當然,死鎖的問題不能每次都靠 kill 線程來解決,這是治標不治本的行為。我們應該 盡量在應用端,也就是在編碼的過程中避免。
有哪些可以避免死鎖的方法呢?
死鎖的避免
1、 在程序中,操作多張表時,盡量以相同的順序來訪問(避免形成等待環路);
2、 批量操作單張表數據的時候,先對數據進行排序(避免形成等待環路);
3、 申請足夠級別的鎖,如果要操作數據,就申請排它鎖;
4、 盡量使用索引訪問數據,避免沒有 where 條件的操作,避免鎖表;
5、 如果可以,大事務化成小事務;
6、 使用等值查詢而不是范圍查詢查詢數據,命中記錄,避免間隙鎖對并發的影響。
——學自咕泡學院