目錄
1. 鎖的概述
1.1 鎖的定義
鎖是計算機協調多個進程或線程併發訪問某一資源的機制。
在數據庫中,除了傳統的計算資源(如CPU、RAM、I/O等)的爭用以外,數據也是一種供需要用戶共享的資源。如何保證數據併發訪問的一致性、有效性是所有數據庫必須解決的一個問題,鎖衝突也是影響數據庫併發訪問性能的一個重要因素。從這個角度來說,鎖對數據庫而言顯得尤其重要,也更加複雜。
1.2 鎖的分類
- 從性能上分爲樂觀鎖(用版本對比來實現)和悲觀鎖
- 從對數據庫操作的類型分,分爲讀鎖和寫鎖(都屬於悲觀鎖)
讀鎖(共享鎖):Shared Locks(S鎖),針對同一份數據,多個讀操作可以同時進行而不會互相影響
寫鎖(排它鎖):Exclusive Locks(X鎖),當前寫操作沒有完成前,它會阻斷其他寫鎖和讀鎖
- 從對數據操作的粒度分,分爲表鎖,行鎖,間隙鎖
2. 三種鎖(表鎖、行鎖、間隙鎖)
2.1 表鎖(偏向於讀操作)
表鎖偏向MyISAM存儲引擎(該引擎只支持表鎖),開銷小,加鎖快,無死鎖,鎖定粒度大,發生鎖衝突的概率最高,併發度最低。
在對某個表執行SELECT、INSERT、DELETE、UPDATE語句時,InnoDB存儲引擎是不會爲這個表添加表級別的 S鎖或者X鎖的,如果想加表級鎖需要手動添加。
在對某個表執行ALTER TABLE、DROP TABLE這些DDL語句時,其他事務對這個表執行SELECT、INSERT、 DELETE、UPDATE的語句會發生阻塞,或者,某個事務對某個表執行SELECT、INSERT、DELETE、UPDATE語句時,其他事務對這個表執行DDL語句也會發生阻塞。這個過程是通過使用的元數據鎖(英文名:Metadata Locks,簡稱MDL)來實現的,並不是使用的表級別的S鎖和X鎖。
2.1.1 基本操作
- 建表SQL
CREATE TABLE `mylock` (
`id` INT (11) NOT NULL AUTO_INCREMENT,
`NAME` VARCHAR (20) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE = MyISAM DEFAULT CHARSET = utf8;
- 插入數據
INSERT INTO`test`.`mylock` (`id`, `NAME`) VALUES ('1', 'a');
INSERT INTO`test`.`mylock` (`id`, `NAME`) VALUES ('2', 'b');
INSERT INTO`test`.`mylock` (`id`, `NAME`) VALUES ('3', 'c');
INSERT INTO`test`.`mylock` (`id`, `NAME`) VALUES ('4', 'd');
- 手動增加表鎖(讀鎖/寫鎖)
lock table 表名稱 read/write,表名稱2 read/write;
- 查看錶上加過的鎖
show open tables;
- 刪除表鎖
unlock tables;
- LOCK TABLES t1 READ:對錶t1加表級別的S鎖。
- LOCK TABLES t1 WRITE:對錶t1加表級別的S鎖。
儘量不用這兩種方式去加鎖,因爲InnoDB的優點就是行鎖,所以儘量使用行鎖,性能更高。
2.1.2 案例分析(加讀鎖)
當前session(一個用戶就有一個session,其實就可以類比成線程,不同的session就是不同的線程)和其他session都可以讀該表
當前session中插入或者更新該表就會報錯,其他session插入或更新則會等待。
因爲讀鎖主要是用來做數據遷移的,在遷移過程中除了不能讓其他的session對數據進行修改,自己也不能對數據進行修改
2.1.3 案例分析(加寫鎖)
當前session對該表的增刪改查都沒有問題,其他session對該表的所有操作(包括讀操作)被阻塞
2.1.4 案例結論
MyISAM在執行查詢語句(SELECT)前,會自動給涉及的所有表加讀鎖,在執行增刪改操作前,會自動給涉及的表加寫鎖。
1、對MyISAM表的讀操作(加讀鎖) ,不會阻寒其他進程對同一表的讀請求,但會阻賽所有進程對錶的寫請求。只有當讀鎖釋放後,纔會執行其它進程的寫操作。
2、對MylSAM表的寫操作(加寫鎖) ,會阻塞其他進程對同一表的讀和寫操作,只有當寫鎖釋放後,纔會執行其它進程的讀寫操作。
總結:
簡而言之,就是讀鎖會阻塞寫,但是不會阻塞讀。而寫鎖則會把其他session的讀和寫都阻塞。
2.1.5 IS鎖和IX鎖
- IS鎖:意向共享鎖、Intention Shared Lock。當事務準備在某條記錄上加S鎖時,需要先在表級別加一個IS鎖。
- IX鎖,意向排他鎖、Intention Exclusive Lock。當事務準備在某條記錄上加X鎖時,需要先在表級別加一個IX鎖。
IS、IX鎖是表級鎖,它們的提出僅僅爲了在之後加表級別的S鎖和X鎖時可以快速判斷表中的記錄是否被上鎖,以避免用遍歷的方式來查看錶中有沒有上鎖的記錄。就是說當對一個行加鎖之後,如果有打算給行所在的表加一個表鎖,必須先看看該表的行有沒有被加鎖,否則就會出現衝突。IS鎖和IX鎖就避免了判斷表中行有沒有加鎖時對每一行的遍歷。直接查看錶有沒有意向鎖就可以知道表中有沒有行鎖。
注意:如果一個表中有多個行鎖,他們都會給表加上意向鎖,意向鎖和意向鎖之間是不會衝突的。
2.1.6 鎖的兼容性
|
IS |
IX |
S |
X |
IS |
兼容 |
兼容 |
兼容 |
不兼容 |
IX |
兼容 |
兼容 |
不兼容 |
不兼容 |
S |
兼容 |
不兼容 |
兼容 |
不兼容 |
X |
不兼容 |
不兼容 |
不兼容 |
不兼容 |
2.1.6 AUTO-INC鎖
- 在執行插入語句時就在表級別加一個AUTO-INC鎖,然後爲每條待插入記錄的AUTO_INCREMENT修飾的列分配遞增的值,在該語句執行結束後,再把AUTO-INC鎖釋放掉。這樣一個事務在持有 AUTO-INC鎖的過程中,其他事務的插入語句都要被阻塞,可以保證一個語句中分配的遞增值是連續的。
- 採用一個輕量級的鎖,在爲插入語句生成AUTO_INCREMENT修飾的列的值時獲取一下這個輕量級 鎖,然後生成本次插入語句需要用到的AUTO_INCREMENT列的值之後,就把該輕量級鎖釋放掉, 並不需要等到整個插入語句執行完才釋放鎖。
系統變量innodb_autoinc_lock_mode:
- innodb_autoinc_lock_mode值爲0:採用AUTO-INC鎖。
- innodb_autoinc_lock_mode值爲2:採用輕量級鎖。
- 當innodb_autoinc_lock_mode值爲1:當插入記錄數不確定是採用AUTO-INC鎖,當插入記錄數確定時採用輕量級鎖
2.2 行鎖(偏寫)
行鎖偏向InnoDB存儲引擎(該引擎支持行鎖和表鎖),開銷大,加鎖慢,會出現死鎖,鎖定粒度最小,發生鎖衝突的概率最低,併發度也最高。InnoDB與MYISAM的最大不同有兩點:一是支持事務(TRANSACTION);二是採用了行級鎖。
行鎖的種類:
- LOCK_REC_NOT_GAP:單個行記錄上的鎖。
- LOCK_GAP:間隙鎖,鎖定一個範圍,但不包括記錄本身。比如鎖定a=5以及其前後2個範圍內的數據,也就是將a=3,4,6,7這些行都鎖了起來,不包括a=5本身。GAP鎖的目的,是爲了防止同一事務的兩次當前讀,出現幻讀的情況。
- LOCK_ORDINARY:鎖定一個範圍,並且鎖定記錄本身。比如鎖定a=5以及其前後2個範圍內的數據,也就是將a=3,4,5,6,7這些行都鎖了起來。對於行的查詢,都是採用該方法,主要目的是解決幻讀的問題。
2.2.1 事務的相關使用方法
2.2.1.1 開啓事務
- begin[work];
BEGIN語句代表開啓一個事務,後邊的單詞WORK可有可無。開啓事務後,就可以繼續寫若干條語句,這些語句 都屬於剛剛開啓的這個事務。
mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)
mysql> sql...
- START TRANSACTION
START TRANSACTION語句和BEGIN語句有着相同的功效,都標誌着開啓一個事務,比如這樣:
mysql> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)
mysql> sql..
2.2.1.2 提交事務
COMMIT
mysql> BEGIN; Query OK, 0 rows affected (0.00 sec)
mysql> UPDATE account SET balance = balance - 10 WHERE id = 1;
Query OK, 1 row affected (0.02 sec) Rows matched: 1 Changed: 1 Warnings: 0
mysql> UPDATE account SET balance = balance + 10 WHERE id = 2;
Query OK, 1 row affected (0.00 sec) Rows matched: 1 Changed: 1 Warnings: 0
mysql> COMMIT;
Query OK, 0 rows affected (0.00 sec)
2.2.1.3 手動中止事務
ROLLBACK
mysql> BEGIN; Query OK, 0 rows affected (0.00 sec)
mysql> UPDATE account SET balance = balance - 10 WHERE id = 1;
Query OK, 1 row affected (0.00 sec) Rows matched: 1 Changed: 1 Warnings: 0
mysql> UPDATE account SET balance = balance + 1 WHERE id = 2;
Query OK, 1 row affected (0.00 sec) Rows matched: 1 Changed: 1 Warnings: 0
mysql> ROLLBACK; Query OK, 0 rows affected (0.00 sec)
這裏需要強調一下,ROLLBACK語句是我們程序員手動的去回滾事務時纔去使用的,如果事務在執行過程中遇到 了某些錯誤而無法繼續執行的話,事務自身會自動的回滾
2.2.1.4 自動提交事務
mysql> SHOW VARIABLES LIKE 'autocommit';
默認情況下,如果我們不顯式的使用START TRANSACTION或者BEGIN語句開啓一個事務,那麼每一條語句都算 是一個獨立的事務,這種特性稱之爲事務的自動提交。
如果我們想關閉這種自動提交的功能,可以使用下邊兩種方法之一:
- 顯式的的使用START TRANSACTION或者BEGIN語句開啓一個事務。這樣在本次事務提交或者回滾前會暫時關閉掉自動提交的功能。
- 把系統變量autocommit的值設置爲OFF,就像這樣: SET autocommit = OFF; 這樣的話,我 們寫入的多條語句就算是屬於同一個事務了,直到我們顯式的寫出COMMIT語句來把這個事務提交掉,或者顯式的寫出ROLLBACK語句來把這個事務回滾掉。
2.2.1.5 隱式提交
當我們使用START TRANSACTION或者BEGIN語句開啓了一個事務,或者把系統變量autocommit的值設置爲 OFF時,事務就不會進行自動提交,但是如果我們輸入了某些語句之後就會悄悄的提交掉,就像我們輸入了 COMMIT語句了一樣,這種因爲某些特殊的語句而導致事務提交的情況稱爲隱式提交,這些會導致事務隱式提交 的語句包括:
- 定義或修改數據庫對象的數據定義語言(Data definition language,縮寫爲:DDL)。所謂的數據庫對象,指的就是數據庫、表、視圖、存儲過程等等這些東西。當我們使用CREATE、ALTER、 DROP等語句去修改這些所謂的數據庫對象時,就會隱式的提交前邊語句所屬於的事務。
- 隱式使用或修改mysql數據庫中的表:當我們使用ALTER USER、CREATE USER、DROP USER、 GRANT、RENAME USER、SET PASSWORD等語句時也會隱式的提交前邊語句所屬於的事務。
- 事務控制或關於鎖定的語句:當我們在一個事務還沒提交或者回滾時就又使用START TRANSACTION或者BEGIN語句開啓了另一個事務時,會隱式的提交上一個事務。或者當前的 autocommit系統變量的值爲OFF,我們手動把它調爲ON時,也會隱式的提交前邊語句所屬的事 務。或者使用LOCK TABLES、UNLOCK TABLES等關於鎖定的語句也會隱式的提交前邊語句所屬 的事務。
- 加載數據的語句:比如我們使用LOAD DATA語句來批量往數據庫中導入數據時,也會隱式的提交 前邊語句所屬的事務。
- 其它的一些語句:使用ANALYZE TABLE、CACHE INDEX、CHECK TABLE、FLUSH、 LOAD INDEX INTO CACHE、OPTIMIZE TABLE、REPAIR TABLE、RESET等語句也會隱式的提交前邊語句所屬的事務
2.2.1.6 保存點
如果你開啓了一個事務,並且已經敲了很多語句,忽然發現上一條語句有點問題,你只好使用ROLLBACK語句來 讓數據庫狀態恢復到事務執行之前的樣子,然後一切從頭再來,總有一種一夜回到解放前的感覺。所以MYSQL 提出了一個保存點(英文:savepoint)的概念,就是在事務對應的數據庫語句中打幾個點,我們在調用 ROLLBACK語句時可以指定會滾到哪個點,而不是回到最初的原點。定義保存點的語法如下:
SAVEPOINT 保存點名稱;
當我們想回滾到某個保存點時,可以使用下邊這個語句(下邊語句中的單詞WORK和SAVEPOINT是可有可無 的):
ROLLBACK [WORK] TO [SAVEPOINT] 保存點名稱;
不過如果ROLLBACK語句後邊不跟隨保存點名稱的話,會直接回滾到事務執行之前的狀態。 如果我們想刪除某個保存點,可以使用這個語句:
RELEASE SAVEPOINT 保存點名稱;
2.2.2 行鎖支持事務
- 事務(Transaction)及其ACID屬性
事務是由一組SQL語句組成的邏輯處理單元,事務具有以下4個屬性,通常簡稱爲事務的ACID屬性。我們把需要保證原子性、隔離性、一致性和持久性的一個或多個數據庫操作稱之爲一個事務。
- 原子性(Atomicity) :事務是一個原子操作單元,其對數據的修改,要麼全都執行,要麼全都不執行。
- 一致性(Consistent) :在事務開始和完成時,數據都必須保持一致狀態。這意味着所有相關的數據規則都必須應用於事務的修改,以保持數據的完整性;事務結束時,所有的內部數據結構(如B樹索引或雙向鏈表)也都必須是正確的。如果不一致會出現同一個事務上半部分使用到的數據和下半部分不一致,也就出現了同一個事物前後使用數據不一致的情況,造成事務出現混亂。
- 隔離性(Isolation) :數據庫系統提供一定的隔離機制,保證事務在不受外部併發操作影響的“獨立”環境執行。這意味着事務處理過程中的中間狀態對外部是不可見的,反之亦然。
- 持久性(Durable) :事務完成之後,它對於數據的修改是永久性的,即使出現系統故障也能夠保持。
以一個小明轉賬的場景來逐個分析事務和ACID屬性:
事務:
場景:小明向小強轉賬10元
原子性:
轉賬操作是一個不可分割的操作,要麼轉失敗,要麼轉成功,不能存在中間的狀態,也就是轉了一半的這種情 況。我們把這種要麼全做,要麼全不做的規則稱之爲原子性。
隔離性:
另外一個場景:
1. 小明向小強轉賬10元
2. 小明向小紅轉賬10元
隔離性表示上面兩個操作是不能相互影響的
一致性:
對於上面的轉賬場景,一致性表示每一次轉賬完成後,都需要保證整個系統的餘額等於所有賬戶的收入減去所有 賬戶的支出。 如果不遵循原子性,也就是如果小明向小強轉賬10元,但是隻轉了一半,小明賬戶少了10元,小強賬戶並沒有增加,所以沒有滿足一致性了。
同樣,如果不滿足隔離性,也有可能導致破壞一致性。
所以說,數據庫某些操作的原子性和隔離性都是保證一致性的一種手段,在操作執行完成後保證符合所有既定的 約束則是一種結果。
實際上我們也可以對錶建立約束來保證一致性。
持久性:
對於轉賬的交易記錄,需要永久保存。
- 併發事務處理帶來的問題
更新丟失(Lost Update)
當兩個或多個事務選擇同一行,然後基於最初選定的值更新該行時,由於每個事務都不知道其他事務的存在(隔離性),就會發生丟失更新問題–最後的更新覆蓋了由其他事務所做的更新。可以用樂觀鎖引入版本概念來解決。
髒讀(Dirty Reads)
一個事務正在對一條記錄做修改,在這個事務完成並提交前,這條記錄的數據就處於不一致的狀態;這時,另一個事務也來讀取同一條記錄,如果不加控制,第二個事務讀取了這些“髒”數據,並據此作進一步的處理,就會產生未提交的數據依賴關係。這種現象被形象的叫做“髒讀”。
一句話:事務A讀取到了事務B已經修改但尚未提交的數據,還在這個數據基礎上做了操作。此時,如果B事務回滾,A讀取的數據無效,不符合一致性要求。
不可重複讀(Non-Repeatable Reads)
一個事務在讀取某些數據後的某個時間,再次讀取以前讀過的數據,卻發現其讀出的數據已經發生了改變、或某些記錄已經被刪除了!這種現象就叫做“不可重複讀”。
一句話:事務A讀取到了事務B已經提交的修改數據,不符合隔離性
幻讀(Phantom Reads)
一個事務按相同的查詢條件重新讀取以前檢索過的數據,卻發現其他事務插入了滿足其查詢條件的新數據,上一次讀取還沒有,下一次讀取就有了,好像出現了“幻覺”,這種現象就稱爲“幻讀”。
一句話:事務A讀取到了事務B提交的新增數據,不符合隔離性
髒讀是事務B裏面修改了數據
幻讀是事務B裏面新增了數據
- 事務隔離級別
“髒讀”、“不可重複讀”和“幻讀”,其實都是數據庫讀一致性問題,必須由數據庫提供一定的事務隔離機制來解決。
數據庫的事務隔離越嚴格,併發副作用越小,但付出的代價也就越大,因爲事務隔離實質上就是使事務在一定程度上“串行化”進行,這顯然與“併發”是矛盾的。
同時,不同的應用對讀一致性和事務隔離程度的要求也是不同的,比如許多應用對“不可重複讀"和“幻讀”並不敏感,可能更關心數據併發訪問的能力。
注意:這四種隔離級別是SQL的標準定義,不同的數據庫會有不同的實現。
常看當前數據庫的事務隔離級別: show variables like 'tx_isolation';
設置事務隔離級別:set tx_isolation='REPEATABLE-READ';
每一個事務都會遵守設置好的事務隔離級別,MySQL默認隔離級別是可重複讀
2.2.3 行鎖案例分析
用下面的表演示,需要開啓事務,Session_1更新某一行,Session_2更新同一行被阻塞,但是更新其他行正常
2.2.4 隔離級別案例分析
CREATE TABLE `account` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) DEFAULT NULL,
`balance` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO `test`.`account` (`name`, `balance`) VALUES ('lilei', '450');
INSERT INTO `test`.`account` (`name`, `balance`) VALUES ('hanmei', '16000');
INSERT INTO `test`.`account` (`name`, `balance`) VALUES ('lucy', '2400');
1、讀未提交:
(1)打開一個客戶端A,並設置當前事務模式爲read uncommitted(未提交讀),查詢表account的初始值: 兩個客戶端都要修改事務隔離級別
set tx_isolation='read-uncommitted';
(2)在客戶端A的事務提交之前,打開另一個客戶端B,更新表account:
(3)這時,雖然客戶端B的事務還沒提交,但是客戶端A就可以查詢到B已經更新的數據:
(4)一旦客戶端B的事務因爲某種原因回滾,所有的操作都將會被撤銷,那客戶端A查詢到的數據其實就是髒數據:
(5)在客戶端A執行更新語句update account set balance = balance - 50 where id =1,lilei的balance沒有變成350,居然是400,是不是很奇怪,數據不一致啊,如果你這麼想就太天真 了,在應用程序中,我們會用400-50=350,並不知道其他會話回滾了,要想解決這個問題可以採用讀已提交的隔離級別
2、讀已提交
(1)打開一個客戶端A,並設置當前事務模式爲read committed(未提交讀),查詢表account的所有記錄:
set tx_isolation='read-committed';
(2)在客戶端A的事務提交之前,打開另一個客戶端B,更新表account:
(3)這時,客戶端B的事務還沒提交,客戶端A不能查詢到B已經更新的數據,解決了髒讀問題:
(4)客戶端B的事務提交
(5)客戶端A執行與上一步相同的查詢,結果 與上一步不一致,即產生了不可重複讀的問題
3、可重複讀
(1)打開一個客戶端A,並設置當前事務模式爲repeatable read,查詢表account的所有記錄
set tx_isolation='repeatable-read';
(2)在客戶端A的事務提交之前,打開另一個客戶端B,更新表account並提交
(3)在客戶端A查詢表account的所有記錄,與步驟(1)查詢結果一致,沒有出現不可重複讀的問題
(4)在客戶端A,接着執行update account set balance = balance - 50 where id = 1,balance沒有變成400-50=350,lilei的balance值用的是步驟(2)中的350來算的,所以是300,數據的一致性倒是沒有被破壞。可重複讀的隔離級別下使用了MVCC機制(多版本併發控制機制),select操作不會更新版本號,是快照讀(歷史版本),讀快照的數據比讀真是在數據庫中的最新數據速度要快;insert、update和delete會更新版本號,是當前讀(當前版本)。可以解決超賣問題。
以後些項目的時候在MySQL默認事務隔離級別:可重複讀下不要先用select查詢到數據A的值a1,然後在Java代碼中將查詢出來的數據a1做加減操作後再寫回到數據庫的A數據中,這樣是有可能出現併發問題的。最正確的方式就是直接用數據庫的update語句來直接更新數據,這樣使用的A值纔會使真正的A值,而不會出現使用歷史數據(快照)
(5)重新打開客戶端B,插入一條新數據後提交
(6)在客戶端A查詢表account的所有記錄,沒有 查出 新增數據,所以沒有出現幻讀
(7)驗證幻讀
在客戶端A執行update account set balance=888 where id = 4;能更新成功,再次查詢能查到客戶端B新增的數據
因爲可重複讀只是在讀方面直接讀取快照,但是當使用修改,刪除等語句的時候,數據庫就會直接去讀取在數據庫存儲的最新真實數據,這就出現了幻讀現象。
4.串行化
(1)打開一個客戶端A,並設置當前事務模式爲serializable,查詢表account的初始值:
set tx_isolation='serializable';
mysql> set session transaction isolation level serializable;
Query OK, 0 rows affected (0.00 sec)
mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from account;
+------+--------+---------+
| id | name | balance |
+------+--------+---------+
| 1 | lilei | 10000 |
| 2 | hanmei | 10000 |
| 3 | lucy | 10000 |
| 4 | lily | 10000 |
+------+--------+---------+
4 rows in set (0.00 sec)
(2)打開一個客戶端B,並設置當前事務模式爲serializable,插入一條記錄報錯,表被鎖了插入失敗,mysql中事務隔離級別爲serializable時會鎖表,因此不會出現幻讀的情況,將所有的併發操作都進行了同步操作變成了串行了,同一個表在同一時間只能被同一個session操作,這種隔離級別併發性極低,開發中很少會用到。注意:兩個session都是讀的時候,串行化不會阻塞session,只有出現修改操作纔會阻塞
mysql> set session transaction isolation level serializable;
Query OK, 0 rows affected (0.00 sec)
mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)
mysql> insert into account values(5,'tom',0);
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
2.2.5 間隙鎖
Mysql默認級別是repeatable-read,有辦法解決幻讀問題嗎?
間隙鎖在某些情況下可以解決幻讀問題
除了串行化的事務隔離級別,要避免幻讀可以用間隙鎖在Session_1下面執行
update account set name = 'zhuge' where id > 10 and id <=20;,
則其他Session沒法插入這個範圍內的數據
間隙鎖的官方定義:
當我們用範圍條件條件檢索數據(非聚簇索引、非唯一索引),並請求共享或排他鎖時,InnoDB會給符合條件的數據記錄的索引項加鎖;對於鍵值在條件範圍內但並不存在的記錄,稱爲間隙,InnoDB也會爲這些間隙加鎖,即間隙鎖。
Next-Key鎖是符合條件的行鎖加上間隙鎖
2.2.5.1 間隙鎖產生的條件
在InnoDB下,間隙鎖的產生需要滿足三個條件:
- 隔離級別爲RR
- 當前讀
- 查詢條件能夠走到索引
2.2.5.2 間隙鎖的作用
MySQL官方文檔:間隙鎖的目的是爲了讓其他事務無法在間隙中新增數據。
在RR模式的InnoDB中,間隙鎖能起到兩個作用:
1. 保障數據的恢復和複製
2. 防止幻讀
- 防止在間隙中執行insert語句
- 防止將已有數據update到間隙中
數據庫數據的恢復和複製是通過binlog(用來實現數據庫恢復和複製的一個日誌文件)實現的,binlog中記錄了執行成功的DML語句(在阿里得到了極廣泛的應用),在數據恢復時需要保證數據之間的事務順序,間隙鎖可以避免在一批數據中插入其他事務。
2.2.6 MVCC機制原理
2.2.6.1 版本鏈
對於使用InnoDB存儲引擎的表來說,它的聚簇索引記錄中都包含兩個必要的隱藏列(row_id並不是必要的,我們 創建的表中有主鍵或者非NULL唯一鍵時都不會包含row_id列):
- trx_id:每次對某條記錄進行改動時,都會把對應的事務id賦值給trx_id隱藏列。
- roll_pointer:每次對某條記錄進行改動時,這個隱藏列會存一個指針,可以通過這個指針找到該記 錄修改前的信息。
版本鏈示意圖:
版本鏈是從上到下依次鏈接的,每一層就代表的一個事務對某一行進行的操作。最上面是最新的事務版本,最下面是最老的事務版本。
每一層的最右邊是一個指針(roll_pointer),指向的是當前事務修改前的記錄。這個指針也可以用來回滾事務。
每一層右數第二個是事務id(trx_id),是事務的唯一標識。版本年的事務id時遞增的,事務id越大,表示事務越新。
每一層剩下的就是真實的行數據(也有可能包含數據庫自己生成的row_id)。
2.2.6.2 ReadView
對於使用READ UNCOMMITTED(讀未提交)隔離級別的事務來說,直接讀取記錄的最新版本就好了,對於使用 SERIALIZABLE隔離級別的事務來說,使用加鎖的方式來訪問記錄。對於使用READ COMMITTED和 REPEATABLE READ隔離級別的事務來說,就需要用到我們上邊所說的版本鏈了,核心問題就是:需要判斷一下版本鏈中的哪個版本是當前事務可見的。爲了實現這個功能,就引入了ReadView。
ReadView中主要包含4個比較重要的內容:
- m_ids:表示在生成ReadView時當前系統中活躍的讀寫事務的事務id列表。就可以理解爲裏面存的是還沒有提交的事務id。
- min_trx_id:表示在生成ReadView時當前系統中活躍的讀寫事務中最小的事務id,也就是m_ids中的最小 值。 就是還未提交的事務中最老的一個事務。
- max_trx_id:表示生成ReadView時系統中應該分配給下一個事務的id值。
- creator_trx_id:表示生成該ReadView的事務的事務id。
ReadView會在事務進行select的時候被創建,每一個事務都會有一個自己的ReadView,但是增刪改操作不會使用ReadView,只有查詢操作會使用。
注意max_trx_id並不是m_ids中的最大值,事務id是遞增分配的。比方說現在有id爲1,2,3這三個事務,都做出了修改但是還未提交,之後id爲3的事務提交了。那麼一個新的讀事務在生成ReadView時,m_ids就包括1和2,min_trx_id的值就是1, max_trx_id的值就是4。
有了這個ReadView,這樣在訪問某條記錄時,只需要按照下邊的步驟判斷記錄的某個版本是否可見:
- 如果被訪問版本的trx_id屬性值與ReadView中的creator_trx_id值相同,意味着當前事務在訪問它自己修改過的記錄,所以該版本可以被當前事務訪問。
- 如果被訪問版本的trx_id屬性值小於ReadView中的min_trx_id值,表明生成該版本的事務在當前事務生成ReadView前已經提交,所以該版本可以被當前事務訪問。
- 如果被訪問版本的trx_id屬性值大於ReadView中的max_trx_id值,表明生成該版本的事務在當前事務生成ReadView後纔開啓,所以該版本不可以被當前事務訪問。
- 如果被訪問版本的trx_id屬性值在ReadView的min_trx_id和max_trx_id之間,那就需要判斷一下 trx_id屬性值是不是在m_ids列表中,如果在,說明創建ReadView時生成該版本的事務還是活躍的(未提交),該版本不可以被訪問;如果不在,說明創建ReadView時生成該版本的事務已經被提交,該版本可以被訪問。
2.2.6.3 讀已提交(READ COMMITTED)的實現方式
每次讀取數據前都生成一個ReadView,然後查詢版本鏈中的事務id,從上面的事務開始向下找,找到的第一個事務id不在ReadView的m_ids中的,讀取版本鏈中該事務中的真實行數據。即讀最新的已提交事務的行數據。
2.2.6.4 可重複讀(REPEATABLE READ)的實現方式
在第一次讀取數據時生成一個ReadView,然後後面的所有讀操作都是用第一次生成的這個ReadView。這樣只會讀取第一次讀數據時最新的已提交數據,就算是後來那些存在ReadView的m_ids的事務提交了,已經從m_ids中去除了,但是後面的查詢操作還是會使用最開始的ReadView,認爲他們還沒有提交,這也就實現了可重複讀。
2.2.6.5 MVCC總結
MVCC(Multi-Version Concurrency Control ,多版本併發控制)指的就是在使用READ COMMITTD、 REPEATABLE READ這兩種隔離級別的事務在執行普通的SEELCT操作時(其他的修改錯做不會使用MVCC)訪問記錄的版本鏈的過程。可以使不同事務的讀-寫、寫-讀操作併發執行,從而提升系統性能。READ COMMITTD、REPEATABLE READ這兩個隔離級 別的一個很大不同就是:生成ReadView的時機不同,READ COMMITTD在每一次進行普通SELECT操作前都會 生成一個ReadView,而REPEATABLE READ只在第一次進行普通SELECT操作前生成一個ReadView,之後的查 詢操作都重複使用這個ReadView就好了。
2.2.7 案例結論
Innodb存儲引擎由於實現了行級鎖定,雖然在鎖定機制的實現方面所帶來的性能損耗可能比表級鎖定會要更高一下,但是在整體併發處理能力方面要遠遠優於MYISAM的表級鎖定的。當系統併發量高的時候,Innodb的整體性能和MYISAM相比就會有比較明顯的優勢了。
但是,Innodb的行級鎖定同樣也有其脆弱的一面,當我們使用不當的時候,可能會讓Innodb的整體性能表現不僅不能比MYISAM高,甚至可能會更差。
2.2.8 行鎖分析
通過檢查InnoDB_row_lock狀態變量來分析系統上的行鎖的爭奪情況
show status like'innodb_row_lock%';
對各個狀態量的說明如下:
Innodb_row_lock_current_waits: 當前正在等待鎖定的數量
Innodb_row_lock_time: 從系統啓動到現在鎖定總時間長度
Innodb_row_lock_time_avg: 每次等待所花平均時間
Innodb_row_lock_time_max:從系統啓動到現在等待最長的一次所花時間
Innodb_row_lock_waits:系統啓動後到現在總共等待的次數
對於這5個狀態變量,比較重要的主要是:
Innodb_row_lock_time_avg (等待平均時長)
Innodb_row_lock_waits (等待總次數)
Innodb_row_lock_time(等待總時長)
尤其是當等待次數很高,而且每次等待時長也不小的時候,我們就需要分析系統中爲什麼會有如此多的等待,然後根據分析結果着手製定優化計劃。
2.2.9 死鎖
set tx_isolation='repeatable-read';
Session_1執行:select * from account where id=1 for update;
Session_2執行:select * from account where id=2 for update;
Session_1執行:select * from account where id=2 for update;
Session_2執行:select * from account where id=1 for update;
查看近期死鎖日誌信息:show engine innodb status\G;
大多數情況mysql可以自動檢測死鎖並回滾產生死鎖的那個事務,但是有些情況mysql沒法自動檢測死鎖
2.2.10 優化建議
- 儘可能讓所有數據檢索都通過索引來完成,避免無索引行鎖升級爲表鎖
- 合理設計索引,儘量縮小鎖的範圍
- 儘可能減少檢索條件,避免間隙鎖
- 儘量控制事務大小,減少鎖定資源量和時間長度
- 儘可能低級別事務隔離
3. 讀鎖和寫鎖
上文講了讀鎖(S鎖)和寫鎖(X鎖)
S鎖和X鎖的衝突情況:
|
X鎖 |
S鎖 |
X鎖 |
衝突 |
衝突 |
S鎖 |
衝突 |
不衝突 |
3.1 讀操作
對於普通 SELECT 語句,InnoDB 不會加任何鎖。如果select語句想要加鎖需要手動添加。
3.1.1 select 加讀鎖
- select … lock in share mode
將查找到的數據加上一個S鎖,允許其他事務繼續獲取這些記錄的S鎖,不能獲取這些記錄的X鎖(會阻塞)
3.1.2 select 加寫鎖
- select … for update
將查找到的數據加上一個X鎖,不允許其他事務獲取這些記錄的S鎖和X鎖。
3.2 寫操作
對於寫操作,InnoDB會自動爲其加讀鎖,
- DELETE:刪除一條數據時,先對記錄加X鎖,再執行刪除操作。
- INSERT:插入一條記錄時,會先加隱式鎖 隱式鎖來保護這條新插入的記錄在本事務提交前不被別的事務 訪問到。
- UPDATE:
- 如果被更新的列,修改前後沒有導致存儲空間變化,那麼會先給記錄加X鎖,再直接對記錄進行修改。
- 如果被更新的列,修改前後導致存儲空間發生了變化,那麼會先給記錄加X鎖,然後將記錄刪掉,再Insert一條新記錄
隱式鎖:一個事務插入一條記錄後,還未提交,這條記錄會保存本次事務id,而其他事務如果想來讀取這個記錄會發現事務id不對應,所以相當於在插入一條記錄時,隱式的給這條記錄加了一把隱式鎖
4. 悲觀鎖和樂觀鎖
4.1 悲觀鎖
悲觀鎖(Pessimistic Locking),悲觀鎖是指在數據處理過程,使數據處於鎖定狀態,一般使用數據庫的鎖機制實現。
悲觀鎖用的就是數據庫的行鎖,認爲數據庫會發生併發衝突,直接上來就把數據鎖住,其他事務不能修改,直至提交了當前事務。
現在互聯網高併發的架構中,受到fail-fast思路的影響,悲觀鎖已經非常少見了。上面講的那些讀鎖和寫鎖都是悲觀鎖,所以在這裏就不再贅述。
4.2 樂觀鎖
樂觀鎖相對悲觀鎖而言,它認爲數據一般情況下不會造成衝突,所以在數據進行提交更新的時候,纔會正式對數據的衝突與否進行檢測,如果發現衝突了,則讓返回錯誤信息,讓用戶決定如何去做。
樂觀鎖其實是一種思想,認爲不會鎖定的情況下去更新數據,如果發現不對勁,纔不更新(回滾)。在數據庫中往往添加一個version字段(版本號)來實現。樂觀鎖可以用來避免更新丟失。接下來我們看一下樂觀鎖在數據表和緩存中的實現。
4.2.1 數據表中的實現
利用數據版本號(version)機制是樂觀鎖最常用的一種實現方式。一般通過爲數據庫表增加一個數字類型的 “version” 字段,當讀取數據時,將version字段的值一同讀出,數據每更新一次,對此version值+1。當我們提交更新的時候,判斷數據庫表對應記錄的當前版本信息與第一次取出來的version值進行比對,如果數據庫表當前版本號與第一次取出來的version值相等,則予以更新,否則認爲是過期數據,返回更新失敗。
例子:
//step1: 查詢出商品信息
select (quantity,version) from items where id=100;
//step2: 根據商品信息生成訂單
insert into orders(id,item_id) values(null,100);
//step3: 修改商品的庫存
update items set quantity=quantity-1,version=version+1 where id=100 and version=#{version};
既然可以用version,那還可以使用時間戳字段,該方法同樣是在表中增加一個時間戳字段,和上面的version類似,也是在更新提交的時候檢查當前數據庫中數據的時間戳和自己更新前取到的時間戳進行對比,如果一致則OK,否則就是版本衝突。
需要注意的是,如果你的數據表是讀寫分離的表,當master表中寫入的數據沒有及時同步到slave表中時會造成更新一直失敗的問題。此時,需要強制讀取master表中的數據(將select語句放在事務中)。即:把select語句放在事務中,查詢的就是master主庫了!
4.2.2 樂觀鎖的鎖粒度
樂觀鎖廣泛用於狀態同步,我們經常會遇到併發對一條物流訂單修改狀態的場景,所以此時樂觀鎖就發揮了巨大作用。但是樂觀鎖字段的選用也需要非常講究,一個好的樂觀鎖字段可以縮小鎖粒度。
商品庫存扣減時,尤其是在秒殺、聚划算這種高併發的場景下,若採用version號作爲樂觀鎖,則每次只有一個事務能更新成功,業務感知上就是大量操作失敗。因爲version的粒度太大,更新失敗的概率也就會變大。
但是如果我們挑選庫存字段作爲樂觀鎖(通過比較庫存數來判斷數據版本),這樣我們的鎖粒度就會減小,更新失敗的概率也會大大減小。
// 以庫存數作爲樂觀鎖
//step1: 查詢出商品信息
select (inventory) from items where id=100;
//step2: 根據商品信息生成訂單
insert into orders(id,item_id) values(null,100);
//step3: 修改商品的庫存
update items set inventory=inventory-1 where id=100 and inventory-1>0;
沒錯!你參加過的天貓、淘寶秒殺、聚划算,跑的就是這條SQL,通過挑選樂觀鎖,可以減小鎖力度,從而提升吞吐。
4.2.3 擴展訓練
在阿里很多系統中都能看到常用的features、params等字段,這些字段如果不進行版本控制,在併發場景下非常容易出現更新丟失的問題。
比如:
線程 |
原始features |
目標features |
T-A |
a=1; |
a=1;b=1; |
T-B |
a=1; |
a=1;c=1; |
我們期望最終更新的結果爲:
a=1;b=1;c=1;
此時若SQL寫成了
update
lg_order
set
features=#features#
where
order_id=#order_id#
那麼隨着T-A和T-B的先後順序不同,我們得到的結果有可能會是a=1;b=1;或a=1;c=1;
所以就要引入樂觀鎖進行版本控制。
若此時採用樂觀鎖,利用全局字段version進行處理,則會發現與lg_order的其他字段變更有非常高的衝突率,因爲version字段是全局的。不管是不是features字段的更新,只要是這一行數據有更新就都會自增version字段。
update
lg_order
set
features=#features#,
version=version+1
where
order_id=#order_id#
and version=#ori_version#
這種SQL會因爲version的失敗而導致非常高的失敗率,因爲其他字段也在併發變更。
怎麼辦?
我們會發現一般設計庫表的時,凡事擁有features類似字段的,都會有一個features_cc與之成對出現,很多廠內年輕一輩的程序員很少注意到這個字段,我們努力糾正過很久,現在應該好很多了。
version和_cc的區別是什麼?
version和_cc是2個版本控制,一個是控制這一條數據,一個是控制這個一個字段的。比如一個字段a,如果引入version字段,那麼這個version字段表示的時這一行數據的版本,就是說不管這一行數據哪個字段進行更新,都會對version字段進行自增。但是a_cc就不一樣,a_cc專門用來表示a這個字段的版本號,只有a字段更新纔會對a_cc進行自增,其他字段更新不會對a_cc進行自增。
features_cc的作用就是features的樂觀鎖版本的控制,這樣就規避了使用version與整個字段衝突的尷尬。
update
lg_order
set
features=#features#,
features_cc= features_cc +1
where
order_id=#order_id#
and features_cc =#ori_ features_cc#
這裏需要注意的是,需要應用owner仔細review自己相關表的SQL,要求所有涉及到這個表features字段的變更都必須加上features_cc= features_cc +1進行計算,否則會引起併發衝突,平時要做好保護措施,不然很中意中標。
在實際的環境中,這種高併發的場景中尤其多,大家思考一下是否自覺的加上了對features字段的樂觀鎖保護。
不過需要提出的是,做這種字段的精耕細作控制,是以提高維護成本作爲代價的。
若變更太頻繁,可以提出來單獨維護,做到冷熱數據分離。
參考資料:
https://www.jianshu.com/p/ed896335b3b4
高性能MySQL(第3版)_掃描版