MySQL · 引擎特性 · WAL那些事兒

前言

日誌先行的技術普遍應用於現代數據庫中,其保證了數據庫在數據不丟的狀況下,進一步提升了數據庫的性能。本文主要分析了WAL模塊在MySQL各個版本中的演進以及在阿里雲新一代數據庫POLARDB中的改進。mysql

基礎知識

用戶若是對數據庫中的數據就好了修改,必須保證日誌先於數據落盤。當日志落盤後,就能夠給用戶返回操做成功,並不須要保證當時對數據的修改也落盤。若是數據庫在日誌落盤前crash,那麼相應的數據修改會回滾。在日誌落盤後crash,會保證相應的修改不丟失。有一點要注意,雖然日誌落盤後,就能夠給用戶返回操做成功,可是因爲落盤和返回成功包之間有一個微小的時間差,因此即便用戶沒有收到成功消息,修改也可能已經成功了,這個時候就須要用戶在數據庫恢復後,經過再次查詢來肯定當前的狀態。 在日誌先行技術以前,數據庫只須要把修改的數據刷回磁盤便可,用了這項技術,除了修改的數據,還須要多寫一份日誌,也就是磁盤寫入量反而增大,可是因爲日誌是順序的且每每先存在內存裏而後批量往磁盤刷新,相比數據的離散寫入,日誌的寫入開銷比較小。 日誌先行技術有兩個問題須要工程上解決:算法

  1. 日誌刷盤問題。因爲全部對數據的修改都須要寫日誌,當併發量很大的時候,必然會致使日誌的寫入量也很大,爲了性能考慮,每每須要先寫到一個日誌緩衝區,而後在按照必定規則刷入磁盤,此外日誌緩衝區大小有限,用戶會源源不斷的生產日誌,數據庫還須要不斷的把緩存區中的日誌刷入磁盤,緩存區才能夠複用,所以,這裏就構成了一個典型的生產者和消費者模型。現代數據庫必須直面這個問題,在高併發的狀況下,這必定是個性能瓶頸,也必定是個鎖衝突的熱點。
  2. 數據刷盤問題。在用戶收到操做成功的時候,用戶的數據不必定已經被持久化了,頗有可能修改尚未落盤,這就須要數據庫有一套刷數據的機制,專業術語叫作刷髒頁算法。髒頁(內存中被修改的可是還沒落盤的數據頁)在源源不斷的產生,而後要持續的刷入磁盤,這裏又湊成一個生產者消費者模型,影響數據庫的性能。若是在髒頁沒被刷入磁盤,可是數據庫異常crash了,這個就須要作奔潰恢復,具體的流程是,在接受用戶請求以前,從checkpoint點(這個點以前的日誌對應的數據頁必定已經持久化到磁盤了)開始掃描日誌,而後應用日誌,從而把在內存中丟失的更新找回來,最後從新刷入磁盤。這裏有一個很重要的點:在數據庫正常啓動的期間,checkpoint怎麼肯定,若是checkpoint作的慢了,就會致使奔潰恢復時間過長,從而影響數據庫可用性,若是作的快了,會致使刷髒壓力過大,甚至數據丟失。

