WiredTiger實現:一個LRU cache深坑引發的分析

從mongoDB 3.0版本引入WiredTiger存儲引擎(以下稱爲WT)以來,一直有同學反應在高速寫入數據時WT引擎會間歇性寫掛起,有時候寫延遲達到了幾十秒,這確實是個嚴重的問題。引起這類問題的關鍵在於WT的LRU cache的設計模型,WT在設計LRU cache時採用分段掃描標記hazardpointer的淘汰機制,在WT內部稱這種機制叫eviction cache或者WT cache,其設計目標是充分利用現代計算機超大內存容量來提高事務讀寫併發。在高速不間斷寫時內存操作是非常快的,但是內存中的數據最終必須寫入到磁盤上,將頁數據(page)由內存中寫入磁盤上是需要寫入時間,必定會和應用程序的高速不間斷寫產生競爭,這在任何數據庫存儲引擎都是無法避免的,只是由於WT利用大內存和寫無鎖的特性,讓這種不平衡現象更加顯著。下圖是一位網名叫chszs同學對mongoDB 3.0和3.2版本測試高速寫遇到的hang現象.

圖1

從上圖可以看出,數據週期性出現了hang現象,筆者在單獨對WT進行高併發順序寫時遇到的情況和上圖基本一致,有時候掛起長達20秒。針對此問題我結合WT源碼和調試測試進行了分析,基本得出的結論如下:

1.       WT引擎的eviction cache實現時沒有考慮lru cache的分級淘汰,只是通過掃描btree來標記,這使得它和一些獨佔式btree操作(例如:checkpoint)容易發生競爭。

2.       WTbtree的checkpoint機制設計存在bug,在大量併發寫事務發生時,checkpoint需要很長時間才能完成,造成刷入磁盤的數據很大,寫盤時間很長。容易引起cache 滿而掛起所有的讀寫操作。

3.       WT引擎的redo log文件超過1GB大小後就會另外新建一個新的redo log文件來繼續存儲新的日誌,在操作系統層面上新建一個文件的是需要多次I/O操作,一旦和checkpoint數據刷盤操作同時發生,所有的寫也就掛起了。

要徹底弄清楚這幾個問題,就需要對從WT引擎的eviction cache原理來剖析,通過分析原理找到解決此類問題的辦法。先來看eviction cache是怎麼實現的,爲什麼要這麼實現。

eviction cahce原理

         eviction cache是一個LRU cache,即頁面置換算法緩衝區,LRU cache最早出現的地方是操作系統中關於虛擬內存和物理內存數據頁的置換實現,後被數據庫存儲引擎引入解決內存和磁盤不對等的問題。所以LRU cache主要是解決內存與數據大小不對稱的問題,讓最近將要使用的數據緩存在cache中,把最遲使用的數據淘汰出內存,這是LRU置換算法的基本原則。但程序代碼是無法預測未來的行爲,只能根據過去數據頁的情況來確定,一般我們認爲過去經常使用的數據比不常用的數據未來被訪問的概率更高,很多LRU cache大部分是基於這個規則來設計。

WT的eviction cache也不例外的遵循了這個LRU原理,不過WT的eviction cache對數據頁採用的是分段局部掃描和淘汰,而不是對內存中所有的數據頁做全局管理。基本思路是一個線程階段性的去掃描各個btree,並把btree可以進行淘汰的數據頁添加到一個lru queue中,當queue填滿了後記錄下這個過程當前的btree對象和btree的位置(這個位置是爲了作爲下次階段性掃描位置),然後對queue中的數據頁按照訪問熱度排序,最後各個淘汰線程按照淘汰優先級淘汰queue中的數據頁,整個過程是週期性重複。WT的這個evict過程涉及到多個eviction thread和hazard pointer技術。

WT的evict過程都是以page爲單位做淘汰,而不是以K/V。這一點和memcache、redis等常用的緩存LRU不太一樣,因爲在磁盤上數據的最小描述單位是page block,而不是記錄。

eviction線程模型

         從上面的介紹可以知道WT引擎的對page的evict過程是個多線程協同操作過程,WT在設計的時候採用一種叫做leader-follower的線程模型,模型示意圖如下:

圖2

Leader thread負責週期性掃描所有內存中的btree索引樹,將符合evict條件的page索引信息填充到eviction queue,當填充queue滿時,暫停掃描並記錄下最後掃描的btree對象和btree上的位置,然後對queue中的page按照事務的操作次數和訪問次做一次淘汰評分,再按照評分從小到大做排序。也就是說最評分越小的page越容易淘汰。下個掃描階段的起始位置就是上個掃描階段的結束位置,這樣能保證在若干個階段後所有內存中的page都被掃描過一次,這是爲了公平性。這裏必須要說明的是一次掃描很可能只是掃描內存一部分btree對象,而不是全部,所以我對這個過程稱爲階段性掃描(evict pass),它不是對整個內存中的page做評分排序。這個階段性掃描的間隔時間是100毫秒,而觸發這個evict pass的條件就是WT cache管理的內存超出了設置的閾值,這個在後面的eviction cache管理的內存小節中詳細介紹。

         在evict pass後,如果evction queue中有等待淘汰的page存在就會觸發一個操作系統信號來激活follower thread來進行evict page工作。雖然evict pass的間隔時間通常是100毫秒,這裏有個問題就是當WT cache的內存觸及上限並且有大量寫事務發生時,讀寫事務線程在事務開始時會喚醒leader thread和follower thread,這就會產生大量的操作系統上下文切換,系統性能急劇下降。好在WT-2.8版本修復了這個問題,leader follower通過搶鎖來成爲leader,通過多線程信號合併和週期性喚醒來follower,而且leader thread也承擔evict page的工作,可以避免大部分的線程喚醒和上下文切換。是不是有點像Nginx的網絡模型?

 

hazard pointer

hazard pointer是一個無鎖併發技術,其應用場景是單個線程寫和多個線程讀的場景,大致的原理是這樣的,每個讀的線程設計一個與之對應的無鎖數組用於標記這個線程引用的hazard pointer對象。讀線程的步驟如下:

1.       讀線程在訪問某個hazard pointer對象時,先將在自己的標記數組中標記訪問的對象。

2.       讀線程在訪問完畢某個hazard pointer對象時,將其對應的標記從標記數組中刪除。

寫線程的步驟大致是這樣的,寫線程如果需要對某個hazard pointer對象寫時,先判斷所有讀線程是否標記了這個對象,如果標記了,放棄寫。如果未標記,進行寫。

關於hazard pointer理論可以訪問https://www.research.ibm.com/people/m/michael/ieeetpds-2004.pdf

 

         Hazardpointer是怎樣應用在WT中呢?我們這樣來看待這個事情,把內存page的讀寫看做hazard pointer的讀操作,把page從內存淘汰到磁盤上的過程看做hazard pointer的寫操作,這樣瞬間就能明白爲什麼WT在頁的操作上可以不遵守The FIX Rules規則,而是採用無鎖併發的頁操作。要達到這種訪問方式有個條件就是內存中page本身的結構要支持lock free訪問,這個在《剖析WiredTiger數據頁無鎖及壓縮》一文中介紹過了。從上面的描述可以看出evict page的過程中首先要做一次hazard pointer寫操作檢查而後才能進行page的reconcile和數據落盤。

hazard pointer併發技術的應用是整個WT存儲引擎的關鍵,它關係到btree結構、internal page的構造、事務線程模型、事務併發等實現。Hazard pointer使得WT不依賴The Fix Rules規則,也讓WT的btree結構更加靈活多變。

Hazard pointer是比較新的無鎖編程模式,可以應用在很多地方,筆者曾在一個高併發媒體服務器上用到這個技術,以後有機會把裏面的技術細節分享出來。

eviction cache管理的內存

         evictioncache其實就是內存管理和page淘汰系統,目標就是爲了使得管轄的內存不超過物理內存的上限,而觸發淘汰evict page動作的基礎依據就是內存上限。eviction cache管理的內存就是內存中page的內存空間,page的內存分爲幾部分:

1.       從磁盤上讀取到已經刷盤的數據,在page中稱作disk buffer。如果WT沒有開啓壓縮且使用的MMAP方式讀寫磁盤,這個disk buffer的數據大小是不計在WT eviction cache管理範圍之內的。如果是開啓壓縮,會將從MMAP讀取到的page數據解壓到一個WT 分配的內存中,這個新分配的內存是計在WT eviction cache中的。

2.       Page在內存中新增的修改事務數據內存空間,計入在eviction cache中。

3.       Page基本的數據結構所有的內存空間,計入在eviction cache中。

PS:關於page結構和內存相關的細節請查看《剖析WiredTiger數據頁無鎖及壓縮》。

WT在統計page的內存總量是通過一個footprint機制來統計兩項數據,一項是總的內存使用量mem_size,一項是增刪改造成的髒頁數據總量dirty_mem_size。統計方式很簡單,就是每次對頁進行載入、增刪改、分裂和銷燬時對上面兩項數據做原子增加或者減少計數,這樣可以精確計算到當前系統中WT引擎內存佔用量。假設引擎外部配置最大內存空間爲cache_size,內存上限觸發evict的比例爲80%,內存髒頁上限觸發evict的比例爲75%.那麼系統觸發evict pass操作的條件爲:

         mem_size> cache_size * 80%

或者

         dirty_mem_size> cache_size * 75%

滿足這個條件leader線程就會進行evict pass階段性掃描並填充eivction queue,最後驅使follower線程進行evict page操作。

evict pass策略

         前面介紹過evict pass是一個階段性掃描的過程,整個過程分爲掃描階段、評分排序階段和evict調度階段。掃描階段是通過掃描內存中btree,檢查btree在內存中的page對象是否可以進行淘汰。掃描步驟如下:

1.       根據上次evict pass最後掃描的btree和它對應掃描的位置最爲本次evict pass的起始位置,如果當前掃描的btree被其他事務線程設成獨佔訪問方式,跳過當前btree掃描下個btree對象

2.       進行btree遍歷掃描,如果page滿足淘汰條件,將page的索引對象添加到evict queue中,淘汰條件爲:

ü  如果page是數據頁,必須page當前最新的修改事務必須早以evict pass事務。

ü  如果page是btree內部索引頁,必須page當前最新的修改事務必須早以evict pass事務且當前處於evict queue中的索引頁對象不多於10個。

ü  當前btree不處於正建立checkpoint狀態

3.       如果本次evict pass當前的btree有超過100個page在evict queue中或者btree處於正在建立checkpoint時,結束這個btree的掃描,切換到下一個btree繼續掃描。

4.       如果evict queue填充滿時或者本次掃描遍歷了所有btree,結束本次evict pass。

PS:在開始evict pass時,evict queue可能存在有上次掃描且未淘汰出內存的page,那麼這次evict pass一定會讓queue填滿(大概400個page)。

 

評分排序階段是在evict pass後進行的,當queue中有page時,會根據每個page當前的訪問次數、page類型和淘汰失敗次數等計算一個淘汰評分,然後按照評分從小打到進行快排,排序完成後,會根據queue中最大分數和最小分數計算一個淘汰邊界evict_throld,queue中所有大於evict_throld的page不列爲淘汰對象。

WT爲了讓btree索引頁儘量保存在內存中,在評分的時候索引頁的分值會加上1000000分,讓btree索引頁免受淘汰。

evict pass最後會做個判斷,如果有follower線程存在,用系統信號喚醒follower進行evict page。如果系統中沒有follower,leader線程進行eivct page操作。這個模型在WT-2.81版本已經修改成搶佔模式。

 

evict page過程

         evictpage其實就是將evict queue中的page數據先寫入到磁盤文件中,然後將內存中的page對象銷燬回收。整個evict page也分爲三個階段:從evict queue中獲取page對象、hazard pointer判斷和page的reconcile過程,整個過程的步驟如下:

1.       從evict queue頭開始獲取page,如果發現page的索引對象不爲空,對page進行LOCKED原子性標記防止其他讀事務線程引用並將page的索引從queue中刪除。

2.       對淘汰的page進行hazard pointer,如果有其他線程對page標記hazard pointer, page不能被evict出內存,將page的評分加100.

3.       如果沒有其他線程對page標記hazard pointer,對page進行reconcile並銷燬page內存中的對象。

