天天看點

MySQL · 引擎特性 · InnoDB 檔案系統之IO系統和記憶體管理

為了管理磁盤檔案的讀寫操作,innodb設計了一套檔案io操作接口,提供了同步io和異步io兩種檔案讀寫方式。針對異步io,支援兩種方式:一種是native aio,這需要你在編譯階段加上libaio的dev包,另外一種是simulated aio模式,innodb早期實作了一套系統來模拟異步io,但現在native aio已經很成熟了,并且simulated aio本身存在性能問題,建議生産環境開啟native aio模式。

對于資料讀操作,通常使用者線程觸發的資料塊請求讀是同步讀,如果開啟了資料預讀機制的話,預讀的資料塊則為異步讀,由背景io線程進行。其他背景線程也會觸發資料讀操作,例如purge線程在無效資料清理,會讀undo頁和資料頁;master線程定期做ibuf merge也會讀入資料頁。崩潰恢複階段也可能觸發異步讀來加速recover的速度。

對于資料寫操作,innodb和大部分資料庫系統一樣,都是wal模式,即先寫日志,延遲寫資料頁。事務日志的寫入通常在事務送出時觸發,背景master線程也會每秒做一次redo fsync。資料頁則通常由背景page cleaner線程觸發。但當buffer pool空閑block不夠時,或者沒做checkpoint的lsn age太長時,也會驅動刷髒操作,這兩種場景由使用者線程來觸發。percona server據此做了優化來避免使用者線程參與。mysql5.7也對應做了些不一樣的優化。

除了資料塊操作,還有實體檔案級别的操作,例如truncate、drop table、rename table等ddl操作,innodb需要對這些操作進行協調,目前的解法是通過特殊的flag和計數器的方式來解決。

當檔案讀入記憶體後,我們需要一種統一的方式來對資料進行管理,在啟動執行個體時,innodb會按照instance分區配置設定多個一大塊記憶體(在5.7裡則是按照可配置的chunk size進行記憶體塊劃分),每個chunk又以univ_page_size為機關進行劃分。資料讀入記憶體時,會從buffer pool的free list中配置設定一個空閑block。所有的資料頁都存儲在一個lru連結清單上,修改過的block被加到<code>flush_list</code>上,解壓的資料頁被放到unzip_lru連結清單上。我們可以配置buffer pool為多個instance,以降低對連結清單的競争開銷。

在關鍵的地方本文注明了代碼函數,建議讀者邊參考代碼邊閱讀本文,本文的代碼部分基于mysql 5.7.11版本,不同的版本函數名或邏輯可能會有所不同。請讀者閱讀本文時盡量選擇該版本的代碼。

本小節我們介紹下磁盤檔案與記憶體資料的中樞,即io子系統。innodb對page的磁盤操作分為讀操作和寫操作。

對于讀操作,在将資料讀入磁盤前,總是為其先預先配置設定好一個block,然後再去磁盤讀取一個新的page,在使用這個page之前,還需要檢查是否有change buffer項,并根據change buffer進行資料變更。讀操作分為兩種場景:普通的讀page及預讀操作,前者為同步讀,後者為異步讀

資料寫操作也分為兩種,一種是batch write,一種是single page write。寫page預設受double write buffer保護,是以對double write buffer的寫磁盤為同步寫,而對資料檔案的寫入為異步寫。

同步讀寫操作通常由使用者線程來完成,而異步讀寫操作則需要背景線程的協同。

舉個簡單的例子,假設我們向磁盤批量寫資料,首先先寫到double write buffer,當dblwr滿了之後,一次性将dblwr中的資料同步刷到ibdata,在確定sync到dblwr後,再将這些page分别異步寫到各自的檔案中。注意這時候dblwr依舊未被清空,新的寫page請求會進入等待。當異步寫page完成後,io helper線程會調用<code>buf_flush_write_complete</code>,将寫入的page從flush list上移除。當dblwr中的page完全寫完後,在函數<code>buf_dblwr_update</code>裡将dblwr清空。這時候才允許新的寫請求進dblwr。

同樣的,對于異步寫操作,也需要io helper線程來檢查page是否完好、merge change buffer等一系列操作。

除了資料頁的寫入,還包括日志異步寫入線程、及ibuf背景線程。

