MySQL實戰45講學習筆記:第三十九講

1、本節概況

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;

2、自增值保存在哪兒?

在這個空表 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 對自增值的保存策略之後,咱們再看看自增值修改機制。

3、自增值修改機制

在 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 卻不能保證是連續的,這是什麼緣由呢?

4、自增值的修改時機

要回答這個問題,咱們就要看一下自增值的修改時機。

一、惟一鍵衝突是致使自增主鍵 id 不連續的第一種緣由

一、insert(null, 1,1) 惟一鍵衝突

假設,表 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 不連續的復現步驟

因此,在這以後,再插入新的數據行時,拿到的自增 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 是遞增的,但不保證是連續的。

5、自增鎖的優化

能夠看到,自增 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?

你必定有兩個疑問:爲何默認設置下,insert … select 要使用語句級的鎖?爲何這個參數的默認值不是 2?

答案是,這麼設計仍是爲了數據的一致性。

咱們一塊兒來看一下這個場景:

圖 4 批量插入數據的自增鎖

在這個例子裏,我往表 t1 中插入了 4 行數據,而後建立了一個相同結構的表 t2,而後兩個 session 同時執行向表 t2 中插入數據的操做。

你能夠設想一下,若是 session B 是申請了自增值之後立刻就釋放自增鎖,那麼就可能出現這樣的狀況:

  • session B 先插入了兩個記錄,(1,1,1)、(2,2,2);
  • 而後,session A 來申請自增 id 獲得 id=3,插入了(3,5,5);
  • 以後,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 的策略:

所以,對於批量插入數據的語句,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 不連續的第三種緣由。

6、小結

今天,咱們從「自增主鍵爲何會出現不連續的值」這個問題開始,首先討論了自增值的存儲。

在 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
的全部記錄和間隙加鎖。

你以爲爲何須要這麼作呢?

你能夠把你的思考和分析寫在評論區,我會在下一篇文章和你討論這個問題。感謝你的收聽,也歡迎你把這篇文章分享給更多的朋友一塊兒閱讀。

7、上期問題時間

上期的問題是,若是你維護的 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,主備的內存表都清空了。

相關文章
相關標籤/搜索