概述
MySQL經(jīng)過多年的發(fā)展已然成為最流行的數(shù)據(jù)庫,廣泛用于互聯(lián)網(wǎng)行業(yè),并逐步向各個傳統(tǒng)行業(yè)滲透。之所以流行,一方面是其優(yōu)秀的高并發(fā)事務(wù)處理的能力,另一方面也得益于MySQL豐富的生態(tài)。MySQL在處理OLTP場景下的短查詢效果很好,但對于復雜大查詢則能力有限。最直接一點就是,對于一個SQL語句,MySQL最多只能使用一個CPU核來處理,在這種場景下無法發(fā)揮主機CPU多核的能力。MySQL沒有停滯不前,一直在發(fā)展,新推出的8.0.14版本第一次引入了并行查詢特性,使得check table和select count(*)類型的語句性能成倍提升。雖然目前使用場景還比較有限,但后續(xù)的發(fā)展值得期待。
推薦:《mysql視頻教程》
使用方式
通過配置參數(shù)innodb_parallel_read_threads來設(shè)置并發(fā)線程數(shù),就能開始并行掃描功能,默認這個值為4。我這里做一個簡單的實驗,通過sysbench導入2億條數(shù)據(jù),分別配置innodb_parallel_read_threads為1,2,4,8,16,32,64,測試并行執(zhí)行的效果。測試語句為select count(*) from sbtest1;
橫軸是配置并發(fā)線程數(shù),縱軸是語句執(zhí)行時間。從測試結(jié)果來看,整個并行表現(xiàn)還是不錯的,掃描2億條記錄,從單線程的18s,下降到32線程的1s。后面并發(fā)開再多,由于數(shù)據(jù)量有限,多線程的管理消耗超過了并發(fā)帶來的性能提升,不能再繼續(xù)縮短SQL執(zhí)行時間。
MySQL并行執(zhí)行
實際上目前MySQL的并行執(zhí)行還處于非常初級階段,如下圖所示,左邊是之前MySQL串行處理單個SQL形態(tài);中間的是目前MySQL版本提供的并行能力,InnoDB引擎并行掃描的形態(tài);最右邊的是未來MySQL要發(fā)展的形態(tài),優(yōu)化器根據(jù)系統(tǒng)負載和SQL生成并行計劃,并將分區(qū)計劃下發(fā)給執(zhí)行器并行執(zhí)行。并行執(zhí)行不僅僅是并行掃描,還包括并行聚集,并行連接,并行分組,以及并行排序等。目前版本MySQL的上層的優(yōu)化器以及執(zhí)行器并沒有配套的修改。因此,下文的討論主要集中在InnoDB引擎如何實現(xiàn)并行掃描,主要包括分區(qū),并行掃描,預(yù)讀以及與執(zhí)行器交互的適配器類。
分區(qū)
并行掃描的一個核心步驟就是分區(qū),將掃描的數(shù)據(jù)劃分成多份,讓多個線程并行掃描。InnoDB引擎是索引組織表,數(shù)據(jù)以B+tree的形式存儲在磁盤上,節(jié)點的單位是頁面(block/page),同時緩沖池中會對熱點頁面進行緩存,并通過LRU算法進行淘汰。分區(qū)的邏輯就是,從根節(jié)點頁面出發(fā),逐層往下掃描,當判斷某一層的分支數(shù)超過了配置的線程數(shù),則停止拆分。在實現(xiàn)時,實際上總共會進行兩次分區(qū),第一次是按根節(jié)點頁的分支數(shù)劃分分區(qū),每個分支的最左葉子節(jié)點的記錄為左下界,并將這個記錄記為相鄰上一個分支的右上界。通過這種方式,將B+tree劃分成若干子樹,每個子樹就是一個掃描分區(qū)。經(jīng)過第一次分區(qū)后,可能出現(xiàn)分區(qū)數(shù)不能充分利用多核問題,比如配置了并行掃描線程為3,第一次分區(qū)后,產(chǎn)生了4個分區(qū),那么前3個分區(qū)并行做完后,第4個分區(qū)至多只有一個線程掃描,最終效果就是不能充分利用多核資源。
二次分區(qū)
為了解決這個問題,8.0.17版本引入了二次分區(qū),對于第4個分區(qū),繼續(xù)下探拆分,這樣多個子分區(qū)又能并發(fā)掃描,InnoDB引擎并發(fā)掃描的最小粒度是頁面級別。具體判斷二次分區(qū)的邏輯是,一次分區(qū)后,若分區(qū)數(shù)大于線程數(shù),則編號大于線程數(shù)的分區(qū),需要繼續(xù)進行二次分區(qū);若分區(qū)數(shù)小于線程數(shù)且B+tree層次很深,則所有的分區(qū)都需要進行二次分區(qū)。
相關(guān)代碼如下:
split_point?=?0; if?(ranges.size()?>?max_threads())?{ ???//最后一批分區(qū)進行二次分區(qū)?????????????????????????????????????? ???split_point?=?(ranges.size()?/?max_threads())?*?max_threads();?????????? ?}?else?if?(m_depth?<p>無論是一次分區(qū),還是二次分區(qū),分區(qū)邊界的邏輯都一樣,以每個分區(qū)的最左葉子節(jié)點的記錄為左下界,并且將這個記錄記為相鄰上一個分支的右上界。這樣確保分區(qū)足夠多,粒度足夠細,充分并行。下圖展示了配置為3的并發(fā)線程,掃描進行二次分區(qū)的情況。</p><p>相關(guān)代碼如下:</p><p><img src="https://img.php.cn/upload/image/434/847/451/1585270504845446.png" title="1585270504845446.png" alt="2072eee3859cea60f14ba0a382bad65.png"></p><pre class="brush:php;toolbar:false">create_ranges(size_t?depth,?size_t?level) 一次分區(qū): parallel_check_table ?add_scan ???partition(scan_range,?level=0)??/*?start?at?root-page?*/ ?????create_ranges(scan_range,?depth=0,?level=0) ???create_contexts(range,?index?>=?split_point) 二次分區(qū):?????????????????????????????????????????????????????? split() ?partition(scan_range,?level=1) ???create_ranges(depth=0,level)
并行掃描
在一次分區(qū)后,將每個分區(qū)掃描任務(wù)放入到一個lock-free隊列中,并行的worker線程從隊列中獲取任務(wù),執(zhí)行掃描任務(wù),如果獲取的任務(wù)帶有split屬性,這個時候worker會將任務(wù)進行二次拆分,并投入到隊列中。這個過程主要包括兩個核心接口,一個是工作線程接口,另外一個是遍歷記錄接口,前者從隊列中獲取任務(wù)并執(zhí)行,并維護統(tǒng)計計數(shù);后者根據(jù)可見性獲取合適的記錄,并通過上層注入的回調(diào)函數(shù)處理,比如計數(shù)等。
Parallel_reader::worker(size_t thread_id)
{
?1.從ctx-queue提取ctx任務(wù)
?2.根據(jù)ctx的split屬性,確定是否需要進一步拆分分區(qū)(split())
?3.遍歷分區(qū)所有記錄(traverse())
?4.一個分區(qū)任務(wù)結(jié)束后,維護m_n_completed計數(shù)
?5.如果m_n_compeleted計數(shù)達到ctx數(shù)目,喚醒所有worker線程結(jié)束
?6.根據(jù)traverse接口,返回err信息。
}
Parallel_reader::Ctx::traverse()
{
?1.根據(jù)range設(shè)置pcursor
?2.找到btree,將游標定位到range的起始位置
?3.判斷可見性(check_visibility)
?4.如果可見,根據(jù)回調(diào)函數(shù)計算(比如統(tǒng)計)
?5.向后遍歷,若達到了頁面的最后一條記錄,啟動預(yù)讀機制(submit_read_ahead)
?6.超出范圍后結(jié)束
}
同時在8.0.17版本還引入了預(yù)讀機制,避免因為IO瓶頸導致并行效果不佳的問題。目前預(yù)讀的線程數(shù)不能配置,在代碼中硬編碼為2個線程。每次預(yù)讀的單位是一個簇(InnoDB文件通過段,簇,頁三級結(jié)構(gòu)管理,一個簇是一組連續(xù)的頁),根據(jù)頁面配置的大小,可能為1M或者2M。對于常見的16k頁面配置,每次預(yù)讀1M,也就是64個頁面。worker線程在進行掃描時,會先判斷相鄰的下一個頁面是否為簇的第一個頁面,如果是,則發(fā)起預(yù)讀任務(wù)。預(yù)讀任務(wù)同樣通過lock-free 隊列緩存,worker線程是生產(chǎn)者,read-ahead-worker是消費者。由于所有分區(qū)頁面沒有重疊,因此預(yù)讀任務(wù)也不會重復。
執(zhí)行器交互(適配器)
實際上,MySQL已經(jīng)封裝了一個適配器類Parallel_reader_adapter來供上層使用,為后續(xù)的更豐富的并行執(zhí)行做準備。首先這個類需要解決記錄格式的問題,將引擎層掃描的記錄轉(zhuǎn)換成MySQL格式,這樣做到上下層解耦,執(zhí)行器不用感知引擎層格式,統(tǒng)一按MySQL格式處理。整個過程是一個流水線,通過一個buffer批量存儲MySQL記錄,worker線程不停的將記錄從引擎層上讀上來,同時有記錄不停的被上層處理,通過buffer可以平衡讀取和處理速度的差異,確保整個過程流動起來。緩存大小默認是2M,根據(jù)表的記錄行長來確定buffer可以緩存多少個MySQL記錄。核心流程主要在process_rows接口中,流程如下
process_rows
{
?1.將引擎記錄轉(zhuǎn)換成MySQL記錄
?2.獲取本線程的buffer信息(轉(zhuǎn)換了多少mysql記錄,發(fā)送了多少給上層)
?3.將MySQL記錄填充進buffer,自增統(tǒng)計m_n_read
?4.調(diào)用回調(diào)函數(shù)處理(比如統(tǒng)計,聚合,排序等),自增統(tǒng)計m_n_send
}
對于調(diào)用者來說,需要設(shè)置表的元信息,以及注入處理記錄回調(diào)函數(shù),比如處理聚集,排序,分組的工作。回調(diào)函數(shù)通過設(shè)置m_init_fn,m_load_fn和m_end_fn來控制。
總結(jié)
MySQL8.0引入了并行查詢雖然還比較初級,但已經(jīng)讓我們看到了MySQL并行查詢的潛力,從實驗中我們也看到了開啟并行執(zhí)行后,SQL語句執(zhí)行充分發(fā)揮了多核能力,響應(yīng)時間急劇下降。相信在不久的將來,8.0的會支持更多并行算子,包括并行聚集,并行連接,并行分組以及并行排序等。