innodb的io背景線程主要包括如下幾類:

io read 線程:背景讀線程,線程數目通過參數<code>innodb_read_io_threads</code>配置,主要處理innodb 資料檔案異步讀請求,任務隊列為<code>aio::s_reads</code>,任務隊列包含slot數為線程數 * 256(linux 平台),也就是說,每個read線程最多可以pend 256個任務;

io write 線程:背景寫線程數,線程數目通過參數<code>innodb_write_io_threads</code>配置。主要處理innodb 資料檔案異步寫請求,任務隊列為<code>aio::s_writes</code>,任務隊列包含slot數為線程數 * 256(linux 平台),也就是說,每個read線程最多可以pend 256個任務;

log 線程:寫日志線程。隻有在寫checkpoint資訊時才會發出一次異步寫請求。任務隊列為<code>aio::s_log</code>,共1個segment,包含256個slot;

ibuf 線程:負責讀入change buffer頁的背景線程,任務隊列為<code>aio::s_ibuf</code>,共1個segment,包含256個slot

所有的同步寫操作都是由使用者線程或其他背景線程執行。上述io線程隻負責異步操作。

入口函數:<code>os_aio_func</code>

首先對于同步讀寫請求(<code>os_aio_sync</code>),發起請求的線程直接調用<code>os_file_read_func</code> 或者<code>os_file_write_func</code> 去讀寫檔案,然後傳回。

對于異步請求,使用者線程從對應操作類型的任務隊列(<code>aio::select_slot_array</code>)中選取一個slot,将需要讀寫的資訊存儲于其中(<code>aio::reserve_slot</code>):

首先在任務隊列數組中選擇一個segment;這裡根據偏移量來算segment,是以可以盡可能的将相鄰的讀寫請求放到一起,這有利于在io層的合并操作

從該segment範圍内選擇一個空閑的slot,如果沒有則等待;

将對應的檔案讀寫請求資訊指派到slot中,例如寫入的目标檔案,偏移量,資料等;

如果這是一次io寫入操作,且使用native aio時,如果表開啟了transparent compression,則對要寫入的資料頁先進行壓縮并punch hole;如果設定了表空間加密,再對資料頁進行加密;

對于native aio(使用linux自帶的libaio庫),調用函數<code>aio::linux_dispatch</code>,将io請求分發給kernel層。

如果沒有開啟native aio,且沒有設定wakeup later 标記,則會去喚醒io線程(<code>aio::wake_simulated_handler_thread</code>),這是早期libaio還不成熟時,innodb在内部模拟aio實作的邏輯。

tips:編譯native aio需要安裝libaio-dev包,并打開選項<code>srv_use_native_aio</code>。

io線程入口函數為<code>io_handler_thread --&gt; fil_aio_wait</code>

首先調用<code>os_aio_handler</code>來擷取請求:

對于native aio,調用函數<code>os_aio_linux_handle</code> 擷取讀寫請求。io線程會反複以500ms(<code>os_aio_reap_timeout</code>)的逾時時間通過io_getevents确認是否有任務已經完成了(<code>linuxaiohandler::collect()</code>),如果有讀寫任務完成,找到已完成任務的slot後,釋放對應的槽位;

對于simulated aio,調用函數<code>os_aio_simulated_handler</code> 處理讀寫請求,這裡相比native aio要複雜很多;

如果這是異步讀隊列,并且<code>os_aio_recommend_sleep_for_read_threads</code>被設定,則暫時不處理,而是等待一會,讓其他線程有機會将更過的io請求發送過來。目前linear readhaed 會使用到該功能。這樣可以得到更好的io合并效果(<code>simulatedaiohandler::check_pending</code>);

已經完成的slot需要及時被處理(<code>simulatedaiohandler::check_completed</code>,可能由上次的io合并操作完成);

如果有超過2秒未被排程的請求(<code>simulatedaiohandler::select_oldest</code>),則優先選擇最老的slot,防止餓死,否則,找一個檔案讀寫偏移量最小的位置的slot(<code>simulatedaiohandler::select()</code>);

沒有任何請求時進入等待狀态;

當找到一個未完成的slot時,會嘗試merge相鄰的io請求(<code>simulatedaiohandler::merge()</code>),并将對應的slot加入到<code>simulatedaiohandler::m_slots</code>數組中,最多不超過64個slot;