MySQL中爲了解決上述兩個問題,採用瞭如下機制:sql

  1. 當用戶線程產生日誌的時候,首先緩存在一個線程私有的變量(mtr)裏面,只有完成某些原子操做(例如完成索引分裂或者合併等)的時候,才把日誌提交到全局的日誌緩存區中。全局緩存區的大小(innodb_log_file_size)能夠動態配置。當線程的事務執行完後,會按照當前的配置(innodb_flush_log_at_trx_commit)決定是否須要把日誌從緩衝區刷到磁盤。
  2. 當把日誌成功拷貝到全局日誌緩衝區後,會繼續把當前已經被修改過的髒頁加入到一個全局的髒頁鏈表中。這個鏈表有一個特性:按照最先被修改的時間排序。例如,有數據頁A,B,C,數據頁A早上9點被第一次修改,數據頁B早上9點01分被第一次修改,數據頁C早上9點02分被第一次修改,那麼在這個鏈表上數據頁A在最前,B在中間,C在最後。即便數據頁A在早上9點以後又一次被修改了,他依然排在B和C以前。在數據頁上,有一個字段來記錄這個最先被修改的時間:oldest_modification,只不過單位不是時間,而是lsn,即從數據庫初始化開始,一共寫了多少個字節的日誌,因爲其是一個遞增的值,所以能夠理解爲廣義的時間,先寫的數據,其產生的日誌對應的lsn必定比後寫的小。在髒頁列表上的數據頁,就是按照oldest_modification從小到大排序,刷髒頁的時候,就從oldest_modification小的地方開始。checkpoint就是髒頁列表中最小的那個oldest_modification,由於這種機制保證小於最小oldest_modification的修改都已經刷入磁盤了。這裏最重要的是,髒頁鏈表的有序性,假設這個有序性被打破了,若是數據庫異常crash,就會致使數據丟失。例如,數據頁ABC的oldest_modification分別爲120,100,150,同時在髒頁鏈表上的順序依然爲A,B,C,A在最前面,C在最後面。數據頁A被刷入磁盤,而後checkpoint被更新爲120,可是數據頁B和C都還沒被刷入磁盤,這個時候,數據庫crash,重啓後,從checkpoint爲120開始掃描日誌,而後恢復數據,咱們會發現,數據頁C的修改被恢復了,可是數據頁B的修改丟失了。

在第一點中的,咱們提到了私有變量mtr,這個結構除了存儲了修改產生的日誌和髒頁外,還存儲了修改髒頁時加的鎖。在適當的時候(例如日誌提交完且髒頁加入到髒頁鏈表)能夠把鎖給釋放。數據庫

接下來,咱們結合各個版本的實現,來剖析一下具體實現細節。注意,如下內容須要一點MySQL源碼基礎,適合MySQL內核開發者以及資深的DBA。數組

MySQL 5.1版本的處理方式

5.1的版本是MySQL比較早的版本,那個時候InnoDB仍是一個插件。所以設計也相對粗糙,簡化後的僞代碼以下:緩存

日誌進入全局緩存:數據結構

mutex_enter(log_sys->mutex);
copy local redo log to global log buffer
mtr.start_lsn = log_sys->lsn
mtr.end_lsn = log_sys->lsn + log_len + log_block_head_or_tail_len
increase global lsn: log_sys->lsn, log_sys->buf_free
for every lock in mtr
    if (lock == share lock)
        release share lock directly
    else if (lock == exclusive lock)
        if (lock page is dirty)
            if (page.oldest_modification == 0)  //This means this page is not in flush list
        page.oldest_modification = mtr.start_lsn
               add to flush list           // have one flush list only
        release exclusive lock
mutex_exit(log_sys->mutex);

日誌寫入磁盤:多線程

mutex_enter(log_sys->mutex);
log_sys->write_lsn = log_sys->lsn;
write log to log file
mutex_exit(log_sys->mutex);

更新checkpoint:併發

page = get_first_page(flush_list)
checkpoint_lsn = page.oldest_modification
write checkpoint_lsn to log file

奔潰恢復:app

read checkpoint_lsn from log file
start parse and apply redo log from checkpoint_lsn point

從上述僞代碼中能夠看出,因爲日誌進入全局的緩存都在臨界區內,不但保證了拷貝日誌的有序性,也保證了髒頁進入髒頁鏈表的有序性。須要獲取checkpoint_lsn時,只需從髒頁鏈表中獲取第一個數據頁的oldest_modification便可。奔潰恢復也只須要從記錄的checkpoint點開始掃描便可。在高併發的場景下,有不少線程須要把本身的local日誌拷貝到全局緩存,會形成鎖熱點,另外在全局日誌寫入日誌文件的地方,也須要加鎖,進一步形成了鎖的爭搶。此外,這個數據庫的緩存(Buffer Pool)只有一個髒頁鏈表,性能也不高。這種方式存在於早期的InnoDB代碼中,通俗易懂,但在如今的多核系統上,顯然不能作到很好的擴展性。

