MySQL實戰45講學習筆記:第四十五講

1、本節概述

MySQL 裏有不少自增的 id,每一個自增 id 都是定義了初始值,而後不停地往上加步長。雖然天然數是沒有上限的,可是在計算機裏,只要定義了表示這個數的字節長度,那它就有
上限。好比,無符號整型 (unsigned int) 是 4 個字節,上限就是 2 -1。mysql

既然自增 id 有上限,就有可能被用完。可是,自增 id 用完了會怎麼樣呢?算法

今天這篇文章,咱們就來看看 MySQL 裏面的幾種自增 id,一塊兒分析一下它們的值達到上限之後,會出現什麼狀況。sql

2、表定義自增值 id

說到自增 id,你第一個想到的應該就是表結構定義裏的自增字段,也就是我在第 39 篇文章《自增主鍵爲何不是連續的?》中和你介紹過的自增主鍵 id。數據庫

表定義的自增值達到上限後的邏輯是:再申請下一個 id 時,獲得的值保持不變。數組

咱們能夠經過下面這個語句序列驗證一下:bash

create table t(id int unsigned auto_increment primary key) auto_increment=4294967295;
insert into t values(null);
//成功插入一行 4294967295
show create table t;
/* CREATE TABLE `t` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4294967295;
*/

insert into t values(null);
//Duplicate entry '4294967295' for key 'PRIMARY'

能夠看到,第一個 insert 語句插入數據成功後,這個表的 AUTO_INCREMENT 沒有改變(仍是 4294967295),就致使了第二個 insert 語句又拿到相同的自增 id 值,再試圖執
行插入語句,報主鍵衝突錯誤。session

2 -1(4294967295)不是一個特別大的數,對於一個頻繁插入刪除數據的表來講,是可能會被用完的。所以在建表的時候你須要考察你的表是否有可能達到這個上限,若是有可
能,就應該建立成 8 個字節的 bigint unsigned。併發

3、InnoDB 系統自增 row_id

一、 row_id的兩個特徵

若是你建立的 InnoDB 表沒有指定主鍵,那麼 InnoDB 會給你建立一個不可見的,長度爲6 個字節的 row_id。InnoDB 維護了一個全局的 dict_sys.row_id 值,全部無主鍵的
InnoDB 表,每插入一行數據,都將當前的 dict_sys.row_id 值做爲要插入數據的row_id,而後把 dict_sys.row_id 的值加 1。學習

實際上,在代碼實現時 row_id 是一個長度爲 8 字節的無符號長整型 (bigint unsigned)。可是,InnoDB 在設計時,給 row_id 留的只是 6 個字節的長度,這樣寫到數據表中時只
放了最後 6 個字節,因此 row_id 能寫到數據表中的值,就有兩個特徵:測試

1. row_id 寫入表中的值範圍,是從 0 到 2 -1;
2. 當 dict_sys.row_id=2 時,若是再有插入數據的行爲要來申請 row_id,拿到之後再取最後 6 個字節的話就是 0。

也就是說,寫入表的 row_id 是從 0 開始到 2 -1。達到上限後,下一個值就是 0,而後繼續循環。

二、若是表中已經存在 row_id=N 的行,新寫入的行就會覆蓋原有的行(驗證過程)

固然,2 -1 這個值自己已經很大了,可是若是一個 MySQL 實例跑得足夠久的話,仍是可能達到這個上限的。在 InnoDB 邏輯裏,申請到 row_id=N 後,就將這行數據寫入表
中;若是表中已經存在 row_id=N 的行,新寫入的行就會覆蓋原有的行。

要驗證這個結論的話,你能夠經過 gdb 修改系統的自增 row_id 來實現。注意,用 gdb改變量這個操做是爲了便於咱們復現問題,只能在測試環境使用。

圖 1 row_id 用完的驗證序列

圖 2 row_id 用完的效果驗證


能夠看到,在我用 gdb 將 dict_sys.row_id 設置爲 2 以後,再插入的 a=2 的行會出如今表 t 的第一行,由於這個值的 row_id=0。以後再插入的 a=3 的行,因爲 row_id=1,就
覆蓋了以前 a=1 的行,由於 a=1 這一行的 row_id 也是 1。