然而在5.7版本裡,合并操作已經被禁止了,全部改成了一個個slot進行讀寫,更新到5.7的使用者一定要注意這個改變,或者改為使用更好的native aio方式;

完成io後,釋放slot; 并選擇第一個處理完的slot作為随後優先完成的請求。

從上一步獲得完成io的slot後,調用函數<code>fil_node_complete_io</code>, 遞減<code>node-&gt;n_pending</code>。對于檔案寫操作,需要加入到<code>fil_system-&gt;unflushed_spaces</code>連結清單上,表示這個檔案修改過了,後續需要被sync到磁盤。

如果設定為<code>o_direct_no_fsync</code>的檔案io模式,則資料檔案無需加入到<code>fil_system_t::unflushed_spaces</code>連結清單上。通常我們即時使用<code>o_direct</code>的方式操作檔案,也需要做一次sync,來保證檔案中繼資料的持久化,但在某些檔案系統下則沒有這個必要,通常隻要檔案的大小這些關鍵中繼資料沒發生變化,可以省略一次fsync。

最後在io完成後,調用<code>buf_page_io_complete</code>,做page corruption檢查、change buffer merge等操作;對于寫操作,需要從flush list上移除block并更新double write buffer;對于lru flush産生的寫操作,還會将其對應的block釋放到free list上;

對于日志檔案操作,調用<code>log_io_complete</code>執行一次fil_flush,并更新記憶體内的checkpoint資訊(<code>log_complete_checkpoint</code>)。

由于檔案底層使用pwrite/pread來進行檔案i/o,是以使用者線程對檔案普通的并發i/o操作無需加鎖。但在windows平台下,則需要加鎖進行讀寫。

對相同檔案的io操作通過大量的counter/flag來進行并發控制。

當檔案處于擴充階段時(<code>fil_space_extend</code>),将<code>fil_node_t::being_extended</code>設定為true,避免産生并發extent,或其他關閉檔案或者rename操作等。

當正在删除一個表時,會檢查是否有pending的操作(<code>fil_check_pending_operations</code>)。

将<code>fil_space_t::stop_new_ops</code>設定為true;

檢查是否有pending的change buffer merge (<code>fil_space_t::n_pending_ops</code>);有則等待;

檢查是否有pending的io(<code>fil_node_t::n_pending</code>) 或者pending的檔案flush操作(<code>fil_node_t::n_pending_flushes</code>);有則等待。

當truncate一張表時,和drop table類似,也會調用函數<code>fil_check_pending_operations</code>,檢查表上是否有pending的操作,并将<code>fil_space_t::is_being_truncated</code>設定為true。

當rename一張表時(<code>fil_rename_tablespace</code>),将檔案的stop_ios标記設定為true,阻止其他線程所有的i/o操作。

當進行檔案讀寫操作時,如果是異步讀操作,發現<code>stop_new_ops</code>或者被設定了但<code>is_being_truncated</code>未被設定,會傳回報錯;但依然允許同步讀或異步寫操作(<code>fil_io</code>)。

當進行檔案flush操作時,如果發現<code>fil_space_t::stop_new_ops</code>或者<code>fil_space_t::is_being_truncated</code>被設定了,則忽略該檔案的flush操作 (<code>fil_flush_file_spaces</code>)。

檔案預讀是一項在ssd普及前普通磁盤上比較常見的技術,通過預讀的方式進行連續io而非帶價高昂的随機io。innodb有兩種預讀方式:随機預讀及線性預讀;facebook另外還實作了一種邏輯預讀的方式。

随機預讀

入口函數:<code>buf_read_ahead_random</code>

以64個page為機關(這也是一個extent的大小),目前讀入的page no所在的64個pagno 區域[ (page_no/64)*64, (page_no/64) *64 + 64],如果最近被通路的page數超過<code>buf_read_ahead_random_threshold</code>(通常值為13),則将其他page也讀進記憶體。這裡采取異步讀。

随機預讀受參數<code>innodb_random_read_ahead</code>控制

線性預讀

入口函數:<code>buf_read_ahead_linear</code>