MySQL 5.5,5.6,5.7版本的處理方式

這三個版本是目前主流的MySQL版本,不少分支都在上面作了很多優化,可是主要的處理邏輯變化依然不大:

日誌進入全局緩存:

mutex_enter(log_sys->mutex);
copy local redo log to global log buffer
mtr.start_lsn = log_sys->lsn
mtr.end_lsn = log_sys->lsn + log_len + log_block_head_or_tail_len
increase global lsn: log_sys->lsn, log_sys->buf_free
mutex_enter(log_sys->log_flush_order_mutex);
mutex_exit(log_sys->mutex);
for every page in mtr
   if (lock == exclusive lock)
    if (page is dirty)
        if (page.oldest_modification == 0)  //This means this page is not in flush list
        page.oldest_modification = mtr.start_lsn
                add to flush list according to its buffer pool instance
mutex_exit(log_sys->log_flush_order_mutex);
for every lock in mtr
    release all lock directly

日誌寫入磁盤:

mutex_enter(log_sys->mutex);
log_sys->write_lsn = log_sys->lsn;
write log to log file
mutex_exit(log_sys->mutex);

更新checkpoint:

for ervery flush list:
    page = get_first_page(curr_flush_list);
    if current_oldest_modification > page.oldest_modification
    current_oldest_modification = page.oldest_modification
checkpoint_lsn = current_oldest_modification
write checkpoint_lsn to log file

奔潰恢復:

read checkpoint_lsn from log file
start parse and apply redo log from checkpoint_lsn point

主流的版本中最重要的一個優化是,除了log_sys->mutex外,引入了另一把鎖log_sys->log_flush_order_mutex。在髒頁加入到髒頁鏈表的操做中,不須要log_sys->mutex保護,而是須要log_sys->log_flush_order_mutex保護,這樣減小了log_sys->mutex的臨界區,從而減小了熱點。此外,引入多個髒頁鏈表,減小了單個鏈表帶來的衝突。 注意,主流的分支還作了不少其餘的優化,例如:

  1. 引入雙全局日誌緩存。若是隻有一個全局日誌緩存,當這個日誌緩存在寫盤的時候,會致使後續的用戶線程沒法往裏面拷貝日誌,直到刷盤結束。有了雙日誌緩存,其中一個用來接收用戶提交過來的日誌,另一個能夠用來把以前的日誌刷盤,這樣用戶線程不須要等待。
  2. 日誌自動擴展。若是發現當前須要拷貝的日誌比全局的日誌緩存一半還大,就會自動把全局日誌緩存給擴大一倍。注意,只要擴大後,就不會再縮小了。
  3. 日誌對齊。早期的磁盤都是512原子寫,現代的SSD磁盤大部分是4K原子寫。若是小於4K的寫入,會致使先把4K先讀取出來,而後內存中修改,再寫下去,性能低下。可是有了日誌對齊這個優化後,能夠以指定大小刷日誌,不夠大的後面填0補齊,能提升寫入效率。 這裏貼一個優化後的日誌寫入磁盤的僞代碼:
    mutex_enter(log_sys->write_mutex);
    check if other thead has done write for us
    mutex_enter(log_sys->mutex);
    calculate the range log need to be write
    switch log buffer so that user threads can still copy log during writing
    mutex_exit(log_sys->mutex);
    align log to specified size if needed
    write log to log file 
    log_sys->write_lsn = log_sys->lsn;
    mutex_exit(log_sys->write_mutex);

    能夠看到log_sys->mutex被進一步縮小。往日誌文件裏面寫日誌的階段已經不準要log_sys->mutex保護了。 有了以上的優化,MySQL的日誌子系統在大多數場景下不會達到瓶頸。可是,用戶線程往全局日誌緩存拷貝日誌以及髒頁加入髒頁鏈表這兩個操做,依然是基於鎖機制的,很難發揮出多核系統的性能。