從這個角度看,咱們仍是應該在 InnoDB 表中主動建立自增主鍵。由於,表自增 id 到達上限後,再插入數據時報主鍵衝突錯誤,是更能被接受的。

畢竟覆蓋數據,就意味着數據丟失,影響的是數據可靠性;報主鍵衝突,是插入失敗,影響的是可用性。而通常狀況下,可靠性優先於可用性。

4、Xid

在第 15 篇文章《答疑文章(一):日誌和索引相關問題》中,我和你介紹 redo log 和binlog 相配合的時候,提到了它們有一個共同的字段叫做 Xid。它在 MySQL 中是用來對
應事務的。

那麼,Xid 在 MySQL 內部是怎麼生成的呢?

MySQL 內部維護了一個全局變量 global_query_id,每次執行語句的時候將它賦值給Query_id,而後給這個變量加 1。若是當前語句是這個事務執行的第一條語句,那麼
MySQL 還會同時把 Query_id 賦值給這個事務的 Xid。

而 global_query_id 是一個純內存變量,重啓以後就清零了。因此你就知道了,在同一個數據庫實例中,不一樣事務的 Xid 也是有可能相同的。

可是 MySQL 重啓以後會從新生成新的 binlog 文件,這就保證了,同一個 binlog 文件裏,Xid 必定是唯一的。

雖然 MySQL 重啓不會致使同一個 binlog 裏面出現兩個相同的 Xid,可是若是global_query_id 達到上限後,就會繼續從 0 開始計數。從理論上講,仍是就會出現同一
個 binlog 裏面出現相同 Xid 的場景。

由於 global_query_id 定義的長度是 8 個字節,這個自增值的上限是 2 -1。要出現這種狀況,必須是下面這樣的過程:

1. 執行一個事務,假設 Xid 是 A;
2. 接下來執行 2 次查詢語句,讓 global_query_id 回到 A;
3. 再啓動一個事務,這個事務的 Xid 也是 A。

不過,2 這個值太大了,大到你能夠認爲這個可能性只會存在於理論上。

5、Innodb trx_id

一、Xid 和 InnoDB 的 trx_id 是兩個容易混淆的概念。

Xid 和 InnoDB 的 trx_id 是兩個容易混淆的概念。

Xid 是由 server 層維護的。InnoDB 內部使用 Xid,就是爲了可以在 InnoDB 事務和server 之間作關聯。可是,InnoDB 本身的 trx_id,是另外維護的。

其實,你應該很是熟悉這個 trx_id。它就是在咱們在第 8 篇文章《事務究竟是隔離的仍是不隔離的?》中講事務可見性時,用到的事務 id(transaction id)。

InnoDB 內部維護了一個 max_trx_id 全局變量,每次須要申請一個新的 trx_id 時,就得到 max_trx_id 的當前值,而後並將 max_trx_id 加 1。

InnoDB 數據可見性的核心思想是:每一行數據都記錄了更新它的 trx_id,當一個事務讀到一行數據的時候,判斷這個數據是否可見的方法,就是經過事務的一致性視圖與這行數
據的 trx_id 作對比。

對於正在執行的事務,你能夠從 information_schema.innodb_trx 表中看到事務的trx_id。

二、事務現場(事務的 trx_id)

我在上一篇文章的末尾留給你的思考題,就是關於從 innodb_trx 表裏面查到的 trx_id的。如今,咱們一塊兒來看一個事務現場:

圖 3 事務的 trx_id

session B 裏,我從 innodb_trx 表裏查出的這兩個字段,第二個字段trx_mysql_thread_id 就是線程 id。顯示線程 id,是爲了說明這兩次查詢看到的事務對應
的線程 id 都是 5,也就是 session A 所在的線程。

三、T2 時刻顯示的 trx_id 是一個很大的數;T4 時刻顯示的 trx_id 是 1289,看上去是一個比較正常的數字。這是什麼緣由呢?

能夠看到,T2 時刻顯示的 trx_id 是一個很大的數;T4 時刻顯示的 trx_id 是 1289,看上去是一個比較正常的數字。這是什麼緣由呢?

