36講爲什麼臨時表可以重名

在上一篇文章中,我們在優化join查詢的時候使用到了臨時表。當時,我們是這麼用的:

create temporary table temp_t like t1;
alter table temp_t add index(b);
insert into temp_t select * from t2 where b>=1 and b<=2000;
select * from t1 join temp_t on (t1.b=temp_t.b);

你可能會有疑問,爲什麼要用臨時表呢?直接用普通表是不是也可以呢?

今天我們就從這個問題說起:臨時表有哪些特徵,爲什麼它適合這個場景?

這裏,我需要先幫你釐清一個容易誤解的問題:有的人可能會認爲,臨時表就是內存表。但是,這兩個概念可是完全不同的。

  • 內存表,指的是使用Memory引擎的表,建表語法是create table … engine=memory。這種表的數據都保存在內存裏,系統重啓的時候會被清空,但是表結構還在。除了這兩個特性看上去比較“奇怪”外,從其他的特徵上看,它就是一個正常的表。

  • 而臨時表,可以使用各種引擎類型 。如果是使用InnoDB引擎或者MyISAM引擎的臨時表,寫數據的時候是寫到磁盤上的。當然,臨時表也可以使用Memory引擎。

弄清楚了內存表和臨時表的區別以後,我們再來看看臨時表有哪些特徵。

臨時表的特性

爲了便於理解,我們來看下下面這個操作序列:
圖1 臨時表特性示例
可以看到,臨時表在使用上有以下幾個特點:

  1. 建表語法是create temporary table …。

  2. 一個臨時表只能被創建它的session訪問,對其他線程不可見。所以,圖中session A創建的臨時表t,對於session B就是不可見的。

  3. 臨時表可以與普通表同名。

  4. session A內有同名的臨時表和普通表的時候,show create語句,以及增刪改查語句訪問的是臨時表。

  5. show tables命令不顯示臨時表。

由於臨時表只能被創建它的session訪問,所以在這個session結束的時候,會自動刪除臨時表。也正是由於這個特性,臨時表就特別適合我們文章開頭的join優化這種場景。爲什麼呢?

原因主要包括以下兩個方面:

  1. 不同session的臨時表是可以重名的,如果有多個session同時執行join優化,不需要擔心表名重複導致建表失敗的問題。

  2. 不需要擔心數據刪除問題。如果使用普通表,在流程執行過程中客戶端發生了異常斷開,或者數據庫發生異常重啓,還需要專門來清理中間過程中生成的數據表。而臨時表由於會自動回收,所以不需要這個額外的操作。

臨時表的應用

由於不用擔心線程之間的重名衝突,臨時表經常會被用在複雜查詢的優化過程中。其中,分庫分表系統的跨庫查詢就是一個典型的使用場景。

一般分庫分表的場景,就是要把一個邏輯上的大表分散到不同的數據庫實例上。比如。將一個大表ht,按照字段f,拆分成1024個分表,然後分佈到32個數據庫實例上。如下圖所示:
圖2 分庫分表簡圖
一般情況下,這種分庫分表系統都有一箇中間層proxy。不過,也有一些方案會讓客戶端直接連接數據庫,也就是沒有proxy這一層。

在這個架構中,分區key的選擇是以“減少跨庫和跨表查詢”爲依據的。如果大部分的語句都會包含f的等值條件,那麼就要用f做分區鍵。這樣,在proxy這一層解析完SQL語句以後,就能確定將這條語句路由到哪個分表做查詢。

比如下面這條語句:

select v from ht where f=N;

這時,我們就可以通過分表規則(比如,N%1024)來確認需要的數據被放在了哪個分表上。這種語句只需要訪問一個分表,是分庫分表方案最歡迎的語句形式了。

但是,如果這個表上還有另外一個索引k,並且查詢語句是這樣的:

select v from ht where k >= M order by t_modified desc limit 100;

這時候,由於查詢條件裏面沒有用到分區字段f,只能到所有的分區中去查找滿足條件的所有行,然後統一做order by 的操作。這種情況下,有兩種比較常用的思路。

第一種思路是,在proxy層的進程代碼中實現排序。

這種方式的優勢是處理速度快,拿到分庫的數據以後,直接在內存中參與計算。不過,這個方案的缺點也比較明顯:

  1. 需要的開發工作量比較大。我們舉例的這條語句還算是比較簡單的,如果涉及到複雜的操作,比如group by,甚至join這樣的操作,對中間層的開發能力要求比較高;

  2. 對proxy端的壓力比較大,尤其是很容易出現內存不夠用和CPU瓶頸的問題。

另一種思路就是,把各個分庫拿到的數據,彙總到一個MySQL實例的一個表中,然後在這個彙總實例上做邏輯操作。

比如上面這條語句,執行流程可以類似這樣:

  • 在彙總庫上創建一個臨時表temp_ht,表裏包含三個字段v、k、t_modified;
  • 在各個分庫上執行
select v,k,t_modified from ht_x where k >= M order by t_modified desc limit 100;
  • 把分庫執行的結果插入到temp_ht表中;
  • 執行