MySQL 8.0版本的處理方式

以前的版本雖然作了不少優化,可是沒有真正作到lock free,在高併發下,能夠看到不少鎖衝突。官方所以在這塊下了大力氣,徹頭徹尾的大改了一番。 詳細細節能夠參考上個月這篇月報。 這裏再簡單歸納一下。 在日誌寫入階段,經過atomic變量分配保留空間,因爲atomic變量增加是個原子操做,因此這一步不要加鎖。分配完空間後,就能夠拷貝日誌,因爲上一步中空間已經被預留,因此多線程能夠同時進行拷貝,而不會致使日誌有重疊。可是不能保證拷貝完成的前後順序,有可能先拷貝的,後完成,因此須要有一種機制來保證某個點以前的日誌已經都拷貝到全局日誌緩存了。這裏,官方就引入了一種新的lock free數據結構Link_buf,它是一個數組,用來標記拷貝完成的狀況。每一個用戶線程完成拷貝後,就在那個數組中標記一下,而後後臺再開一個線程來計算是否有連續的塊完成拷貝了,完成了就能夠把這些日誌刷到磁盤。 在髒頁插入髒頁鏈表這一塊,官方也提出了一種有趣的算法,它也是基於新的lock free數據結構Link_buf。基本思想是,髒頁鏈表的有序性能夠被部分的打破,也就是說,在必定範圍內能夠無序,可是總體仍是有序的。這個無序程序是受控的。假設髒頁鏈表第一個數據頁的oldest_modification爲A, 在以前的版本中,這個髒頁鏈表後續的page的oldest_modification都嚴格大於等於A,也就是不存在一個數據頁比第一個數據頁還老。在MySQL 8.0中,後續的page的oldest_modification並非嚴格大於等於A,能夠比A小,可是必須大於等於A-L,這個L能夠理解爲無序度,是一個定值。那麼問題來了,若是髒頁鏈表順序亂了,那麼checkpoint怎麼肯定,或者說是,奔潰恢復後,從那個checkpoint_lsn開始掃描日誌才能保證數據不丟。官方給出的解法是,checkpoint依然由髒頁鏈表中第一個數據頁的oldest_modification的肯定,可是奔潰恢復從checkpoint_lsn-L開始掃描(有可能這個值不是一個mtr的邊界,所以須要調整)。 因此能夠看到,官方經過link_buf這個數據結構很巧妙的解決了局部日誌往全局日誌拷貝的問題以及髒頁插入髒頁鏈表的問題。因爲都是lock free算法,所以擴展性會比較好。 可是,從實際測試的狀況來看,彷佛是由於用了太多的條件變量event,在咱們的測試中沒有官方標稱的性能。後續咱們會進一步分析緣由。

POLARDB FOR MYSQL的處理方式

POLARDB做爲阿里雲下一代關係型雲數據庫,咱們天然在InnoDB日誌子系統作了不少優化,其中也包含了上述的領域。這裏能夠簡單介紹一下咱們的思路:

每一個buffer pool instance都額外增長了一把讀寫鎖(rw_locks),主要用來控制對全局日誌緩存的訪問。 此外還引入兩個存儲髒頁信息的集合,咱們這裏簡稱in-flight set和ready-to-process set。主要用來臨時存儲髒頁信息。

日誌進入全局緩存:

release all share locks holded by this mtr's page
acquire log_buf s-locks for all buf_pool instances for which we have dirty pages
reserver enough space on log_buf via increasing atomit variables        //Just like MySQL 8.0
copy local log to global log buffer
add all pages dirtied by this mtr to in-flight set
release all exclusive locks holded by this mtr's page
release log_buf s-locks for all buf_pool instances

日誌寫入磁盤:

mutex_enter(log_sys->write_mutex)
check if other thead has done write for us
mutex_enter(log_sys->mutex)
acquire log_buf x-locks for all buf_pool instances
update log_sys->lsn to newest
switch log buffer so that user threads can still copy log during writing
mutex_exit(log_sys->mutex)
release log_buf x-locks for all buf_pool instances
align log to specified size if needed
write log to log file 
log_sys->write_lsn = log_sys->lsn;
mutex_exit(log_write_mutex)