evict page的過程大部分是由follower thread來執行,這個在上面的線程模型一節中已經描述過。但在一個讀寫事務開始之前,會先檢查WT cache是否有足夠的內存空間進行事務執行,如果WT cache的內存容量觸及上限閾值,事務執行線程會嘗試去執行evict page工作,如果evict page失敗,會進行線程堵塞等待直到 WT cache有執行讀寫事務的內存空間(是不是讀寫掛起了?)。這種狀況一般出現在正在建立checkpoint的時候,那麼checkpoint是怎麼引起這個現象的呢?下面來分析緣由。

 

eviction cache與checkpoint之間的事

     衆所周知,建立checkpoint的過程是將內存中所有的髒頁(dirty page)同步刷入磁盤上並將redo log的重演位置設置到最後修改提交事務的redo log位置,相對於WT引擎來說,就是將eviction cache中的所有髒頁數據刷入磁盤但並不將內存中的page淘汰出內存。這個過程其實和正常的evict過程是衝突的,而且checkpoint過程中需要更多的內存完成這項工作,這使得在一個高併發寫的數據庫中有可能出現掛起的狀況發生。爲了更好的理解整個問題的細節,我們先來看看WT checkpoint的原理和過程。

btree的checkpoint

         WT引擎中的btree建立checkpoint過程還是比較複雜的,過程的步驟也比較多,而且很多步驟會涉及到索引、日誌、事務和磁盤文件等。我以WT-2.7(mongoDB 3.2)版本爲例子,checkpoint大致的步驟如下圖:

圖3

在上圖中,其中綠色的部分是在開始checkpoint事務之前會將所有的btree的髒頁寫入文件OS cache中,如果在高速寫的情況下,寫的速度接近也reconcile的速度,那麼這個過程將會持續很長時間,也就是說OS cache中會存在大量未落盤的數據。而且在WT中btree採用的copy on write(寫時複製)和extent技術,這意味OS cache中的文件數據大部分是在磁盤上是連續存儲的,那麼在綠色框最後一個步驟會進行同步刷盤,這個時候如果OS cache的數據量很大就會造成這個操作長時間佔用磁盤I/O。這個過程是會把所有提交的事務修改都進行reconcile落盤操作。

 

在上圖的紫色是真正開始checkpoint事務的步驟,這裏需要解釋的是由於前面綠色步驟刷盤時間會比較長,在這個時間範圍裏會有新的寫事務發生,也就意味着會新的髒頁,checkpint必須把最新提交的事務修改落盤而且還要防止btree的分裂,這個時候就會獲得btree的獨佔排他式訪問,這時 eviction cache不能對這個btree上的頁進行evict操作(在這種情況下是不是容易造成WT cache滿而掛起讀寫事務?)。

 

PS:WT-2.8版本之後對checkpoint改動非常大,主要是針對上面兩點做了拆分,防止讀寫事務掛起發生,但大體過程是差不多的。

寫掛起

         通過前面的分析大概知道寫掛起的原因了,主要引起掛起的現象主要是因爲寫內存的速度遠遠高於寫磁盤的速度。先來看一份內存和磁盤讀寫的速度的數據吧。順序讀寫的對比:

圖4

從上圖可以看出,SATA磁盤的順序讀寫1MB數據大概需要8ms, SSD相對快一點,大概只需2ms.但內存的讀寫遠遠大於磁盤的速度。SATA的隨機讀取算一次I/O時間,大概在8ms 到10ms,SSD的隨機讀寫時間比較快,大概0.1ms。

         我們來分析checkpoint時掛起讀寫事務的幾種情況,假設系統在高速寫某一張表(每秒以100MB/S的速度寫入),每1分鐘做一次checkpoint。那麼1分鐘後開始進行圖3中綠色的步驟,這個步驟會在這一分鐘之內寫入的髒數據壓縮先後寫入到OS cache中,OS Cache可能存有近2GB的數據。這2GB的sync刷到磁盤上的時間至少需要10 ~ 20秒,而且磁盤I/O是被這個同步刷盤的任務佔用了。這個時候有可能發生幾件事情:

1.       外部的寫事務還在繼續,事務提交時需要寫redo log文件,這個時候磁盤I/O被佔用了,寫事務掛起等待。

2.       外部的讀寫事務還在繼續,redo log文件滿了,需要新建一個新的redo log文件,但是新建文件需要多次隨機I/O操作,磁盤I/O暫時無法調度來創建文件,所有寫事務掛起。

3.       外部讀寫事務線程還在繼續,因爲WT cache觸發上限閾值需要evict page。Evict page時也會調用reconcile將page寫入OS cache,但這個文件的OS cache正在進行sync,evict page只能等sync完成才能寫入OS cache,evict page線程掛起,其他讀寫事務在開始時會判斷是否有足夠的內存進行事務執行,如果沒有足夠內存,所有讀寫事務掛起。

這三種情況是因爲階段性I/O被耗光而造成讀寫事務掛起的。

 

         在圖3紫色步驟中,checkpoint事務開始後會先獲得btree的獨佔排他訪問方式,這意味這個btree對象上的page不能進行evict,如果這個btree索引正在進行高速寫入,有可能讓checkpoint過程中數據頁的reconcile時間很長,從而耗光WT cache內存造成讀寫事務掛起現象,這個現象極爲在測試中極爲少見(碰見過兩次)。 要解決這幾個問題只要解決內存和磁盤I/O不對等的問題就可以了。

內存和磁盤I/O的權衡

         引起寫掛起問題的原因多種多樣,但歸根結底是因爲內存和磁盤速度不對稱的問題。因爲WT的設計原則就是讓數據儘量利用現代計算機的超大內存,可是內存中的髒數據在checkpoint時需要同步寫入磁盤造成瞬間I/O很高,這是矛盾的。要解決這些問題個人認爲有以下幾個途徑:

1.       將MongoDB的WT版本升級到2.8,2.8版本對evict queue模型做了分級,儘量避免evict page過程中堵塞問題,2.8的checkpoint機制不在是分爲預前刷盤和checkpoint刷盤,而是採用逐個對btree直接做checkpoint刷盤,緩解了OS cache緩衝太多的文件髒數據問題。

2.       試試direct I/O或許會有不同的效果,WT是支持direct I/O模式。筆者試過direct I/O模式,讓WT cache徹底接管所有的物理內存管理,寫事務的併發會比MMAP模式少10%,但沒有出現過超過1秒的寫延遲問題。

3.       嘗試將WT cache設小點,大概設置成整個內存的1/4左右。這種做法是可以緩解OS cache中瞬間緩存太多文件髒數據的問題,但會引起WT cache頻繁evict page和頻繁的leader-follower線程上下文切換。而且這種機制也依賴於OS page cache的刷盤週期,週期太長效果不明顯。

4.       用多個磁盤來存儲,redo log文件放在一個單獨的機械磁盤上,數據放在單獨一個磁盤上,避免redo log與checkpoint刷盤發生競爭。

5.       有條件的話,換成將磁盤換成SSD吧。這一點比較難,mongoDB現在也大量使用在OLAP和大數據存儲,而高速寫的場景都發生這些場景,成本是個問題。如果是OLTP建議用SSD。

這些方法只能緩解讀寫事務掛起的問題,不能說徹底解決這個問題,WT引擎發展很快,開發團隊正對WT eviction cache和checkpoint正在做優化,這個問題慢慢變得不再是問題,尤其是WT-2.8版本,大量的模型和代碼優化都是集中在這個問題上。

 

後記

         WT的eviction cache可能有很多不完善的地方,也確實給我們在使用的過程造成了一些困撓,應該用中立的角度去看待它。可以說它的讀寫併發速度是其他數據庫引擎不能比的,正是由於它很快,纔會有寫掛起的問題,因爲磁盤的速度就那麼快。以上的分析和建議或許對碰到類似問題的同學有用。

WT團隊的研發速度也很快,每年會發布2 到3個版本,這類問題是他們正在重點解決的問題。在國內也有很多mongoDB這方面相關的專家,他們在解決此類問題有非常豐富的經驗,也可以請求他們來幫忙解決這類問題。

在本文問題分析過程中得到了阿里雲張友東的幫助,在此表示感謝

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章