1. 程式人生 > 資料庫 >mysql自增主鍵為什麼不是連續的?

mysql自增主鍵為什麼不是連續的?

我們在建表的時候,通常會加一個id的主鍵,並將它設定為AUTO_INCREMENT,很明顯這個id是自增的,那麼自增的id是不是就是連續的呢?先說結論,自增主鍵不能保證連續遞增;那麼什麼什麼情況下自增主鍵會出現 “空洞”,不連續呢?

為了便於說明,我們建立一個表 t,其中 id 是自增主鍵欄位、c 是唯一索引。

CREATE TABLE `t` ( 
	`id` int(11) NOT NULL AUTO_INCREMENT, 
	`c` int(11) DEFAULT NULL, 
	`d` int(11) DEFAULT NULL, 
	PRIMARY KEY (`id`), 
	UNIQUE KEY `c` (`c`)
) ENGINE=InnoDB;

自增值儲存在哪兒?
在這個空表 t 裡面執行 insert into t values(null, 1, 1); 插入一行資料,再執行 show create table 命令,就可以看到如下圖所示的結果:

可以看到,表定義裡面出現了一個 AUTO_INCREMENT=2,表示下一次插入資料時,如果需要自動生成自增值,會生成 id=2。
其實,這個輸出結果容易引起這樣的誤解:自增值是儲存在表結構定義裡的。實際上,表的結構定義存放在後綴名為.frm 的檔案中,但是並不會儲存自增值。

不同的引擎對於自增值的儲存策略不同:

MyISAM 引擎的自增值儲存在資料檔案中;
InnoDB 引擎的自增值,其實是儲存在了記憶體裡,並且到了 MySQL 8.0 版本後,才有了“自增值持久化”的能力,也就是才實現了“如果發生重啟,表的自增值可以恢復為 MySQL 重啟前的值”

具體情況是:
一.在 MySQL 5.7 及之前的版本,自增值儲存在記憶體裡,並沒有持久化。每次重啟後,第一次開啟表的時候,都會去找自增值的最大值 max(id),然後將 max(id)+1 作為這個表當前的自增值。
舉例來說,如果一個表當前資料行裡最大的 id 是 10,AUTO_INCREMENT=11。這時候,我們刪除 id=10 的行,AUTO_INCREMENT 還是 11。但如果馬上重啟例項,重啟後這個表的 AUTO_INCREMENT 就會變成 10。也就是說,MySQL 重啟可能會修改一個表的 AUTO_INCREMENT 的值

二.在 MySQL 8.0 版本,將自增值的變更記錄在了 redo log 中,重啟的時候依靠 redo log 恢復重啟之前的值。

理解了 MySQL 對自增值的儲存策略以後,我們再看看自增值修改機制。

自增值修改機制在 MySQL 裡面,如果欄位 id 被定義為 AUTO_INCREMENT,在插入一行資料的時候,自增值的行為如下:
1.如果插入資料時 id 欄位指定為 0、null 或未指定值,那麼就把這個表當前的 AUTO_INCREMENT 值填到自增欄位;
2.如果插入資料時 id 欄位指定了具體的值,就直接使用語句裡指定的值。

根據要插入的值和當前自增值的大小關係,自增值的變更結果也會有所不同。假設,某次要插入的值是 X,當前的自增值是 Y。
1.如果 X<Y,那麼這個表的自增值不變;
2.如果 X≥Y,就需要把當前自增值修改為新的自增值。

新的自增值生成演算法是:從 auto_increment_offset 開始,以 auto_increment_increment 為步長,持續疊加,直到找到第一個大於 X 的值,作為新的自增值。
其中,auto_increment_offset 和 auto_increment_increment 是兩個系統引數,分別用來表示自增的初始值和步長,預設值都是 1。
備註:在一些場景下,使用的就不全是預設值。比如,雙 M 的主備結構裡要求雙寫的時候,我們就可能會設定成 auto_increment_increment=2,讓一個庫的自增 id 都是奇數,另一個庫的自增 id 都是偶數,避免兩個庫生成的主鍵發生衝突。

當 auto_increment_offset 和 auto_increment_increment 都是 1 的時候,新的自增值生成邏輯很簡單,就是:
如果準備插入的值 >= 當前自增值,新的自增值就是“準備插入的值 +1”;否則,自增值不變。

那如果在這兩個引數都設定為 1 的時候,自增主鍵 id 卻還不能保證是連續的,這是什麼原因呢?
假設,表 t 裡面已經有了 (1,1,1) 這條記錄,這時我再執行一條插入資料命令:
insert into t values(null, 1, 1); 

這個語句的執行流程就是:
1.執行器呼叫 InnoDB 引擎介面寫入一行,傳入的這一行的值是 (0,1,1);
2.InnoDB 發現使用者沒有指定自增 id 的值,獲取表 t 當前的自增值 2;
3.將傳入的行的值改成 (2,1,1),將表的自增值改成 3;
4.繼續執行插入資料操作,由於已經存在 c=1 的記錄,所以報 Duplicate key error,語句返回。

可以看到,這個表的自增值改成 3,是在真正執行插入資料的操作之前。這個語句真正執行的時候,因為碰到唯一鍵 c 衝突,所以 id=2 這一行並沒有插入成功,但也沒有將自增值再改回去。
所以,在這之後,再插入新的資料行時,拿到的自增 id 就是 3。也就是說,出現了自增主鍵不連續的情況。

可以看到,這個操作序列復現了一個自增主鍵 id 不連續的現場 (沒有 id=2 的行)。可見,唯一鍵衝突是導致自增主鍵 id 不連續的第一種原因。
同樣地,事務回滾也會產生類似的現象,這就是第二種原因。

下面這個語句序列就可以構造不連續的自增 id,你可以自己驗證一下:
insert into t values(null,1,1);
begin;
insert into t values(null,2,2);
rollback;
insert into t values(null,2,2);
//插入的行是(3,2,2)

這裡可能會有新的疑問:為什麼在出現唯一鍵衝突或者回滾的時候,MySQL 沒有把表 t 的自增值改回去呢?如果把表 t 的當前自增值從 3 改回 2,再插入新資料的時候,不就可以生成 id=2 的一行資料了嗎?
其實,MySQL 這麼設計是為了提升效能。接下來,我們再來看看自增值為什麼不能回退。

假設有兩個並行執行的事務,在申請自增值的時候,為了避免兩個事務申請到相同的自增 id,肯定要加鎖,然後順序申請。
1.假設事務 A 申請到了 id=2, 事務 B 申請到 id=3,那麼這時候表 t 的自增值是 4,之後繼續執行。
2.事務 B 正確提交了,但事務 A 出現了唯一鍵衝突。
3.如果允許事務 A 把自增 id 回退,也就是把表 t 的當前自增值改回 2,那麼就會出現這樣的情況:表裡面已經有 id=3 的行,而當前的自增 id 值是 2。
4.接下來,繼續執行的其他事務就會申請到 id=2,然後再申請到 id=3。這時,就會出現插入語句報錯“主鍵衝突”。

而為了解決這個主鍵衝突,有兩種方法:
1.每次申請 id 之前,先判斷表裡面是否已經存在這個 id。如果存在,就跳過這個 id。但是,這個方法的成本很高。因為,本來申請 id 是一個很快的操作,現在還要再去主鍵索引樹上判斷 id 是否存在。
2.把自增 id 的鎖範圍擴大,必須等到一個事務執行完成並提交,下一個事務才能再申請自增 id。這個方法的問題,就是鎖的粒度太大,系統併發能力大大下降。
可見,這兩個方法都會導致效能問題。造成這些麻煩的罪魁禍首,就是我們假設的這個“允許自增 id 回退”的前提導致的。因此,InnoDB 放棄了這個設計,語句執行失敗也不回退自增 id。也正是因為這樣,所以才只保證了自增 id 是遞增的,但不保證是連續的。

可以看到,自增 id 鎖並不是一個事務鎖,而是每次申請完就馬上釋放,以便允許別的事務再申請。其實,在 MySQL 5.1 版本之前,並不是這樣的。

在 MySQL 5.0 版本的時候,自增鎖的範圍是語句級別。也就是說,如果一個語句申請了一個表自增鎖,這個鎖會等語句執行結束以後才釋放。顯然,這樣設計會影響併發度。
MySQL 5.1.22 版本引入了一個新策略,新增引數 innodb_autoinc_lock_mode,預設值是 1。
1.這個引數的值被設定為 0 時,表示採用之前 MySQL 5.0 版本的策略,即語句執行結束後才釋放鎖;
2.這個引數的值被設定為 1 時:普通 insert 語句,自增鎖在申請之後就馬上釋放;類似 insert … select 這樣的批量插入資料的語句,自增鎖還是要等語句結束後才被釋放;
3.這個引數的值被設定為 2 時,所有的申請自增主鍵的動作都是申請後就釋放鎖。

那麼為什麼預設設定下,insert … select 要使用語句級的鎖?為什麼這個引數的預設值不是 2?
答案是,這麼設計還是為了資料的一致性。
假設有下面這個場景:

在這個例子裡,我往表 t1 中插入了 4 行資料,然後建立了一個相同結構的表 t2,然後兩個 session 同時執行向表 t2 中插入資料的操作。
如果 session B 是申請了自增值以後馬上就釋放自增鎖,那麼就可能出現這樣的情況:
1.session B 先插入了兩個記錄,(1,1,1)、(2,2,2);
2.然後,session A 來申請自增 id 得到 id=3,插入了(3,5,5);
3.之後,session B 繼續執行,插入兩條記錄 (4,3,3)、 (5,4,4)。

你可能會說,這也沒關係吧,畢竟 session B 的語義本身就沒有要求表 t2 的所有行的資料都跟 session A 相同。
是的,從資料邏輯上看是對的。但是,如果我們現在的 binlog_format=statement(序列記錄),可以設想下,binlog 會怎麼記錄呢?
由於兩個 session 是同時執行插入資料命令的,所以 binlog 裡面對錶 t2 的更新日誌只有兩種情況:要麼先記 session A 的,要麼先記 session B 的。
但不論是哪一種,這個 binlog 拿去從庫執行,或者用來恢復臨時例項,備庫和臨時例項裡面,session B 這個語句執行出來,生成的結果裡面,id 都是連續的。這時,這個庫就發生了資料不一致。

那麼出現這個問題的原因是什麼?其實,這是因為原庫 session B 的 insert 語句,生成的 id 不連續。這個不連續的 id,用 statement 格式的 binlog 來序列執行,是執行不出來的。

而要解決這個問題,有兩種思路:

1.一種思路是,讓原庫的批量插入資料語句,固定生成連續的 id 值。所以,自增鎖直到語句執行結束才釋放,就是為了達到這個目的。
2.另一種思路是,在 binlog 裡面把插入資料的操作都如實記錄進來,到備庫執行的時候,不再依賴於自增主鍵去生成。這種情況,其實就是 innodb_autoinc_lock_mode 設定為 2,同時 binlog_format 設定為 row。
因此,在生產上,尤其是有 insert … select 這種批量插入資料的場景時,從併發插入資料效能的角度考慮,我建議你這樣設定:innodb_autoinc_lock_mode=2 ,並且 binlog_format=row. 這樣做,既能提升併發性,又不會出現資料一致性問題。

需要注意的是,我這裡說的批量插入資料,包含的語句型別是 insert … select、replace … select 和 load data 語句。
但是,在普通的 insert 語句裡面包含多個 value 值的情況下,即使 innodb_autoinc_lock_mode 設定為 1,也不會等語句執行完成才釋放鎖。因為這類語句在申請自增 id 的時候,是可以精確計算出需要多少個 id 的,然後一次性申請,申請完成後鎖就可以釋放了。

也就是說,批量插入資料的語句,之所以需要這麼設定,是因為“不知道要預先申請多少個 id”。既然預先不知道要申請多少個自增 id,那麼一種直接的想法就是需要一個時申請一個。
但如果一個 select … insert 語句要插入 10 萬行資料,按照這個邏輯的話就要申請 10 萬次。顯然,這種申請自增 id 的策略,在大批量插入資料的情況下,不但速度慢,還會影響併發插入的效能。

因此,對於批量插入資料的語句,MySQL 有一個批量申請自增 id 的策略:
1.語句執行過程中,第一次申請自增 id,會分配 1 個;
2.1 個用完以後,這個語句第二次申請自增 id,會分配 2 個;
3.2 個用完以後,還是這個語句,第三次申請自增 id,會分配 4 個;
4.依此類推,同一個語句去申請自增 id,每次申請到的自增 id 個數都是上一次的兩倍。

比如下面的例子:


insert into t values(null, 1,1);
insert into t values(null, 2,2);
insert into t values(null, 3,3);
insert into t values(null, 4,4);
create table t2 like t;
insert into t2(c,d) select c,d from t;
insert into t2 values(null, 5,5);

insert…select,實際上往表 t2 中插入了 4 行資料。但是,這四行資料是分三次申請的自增 id,第一次申請到了 id=1,第二次被分配了 id=2 和 id=3, 第三次被分配到 id=4 到 id=7。
由於這條語句實際只用上了 4 個 id,所以 id=5 到 id=7 就被浪費掉了。之後,再執行 insert into t2 values(null, 5,5),實際上插入的資料就是(8,5,5)。

這是主鍵 id 出現自增 id 不連續的第三種原因。

 

 

文章參考:,如有侵權,請聯絡刪除