1. 程式人生 > 其它 >mysql_45 _ 自增id用完怎麼辦

mysql_45 _ 自增id用完怎麼辦

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

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

今天這篇文章,我們就來看看MySQL裡面的幾種自增id,一起分析一下它們的值達到上限以後,會出現什麼情況。

表定義自增值id

說到自增id,你第一個想到的應該就是表結構定義裡的自增欄位,也就是我在第39篇文章《自增主鍵為什麼不是連續的?》中和你介紹過的自增主鍵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。

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能寫到資料表中的值,就有兩個特徵:

  1. row_id寫入表中的值範圍,是從0到248-1;

  2. 當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改變數這個操作是為了便於我們復現問題,只能在測試環境使用。

圖1 row_id用完的驗證序列
圖2 row_id用完的效果驗證

可以看到,在我用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到達上限後,再插入資料時報主鍵衝突錯誤,是更能被接受的。

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

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個位元組,這個自增值的上限是264-1。要出現這種情況,必須是下面這樣的過程:

  1. 執行一個事務,假設Xid是A;

  2. 接下來執行264次查詢語句,讓global_query_id回到A;

  3. 再啟動一個事務,這個事務的Xid也是A。

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

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。

我在上一篇文章的末尾留給你的思考題,就是關於從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,看上去是一個比較正常的數字。這是什麼原因呢?

實際上,在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 語句後面加上for update,這個事務也不是隻讀事務。

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

  1. update 和 delete語句除了事務本身,還涉及到標記刪除舊資料,也就是要把資料放到purge佇列裡等待後續物理刪除,這個操作也會把max_trx_id+1, 因此在一個事務中至少加2;

  2. InnoDB的後臺操作,比如表的索引資訊統計這類操作,也是會啟動內部事務的,因此你可能看到,trx_id值並不是按照加1遞增的。

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

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

  1. 因為同一個只讀事務在執行期間,它的指標地址是不會變的,所以不論是在 innodb_trx還是在innodb_locks表裡,同一個只讀事務查出來的trx_id就會是一樣的。

  2. 如果有並行的多個只讀事務,每個事務的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裡使用的是可重複讀隔離級別。具體的操作流程如下:

圖 4 復現髒讀

由於我們已經把系統的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例項服務時間夠長,就會必然出現的。

當然,這個例子更現實的意義是,可以加深我們對低水位和資料可見性的理解。你也可以藉此機會再回顧下第8篇文章《事務到底是隔離的還是不隔離的?》中的相關內容。

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);

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

小結

今天這篇文章,我給你介紹了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了,課後也就沒有思考題了。今天,我們換一個輕鬆的話題,請你來說說,讀完專欄以後有什麼感想吧。

這個“感想”,既可以是你讀完專欄前後對某一些知識點的理解發生的變化,也可以是你積累的學習專欄文章的好方法,當然也可以是吐槽或者對未來的期望。

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