日誌先行的技術普遍應用於現代數據庫中,其保證了數據庫在數據不丟的狀況下,進一步提升了數據庫的性能。本文主要分析了WAL模塊在MySQL各個版本中的演進以及在阿里雲新一代數據庫POLARDB中的改進。mysql
用戶若是對數據庫中的數據就好了修改,必須保證日誌先於數據落盤。當日志落盤後,就能夠給用戶返回操做成功,並不須要保證當時對數據的修改也落盤。若是數據庫在日誌落盤前crash,那麼相應的數據修改會回滾。在日誌落盤後crash,會保證相應的修改不丟失。有一點要注意,雖然日誌落盤後,就能夠給用戶返回操做成功,可是因爲落盤和返回成功包之間有一個微小的時間差,因此即便用戶沒有收到成功消息,修改也可能已經成功了,這個時候就須要用戶在數據庫恢復後,經過再次查詢來肯定當前的狀態。 在日誌先行技術以前,數據庫只須要把修改的數據刷回磁盤便可,用了這項技術,除了修改的數據,還須要多寫一份日誌,也就是磁盤寫入量反而增大,可是因爲日誌是順序的且每每先存在內存裏而後批量往磁盤刷新,相比數據的離散寫入,日誌的寫入開銷比較小。 日誌先行技術有兩個問題須要工程上解決:算法
MySQL中爲了解決上述兩個問題,採用瞭如下機制:sql
在第一點中的,咱們提到了私有變量mtr,這個結構除了存儲了修改產生的日誌和髒頁外,還存儲了修改髒頁時加的鎖。在適當的時候(例如日誌提交完且髒頁加入到髒頁鏈表)能夠把鎖給釋放。數據庫
接下來,咱們結合各個版本的實現,來剖析一下具體實現細節。注意,如下內容須要一點MySQL源碼基礎,適合MySQL內核開發者以及資深的DBA。數組
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版本,不少分支都在上面作了很多優化,可是主要的處理邏輯變化依然不大:
日誌進入全局緩存:
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的臨界區,從而減小了熱點。此外,引入多個髒頁鏈表,減小了單個鏈表帶來的衝突。 注意,主流的分支還作了不少其餘的優化,例如:
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的日誌子系統在大多數場景下不會達到瓶頸。可是,用戶線程往全局日誌緩存拷貝日誌以及髒頁加入髒頁鏈表這兩個操做,依然是基於鎖機制的,很難發揮出多核系統的性能。
以前的版本雖然作了不少優化,可是沒有真正作到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做爲阿里雲下一代關係型雲數據庫,咱們天然在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上,還作了額外的優化:
最後咱們測試了一下性能,在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機制的實現,但願對你們有所幫助。