select v from temp_ht order by t_modified desc limit 100; 
  • 得到結果。

這個過程對應的流程圖如下所示:
圖3 跨庫查詢流程示意圖
在實踐中,我們往往會發現每個分庫的計算量都不飽和,所以會直接把臨時表temp_ht放到32個分庫中的某一個上。這時的查詢邏輯與圖3類似,你可以自己再思考一下具體的流程。

爲什麼臨時表可以重名?

你可能會問,不同線程可以創建同名的臨時表,這是怎麼做到的呢?

接下來,我們就看一下這個問題。

我們在執行

create temporary table temp_t(id int primary key)engine=innodb;

這個語句的時候,MySQL要給這個InnoDB表創建一個frm文件保存表結構定義,還要有地方保存表數據。

這個frm文件放在臨時文件目錄下,文件名的後綴是.frm,前綴是“#sql{進程id}_{線程id}_序列號”。你可以使用select @@tmpdir命令,來顯示實例的臨時文件目錄。

而關於表中數據的存放方式,在不同的MySQL版本中有着不同的處理方式:

  • 在5.6以及之前的版本里,MySQL會在臨時文件目錄下創建一個相同前綴、以.ibd爲後綴的文件,用來存放數據文件;
  • 而從 5.7版本開始,MySQL引入了一個臨時文件表空間,專門用來存放臨時文件的數據。因此,我們就不需要再創建ibd文件了。

從文件名的前綴規則,我們可以看到,其實創建一個叫作t1的InnoDB臨時表,MySQL在存儲上認爲我們創建的表名跟普通表t1是不同的,因此同一個庫下面已經有普通表t1的情況下,還是可以再創建一個臨時表t1的。

爲了便於後面討論,我先來舉一個例子。
圖4 臨時表的表名
這個進程的進程號是1234,session A的線程id是4,session B的線程id是5。所以你看到了,session A和session B創建的臨時表,在磁盤上的文件不會重名。

MySQL維護數據表,除了物理上要有文件外,內存裏面也有一套機制區別不同的表,每個表都對應一個table_def_key。

  • 一個普通表的table_def_key的值是由“庫名+表名”得到的,所以如果你要在同一個庫下創建兩個同名的普通表,創建第二個表的過程中就會發現table_def_key已經存在了。
  • 而對於臨時表,table_def_key在“庫名+表名”基礎上,又加入了“server_id+thread_id”。

也就是說,session A和sessionB創建的兩個臨時表t1,它們的table_def_key不同,磁盤文件名也不同,因此可以並存。

在實現上,每個線程都維護了自己的臨時錶鏈表。這樣每次session內操作表的時候,先遍歷鏈表,檢查是否有這個名字的臨時表,如果有就優先操作臨時表,如果沒有再操作普通表;在session結束的時候,對鏈表裏的每個臨時表,執行 “DROP TEMPORARY TABLE +表名”操作。

這時候你會發現,binlog中也記錄了DROP TEMPORARY TABLE這條命令。你一定會覺得奇怪,臨時表只在線程內自己可以訪問,爲什麼需要寫到binlog裏面?

這,就需要說到主備複製了。

臨時表和主備複製

既然寫binlog,就意味着備庫需要。

你可以設想一下,在主庫上執行下面這個語句序列:

create table t_normal(id int primary key, c int)engine=innodb;/*Q1*/
create temporary table temp_t like t_normal;/*Q2*/
insert into temp_t values(1,1);/*Q3*/
insert into t_normal select * from temp_t;/*Q4*/

如果關於臨時表的操作都不記錄,那麼在備庫就只有create table t_normal表和insert into t_normal select * from temp_t這兩個語句的binlog日誌,備庫在執行到insert into t_normal的時候,就會報錯“表temp_t不存在”。

你可能會說,如果把binlog設置爲row格式就好了吧?因爲binlog是row格式時,在記錄insert into t_normal的binlog時,記錄的是這個操作的數據,即:write_row event裏面記錄的邏輯是“插入一行數據(1,1)”。

確實是這樣。如果當前的binlog_format=row,那麼跟臨時表有關的語句,就不會記錄到binlog裏。也就是說,只在binlog_format=statment/mixed 的時候,binlog中才會記錄臨時表的操作。

這種情況下,創建臨時表的語句會傳到備庫執行,因此備庫的同步線程就會創建這個臨時表。主庫在線程退出的時候,會自動刪除臨時表,但是備庫同步線程是持續在運行的。所以,這時候我們就需要在主庫上再寫一個DROP TEMPORARY TABLE傳給備庫執行。

之前有人問過我一個有趣的問題:MySQL在記錄binlog的時候,不論是create table還是alter table語句,都是原樣記錄,甚至於連空格都不變。但是如果執行drop table t_normal,系統記錄binlog就會寫成:

DROP TABLE `t_normal` /* generated by server */

也就是改成了標準的格式。爲什麼要這麼做呢 ?