所謂線性預讀,就是在讀入一個新的page時,和随機預讀類似的64個連續page範圍内,預設從低到高page no,如果最近連續被通路的page數超過<code>innodb_read_ahead_threshold</code>,則将該extent之後的其他page也讀取進來。

邏輯預讀

由于表可能存在碎片空間,是以很可能對于諸如全表掃描這樣的場景,連續讀取的page并不是實體連續的,線性預讀不能解決這樣的問題,另外一次讀取一個extent對于需要全表掃描的負載并不足夠。是以facebook引入了邏輯預讀。

其大緻思路為,掃描聚集索引,搜集葉子節點号,然後根據葉子節點的page no (可以從非葉子節點擷取)順序異步讀入一定量的page。

由于innodb aio一次隻支援送出一個page讀請求,雖然kernel層本身會做讀請求合并,但那顯然效率不夠高。他們對此做了修改,使innodb可以支援一次送出(<code>io_submit</code>)多個aio請求。

入口函數:<code>row_search_for_mysql --&gt; row_read_ahead_logical</code>

或者webscalesql上的幾個commit:

由于現代磁盤通常的block size都是大于512位元組的,例如一般是4096位元組,為了避免 “read-on-write” 問題,在5.7版本裡添加了一個參數<code>innodb_log_write_ahead_size</code>,你可以通過配置該參數,在寫入redo log時,将寫入區域配置到block size對齊的位元組數。

在代碼裡的實作,就是在寫入redo log 檔案之前,為尾部位元組填充0(參考函數<code>log_write_up_to</code>)。

tips:所謂read-on-write問題,就是當修改的位元組不足一個block時,需要将整個block讀進記憶體,修改對應的位置,然後再寫進去;如果我們以block為機關來寫入的話,直接完整覆寫寫入即可。

innodb buffer pool從5.6到5.7版本發生了很大的變化。首先是配置設定方式上不同,其次實作了更好的刷髒效率。對buffer pool上的各個連結清單的管理也更加高效。

在5.7之前的版本中,一個buffer pool instance擁有一個chunk,每個chunk的大小為buffer pool size / instance個數。

在5.7裡有個問題值得關注,即buffer pool size會根據instances * chunk size向上對齊,舉個簡單的例子,假設你配置了64個instance, chunk size為預設128mb,就需要以8gb進行對齊,這意味着如果你配置了9gb的buffer pool,實際使用的會是16gb。是以盡量不要配置太多的buffer pool instance。

出于不同的目的,每個buffer pool instance上都維持了多個連結清單,可以根據space id及page no找到對應的instance(<code>buf_pool_get</code>)。

一些關鍵的結構對象及描述如下表所示:

name

desc

buf_pool_t::page_hash

page_hash用于存儲已經或正在讀入記憶體的page。根據&lt;space_id, page_no&gt;快速查找。當不在page hash時,才會去嘗試從檔案讀取

buf_pool_t::lru

lru上維持了所有從磁盤讀入的資料頁,該lru上又在連結清單尾部開始大約3/8處将連結清單劃分為兩部分,新讀入的page被加入到這個位置;當我們設定了innodb_old_blocks_time,若兩次通路page的時間超過該閥值,則将其挪動到lru頭部;這就避免了類似一次性的全表掃描操作導緻buffer pool污染

buf_pool_t::free

存儲了目前空閑可配置設定的block

buf_pool_t::flush_list

存儲了被修改過的page,根據oldest_modification(即載入記憶體後第一次修改該page時的redo lsn)排序

buf_pool_t::flush_rbt

在崩潰恢複階段在flush list上建立的紅黑數,用于将apply redo後的page快速的插入到flush list上,以保證其有序

buf_pool_t::unzip_lru

壓縮表上解壓後的page被存儲到unzip_lru。 buf_block_t::frame存儲解壓後的資料,buf_block_t::page-&gt;zip.data指向原始壓縮資料。

buf_pool_t::zip_free[buf_buddy_sizes_max]

用于管理壓縮頁産生的空閑碎片page。壓縮頁占用的記憶體采用buddy allocator算法進行配置設定。

除了不同的使用者線程會并發操作buffer pool外,還有背景線程也會對buffer pool進行操作。innodb通過讀寫鎖、buf fix計數、io fix标記來進行并發控制。

讀寫并發控制

