MySQL 引擎特性:InnoDB IO 子系統

發表於2017-12-12

前言

InnoDB做為一款成熟的跨平臺資料庫引擎,其實現了一套高效易用的IO介面,包括同步非同步IO,IO合併等。本文簡單介紹一下其內部實現,主要的程式碼集中在os0file.cc這個檔案中。本文的分析預設基於MySQL 5.6,CentOS 6,gcc 4.8,其他版本的資訊會另行指出。

基礎知識

WAL技術 : 日誌先行技術,基本所有的資料庫,都使用了這個技術。簡單的說,就是需要寫資料塊的時候,資料庫前臺執行緒把對應的日誌先寫(批量順序寫)到磁碟上,然後就告訴客戶端操作成功,至於真正寫資料塊的操作(離散隨機寫)則放到後臺IO執行緒中。使用了這個技術,雖然多了一個磁碟寫入操作,但是由於日誌是批量順序寫,效率很高,所以客戶端很快就能得到相應。此外,如果在真正的資料塊落盤之前,資料庫奔潰,重啟時候,資料庫可以使用日誌來做崩潰恢復,不會導致資料丟失。
資料預讀 : 與資料塊A“相鄰”的資料塊B和C在A被讀取的時候,B和C也會有很大的概率被讀取,所以可以在讀取B的時候,提前把他們讀到記憶體中,這就是資料預讀技術。這裡說的相鄰有兩種含義,一種是物理上的相鄰,一種是邏輯上的相鄰。底層資料檔案中相鄰,叫做物理上相鄰。如果資料檔案中不相鄰,但是邏輯上相鄰(id=1的資料和id=2的資料,邏輯上相鄰,但是物理上不一定相鄰,可能存在同一個檔案中不同的位置),則叫邏輯相鄰。
檔案開啟模式 : Open系統呼叫常見的模式主要三種:O_DIRECT,O_SYNC以及default模式。O_DIRECT模式表示後續對檔案的操作不使用檔案系統的快取,使用者態直接操作裝置檔案,繞過了核心的快取和優化,從另外一個角度來說,使用O_DIRECT模式進行寫檔案,如果返回成功,資料就真的落盤了(不考慮磁碟自帶的快取),使用O_DIRECT模式進行讀檔案,每次讀操作是真的從磁碟中讀取,不會從檔案系統的快取中讀取。O_SYNC表示使用作業系統快取,對檔案的讀寫都經過核心,但是這個模式還保證每次寫資料後,資料一定落盤。default模式與O_SYNC模式類似,只是寫資料後不保證資料一定落盤,資料有可能還在檔案系統中,當主機當機,資料有可能丟失。
此外,寫操作不僅需要修改或者增加的資料落盤,而且還需要檔案元資訊落盤,只有兩部分都落盤了,才能保證資料不丟。O_DIRECT模式不保證檔案元資訊落盤(但大部分檔案系統都保證,Bug #45892),因此如果不做其他操作,用O_DIRECT寫檔案後,也存在丟失的風險。O_SYNC則保證資料和元資訊都落盤。default模式兩種資料都不保證。
呼叫函式fsync後,能保證資料和日誌都落盤,因此使用O_DIRECT和default模式開啟的檔案,寫完資料,需要呼叫fsync函式。
同步IO : 我們常用的read/write函式(Linux上)就是這類IO,特點是,在函式執行的時候,呼叫者會等待函式執行完成,而且沒有訊息通知機制,因為函式返回了,就表示操作完成了,後續直接檢查返回值就可知道操作是否成功。這類IO操作,程式設計比較簡單,在同一個執行緒中就能完成所有操作,但是需要呼叫者等待,在資料庫系統中,比較適合急需某些資料的時候呼叫,例如WAL中日誌必須在返回客戶端前落盤,則進行一次同步IO操作。
非同步IO : 在資料庫中,後臺刷資料塊的IO執行緒,基本都使用了非同步IO。資料庫前臺執行緒只需要把刷塊請求提交到非同步IO的佇列中即可返回做其他事情,而後臺執行緒IO執行緒,則定期檢查這些提交的請求是否已經完成,如果完成再做一些後續處理工作。同時非同步IO由於常常是一批一批的請求提交,如果不同請求訪問同一個檔案且偏移量連續,則可以合併成一個IO請求。例如,第一個請求讀取檔案1,偏移量100開始的200位元組資料,第二個請求讀取檔案1,偏移量300開始的100位元組資料,則這兩個請求可以合併為讀取檔案1,偏移量100開始的300位元組資料。資料預讀中的邏輯預讀也常常使用非同步IO技術。
目前Linux上的非同步IO庫,需要檔案使用O_DIRECT模式開啟,且資料塊存放的記憶體地址、檔案讀寫的偏移量和讀寫的資料量必須是檔案系統邏輯塊大小的整數倍,檔案系統邏輯塊大小可以使用類似sudo blockdev --getss /dev/sda5的語句查詢。如果上述三者不是檔案系統邏輯塊大小的整數倍,則在呼叫讀寫函式時候會報錯EINVAL,但是如果檔案不使用O_DIRECT開啟,則程式依然可以執行,只是退化成同步IO,阻塞在io_submit函式呼叫上。

InnoDB常規IO操作以及同步IO

在InnoDB中,如果系統有pread/pwrite函式(os_file_read_funcos_file_write_func),則使用它們進行讀寫,否則使用lseek+read/write方案。這個就是InnoDB同步IO。檢視pread/pwrite文件可知,這兩個函式不會改變檔案控制程式碼的偏移量且執行緒安全,所以多執行緒環境下推薦使用,而lseek+read/write方案則需要自己使用互斥鎖保護,在高併發情況下,頻繁的陷入核心態,對效能有一定影響。

在InnoDB中,使用open系統呼叫開啟檔案(os_file_create_func),模式方面除了O_RDONLY(只讀),O_RDWR(讀寫),O_CREAT(建立檔案)外,還使用了O_EXCL(保證是這個執行緒建立此檔案)和O_TRUNC(清空檔案)。預設情況下(資料庫不設定為只讀模式),所有檔案都以O_RDWR模式開啟。innodb_flush_method這個引數比較重要,重點介紹一下:

  • 如果innodb_flush_method設定了O_DSYNC,日誌檔案(ib_logfileXXX)使用O_SYNC開啟,因此寫完資料不需要呼叫函式fsync刷盤,資料檔案(ibd)使用default模式開啟,因此寫完資料需要呼叫fsync刷盤。
  • 如果innodb_flush_method設定了O_DIRECT,日誌檔案(ib_logfileXXX)使用default模式開啟,寫完資料需要呼叫fsync函式刷盤,資料檔案(ibd)使用O_DIRECT模式開啟,寫完資料需要呼叫fsync函式刷盤。
  • 如果innodb_flush_method設定了fsync或者不設定,資料檔案和日誌檔案都使用default模式開啟,寫完資料都需要使用fsync來刷盤。
  • 如果innodb_flush_method設定為O_DIRECT_NO_FSYNC,檔案開啟方式與O_DIRECT模式類似,區別是,資料檔案寫完後,不呼叫fsync函式來刷盤,主要針對O_DIRECT能保證檔案的後設資料也落盤的檔案系統。
    InnoDB目前還不支援使用O_DIRECT模式開啟日誌檔案,也不支援使用O_SYNC模式開啟資料檔案。
    注意,如果使用linux native aio(詳見下一節),innodb_flush_method一定要配置成O_DIRECT,否則會退化成同步IO(錯誤日誌中不會有任務提示)。

InnoDB使用了檔案系統的檔案鎖來保證只有一個程式對某個檔案進行讀寫操作(os_file_lock),使用了建議鎖(Advisory locking),而不是強制鎖(Mandatory locking),因為強制鎖在不少系統上有bug,包括linux。在非只讀模式下,所有檔案開啟後,都用檔案鎖鎖住。

InnoDB中目錄的建立使用遞迴的方式(os_file_create_subdirs_if_neededos_file_create_directory)。例如,需要建立/a/b/c/這個目錄,先建立c,然後b,然後a,建立目錄呼叫mkdir函式。此外,建立目錄上層需要呼叫os_file_create_simple_func函式,而不是os_file_create_func,需要注意一下。

InnoDB也需要臨時檔案,臨時檔案的建立邏輯比較簡單(os_file_create_tmpfile),就是在tmp目錄下成功建立一個檔案後直接使用unlink函式釋放掉控制程式碼,這樣當程式結束後(不管是正常結束還是異常結束),這個檔案都會自動釋放。InnoDB建立臨時檔案,首先複用了server層函式mysql_tmpfile的邏輯,後續由於需要呼叫server層的函式來釋放資源,其又呼叫dup函式拷貝了一份控制程式碼。

如果需要獲取某個檔案的大小,InnoDB並不是去查檔案的後設資料(stat函式),而是使用lseek(file, 0, SEEK_END)的方式獲取檔案大小,這樣做的原因是防止元資訊更新延遲導致獲取的檔案大小有誤。

InnoDB會預分配一個大小給所有新建的檔案(包括資料和日誌檔案),預分配的檔案內容全部置為零(os_file_set_size),當前檔案被寫滿時,再進行擴充套件。此外,在日誌檔案建立時,即install_db階段,會以100MB的間隔在錯誤日誌中輸出分配進度。

總體來說,常規IO操作和同步IO相對比較簡單,但是在InnoDB中,資料檔案的寫入基本都用了非同步IO。

InnoDB非同步IO

由於MySQL誕生在Linux native aio之前,所以在MySQL非同步IO的程式碼中,有兩種實現非同步IO的方案。
第一種是原始的Simulated aio,InnoDB在Linux native air被import進來之前以及某些不支援air的系統上,自己模擬了一條aio的機制。非同步讀寫請求提交時,僅僅把它放入一個佇列中,然後就返回,程式可以去做其他事情。後臺有若干非同步io處理執行緒(innobase_read_io_threads和innobase_write_io_threads這兩個引數控制)不斷從這個佇列中取出請求,然後使用同步IO的方式完成讀寫請求以及讀寫完成後的工作。
另外一種就是Native aio。目前在linux上使用io_submit,io_getevents等函式完成(不使用glibc aio,這個也是模擬的)。提交請求使用io_submit, 等待請求使用io_getevents。另外,window平臺上也有自己對應的aio,這裡就不介紹了,如果使用了window的技術棧,資料庫應該會選用sqlserver。目前,其他平臺(Linux和window之外)都只能使用Simulate aio。

首先介紹一下一些通用的函式和結構,接下來分別詳細介紹一下Simulate alo和Linux上的Native aio。
在os0file.cc中定義了全域性陣列,型別為os_aio_array_t,這些陣列就是Simulate aio用來快取讀寫請求的佇列,陣列的每一個元素是os_aio_slot_t型別,裡面記錄了每個IO請求的型別,檔案的fd,偏移量,需要讀取的資料量,IO請求發起的時間,IO請求是否已經完成等。另外,Linux native io中的struct iocb也在os_aio_slot_t中。陣列結構os_aio_slot_t中,記錄了一些統計資訊,例如有多少資料元素(os_aio_slot_t)已經被使用了,是否為空,是否為滿等。這樣的全域性陣列一共有5個,分別用來儲存資料檔案讀非同步請求(os_aio_read_array),資料檔案寫非同步請求(os_aio_write_array),日誌檔案寫非同步請求(os_aio_log_array),insert buffer寫非同步請求(os_aio_ibuf_array),資料檔案同步讀寫請求(os_aio_sync_array)。日誌檔案的資料塊寫入是同步IO,但是這裡為什麼還要給日誌寫分配一個非同步請求佇列(os_aio_log_array)呢?原因是,InnoDB日誌檔案的日誌頭中,需要記錄checkpoint的資訊,目前checkpoint資訊的讀寫還是用非同步IO來實現的,因為不是很緊急。在window平臺中,如果對特定檔案使用了非同步IO,就這個檔案就不能使用同步IO了,所以引入了資料檔案同步讀寫請求佇列(os_aio_sync_array)。日誌檔案不需要讀非同步請求佇列,因為只有在做奔潰恢復的時候日誌才需要被讀取,而做崩潰恢復的時候,資料庫還不可用,因此完全沒必要搞成非同步讀取模式。這裡有一點需要注意,不管變數innobase_read_io_threads和innobase_write_io_threads兩個引數是多少,os_aio_read_arrayos_aio_write_array都只有一個,只不過資料中的os_aio_slot_t元素會相應增加,在linux中,變數加1,元素數量增加256。例如,innobase_read_io_threads=4,則os_aio_read_array陣列被分成了四部分,每一個部分256個元素,每個部分都有自己獨立的鎖、訊號量以及統計變數,用來模擬4個執行緒,innobase_write_io_threads類似。從這裡我們也可以看出,每個非同步read/write執行緒能快取的讀寫請求是有上限的,即為256,如果超過這個數,後續的非同步請求需要等待。256可以理解為InnoDB層對非同步IO併發數的控制,而在檔案系統層和磁碟層面也有長度限制,分別使用cat /sys/block/sda/queue/nr_requestscat /sys/block/sdb/queue/nr_requests查詢。
os_aio_init在InnoDB啟動的時候呼叫,用來初始化各種結構,包括上述的全域性陣列,還有Simulate aio中用的鎖和互斥量。os_aio_free則釋放相應的結構。os_aio_print_XXX系列的函式用來輸出aio子系統的狀態,主要用在show engine innodb status語句中。

Simulate aio

Simulate aio相對Native aio來說,由於InnoDB自己實現了一套模擬機制,相對比較複雜。

  • 入口函式為os_aio_func,在debug模式下,會校驗一下引數,例如資料塊存放的記憶體地址、檔案讀寫的偏移量和讀寫的資料量是否是OS_FILE_LOG_BLOCK_SIZE的整數倍,但是沒有檢驗檔案開啟模式是否用了O_DIRECT,因為Simulate aio最終都是使用同步IO,沒有必要一定要用O_DIRECT開啟檔案。
  • 校驗通過後,就呼叫os_aio_array_reserve_slot,作用是把這個IO請求分配到某一個後臺io處理執行緒(innobase_xxxx_io_threads分配的,但其實是在同一個全域性陣列中)中,並把io請求的相關資訊記錄下來,方便後臺io執行緒處理。如果IO請求型別相同,請求同一個檔案且偏移量比較接近(預設情況下,偏移量差別在1M內),則InnoDB會把這兩個請求分配到同一個io執行緒中,方便在後續步驟中IO合併。
  • 提交IO請求後,需要喚醒後臺io處理執行緒,因為如果後臺執行緒檢測到沒有IO請求,會進入等待狀態(os_event_wait)。
  • 至此,函式返回,程式可以去幹其他事情了,後續的IO處理交給後臺執行緒了。
    介紹一下後臺IO執行緒怎麼處理的。
  • InnoDB啟動時,後臺IO執行緒會被啟動(io_handler_thread)。其會呼叫os_aio_simulated_handle從全域性陣列中取出IO請求,然後用同步IO處理,結束後,需要做收尾工作,例如,如果是寫請求的話,則需要在buffer pool中把對應的資料頁從髒頁列表中移除。
  • os_aio_simulated_handle首先需要從陣列中挑選出某個IO請求來執行,挑選演算法並不是簡單的先進先出,其挑選所有請求中offset最小的請求先處理,這樣做是為了後續的IO合併比較方便計算。但是這也容易導致某些offset特別大的孤立請求長時間沒有被執行到,也就是餓死,為了解決這個問題,在挑選IO請求之前,InnoDB會先做一次遍歷,如果發現有請求是2s前推送過來的(也就是等待了2s),但是還沒有被執行,就優先執行最老的請求,防止這些請求被餓死,如果有兩個請求等待時間相同,則選擇offset小的請求。
  • os_aio_simulated_handle接下來要做的工作就是進行IO合併,例如,讀請求1請求的是file1,offset100開始的200位元組,讀請求2請求的是file1,offset300開始的100位元組,則這兩個請求可以合併為一個請求:file1,offset100開始的300位元組,IO返回後,再把資料拷貝到原始請求的buffer中就可以了。寫請求也類似,在寫操作之前先把需要寫的資料拷貝到一個臨時空間,然後一次寫完。注意,只有在offset連續的情況下IO才會合併,有間斷或者重疊都不會合並,一模一樣的IO請求也不會合並,所以這裡可以算是一個可優化的點。
  • os_aio_simulated_handle如果發現現在沒有IO請求,就會進入等待狀態,等待被喚醒

綜上所述,可以看出IO請求是一個一個的push的對立面,每push進一個後臺執行緒就拿去處理,如果後臺執行緒優先順序比較高的話,IO合併效果可能比較差,為了解決這個問題,Simulate aio提供類似組提交的功能,即一組IO請求提交後,才喚醒後臺執行緒,讓其統一進行處理,這樣IO合併的效果會比較好。但這個依然有點小問題,如果後臺執行緒比較繁忙的話,其就不會進入等待狀態,也就是說只要請求進入了佇列,就會被處理。這個問題在下面的Native aio中可以解決。
總體來說,InnoDB實現的這一套模擬機制還是比較安全可靠的,如果平臺不支援Native aio則使用這套機制來讀寫資料檔案。

Linux native aio

如果系統安裝了libaio庫且在配置檔案裡面設定了innodb_use_native_aio=on則啟動時候會使用Native aio。

  • 入口函式依然為os_aio_func,在debug模式下,依然會檢查傳入的引數,同樣不會檢查檔案是否以O_DIRECT模式開啟,這算是一個有點風險的點,如果使用者不知道linux native aio需要使用O_DIRECT模式開啟檔案才能發揮出aio的優勢,那麼效能就不會達到預期。建議在此處做一下檢查,有問題輸出到錯誤日誌。
  • 檢查通過之後,與Simulated aio一樣,呼叫os_aio_array_reserve_slot,把IO請求分配給後臺執行緒,分配演算法也考慮了後續的IO合併,與Simulated aio一樣。不同之處,主要是需要用IO請求的引數初始化iocb這個結構。IO請求的相關資訊除了需要初始化iocb外,也需要在全域性陣列的slot中記錄一份,主要是為了在os_aio_print_XXX系列函式中統計方便。
  • 呼叫io_submit提交請求。
  • 至此,函式返回,程式可以去幹其他事情了,後續的IO處理交給後臺執行緒了。
    接下來是後臺IO執行緒。
  • 與Simulate aio類似,後臺IO執行緒也是在InnoDB啟動時候啟動。如果是Linux native aio,後續會呼叫os_aio_linux_handle這個函式。這個函式的作用與os_aio_simulated_handle類似,但是底層實現相對比較簡單,其僅僅呼叫io_getevents函式等待IO請求完成。超時時間為0.5s,也就是說如果即使0.5內沒有IO請求完成,函式也會返回,繼續呼叫io_getevents等待,當然在等待前會判斷一下伺服器是否處於關閉狀態,如果是則退出。

在分發IO執行緒時,儘量把相鄰的IO放在一個執行緒內,這個與Simulate aio類似,但是後續的IO合併操作,Simulate aio是自己實現,Native aio則交給核心完成了,因此程式碼比較簡單。
還要一個區別是,當沒有IO請求的時候,Simulate aio會進入等待狀態,而Native aio則會每0.5秒醒來一次,做一些檢查工作,然後繼續等待。因此,當有新的請求來時,Simulated aio需要使用者執行緒喚醒,而Native aio不需要。此外,在伺服器關閉時,Simulate aio也需要喚醒,Native aio則不需要。

可以發現,Native aio與Simulate aio類似,請求也是一個一個提交,然後一個一個處理,這樣會導致IO合併效果比較差。Facebook團隊提交了一個Native aio的組提交優化:把IO請求首先快取,等IO請求都到了之後,再呼叫io_submit函式,一口氣提交先前的所有請求(io_submit可以一次提交多個請求),這樣核心就比較方便做IO優化。Simulate aio在IO執行緒壓力大的情況下,組提交優化會失效,而Native aio則不會。注意,組提交優化,不能一口氣提交太多,如果超過了aio等待佇列長度,會強制發起一次io_submit。

總結

本文詳細介紹了InnoDB中IO子系統的實現以及使用需要注意的點。InnoDB日誌使用同步IO,資料使用非同步IO,非同步IO的寫盤順序也不是先進先出的模式,這些點都需要注意。Simulate aio雖然有比較大的學習價值,但是在現代作業系統中,推薦使用Native aio。

相關文章