現在你知道原因了,那就是:drop table命令是可以一次刪除多個表的。比如,在上面的例子中,設置binlog_format=row,如果主庫上執行 "drop table t_normal, temp_t"這個命令,那麼binlog中就只能記錄:

DROP TABLE `t_normal` /* generated by server */

因爲備庫上並沒有表temp_t,將這個命令重寫後再傳到備庫執行,纔不會導致備庫同步線程停止。

所以,drop table命令記錄binlog的時候,就必須對語句做改寫。“/* generated by server */”說明了這是一個被服務端改寫過的命令。

說到主備複製,還有另外一個問題需要解決:主庫上不同的線程創建同名的臨時表是沒關係的,但是傳到備庫執行是怎麼處理的呢?

現在,我給你舉個例子,下面的序列中實例S是M的備庫。
圖5 主備關係中的臨時表操作
主庫M上的兩個session創建了同名的臨時表t1,這兩個create temporary table t1 語句都會被傳到備庫S上。

但是,備庫的應用日誌線程是共用的,也就是說要在應用線程裏面先後執行這個create 語句兩次。(即使開了多線程複製,也可能被分配到從庫的同一個worker中執行)。那麼,這會不會導致同步線程報錯 ?

顯然是不會的,否則臨時表就是一個bug了。也就是說,備庫線程在執行的時候,要把這兩個t1表當做兩個不同的臨時表來處理。這,又是怎麼實現的呢?

MySQL在記錄binlog的時候,會把主庫執行這個語句的線程id寫到binlog中。這樣,在備庫的應用線程就能夠知道執行每個語句的主庫線程id,並利用這個線程id來構造臨時表的table_def_key:

  1. session A的臨時表t1,在備庫的table_def_key就是:庫名+t1+“M的serverid”+“session A的thread_id”;

  2. session B的臨時表t1,在備庫的table_def_key就是 :庫名+t1+“M的serverid”+“session B的thread_id”。

由於table_def_key不同,所以這兩個表在備庫的應用線程裏面是不會衝突的。

小結

今天這篇文章,我和你介紹了臨時表的用法和特性。

在實際應用中,臨時表一般用於處理比較複雜的計算邏輯。由於臨時表是每個線程自己可見的,所以不需要考慮多個線程執行同一個處理邏輯時,臨時表的重名問題。在線程退出的時候,臨時表也能自動刪除,省去了收尾和異常處理的工作。

在binlog_format='row’的時候,臨時表的操作不記錄到binlog中,也省去了不少麻煩,這也可以成爲你選擇binlog_format時的一個考慮因素。

需要注意的是,我們上面說到的這種臨時表,是用戶自己創建的 ,也可以稱爲用戶臨時表。與它相對應的,就是內部臨時表,在第17篇文章中我已經和你介紹過。

最後,我給你留下一個思考題吧。

下面的語句序列是創建一個臨時表,並將其改名:
圖6 關於臨時表改名的思考題
可以看到,我們可以使用alter table語法修改臨時表的表名,而不能使用rename語法。你知道這是什麼原因嗎?

你可以把你的分析寫在留言區,我會在下一篇文章的末尾和你討論這個問題。感謝你的收聽,也歡迎你把這篇文章分享給更多的朋友一起閱讀。

上期問題時間

上期的問題是,對於下面這個三個表的join語句,

select * from t1 join t2 on(t1.a=t2.a) join t3 on (t2.b=t3.b) where t1.c>=X and t2.c>=Y and t3.c>=Z;

如果改寫成straight_join,要怎麼指定連接順序,以及怎麼給三個表創建索引。

第一原則是要儘量使用BKA算法。需要注意的是,使用BKA算法的時候,並不是“先計算兩個表join的結果,再跟第三個表join”,而是直接嵌套查詢的。

具體實現是:在t1.c>=X、t2.c>=Y、t3.c>=Z這三個條件裏,選擇一個經過過濾以後,數據最少的那個表,作爲第一個驅動表。此時,可能會出現如下兩種情況。

第一種情況,如果選出來是表t1或者t3,那剩下的部分就固定了。

  1. 如果驅動表是t1,則連接順序是t1->t2->t3,要在被驅動表字段創建上索引,也就是t2.a 和 t3.b上創建索引;

  2. 如果驅動表是t3,則連接順序是t3->t2->t1,需要在t2.b 和 t1.a上創建索引。

同時,我們還需要在第一個驅動表的字段c上創建索引。

第二種情況是,如果選出來的第一個驅動表是表t2的話,則需要評估另外兩個條件的過濾效果。

總之,整體的思路就是,儘量讓每一次參與join的驅動表的數據集,越小越好,因爲這樣我們的驅動表就會越小。

評論區留言點贊板:

@庫淘淘 做了實驗驗證;
@poppy同學做了很不錯的分析;
@dzkk 同學在評論中介紹了MariaDB支持的hash join,大家可以瞭解一下;
@老楊同志提了一個好問題,如果語句使用了索引a,結果還要對a排序,就不用MRR優化了,否則回表完還要增加額外的排序過程,得不償失。

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