MySQL實戰45講學習筆記:自增主鍵爲何不是連續的?(第39講)算法
在第 4 篇文章中,咱們提到過自增主鍵,因爲自增主鍵可讓主鍵索引儘可能地保持遞增順序插入,避免了頁分裂,所以索引更緊湊。sql
以前我見過有的業務設計依賴於自增主鍵的連續性,也就是說,這個設計假設自增主鍵是連續的。但實際上,這樣的假設是錯的,由於自增主鍵不能保證連續遞增。bash
今天這篇文章,咱們就來講說這個問題,看看什麼狀況下自增主鍵會出現 「空洞」?session
爲了便於說明,咱們建立一個表 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 命令,就能夠看到以下圖所示的結果:
工具
圖 1 自動生成的 AUTO_INCREMENT 值性能
能夠看到,表定義裏面出現了一個 AUTO_INCREMENT=2,表示下一次插入數據時,若是須要自動生成自增值,會生成 id=2。學習
其實,這個輸出結果容易引發這樣的誤解:自增值是保存在表結構定義裏的。實際上,表
的結構定義存放在後綴名爲.frm 的文件中,可是並不會保存自增值。優化
不一樣的引擎對於自增值的保存策略不一樣spa
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 中,重啓的時候依靠 redolog 恢復重啓以前的值。
理解了 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」;
2. 不然,自增值不變。
這就引入了咱們文章開頭提到的問題,在這兩個參數都設置爲 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);
4. 將表的自增值改爲 3;
5. 繼續執行插入數據操做,因爲已經存在 c=1 的記錄,因此報 Duplicate key error,語句返回。
對應的執行流程圖以下:
圖 2 insert(null, 1,1) 惟一鍵衝突
能夠看到,這個表的自增值改爲 3,是在真正執行插入數據的操做以前。這個語句真正執行的時候,由於碰到惟一鍵 c 衝突,因此 id=2 這一行並無插入成功,但也沒有將自增
值再改回去。
因此,在這以後,再插入新的數據行時,拿到的自增 id 就是 3。也就是說,出現了自增主鍵不連續的狀況
如圖 3 所示就是完整的演示結果。
圖 3 一個自增主鍵 id 不連續的復現步驟
能夠看到,這個操做序列復現了一個自增主鍵 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?
答案是,這麼設計仍是爲了數據的一致性。
咱們一塊兒來看一下這個場景:
圖 4 批量插入數據的自增鎖
在這個例子裏,我往表 t1 中插入了 4 行數據,而後建立了一個相同結構的表 t2,而後兩個 session 同時執行向表 t2 中插入數據的操做。
你能夠設想一下,若是 session B 是申請了自增值之後立刻就釋放自增鎖,那麼就可能出現這樣的狀況:
你可能會說,這也不要緊吧,畢竟 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 不連續的第三種緣由。
今天,咱們從「自增主鍵爲何會出現不連續的值」這個問題開始,首先討論了自增值的存儲。
在 MyISAM 引擎裏面,自增值是被寫在數據文件上的。而在 InnoDB 中,自增值是被記錄在內存的。MySQL 直到 8.0 版本,纔給 InnoDB 表的自增值加上了持久化的能力,確
保重啓先後一個表的自增值不變。
而後,我和你分享了在一個語句執行過程當中,自增值改變的時機,分析了爲何 MySQL在事務回滾的時候不能回收自增 id。
MySQL 5.1.22 版本開始引入的參數 innodb_autoinc_lock_mode,控制了自增值申請時的鎖範圍。從併發性能的角度考慮,我建議你將其設置爲 2,同時將 binlog_format 設置
爲 row。我在前面的文章中其實屢次提到,binlog_format 設置爲 row,是頗有必要的。今天的例子給這個結論多了一個理由。
最後,我給你留一個思考題吧。
在最後一個例子中,執行 insert into t2(c,d) select c,d from t; 這個語句的時候,若是隔離級別是可重複讀(repeatable read),binlog_format=statement。這個語句會對錶 t
的全部記錄和間隙加鎖。
你以爲爲何須要這麼作呢?
你能夠把你的思考和分析寫在評論區,我會在下一篇文章和你討論這個問題。感謝你的收聽,也歡迎你把這篇文章分享給更多的朋友一塊兒閱讀。
上期的問題是,若是你維護的 MySQL 系統裏有內存表,怎麼避免內存表忽然丟數據,而後致使主備同步中止的狀況。
咱們假設的是主庫暫時不能修改引擎,那麼就把備庫的內存表引擎先都改爲 InnoDB。對於每一個內存表,執行
set sql_log_bin=off; alter table tbl_name engine=innodb;
這樣就能避免備庫重啓的時候,數據丟失的問題。
因爲主庫重啓後,會往 binlog 裏面寫「delete from tbl_name」,這個命令傳到備庫,備庫的同名的表數據也會被清空。
所以,就不會出現主備同步中止的問題。
若是因爲主庫異常重啓,觸發了 HA,這時候咱們以前修改過引擎的備庫變成了主庫。而原來的主庫變成了新備庫,在新備庫上把全部的內存表(這時候表裏沒數據)都改爲
InnoDB 表。
因此,若是咱們不能直接修改主庫上的表引擎,能夠配置一個自動巡檢的工具,在備庫上發現內存表就把引擎改了。
同時,跟業務開發同窗約定好建表規則,避免建立新的內存表。
你們在春節期間還堅持看專欄,而且深刻地思考和回覆,給你們點贊。
@長傑 同窗提到的將數據保存到 InnoDB 表用來持久化,也是一個方法。不過,我仍是建議釜底抽薪,直接修改備庫的內存表的引擎。@老楊同志 提到的是主庫異常重啓的場景,這時候是不會報主備不一致的,由於主庫重啓的時候寫了 delete from tbl_name,主備的內存表都清空了。