通常當我們讀取到一個page時,會對其加block s鎖,并遞增<code>buf_page_t::buf_fix_count</code>,直到mtr commit時才會恢複。而如果讀page的目的是為了進行修改,則會加x鎖。

當一個page準備flush到磁盤時(<code>buf_flush_page</code>),如果目前page正在被通路,其<code>buf_fix_count</code>不為0時,就忽略flush該page,以減少擷取block上sx lock的昂貴代價。

并發讀控制

當多個線程請求相同的page時,如果page不在記憶體,是否可能引發對同一個page的檔案io ?答案是不會。

從函數<code>buf_page_init_for_read</code>我們可以看到,在準備讀入一個page前,會做如下工作:

配置設定一個空閑block;

<code>buf_pool_mutex_enter</code>;

持有page_hash x lock;

檢查page_hash中是否已被讀入,如果是,表示另外一個線程已經完成了io,則忽略本次io請求,退出;

持有<code>block-&gt;mutex</code>,對block進行初始化,并加入到page hash中;

設定io fix為<code>buf_io_read</code>;

釋放hash lock;

将block加入到lru上;

持有block s lock;

完成io後,釋放s lock;

當另外一個線程也想請求相同page時,首先如果看到page hash中已經有對應的block了,說明page已經或正在被讀入buffer pool,如果<code>io_fix</code>為<code>buf_io_read</code>,說明正在進行io,就通過加x鎖的方式做一次sync(<code>buf_wait_for_read</code>),確定io完成。

請求page通常還需要加s或x鎖,而io期間也是持有block x鎖的,如果成功擷取了鎖,說明io肯定完成了。

當buffer pool中的free list不足時,為了擷取一個空閑block,通常會觸發page驅逐操作(<code>buf_lru_free_from_unzip_lru_list</code>)。

首先由于壓縮頁在記憶體中可能存在兩份拷貝:壓縮頁和解壓頁;innodb根據最近的io情況和資料解壓技術來判定執行個體是處于io-bound還是cpu-bound(<code>buf_lru_evict_from_unzip_lru</code>)。如果是io-bound的話,就嘗試從unzip_lru上釋放一個block出來(<code>buf_lru_free_from_unzip_lru_list</code>),而壓縮頁依舊儲存在記憶體中。

其次再考慮從<code>buf_pool_t::lru</code>連結清單上釋放block,如果有可替換的page(<code>buf_flush_ready_for_replace</code>)時,則将其釋放掉,并加入到free list上;對于壓縮表,壓縮頁和解壓頁在這裡都會被同時驅逐。

當無法從lru上獲得一個可替換的page時,說明目前buffer pool可能存在大量髒頁,這時候會觸發single page flush(<code>buf_flush_single_page_from_lru</code>),即使用者線程主動去刷一個髒頁并替換掉。這是個慢操作,尤其是如果并發很高的時候,可能觀察到系統的性能急劇下降。在rds mysql中,我們開啟了一個背景線程, 能夠自動根據目前free list的長度來主動做flush,避免使用者線程陷入其中。

除了single page flush外,在mysql 5.7版本裡還引入了多個page cleaner線程,根據一定的啟發式算法,可以定期且高效的的做page flush操作。

本文對此不展開讨論,感興趣的可以閱讀我之前的月報:

<a href="http://mysql.taobao.org/index.php?title=mysql%e5%86%85%e6%a0%b8%e6%9c%88%e6%8a%a5_2015.03#mysql_.c2.b7_.e6.80.a7.e8.83.bd.e4.bc.98.e5.8c.96.c2.b7_5.7.6_innodb_page_flush_.e4.bc.98.e5.8c.96" target="_blank">mysql · 性能優化· 5.7.6 innodb page flush 優化</a>

<a href="http://mysql.taobao.org/index.php?title=mysql%e5%86%85%e6%a0%b8%e6%9c%88%e6%8a%a5_2015.02#mysql_.c2.b7_.e6.80.a7.e8.83.bd.e4.bc.98.e5.8c.96.c2.b7_innodb_buffer_pool_flush.e7.ad.96.e7.95.a5.e6.bc.ab.e8.b0.88" target="_blank">mysql · 性能優化· innodb buffer pool flush政策漫談</a>

繼續閱讀