MySQL-32.各種不同類型自增id達到最大值時的表現

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

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

1.表定義自增值 id

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

可以通過下面這個語句序列驗證一下:

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 值,再試圖執行插入語句,報主鍵衝突錯誤。

232-1(4294967295)不是一個特別大的數,對於一個頻繁插入刪除數據的表來說,是可能會被用完的。因此在建表的時候你需要考察你的表是否有可能達到這個上限,如果有可能,就應該創建成 8 個字節的 bigint unsigned。

2.InnoDB 系統自增 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 能寫到數據表中的值,就有兩個特徵:

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

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

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

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

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

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

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

3.Xid

之前介紹 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 個字節,這個自增值的上限是 264-1。要出現這種情況,必須是下面這樣的過程:

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

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

4.Innodb trx_id

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

Xid 是由 server 層維護的。InnoDB 內部使用 Xid,就是爲了能夠在 InnoDB 事務和 server 之間做關聯。但是,InnoDB 自己的 trx_id,是另外維護的。

其實,你應該非常熟悉這個 trx_id。它就是在講事務可見性時,用到的事務 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。

問題:查看 innodb_trx,發現這個事務的 trx_id 是一個很大的數(281479535353408),而且似乎在同一個 session 中啓動的會話得到的 trx_id 是保持不變的。當執行任何加寫鎖的語句後,trx_id 都會變成一個很小的數字(118378)。現在,一起來看一個事務現場:

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

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

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

  • 在 T1 時刻,trx_id 的值其實就是 0。而這個很大的數,只是顯示用的。一會兒再說說這個數據的生成邏輯。
  • 直到 session A 在 T3 時刻執行 insert 語句的時候,InnoDB 才真正分配了 trx_id。所以,T4 時刻,session B 查到的這個 trx_id 的值就是 1289。

需要注意的是,除了顯而易見的修改類語句外,如果在 select 語句後面加上 for update,這個事務也不是隻讀事務。

有同學提出,實驗的時候發現不止加 1。這是因爲:

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

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

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

  • 因爲同一個只讀事務在執行期間,它的指針地址是不會變的,所以不論是在 innodb_trx 還是在 innodb_locks 表裏,同一個只讀事務查出來的 trx_id 就會是一樣的。
  • 如果有並行的多個只讀事務,每個事務的 trx 變量的指針地址肯定不同。這樣,不同的併發只讀事務,查出來的 trx_id 就是不同的。

那麼,爲什麼還要再加上 248呢?

在顯示值裏面加上 248,目的是要保證只讀事務顯示的 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 達到 248-1 的上限,然後從 0 開始的情況。

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

首先需要把當前的 max_trx_id 先修改成 248-1。注意:這個 case 裏使用的是可重複讀隔離級別。具體的操作流程如下:

由於已經把系統的 max_trx_id 設置成了 248-1,所以在 session A 啓動的事務 TA 的低水位就是 248-1。

在 T2 時刻,session B 執行第一條 update 語句的事務 id 就是 248-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 實例服務時間夠長,就會必然出現的。

當然,這個例子更現實的意義是,可以加深對低水位和數據可見性的理解。

5.thread_id

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

thread_id 的邏輯很好理解:系統保存了一個全局變量 thread_id_counter,每新建一個連接,就將 thread_id_counter 賦值給這個新連接的線程變量。

thread_id_counter 定義的大小是 4 個字節,因此達到 232-1 後,它就會重置爲 0,然後繼續增加。但是,不會在 show processlist 裏看到兩個相同的 thread_id。

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

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

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

小結

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

  • 表的自增 id 達到上限後,再申請時它的值就不會改變,進而導致繼續插入數據時報主鍵衝突的錯誤。
  • row_id 達到上限後,則會歸 0 再重新遞增,如果出現相同的 row_id,後寫的數據會覆蓋之前的數據。
  • Xid 只需要不在同一個 binlog 文件中出現重複值即可。雖然理論上會出現重複值,但是概率極小,可以忽略不計。
  • InnoDB 的 max_trx_id 遞增值每次 MySQL 重啓都會被保存起來,所以文章中提到的髒讀的例子就是一個必現的 bug,好在留給我們的時間還很充裕。
  • thread_id 是我們使用中最常見的,而且也是處理得最好的一個自增 id 邏輯了。

不同的自增 id 有不同的上限值,上限值的大小取決於聲明的類型長度。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章