刷髒線程(每一個buffer pool instance):

acquire log_buf x-locks for specific buffer pool instance
toggle in-flight set with ready-to-process set. Only this thread will toggle between these two.
release log_buf x-locks for specific buffer pool instance
for each page in ready-to-process
   add page to flush list
do normal flush page operations

更新checkpoint:

for ervery flush list:
    acquire log_buf x-locks for specific buffer pool instance
    ready_to_process_lsn = minimum oldest_modification in ready-to-process set
    flush_list_lsn = get_first_page(curr_flush_list).oldest_modification
    min_lsn = min(ready_to_process_lsn, flush_list_lsn)
    release log_buf x-locks for specific buffer pool instance
    if current_oldest_modification > min_lsn
    current_oldest_modification = min_lsn
checkpoint_lsn = current_oldest_modification
write checkpoint_lsn to log file

奔潰恢復:

read checkpoint_lsn from log file
start parse and apply redo log from checkpoint_lsn point

在局部日誌拷貝入全局日誌這塊,與官方MySQL 8.0相似,首先利用atomic變量的原子增加來分配空間,可是MySQL 8.0是使用link_buf來保證拷貝完成,而在POLARDB中,咱們使用讀寫鎖的機制,即在拷貝以前加上讀鎖,拷貝完才釋放讀鎖,而在日誌寫入磁盤前,首先嚐試加上寫鎖,利用寫鎖和讀鎖互斥的特性,保證在獲取寫鎖時全部讀鎖都釋放,即全部拷貝操做都完成。 在髒頁進入髒頁鏈表這塊,官方MySQL容許髒頁鏈表有必定的無序度(也是經過link_buf保證),而後經過在奔潰恢復的時候從checkpoint_lsn-L開始掃描的機制,來保證數據的一致性。在POLARDB中,咱們解決辦法是,把髒頁臨時加入到一個集合,在刷髒線程工做前再按順序加入髒頁鏈表,經過獲取寫鎖來保證在加入髒頁鏈表前,整個集合是完整的。換句話說,假設這個髒頁集合最小的oldest_modification爲A,那麼能夠保證沒有加入髒頁集合的髒頁的oldest_modification都大於等於A。 從髒頁集合加入到髒頁鏈表的操做,咱們沒有加鎖,因此在更行checkpoint的時候,咱們須要使用min(ready_to_process_lsn, flush_list_lsn)來做爲checkpoint_lsn。在奔潰恢復的時候,直接從checkpoint_lsn掃描便可。 此外,咱們在POLARDB上,還作了額外的優化:

  1. 提早釋放page的共享鎖。若是一個數據頁被加了共享鎖,說明沒有被修改,只是被讀取而已,咱們能夠提早釋放掉,這有助於減小熱點數據頁的鎖衝突。
  2. 在日誌進入全局緩存時,咱們沒有及時更新log_sys->lsn,而是先更新另一個變量,當在日誌寫入磁盤前,即獲取log_buf寫鎖後,而後在更新log_sys->lsn。主要是爲了減小衝突。

最後咱們測試了一下性能,在non_index_updates的全內存高併發測試下,性能有10%的提升。

Upstream 5.6.40: 71K
MySQL-8.0: 132K
PolarDB (master): 162K
PolarDB(master + mtr_optimize): 178K

固然,這不是咱們最高的性能,能夠小小透露一下,經過對事務子系統的優化,咱們能夠達到200K的性能。 更多更好用的功能都在路上,歡迎使用POLARDB!

總結

日誌子系統是關係型數據庫不可獲取的模塊,也是數據庫內核開發者很是感興趣的模塊,本文結合代碼分析了MySQL不一樣版本的WAL機制的實現,但願對你們有所幫助。

文章連接:https://yq.aliyun.com/articles/617331

相關文章
相關標籤/搜索