實際上,在 T1 時刻,session A 尚未涉及到更新,是一個只讀事務。而對於只讀事務,InnoDB 並不會分配 trx_id。也就是說:

1. 在 T1 時刻,trx_id 的值其實就是 0。而這個很大的數,只是顯示用的。一下子我會再和你說說這個數據的生成邏輯。

2. 直到 session A 在 T3 時刻執行 insert 語句的時候,InnoDB 才真正分配了 trx_id。因此,T4 時刻,session B 查到的這個 trx_id 的值就是 1289。

須要注意的是,除了顯而易見的修改類語句外,若是在 select 語句後面加上 forupdate,這個事務也不是隻讀事務

四、在上一篇文章的評論區,有同窗提出,實驗的時候發現不止加 1,這是爲何呢?

在上一篇文章的評論區,有同窗提出,實驗的時候發現不止加 1。這是由於:

1. update 和 delete 語句除了事務自己,還涉及到標記刪除舊數據,也就是要把數據放到purge 隊列裏等待後續物理刪除,這個操做也會把 max_trx_id+1, 所以在一個事務中至少加 2;
2. InnoDB 的後臺操做,好比表的索引信息統計這類操做,也是會啓動內部事務的,所以你可能看到,trx_id 值並非按照加 1 遞增的。

五、T2 時刻查到的這個很大的數字是怎麼來的呢?

那麼,T2 時刻查到的這個很大的數字是怎麼來的呢?

其實,這個數字是每次查詢的時候由系統臨時計算出來的。它的算法是:把當前事務的 trx變量的指針地址轉成整數,再加上 2 。使用這個算法,就能夠保證如下兩點:

1. 由於同一個只讀事務在執行期間,它的指針地址是不會變的,因此不管是在 innodb_trx仍是在 innodb_locks 表裏,同一個只讀事務查出來的 trx_id 就會是同樣的。
2. 若是有並行的多個只讀事務,每一個事務的 trx 變量的指針地址確定不一樣。這樣,不一樣的併發只讀事務,查出來的 trx_id 就是不一樣的。

六、爲何還要再加上 2 呢?

在顯示值裏面加上 2 ,目的是要保證只讀事務顯示的 trx_id 值比較大,正常狀況下就會區別於讀寫事務的 id。可是,trx_id 跟 row_id 的邏輯相似,定義長度也是 8 個字節。因
此,在理論上仍是可能出現一個讀寫事務與一個只讀事務顯示的 trx_id 相同的狀況。不過這個機率很低,而且也沒有什麼實質危害,能夠無論它。

七、只讀事務不分配 trx_id,有什麼好處呢?

一個好處是,這樣作能夠減少事務視圖裏面活躍事務數組的大小。由於當前正在運行的只讀事務,是不影響數據的可見性判斷的。因此,在建立事務的一致性視圖時,InnoDB
就只須要拷貝讀寫事務的 trx_id。

另外一個好處是,能夠減小 trx_id 的申請次數。在 InnoDB 裏,即便你只是執行一個普通的 select 語句,在執行過程當中,也是要對應一個只讀事務的。因此只讀事務優化後,普
通的查詢語句不須要申請 trx_id,就大大減小了併發事務申請 trx_id 的鎖衝突。

因爲只讀事務不分配 trx_id,一個天然而然的結果就是 trx_id 的增長速度變慢了。

可是,max_trx_id 會持久化存儲,重啓也不會重置爲 0,那麼從理論上講,只要一個MySQL 服務跑得足夠久,就可能出現 max_trx_id 達到 2 -1 的上限,而後從 0 開始的狀況。

八、復現髒讀

當達到這個狀態後,MySQL 就會持續出現一個髒讀的 bug,咱們來複現一下這個 bug。

首先咱們須要把當前的 max_trx_id 先修改爲 2 -1。注意:這個 case 裏使用的是可重複讀隔離級別。具體的操做流程以下:

圖 4 復現髒讀

因爲咱們已經把系統的 max_trx_id 設置成了 2 -1,因此在 session A 啓動的事務 TA 的低水位就是 2 -1。

在 T2 時刻,session B 執行第一條 update 語句的事務 id 就是 2 -1,而第二條 update語句的事務 id 就是 0 了,這條 update 語句執行後生成的數據版本上的 trx_id 就是 0。

