MySQL詳解三
- 事務
- ACID特性
- 原子性
- 一致性
- 隔離性
- 持久性
- 事務的隔離級別
- 讀未提交(Read Uncommitted)
- 讀已提交(Read Committed)
- 可重復讀(Repeatable Read)
- 串行化(serializable)
- MVCC
- 聚集索引的隱藏列
- read view
- 鎖
- 全局鎖
- 表級鎖
- 行級鎖
- 死鎖
事務
在談 MySQL 事務之前我們需要達成一個共識,就是事務觸發的前提條件是多條連接并發的的訪問,對于單條連接我們并不會去討論事務這個東西,因為對于單條 MySQL 連接來說,他是一個串行化的操作,串行化的操作沒有必要討論事務,但是對于多條連接來說,每條連接都會存在多個 SQL 語句,那么就會不可避免的出現時序問題,就需要談到事務這個特性。
首先我們要去理解事務是什么東西,事務的本質是并發控制的單元,是用戶定義的一個操作序列,這些操作要么都做,要么都不做,是一個不可分割的工作單位。
就像我們之前談到的原子操作一樣,i++
操作如果沒有進程加鎖或者是他不是一個原子變量,就會出現線程安全的問題,最終就會導致數據的錯亂,而 MySQL 中的事務也是如此,多條 SQL 語句執行的過程中,如果某一條語句執行錯誤,就需要回滾到事務最開始的狀態,也就是說,我們要么看到的是事務沒有被執行的狀態,要么就是看到事務已經被執行以后的狀態,并不會去看到對應的中間態,這就是事務。
事務可由一條非常簡單的 SQL 語句組成,也可以由一組復雜的 SQL 語句組成。
事務控制語句
MySQL 的事務控制語句由以下幾條構成:
-- 顯示開啟事務
START TRANSACTION | BEGIN
-- 提交事務,并使得已對數據庫做的所有修改持久化
COMMIT
-- 回滾事務,結束用戶的事務,并撤銷正在進行的所有未提交的修改
ROLLBACK
-- 創建一個保存點,一個事務可以有多個保存點
SAVEPOINT identifier
-- 刪除一個保存點
RELEASE SAVEPOINT identifier
-- 事務回滾到保存點
ROLLBACK TO [SAVEPOINT] identifier
ACID特性
原子性
一個事務中的所有操作,要么是已經執行完畢,要么就是還沒有開始執行,他不會在事務執行的過程中被打斷,如果一個事務在執行過程中出現了異常,就會回到事務最開始的執行狀態,就像這個事務什么都沒有發生過一樣,這就是事務的原子性。
事務的回滾操作是通過 undolog 來進行實現的, undolog 是事務的回滾日志,它記錄的是事務每步具體操作,當回滾時,回放事務具體操作的逆運算。
一致性
事務的一致性我們需要從兩個方面去進行探討,首先數據庫完整約束,也就是我們前面所說的設置主鍵約束,唯一約束等等,只要我們進行設置了,數據庫就會為我們做出一個保證,這是數據庫所去決定的數據庫的完整約束。
也就是說,事務的前后,所有的數據都保持一個一致的狀態,不能違反數據的一致性檢測,在事務執行前后,數據庫完整性約束沒有被破壞,(例如:一個表的姓名是唯一鍵,如果一個事務對姓名進行修改,但是在事務提交或事務回滾后,表中的姓名變得不唯一了,這樣就破壞了一致性。)
另一方面就是邏輯上的一致性,邏輯上的一致性是用戶進行定義的,用戶認為當前的多條 SQL 語句是一個整體,這個事務單元需要提交之后才會被其他事務可見。
隔離性
隔離性所探討的就是各個事務之間的影響程度,因為數據庫是支持多個事務對數據并發的進行讀寫和修改的能力的,隔離性可以防止多個事務并發執行時由于交叉執行而導致數據的不一致,不同的隔離性就對應著不同的現象,事務隔離分為不同級別,包括讀未提交( Read uncommitted )、讀提交( read committed )、可重復讀( repeatable read )和串行化( Serializable ),隔離性是通過MVCC和鎖進行實現的,我們后續會介紹到。
持久性
事務一旦完成,要將數據所做的變更記錄下來,包括數據存儲和多副本的網絡備份。
事務提交后,事務 DML 操作將會持久化,這是通過 Redlog 進行實現的, Redlog 是重做日志,用于MySQL崩潰后進行數據恢復,保證數據的持久性。
事務的隔離級別
在了解事務的隔離級別之前,我們需要知道一些跟隔離級別相關的 SQL 語句:
-- 設置隔離級別
SET [GLOBAL | SESSION] TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- 或者采用下面的方式設置隔離級別
SET @@tx_isolation = 'REPEATABLE READ';
SET @@global.tx_isolation = 'REPEATABLE READ';
-- 查看全局隔離級別
SELECT @@global.tx_isolation;
-- 查看當前會話隔離級別
SELECT @@session.tx_isolation;
SELECT @@tx_isolation;
-- 手動給讀加 S 鎖
SELECT ... LOCK IN SHARE MODE;
-- 手動給讀加 X 鎖
SELECT ... FOR UPDATE;
-- 查看當前鎖信息
SELECT * FROM information_schema.innodb_locks;
當前創建一張表并且插入數據,后續都是對這張表進行操作:
DROP TABLE IF EXISTS `account_t`;
CREATE TABLE `account_t` (`id` INT(11) NOT NULL AUTO_INCREMENT,`name` VARCHAR(255) DEFAULT NULL,`money` INT(11) DEFAULT 0,PRIMARY KEY (`id`),KEY `idx_name` (`name`)
)ENGINE = INNODB AUTO_INCREMENT=0 DEFAULT CHARSET = utf8;INSERT INTO `account_t` VALUES (7,'M',1000), (1, 'C', 1000),(2, 'B', 1000),(3, 'A', 1000);
讀未提交(Read Uncommitted)
讀未提交(Read Uncommitted):在該隔離級別,所有的事務都可以看到其他事務沒有提交的執行結果。(實際生產中不可能使用這種隔離級別的),但是相當于沒有任何隔離性,也會有很多并發問題,如臟讀,幻讀,不可重復讀等。
我們開啟兩個 MySQL 客戶端來進行操作:
我們會發現,當前 A 客戶端的事務并沒有 commit,B 客戶端已經讀到了 A 客戶端進行的修改,這就是臟讀現象,一個事務讀到了另一個事務還沒有提交的結果,也就是中間執行的結果。
那么當前的解決辦法就是提高我們的隔離級別為讀已提交(Read Committed)。
讀已提交(Read Committed)
讀已提交(Read Committed) :該隔離級別是大多數數據庫的默認的隔離級別(不是 MySQL 默認的)。它滿足了隔離的簡單定義:一個事務只能看到其他的已經提交的事務所做的改變。這種隔離級別會引起不可重復讀,即一個事務執行時,如果多次 select, 可能得到不同的結果;
我們可以看見,在當前隔離級別下,臟讀的問題已經解決掉,但是這個隔離級別會產生不可重復讀的現象發生。
我們可以看見,B 客戶端在一個事務中進行兩次讀取,讀取到了不同的結果,這種現象就叫做不可重復讀。
解決掉不可重復讀的方法就是將隔離級別提高到可重復讀(Repeatable Read)。
可重復讀(Repeatable Read)
可重復讀(Repeatable Read):這是 MySQL 默認的隔離級別,它確保同一個事務,在執行中,多次讀取操作數據時,會看到同樣的數據行,但是會有幻讀問題。
我們可以看見,在當前隔離級別下,A 客戶端的事務已經提交,B 客戶端事務在事務未提交之前,兩次讀取到的結果是一樣的,就解決掉了不可重復讀的問題,但是他又會造成幻讀的問題,當前讀與快照讀不一致,我們來看下面:
我們當前在 A 客戶端插入了一條記錄,然后 commit 當前事務,對于 B 客戶端來說,我在 A commit 之前讀取到的 id > 3的數據是只有 id = 7 這一個的,那么 B 客戶端就覺得自己可以插入數據,但是此時 A 已經插入并提交了,B 在進行插入的時候就會報錯,違反了主鍵約束,這種兩次讀取同一個范圍內的記錄得到的結果集不一樣,當前讀與快照讀不一致稱之為幻讀。
解決幻讀我們可以考慮使用加鎖操作,我們來看下面:
這種方式 A 客戶端已經執行可 insert 語句了,此時 B 客戶端去查詢的時候通過 for update 進行加鎖,就會等待,等到 A 客戶端 commit 以后,此時 B 客戶端才可以查詢到對應的數據,這就很好的解決了幻讀的問題。
另一種加鎖操作就是 B 客戶端先進行加鎖,然后 A 客戶端再去添加數據,此時 A 客戶端就會等待,然后等 B 客戶端 commit 以后會報錯,這本身也是不違反邏輯的。
串行化(serializable)
這是事務的最高隔離級別,它通過強制事務排序,使之不可能相互沖突,從而解決了幻讀的問題。它在每個讀的數據行上面加上共享鎖,但是可能會導致超時和鎖競爭,一般很少進行使用。
MVCC
聚集索引的隱藏列
首先我們要了解,聚集索引當中是存在隱藏列的,他們分別是:
- trx_id:事務修改記錄時,trx_id 記錄該事務修改 id;
- roll_pointer:事務修改記錄時,將舊記錄寫入到 undolog 中,roll_pointer 指向舊版本的記錄。
比如說當前有一個學生表,包含年齡跟姓名,我們在表中插入一條記錄,作為事務1,他的字段就應該是這樣的:
此時我們需要去執行事務 2,修改張三的名字為李四,那么現在當前事務的 trx_id = 2,roll_pointer 就會指向原來事務 1 的那張表的記錄;
然后又有一個事務 3 ,此時要修改李四的 age 為 38,此時版本鏈就應該如下:
我們所說的回滾,其實就是根據這個事務的操作,然后根據版本鏈,去尋找之前的一些記錄,將對應的數據進行回恢復即可。
read view
在 read committed 和 read repeatable 隔離級別下,MVCC 采用 read view 來實現的,它們的區別在于創建 read view 時機不同:
- read committed 隔離級別會在事務中每個 select 都會生成一個新的 read view,也意味著在同一個事務多次讀取同一條數據可能出現數據不一致,因為在多次讀取期間可能有其他事務修改了該條記錄,并提交了;
- read repeatable 隔離級別是啟動事務時生成一個 read view,在整個事務讀取數據都使用這個 read view,這樣保證了在事務期間讀到的數據都是事務啟動前的記錄;
當前讀 VS 快照讀
- 當前讀:讀取最新的記錄,就叫做當前讀。
- 快照讀:讀取歷史版本,就叫做快照讀
事務可見性問題
Read View 在 MySQL 源碼中就是一個類,本質是用來進行可見性判斷的,當事務對某個記錄執行快照讀的時候,對該記錄創建一個Read View,根據這個 Read View 來判斷,當前事務能夠看到該記錄的哪個版本的數據。
構成:
- m_ids:創建 read view 的時候,已啟動但未提交的事務 id 列表;
- min_trx_id:創建 read view 的時候,已啟動但未提交的事務最小事務 id;
- max_trx_id:創建 read view 的時候,預分配給下一個未開始事務分配的 id;
- creator_trx_id:創建該 read view 的 id。
我們需要知道的是,同一個事務是可以看到自身的修改的。
根據 min_trx_id 和 max_trx_id 可以將事務 id 劃分為三部分,就可以總結為下面這個圖結構:
RR與RC本質區別
- 正是Read View生成時機的不同,從而造成RC,RR級別下快照讀的結果的不同;
- 在RR級別下的某個事務的對某條記錄的第一次快照讀會創建一個快照及Read View,將當前系統活躍的其他事務記錄起來;
- 此后在調用快照讀的時候,還是使用的是同一個Read View,所以只要當前事務在其他事務提交更新之前使用過快照讀,那么之后的快照讀使用的都是同一個Read View,所以對之后的修改不可見;
- 即RR級別下,快照讀生成Read View時,Read View會記錄此時所有其他活動事務的快照,這些事務的修改對于當前事務都是不可見的。而早于Read View創建的事務所做的修改均是可見;
- 而在RC級別下的,事務中,每次快照讀都會新生成一個快照和Read View,這就是我們在RC級別下的事務中可以看到別的事務提交的更新的原因;
- 總之在RC隔離級別下,是每個快照讀都會生成并獲取最新的Read View;而在RR隔離級別下,則是同一個事務中的第一個快照讀才會創建Read View, 之后的快照讀獲取的都是同一個Read View;
- 正是RC每次快照讀,都會形成Read View,所以,RC才會有不可重復讀問題。
鎖
在 Mysql 中,通常情況下讀操作使用 MVCC 來解決并發問題,那么寫操作全部都是要進行加鎖操作的,接下來我們就來看幾種鎖操作:
全局鎖
全局鎖通常用于全庫備份,讓整個數據庫處于只讀狀態。
# 加鎖
flush tables with read lock
# 解鎖
unlock tables
表級鎖
MyISAM 默認就是使用的表級鎖,主要有以下幾種類型:
行級鎖
InnoDB 采用的就是行級鎖,可以分為以下幾類:
接下來我們來看一下一張表,更加熟悉一下如何加鎖的,加的什么鎖:
聚集索引,查詢命中:update students set score = 100 where id = 15;
聚集索引,查詢未命中:update students set score = 100 where id = 16;
輔助唯一索引,查詢命中: UPDATE students SET score = 100 WHERE no = ‘S0003’;
輔助唯一索引,查詢未命中: UPDATE students SET score = 100 WHERE no = ‘S0008’;
輔助非唯一索引,查詢命中: UPDATE students SET score = 100 WHERE name = ‘Tom’;
輔助非唯一索引,查詢未命中: UPDATE students SET score = 100 WHERE name = ‘John’;
無索引: UPDATE students SET score = 100 WHERE score = 22;
輔助索引,范圍查詢: UPDATE students SET score = 100 WHERE age <= 23;
修改索引值: UPDATE students SET name = ‘John’ WHERE id = 15;
死鎖
造成死鎖其實就是一個加鎖順序的問題,如下: