1. ACID
在關系型數據庫管理系統中,一個邏輯工作單元要成為事務,必須滿足這 4 個特性,即所謂的 ACID:原子性(Atomicity)、一致性(Consistency)、隔離性(Isolation)和持久性(Durability)。
1.1 原子性
原子性:事務是一個原子操作單元,其對數據的修改,要么全都執行,要么全都不執行。
修改--->Buffer Pool修改--->刷盤。可能會有下面兩種情況:
- 事務提交了,如果此時Buffer Pool的臟頁沒有刷盤,如何保證修改的數據生效? Redo
- 如果事務沒提交,但是Buffer Pool的臟頁刷盤了,如何保證不該存在的數據撤銷?Undo
每一個寫事務,都會修改BufferPool,從而產生相應的Redo/Undo日志,在Buffer Pool 中的頁被刷到磁盤之前,這些日志信息都會先寫入到日志文件中,如果 Buffer Pool 中的臟頁沒有刷成功,此時數據庫掛了,那在數據庫再次啟動之后,可以通過 Redo 日志將其恢復出來,以保證臟頁寫的數據不會丟失。如果臟頁刷新成功,此時數據庫掛了,就需要通過Undo來實現了。
1.2 持久性
持久性:指的是一個事務一旦提交,它對數據庫中數據的改變就應該是永久性的,后續的操作或故障不應該對其有任何影響,不會丟失。
如下圖所示,一個“提交”動作觸發的操作有:binlog落地、發送binlog、存儲引擎提交、flush_logs,check_point、事務提交標記等。這些都是數據庫保證其數據完整性、持久性的手段。
MySQL的持久性也與WAL技術相關,redo log在系統Crash重啟之類的情況時,可以修復數據,從而保障事務的持久性。通過原子性可以保證邏輯上的持久性,通過存儲引擎的數據刷盤可以保證物理上的持久性。
1.3 隔離性
隔離性:指的是一個事務的執行不能被其他事務干擾,即一個事務內部的操作及使用的數據對其他的并發事務是隔離的。
InnoDB 支持的隔離性有 4 種,隔離性從低到高分別為:讀未提交、讀提交、可重復讀、可串行化。鎖和多版本控制(MVCC)技術就是用于保障隔離性的。
1.4 一致性
一致性:指的是事務開始之前和事務結束之后,數據庫的完整性限制未被破壞。一致性包括兩方面的內容,分別是約束一致性和數據一致性。
- 約束一致性:創建表結構時所指定的外鍵、Check、唯一索引等約束,可惜在 MySQL 中不支持Check 。
- 數據一致性:是一個綜合性的規定,因為它是由原子性、持久性、隔離性共同保證的結果,而不是單單依賴于某一種技術。
一致性也可以理解為數據的完整性。數據的完整性是通過原子性、隔離性、持久性來保證的,而這3個特性又是通過 Redo/Undo 來保證的。邏輯上的一致性,包括唯一索引、外鍵約束、check 約束,這屬于業務邏輯范疇。
ACID 及它們之間的關系如下圖所示,4個特性中有3個與 WAL 有關系,都需要通過 Redo、Undo 日志來保證等。
WAL的全稱為Write-Ahead Logging,先寫日志,再寫磁盤。
2. 事務控制
2.1 并發事務
事務并發處理可能會帶來一些問題,比如:更新丟失、臟讀、不可重復讀、幻讀等。
- 更新丟失
當兩個或多個事務更新同一行記錄,會產生更新丟失現象。可以分為回滾覆蓋和提交覆蓋。- 回滾覆蓋:一個事務回滾操作,把其他事務已提交的數據給覆蓋了。
- 提交覆蓋:一個事務提交操作,把其他事務已提交的數據給覆蓋了。
- 臟讀
一個事務讀取到了另一個事務修改但未提交的數據。 - 不可重復讀
一個事務中多次讀取同一行記錄不一致,后面讀取的跟前面讀取的不一致。 - 幻讀
一個事務中多次按相同條件查詢,結果不一致。后續查詢的結果和面前查詢結果不同,多了或少了幾行記錄。
2.2 排隊
最簡單的方法,就是完全順序執行所有事務的數據庫操作,不需要加鎖,簡單的說就是全局排隊。序列化執行所有的事務單元,數據庫某個時刻只處理一個事務操作,特點是強一致性,處理性能低。
2.3 排他鎖
引入鎖之后就可以支持并發處理事務,如果事務之間涉及到相同的數據項時,會使用排他鎖,或叫互斥鎖,先進入的事務獨占數據項以后,其他事務被阻塞,等待前面的事務釋放鎖。
在整個事務1結束之前,鎖是不會被釋放的,所以,事務2必須等到事務1結束之后開始。
2.4 讀寫鎖
讀和寫操作:讀讀、寫寫、讀寫、寫讀。
讀寫鎖就是進一步細化鎖的顆粒度,區分讀操作和寫操作,讓讀和讀之間不加鎖,這樣下面的兩個事務就可以同時被執行了。
讀寫鎖,可以讓讀和讀并行,而讀和寫、寫和讀、寫和寫這幾種之間還是要加排他鎖。
2.5 MVCC
多版本控制MVCC,也就是Copy on Write的思想。MVCC除了支持讀和讀并行,還支持讀和寫、寫和讀的并行,但為了保證一致性,寫和寫是無法并行的。
在事務1開始寫操作的時候會copy一個記錄的副本,其他事務讀操作會讀取這個記錄副本,因此不會影響其他事務對此記錄的讀取,實現寫和讀并行。
2.5.1 MVCC概念
MVCC(Multi Version Concurrency Control)被稱為多版本控制,是指在數據庫中為了實現高并發的數據訪問,對數據進行多版本處理,并通過事務的可見性來保證事務能看到自己應該看到的數據本。多版本控制很巧妙地將稀缺資源的獨占互斥轉換為并發,大大提高了數據庫的吞吐量及讀寫性能。
如何生成多版本?
每次事務修改操作之前,都會在Undo日志中記錄修改之前的數據狀態和事務號,該備份記錄可以用于其他事務的讀取,也可以進行必要時的數據回滾。
2.5.2 MVCC實現原理
MVCC最大的好處是讀不加鎖,讀寫不沖突。在讀多寫少的系統應用中,讀寫不沖突是非常重要的,極大的提升系統的并發性能,這也是為什么現階段幾乎所有的關系型數據庫都支持 MVCC 的原因,不過目前MVCC只在 Read Commited 和 Repeatable Read 兩種隔離級別下工作。
在 MVCC 并發控制中,讀操作可以分為兩類: 快照讀(Snapshot Read)與當前讀 (Current Read)。
- 快照讀:讀取的是記錄的快照版本(有可能是歷史版本),不用加鎖。(select)
-
當前讀:讀取的是記錄的最新版本,并且當前讀返回的記錄,都會加鎖,保證其他事務不會再并發修改這條記錄。(select... for update 或lock in share mode,insert/delete/update)
為了讓大家更直觀地理解 MVCC 的實現原理,舉一個記錄更新的案例來講解 MVCC 中多版本的實現。
假設 F1~F6 是表中字段的名字,1~6 是其對應的數據。后面三個隱含字段分別對應該行的隱含ID、事務號和回滾指針,如下圖所示。
image.png
具體的更新過程如下:
假如一條數據是剛 INSERT 的,DB_ROW_ID 為 1,其他兩個字段為空。當事務 1 更改該行的數據值時,會進行如下操作,如下圖所示。
image.png - 用排他鎖鎖定該行;記錄 Redo log;
- 把該行修改前的值復制到 Undo log,即圖中下面的行;
- 修改當前行的值,填寫事務編號,使回滾指針指向 Undo log 中修改前的行。
接下來事務2操作,過程與事務 1 相同,此時 Undo log 中會有兩行記錄,并且通過回滾指針連在一起,通過當前記錄的回滾指針回溯到該行創建時的初始內容,如下圖所示。
MVCC已經實現了讀讀、讀寫、寫讀并發處理,如果想進一步解決寫寫沖突,可以采用下面兩種方案:
- 樂觀鎖
- 悲觀鎖
3. 事務隔離級別
3.1 隔離級別類型
前面提到的“更新丟失”、”臟讀”、“不可重復讀”和“幻讀”等并發事務問題,其實都是數據庫一致性問題,為了解決這些問題,MySQL數據庫是通過事務隔離級別來解決的,數據庫系統提供了以下 4 種事務隔離級別供用戶選擇。
- 讀未提交
Read Uncommitted 讀未提交:解決了回滾覆蓋類型的更新丟失,但可能發生臟讀現象,也就是可能讀取到其他會話中未提交事務修改的數據。 - 已提交讀
Read Committed 讀已提交:只能讀取到其他會話中已經提交的數據,解決了臟讀。但可能發生不可重復讀現象,也就是可能在一個事務中兩次查詢結果不一致。 - 可重復讀
Repeatable Read 可重復讀:解決了不可重復讀,它確保同一事務的多個實例在并發讀取數據時,會看到同樣的數據行。不過理論上會出現幻讀,簡單的說幻讀指的的當用戶讀取某一范圍的數據行時,另一個事務又在該范圍插入了新行,當用戶在讀取該范圍的數據時會發現有新的幻影行。 - 可串行化
Serializable 串行化:所有的增刪改查串行執行。它通過強制事務排序,解決相互沖突,從而解決幻度的問題。這個級別可能導致大量的超時現象的和鎖競爭,效率低下。
數據庫的事務隔離級別越高,并發問題就越小,但是并發處理能力越差(代價)。讀未提交隔離級別最低,并發問題多,但是并發處理能力好。使用時,可以根據系統特點來選擇一個合適的隔離級別,比如對不可重復讀和幻讀并不敏感,更多關心數據庫并發處理能力,此時可以使用Read Commited隔離級別。
事務隔離級別和鎖的關系
1)事務隔離級別是SQL92定制的標準,相當于事務并發控制的整體解決方案,本質上是對鎖和MVCC使用的封裝,隱藏了底層細節。
2)鎖是數據庫實現并發控制的基礎,事務隔離性是采用鎖來實現,對相應操作加不同的鎖,就可以防止其他事務同時對數據進行讀寫操作。
3)對用戶來講,首先選擇使用隔離級別,當選用的隔離級別不能解決并發問題或需求時,才有必要在開發中手動的設置鎖。
MySQL默認隔離級別:可重復讀
Oracle、SQLServer默認隔離級別:讀已提交
3.2 MySQL隔離級別控制
MySQL默認的事務隔離級別是Repeatable Read,查看MySQL當前數據庫的事務隔離級別命令如下:
show variables like 'tx_isolation';
或
select @@tx_isolation;
設置事務隔離級別可以如下命令:
set tx_isolation='READ-UNCOMMITTED';
set tx_isolation='READ-COMMITTED';
set tx_isolation='REPEATABLE-READ';
set tx_isolation='SERIALIZABLE';
4. 鎖機制
4.1 鎖分類
在 MySQL中鎖有很多不同的分類。
從操作的粒度可分為表級鎖、行級鎖和頁級鎖。
- 表級鎖:每次操作鎖住整張表。鎖定粒度大,發生鎖沖突的概率最高,并發度最低。應用在MyISAM、InnoDB、BDB 等存儲引擎中。
- 行級鎖:每次操作鎖住一行數據。鎖定粒度最小,發生鎖沖突的概率最低,并發度最高。應用在InnoDB 存儲引擎中。
-
頁級鎖:每次鎖定相鄰的一組記錄,鎖定粒度界于表鎖和行鎖之間,開銷和加鎖時間界于表鎖和行鎖之間,并發度一般。應用在BDB 存儲引擎中。
image.png
從操作的類型可分為讀鎖和寫鎖。
- 讀鎖(S鎖):共享鎖,針對同一份數據,多個讀操作可以同時進行而不會互相影響。
- 寫鎖(X鎖):排他鎖,當前寫操作沒有完成前,它會阻斷其他寫鎖和讀鎖。
IS鎖、IX鎖:意向讀鎖、意向寫鎖,屬于表級鎖,S和X主要針對行級鎖。在對表記錄添加S或X鎖之前,會先對表添加IS或IX鎖。
S鎖:事務A對記錄添加了S鎖,可以對記錄進行讀操作,不能做修改,其他事務可以對該記錄追加S鎖,但是不能追加X鎖,需要追加X鎖,需要等記錄的S鎖全部釋放。
X鎖:事務A對記錄添加了X鎖,可以對記錄進行讀和修改操作,其他事務不能對記錄做讀和修改操作。
從操作的性能可分為樂觀鎖和悲觀鎖。
- 樂觀鎖:一般的實現方式是對記錄數據版本進行比對,在數據更新提交的時候才會進行沖突檢測,如果發現沖突了,則提示錯誤信息。
- 悲觀鎖:在對一條數據修改的時候,為了避免同時被其他人修改,在修改數據之前先鎖定,再修改的控制方式。共享鎖和排他鎖是悲觀鎖的不同實現,但都屬于悲觀鎖范疇。
4.2 行鎖原理
在InnoDB引擎中,我們可以使用行鎖和表鎖,其中行鎖又分為共享鎖和排他鎖。InnoDB行鎖是通過對索引數據頁上的記錄加鎖實現的,主要實現算法有 3 種:Record Lock、Gap Lock 和 Next-key Lock。
- RecordLock鎖:鎖定單個行記錄的鎖。(記錄鎖,RC、RR隔離級別都支持)
- GapLock鎖:間隙鎖,鎖定索引記錄間隙,確保索引記錄的間隙不變。(范圍鎖,RR隔離級別支持)
- Next-key Lock 鎖:記錄鎖和間隙鎖組合,同時鎖住數據,并且鎖住數據前后范圍。(記錄鎖+范圍鎖,RR隔離級別支持)
在RR隔離級別,InnoDB對于記錄加鎖行為都是先采用Next-Key Lock,但是當SQL操作含有唯一索引時,Innodb會對Next-Key Lock進行優化,降級為RecordLock,僅鎖住索引本身而非范圍。
1)select ... from 語句:InnoDB引擎采用MVCC機制實現非阻塞讀,所以對于普通的select語句,InnoDB不加鎖
2)select ... from lock in share mode語句:追加了共享鎖,InnoDB會使用Next-Key Lock鎖進行處理,如果掃描發現唯一索引,可以降級為RecordLock鎖。
3)select ... from for update語句:追加了排他鎖,InnoDB會使用Next-Key Lock鎖進行處理,如果掃描發現唯一索引,可以降級為RecordLock鎖。
4)update ... where 語句:InnoDB會使用Next-Key Lock鎖進行處理,如果掃描發現唯一索引,可以降級為RecordLock鎖。
5)delete ... where 語句:InnoDB會使用Next-Key Lock鎖進行處理,如果掃描發現唯一索引,可降級為RecordLock鎖。
6)insert語句:InnoDB會在將要插入的那一行設置一個排他的RecordLock鎖。
下面以“update t1 set name=‘XX’ where id=10”操作為例,舉例子分析下 InnoDB 對不同索引的加鎖行為,以RR隔離級別為例。
主鍵加鎖
加鎖行為:僅在id=10的主鍵索引記錄上加X鎖。
唯一鍵加鎖
加鎖行為:現在唯一索引id上加X鎖,然后在id=10的主鍵索引記錄上加X鎖。
非唯一鍵加鎖
加鎖行為:對滿足id=10條件的記錄和主鍵分別加X鎖,然后在(6,c)-(10,b)、(10,b)-(10,d)、(10,d)-(11,f)范圍分別加Gap Lock。
無索引加鎖
加鎖行為:表里所有行和間隙都會加X鎖。(當沒有索引時,會導致全表鎖定,因為InnoDB引擎
鎖機制是基于索引實現的記錄鎖定)。
4.3 悲觀鎖
悲觀鎖(Pessimistic Locking),是指在數據處理過程,將數據處于鎖定狀態,一般使用數據庫的鎖機制實現。從廣義上來講,前面提到的行鎖、表鎖、讀鎖、寫鎖、共享鎖、排他鎖等,這些都屬于悲觀鎖范疇。
- 表級鎖
表級鎖每次操作都鎖住整張表,并發度最低。常用命令如下:
手動增加表鎖
lock table 表名稱 read|write,表名稱2 read|write;
查看表上加過的鎖
show open tables;
刪除表鎖
unlock tables;
表級讀鎖:當前表追加read鎖,當前連接和其他的連接都可以讀操作;但是當前連接增刪改操作會報錯,其他連接增刪改會被阻塞。
表級寫鎖:當前表追加write鎖,當前連接可以對表做增刪改查操作,其他連接對該表所有操作都被阻塞(包括查詢)。
總結:表級讀鎖會阻塞寫操作,但是不會阻塞讀操作。而寫鎖則會把讀和寫操作都阻塞。
共享鎖(行級鎖-讀鎖)
共享鎖又稱為讀鎖,簡稱S鎖。共享鎖就是多個事務對于同一數據可以共享一把鎖,都能訪問到數據,但是只能讀不能修改。使用共享鎖的方法是在select ... lock in share mode,只適用查詢語句。
總結:事務使用了共享鎖(讀鎖),只能讀取,不能修改,修改操作被阻塞。排他鎖(行級鎖-寫鎖)
排他鎖又稱為寫鎖,簡稱X鎖。排他鎖就是不能與其他鎖并存,如一個事務獲取了一個數據行的排他鎖,其他事務就不能對該行記錄做其他操作,也不能獲取該行的鎖。
使用排他鎖的方法是在SQL末尾加上for update,innodb引擎默認會在update,delete語句加上for update。行級鎖的實現其實是依靠其對應的索引,所以如果操作沒用到索引的查詢,那么會鎖住全表記錄。
總結:事務使用了排他鎖(寫鎖),當前事務可以讀取和修改,其他事務不能修改,也不能獲取記錄鎖(select... for update)。如果查詢沒有使用到索引,將會鎖住整個表記錄。
4.4 樂觀鎖
樂觀鎖是相對于悲觀鎖而言的,它不是數據庫提供的功能,需要開發者自己去實現。在數據庫操作時,想法很樂觀,認為這次的操作不會導致沖突,因此在數據庫操作時并不做任何的特殊處理,即不加鎖,而是在進行事務提交時再去判斷是否有沖突了。
樂觀鎖實現的關鍵點:沖突的檢測。
悲觀鎖和樂觀鎖都可以解決事務寫寫并發,在應用中可以根據并發處理能力選擇區分,比如對并發率要求高的選擇樂觀鎖;對于并發率要求低的可以選擇悲觀鎖。
-
樂觀鎖實現原理
-
使用版本字段(version)
先給數據表增加一個版本(version) 字段,每操作一次,將那條記錄的版本號加 1。version是用來查看被讀的記錄有無變化,作用是防止記錄在業務處理期間被其他事務修改。
image.png - 使用時間戳(Timestamp)
與使用version版本字段相似,同樣需要給在數據表增加一個字段,字段類型使用timestamp時間戳。也是在更新提交的時候檢查當前數據庫中數據的時間戳和自己更新前取到的時間戳進行對比,如果一致則提交更新,否則就是版本沖突,取消操作。
-
-
樂觀鎖案例
- 第一步:查詢商品信息
select (quantity,version) from products where id=1;
- 第二步:根據商品信息生成訂單
insert into orders ...
insert into items ...
- 第三步驟:修改商品庫存
update products set quantity=quantity-1,version=version+1 where id=1 and version=#{version};
除了自己手動實現樂觀鎖之外,許多數據庫訪問框架也封裝了樂觀鎖的實現,比如hibernate框架。MyBatis框架大家可以使用OptimisticLocker插件來擴展。
4.5 死鎖與解決方案
介紹幾種常見的死鎖現象和解決方案:
4.5.1 表鎖死鎖
產生原因:
用戶A訪問表A(鎖住了表A),然后又訪問表B;另一個用戶B訪問表B(鎖住了表B),然后企圖訪問表A;這時用戶A由于用戶B已經鎖住表B,它必須等待用戶B釋放表B才能繼續,同樣用戶B要等用戶A釋放表A才能繼續,這就死鎖就產生了。
用戶A--》A表(表鎖)--》B表(表鎖)
用戶B--》B表(表鎖)--》A表(表鎖)
解決方案:這種死鎖比較常見,是由于程序的BUG產生的,除了調整的程序的邏輯沒有其它的辦法。仔細分析程序的邏輯,對于數據庫的多表操作時,盡量按照相同的順序進行處理,盡量避免同時鎖定兩個資源,如操作A和B兩張表時,總是按先A后B的順序處理, 必須同時鎖定兩個資源時,要保證在任何時刻都應該按照相同的順序來鎖定資源。
4.5.2 行級鎖死鎖
產生原因1:
如果在事務中執行了一條沒有索引條件的查詢,引發全表掃描,把行級鎖上升為全表記錄鎖定(等價于表級鎖),多個這樣的事務執行后,就很容易產生死鎖和阻塞,最終應用系統會越來越慢,發生阻塞或死鎖。
解決方案1:
SQL語句中不要使用太復雜的關聯多表的查詢;使用explain“執行計劃"對SQL語句進行分析,對于有全表掃描和全表鎖定的SQL語句,建立相應的索引進行優化。
產生原因2:
兩個事務分別想拿到對方持有的鎖,互相等待,于是產生死鎖。
解決方案2:
- 在同一個事務中,盡可能做到一次鎖定所需要的所有資源
- 按照id對資源排序,然后按順序進行處理
4.5.3 共享鎖轉換為排他鎖
產生原因:
事務A 查詢一條紀錄,然后更新該條紀錄;此時事務B 也更新該條紀錄,這時事務B 的排他鎖由于事務A 有共享鎖,必須等A 釋放共享鎖后才可以獲取,只能排隊等待。事務A 再執行更新操作時,此處發生死鎖,因為事務A 需要排他鎖來做更新操作。但是,無法授予該鎖請求,因為事務B 已經有一個排他鎖請求,并且正在等待事務A 釋放其共享鎖。
事務A: select * from dept where deptno=1 lock in share mode; //共享鎖,1
update dept set dname='java' where deptno=1;//排他鎖,3
事務B: update dept set dname='Java' where deptno=1;//由于1有共享鎖,沒法獲取排他鎖,需等待,2
解決方案:
- 對于按鈕等控件,點擊立刻失效,不讓用戶重復點擊,避免引發同時對同一條記錄多次操作;
- 使用樂觀鎖進行控制。樂觀鎖機制避免了長事務中的數據庫加鎖開銷,大大提升了大并發量下的系統性能。需要注意的是,由于樂觀鎖機制是在我們的系統中實現,來自外部系統的用戶更新操作不受我們系統的控制,因此可能會造成臟數據被更新到數據庫中;
4.5.4 死鎖排查
MySQL提供了幾個與鎖有關的參數和命令,可以輔助我們優化鎖操作,減少死鎖發生。
- 查看死鎖日志
通過show engine innodb status\G命令查看近期死鎖日志信息。
使用方法:
1、查看近期死鎖日志信息;
2、使用explain查看下SQL執行計劃 - 查看鎖狀態變量
通過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:系統啟動后到現在總共等待的次數
如果等待次數高,而且每次等待時間長,需要分析系統中為什么會有如此多的等待,然后著手定制優化。