在 T3 時刻,session A 執行 select 語句的時候,判斷可見性發現,c=3 這個數據版本的trx_id,小於事務 TA 的低水位,所以認爲這個數據可見。

但,這個是髒讀。

因爲低水位值會持續增長,而事務 id 從 0 開始計數,就致使了系統在這個時刻以後,全部的查詢都會出現髒讀的。

而且,MySQL 重啓時 max_trx_id 也不會清 0,也就是說重啓 MySQL,這個 bug 仍然存在。

九、那麼,這個 bug 也是隻存在於理論上嗎?

假設一個 MySQL 實例的 TPS 是每秒 50 萬,持續這個壓力的話,在 17.8 年後,就會出現這個狀況。若是 TPS 更高,這個年限天然也就更短了。可是,從 MySQL 的真正開始流
行到如今,恐怕都尚未實例跑到過這個上限。不過,這個 bug 是隻要 MySQL 實例服務時間夠長,就會必然出現的。

固然,這個例子更現實的意義是,能夠加深咱們對低水位和數據可見性的理解。你也能夠藉此機會再回顧下第 8 篇文章《事務究竟是隔離的仍是不隔離的?》中的相關內容。

6、thread_id

接下來,咱們再看看線程 id(thread_id)。其實,線程 id 纔是 MySQL 中最多見的一種自增 id。平時咱們在查各類現場的時候,show processlist 裏面的第一列,就是thread_id。

thread_id 的邏輯很好理解:

  • 系統保存了一個全局變量 thread_id_counter,
  • 每新建一個鏈接,就將 thread_id_counter 賦值給這個新鏈接的線程變量。

thread_id_counter 定義的大小是 4 個字節,所以達到 2 -1 後,它就會重置爲 0,而後繼續增長。可是,你不會在 show processlist 裏看到兩個相同的 thread_id。

這,是由於 MySQL 設計了一個惟一數組的邏輯,給新線程分配 thread_id 的時候,邏輯代碼是這樣的:

do {
  new_id= thread_id_counter++;
} while (!thread_ids.insert_unique(new_id).second);

這個代碼邏輯簡單並且實現優雅,相信你一看就能明白。

7、小結

今天這篇文章,我給你介紹了 MySQL 不一樣的自增 id 達到上限之後的行爲。數據庫系統做爲一個可能須要 7*24 小時整年無休的服務,考慮這些邊界是很是有必要的。

每種自增 id 有各自的應用場景,在達到上限後的表現也不一樣:

1. 表的自增 id 達到上限後,再申請時它的值就不會改變,進而致使繼續插入數據時報主鍵衝突的錯誤。
2. row_id 達到上限後,則會歸 0 再從新遞增,若是出現相同的 row_id,後寫的數據會覆蓋以前的數據。
3. Xid 只須要不在同一個 binlog 文件中出現重複值便可。雖然理論上會出現重複值,可是機率極小,能夠忽略不計。
4. InnoDB 的 max_trx_id 遞增值每次 MySQL 重啓都會被保存起來,因此咱們文章中提到的髒讀的例子就是一個必現的 bug,好在留給咱們的時間還很充裕。
5. thread_id 是咱們使用中最多見的,並且也是處理得最好的一個自增 id 邏輯了。

固然,在 MySQL 裏還有別的自增 id,好比 table_id、binlog 文件序號等,就留給你去驗證和探索了。

不一樣的自增 id 有不一樣的上限值,上限值的大小取決於聲明的類型長度。而咱們專欄聲明的上限 id 就是 45,因此今天這篇文章也是咱們的最後一篇技術文章了。

既然沒有下一個 id 了,課後也就沒有思考題了。今天,咱們換一個輕鬆的話題,請你來講說,讀完專欄之後有什麼感想吧。

這個「感想」,既能夠是你讀完專欄先後對某一些知識點的理解發生的變化,也能夠是你積累的學習專欄文章的好方法,固然也能夠是吐槽或者對將來的指望。

歡迎你給我留言,咱們在評論區見,也歡迎你把這篇文章分享給更多的朋友一塊兒閱讀。

相關文章
相關標籤/搜索