上篇介紹了PolarDB數據庫及其后端共享存儲PolarFS系統的基本架構和組成模塊,是最基礎的部分。本篇重點分析PolarFS的數據IO流程,元數據更新流程,以及PolarDB數據庫節點如何適配PolarFS這樣的共享存儲系統。
PolarFS的數據IO操作
寫操作
一般情況下,寫操作不會涉及到卷上文件系統的元數據更新,因為在寫之前就已經通過libpfs的pfs_posix_fallocate()這個API將Block預分配給文件,這就避免在讀寫IO路徑上出現代價較高的文件系統元數據同步過程。上圖是PolarFS的寫操作流程圖,每步操作解釋如下:
POLARDB通過libpfs發送一個寫請求Request1,經由ring buffer發送到PolarSwitch;
PolarSwitch根據本地緩存的元數據,將Request1發送至對應Chunk的Leader節點(ChunkServer1);
Request1到達ChunkServer1后,節點上的RDMA NIC將Request1放到一個預分配好的內存buffer中,基於Request1構造一個請求對象,並將該對象加到請求隊列中。一個IO輪詢線程不斷輪詢這個請求隊列,一旦發現有新請求則立即開始處理;
IO處理線程通過異步調用將Request1通過SPDK寫到Chunk對應的WAL日志塊上,同時將請求通過RDMA異步發向給Chunk的Follower節點(ChunkServer2、ChunkServer3)。由於都是異步調用,所以數據傳輸是並發進行的;
當Request1請求到達ChunkServer2、ChunkServer3后,同樣通過RDMA NIC將其放到預分配好的內存buffer並加入到復制隊列中;
Follower節點上的IO輪詢線程被觸發,Request1通過SPDK異步地寫入該節點的Chunk副本對應的WAL日志塊上;
當Follower節點的寫請求成功后,會在回調函數中通過RDMA向Leader節點發送一個應答響應;
Leader節點收到ChunkServer2、ChunkServer3任一節點成功的應答后,即形成Raft組的majority。主節點通過SPDK將Request1寫到請求中指定的數據塊上;
隨后,Leader節點通過RDMA NIC向PolarSwitch返回請求處理結果;
PolarSwitch標記請求成功並通知上層的POLARDB。
讀請求無需這么復雜的步驟,lipfs發起的讀請求直接通過PolarSwitch路由到數據對應Chunk的Leader節點(ChunkServer1),從其中讀取對應的數據返回即可。需要說明的是,在ChunkServer上有個子模塊叫IoScheduler,用於保證發生並發讀寫訪問時,讀操作能夠讀到最新的已提交數據。
基於用戶態的網絡和IO路徑
在本地IO處理上,PolarFS基於預分配的內存buffer來處理請求,將buffer中的內容直接使用SPDK寫入WAL日志和數據塊中。PolarFS讀寫數據基於SPDK套件直接通過DMA操作硬件設備(SSD卡)而不是操作系統內核IO協議棧,解決了內核IO協議棧慢的問題;通過輪詢的方式監聽硬件設備IO完成事件,消除了上下文切換和中斷的開銷。還可以將IO處理線程和CPU進行一一映射,每個IO處理線程獨占CPU,相互之間處理不同的IO請求,綁定不同的IO設備硬件隊列,一個IO請求生命周期從頭到尾都在一個線程一顆CPU上處理,不需要鎖進行互斥。這種技術實現最大化的和高速設備進行性能交互,實現一顆CPU達每秒約20萬次IO處理的能力,並且保持線性的擴展能力,也就意味着4顆CPU可以達到每秒80萬次IO處理的能力,在性能和經濟型上遠高於內核。
網絡也是類似的情況。過去傳統的以太網,網卡發一個報文到另一台機器,中間通過一跳交換機,大概需要一百到兩百微秒。POLARDB支持ROCE以太網,通過RDMA網絡,直接將本機的內存寫入另一台機器的內存地址,或者從另一台機器的內存讀一塊數據到本機,中間的通訊協議編解碼、重傳機制都由RDMA網卡來完成,不需要CPU參與,使性能獲得極大提升,傳輸一個4K大小報文只需要6、7微秒的時間。
如同內核的IO協議棧跟不上高速存儲設備能力,內核的TCP/IP協議棧跟不上高速網絡設備能力,也被POLARDB的用戶態網絡協議棧代替。這樣就解決了HDFS和Ceph等目前的分布式文件系統存在的性能差、延遲大的問題。
基於ParallelRaft的數據可靠性保證
在PolarFS中,位於不同ChunkServer上的3個Chunk數據副本使用改進型Raft協議ParallelRaft來保障可靠性,通過快速主從切換和majority機制確保能夠容忍少部分Chunk副本離線時仍能夠持續提供在線讀寫服務,即數據的高可用。
在標准的Raft協議中,raft日志是按序被Follower節點確認,按序被Leader節點提交的。這是因為Raft協議不允許出現空洞,一條raft日志被提交,意味着它之前的所有raft日志都已經被提交。在數據庫系統中,對不同數據的並發更新是常態,也正因為這點,才有了事務的組提交技術,但如果引入Raft協議,意味着組提交技術在PolarFS數據多副本可靠性保障這一層退化為串行提交,對於性能會產生很大影響。通過將多個事務batch成一個raft日志,通過在一個Raft Group的Leader和Follower間建立多個連接來同時處理多個raft日志這兩種方式(batching&pipelining)能夠緩解性能退化。但batch會導致額外延遲,batch也不能過大。pipelining由於Raft協議的束縛,仍然需要保證按序確認和提交,如果出現由於網絡等原因導致前后pipeline上的raft日志發送往follow或回復leader時亂序,那么就不可避免得出現等待。
為了進一步優化性能,PolarFS對Raft協議進行了改進。核心思想就是解除按序確認,按序提交的束縛。將其變為亂序確認,亂序提交和亂序應用。首先看看這樣做的可行性,假設每個raft日志代表一個事務,多個事務能夠並行提交說明其不存在沖突,對應到存儲層往往意味着沒有修改相同的數據,比如事務T1修改File1的Block1,事務T2修改File1的Block2。顯然,先修改Block1還是Block2對於存儲層還是數據庫層都沒有影響。這真是能夠亂序的基礎。下圖為優化前后的性能表現:
但T1和T2都修改了同一個表的數據,導致表的統計信息發生了變化,比如T1執行后表中有10條記錄,T2執行后變為15條(舉例而已,不一定正確)。所以,他們都需要更新存儲層的相同BlockX,該更新操作就不能亂序了。
為了解決上述所說的問題,ParallelRaft協議引入look behind buffer(LBB)。每個raft日志都有個LBB,緩存了它之前的N個raft日志所修改的LBA信息。LBA即Logical Block Address,表示該Block在Chunk中的偏移位置,從0到10GB。通過判斷不同的raft日志所包含的LBA是否有重合來決定能否進行亂序/並行應用,比如上面的例子,先后修改了BlockX的raft日志就可以通過LBB發現,如果T2對BlockX的更新先完成了確認和提交,在應用前通過LBB發現所依賴的T1對BlockX的修改還沒有應用。那么就會進入pending隊列,直到T1對BlockX完成應用。
另外,亂序意味着日志會有空洞。因此,Leader選舉階段額外引入了一個Merge階段,填補Leader中raft日志的空洞,能夠有效保障協議的Leader日志的完整性。
PolarFS元數據管理與更新
PolarFS各節點元數據維護
libpfs僅維護文件塊(塊在文件中的偏移位置)到卷塊(塊在卷中的偏移位置)的映射關系,並未涉及到卷中Chunk跟ChunkServer間的關系(Chunk的物理位置信息),這樣libpfs就跟存儲層解耦,為Chunk分配實際物理空間時無需更新libpfs層的元數據。而Chunk到ChunkServer的映射關系,也就是物理存儲空間到卷的分配行為由PolarCtrl組件負責,PolarCtrl完成分配后會更新PolarSwitch上的緩存,確保libpfs到ChunkServer的IO路徑是正確的。
Chunk中Block的LBA到Block真實物理地址的映射表,以及每塊SSD盤的空閑塊位圖均全部緩存在ChunkServer的內存中,使得用戶數據IO訪問能夠全速推進。
PolarFS元數據更新流程
前面我們介紹過,PolarDB為每個數據庫實例創建了一個volume/卷,它是一個文件系統,創建時生成了對應的元數據信息。由於PolarFS是個可多點掛載的共享訪問分布式文件系統,需要確保一個掛載點更新的元數據能夠及時同步到其他掛載點上。比如一個節點增加/刪除了文件,或者文件的大小發生了變化,這些都需要持久化到PolarFS的元數據上並讓其他節點感知到。下面我們來討論PolarFS如何更新元數據並進行同步。
PolarFS的每個卷/文件系統實例都有相應的Journal文件和與之對應的Paxos文件。Journal文件記錄了文件系統元數據的修改歷史,是該卷各個掛載點之間元數據同步的中心。Journal文件邏輯上是一個固定大小的循環buffer,PolarFS會根據水位來回收Journal。如果一個節點希望在Journal文件中追加項,其必需使用DiskPaxos算法來獲取Journal文件控制權。
正常情況下,為了確保文件系統元數據和數據的一致性,PolarFS上的一個卷僅設置一個計算節點進行讀寫模式掛載,其他計算節點以只讀形式掛載文件系統,讀寫節點鎖會在元數據記錄持久化后馬上釋放鎖。但是如果該讀寫節點crash了,該鎖就不會被釋放,為此加在Journal文件上的鎖會有過期時間,在過期后,其他節點可以通過執行DiskPaxos來重新競爭對Journal文件的控制權。當PolarFS的一個掛載節點開始同步其他節點修改的元數據時,它從上次掃描的位置掃描到Journal末尾,將新entry更新到節點的本地緩存中。PolarFS同時使用push和pull方式來進行節點間的元數據同步。
下圖展示了文件系統元數據更新和同步的過程:
Node 1是讀寫掛載點,其在pfs_fallocate()調用中將卷的第201個block分配給FileID為316的文件后,通過Paxos文件請求互斥鎖,並順利獲得鎖。
Node 1開始記錄事務至journal中。最后寫入項標記為pending tail。當所有的項記錄之后,pending tail變成journal的有效tail。
Node1更新superblock,記錄修改的元數據。與此同時,node2嘗試獲取訪問互斥鎖,由於此時node1擁有的互斥鎖,Node2會失敗重試。
Node2在Node1釋放lock后(可能是鎖的租約到期所致)拿到鎖,但journal中node1追加的新項決定了node2的本地元數據是過時的。
Node2掃描新項后釋放lock。然后node2回滾未記錄的事務並更新本地metadata。最后Node2進行事務重試。
Node3開始自動同步元數據,它只需要load增量項並在它本地重放即可。
PolarFS的元速度更新機制非常適合PolarDB一寫多讀的典型應用擴展模式。正常情況下一寫多讀模式沒有鎖爭用開銷,只讀實例可以通過原子IO無鎖獲取Journal信息,從而使得PolarDB可以提供近線性的QPS性能擴展。
數據庫如何適配PolarFS
大家可能認為,如果讀寫實例和只讀實例共享了底層的數據和日志,只要把只讀數據庫配置文件中的數據目錄換成讀寫實例的目錄,貌似就可以直接工作了。但是這樣會遇到很多問題,MySQL適配PolarFS有很多細節問題需要處理,有些問題只有在真正做適配的時候還能想到,下面介紹已知存在的問題並分析數據庫層是如何解決的。
數據緩存和數據一致性
從數據庫到硬件,存在很多層緩存,對基於共享存儲的數據庫方案有影響的緩存層包括數據庫緩存,文件系統緩存。
數據庫緩存主要是InnoDB的Buffer Pool(BP),存在2個問題:
讀寫節點的數據更改會緩存在bp上,只有完成刷臟頁操作后polarfs才能感知,所以如果在刷臟之前只讀節點發起讀數據操作,讀到的數據是舊的;
就算PolarFS感知到了,只讀節點的已經在BP中的數據還是舊的。所以需要解決不同節點間的緩存一致性問題。
PolarDB采用的方法是基於redolog復制的節點間數據同步。可能我們會想到Primary節點通過網絡將redo日志發送給ReadOnly/Replica節點,但其實並不是,現在采用的方案是redo采用非ring buffer模式,每個文件固定大小,大小達到后Rotate到新的文件,在寫模式上走Direct IO模式,確保磁盤上的redo數據是最新的,在此基礎上,Primary節點通過網絡通知其他節點可以讀取的redo文件及偏移位置,讓這些節點自主到共享存儲上讀取所需的redo信息,並進行回放。流程如下圖所示:
由於StandBy節點與讀寫節點不共享底層存儲,所以需要走網絡發送redo的內容。節點在回放redo時需區分是ReadOnly節點還是StandBy節點,對於ReadOnly節點,其僅回放對應的Page頁已在BP中的redo,未在BP中的page不會主動從共享存儲上讀取,且BP中Apply過的Page也不會回刷到共享存儲。但對於StandBy節點,需要全量回放並回刷到底層存儲上。
文件系統緩存主要是元數據緩存問題。文件系統緩存包括Page Cache,Inode/Dentry Cache等,對於Page Cache,可以通過Direct IO繞過。但對於VFS(Virtual File System)層的Inode Cache,無法通過Direct IO模式而需采用o_sync的訪問模式,但這樣導致性能嚴重下降,沒有實際意義。vfs層cache無法通過direct io模式繞過是個很嚴重的問題,這就意味着讀寫節點創建的文件,只讀節點無法感知,那么針對這個新文件的后續IO操作,只讀節點就會報錯,如果采用內核文件系統,不好進行改造。
PolarDB通過元數據同步來解決該問題,它是個用戶態文件系統,數據的IO流程不走內核態的Page Cache,也不走VFS的Inode/Dentry Cache,完全自己掌控。共享存儲上的文件系統元數據通過前述的更新流程實現即可。通過這種方式,解決了最基本的節點間數據同步問題。
事務的數據可見性問題
一、MySQL/InnoDB通過Undo日志來實現事務的MVCC,由於只讀節點跟讀寫節點屬於不同的mysqld進程,讀寫節點在進行Undo日志Purge的時候並不會考慮此時在只讀節點上是否還有事務要訪問即將被刪除的Undo Page,這就會導致記錄舊版本被刪除后,只讀節點上事務讀取到的數據是錯誤的。
針對該問題,PolarDB提供兩種解決方式:
所有ReadOnly定期向Primary匯報自己的最大能刪除的Undo數據頁,Primary節點統籌安排;
當Primary節點刪除Undo數據頁時候,ReadOnly接收到日志后,判斷即將被刪除的Page是否還在被使用,如果在使用則等待,超過一個時間后還未有結束則直接給客戶端報錯。
二、還有個問題,由於InnoDB BP刷臟頁有多種方式,其並不是嚴格按照oldest modification來的,這就會導致有些事務未提交的頁已經寫入共享存儲,只讀節點讀到該頁后需要通過Undo Page來重建可見的版本,但可能此時Undo Page還未刷盤,這就會出現只讀上事務讀取數據的另一種錯誤。
針對該問題,PolarDB解決方法是:
限制讀寫節點刷臟頁機制,如果臟頁的redo還沒有被只讀節點回放,那么該頁不能被刷回到存儲上。這就確保只讀節點讀取到的數據,它之前的數據鏈是完整的,或者說只讀節點已經知道其之前的所有redo日志。這樣即使該數據的記錄版本當前的事務不可見,也可以通過undo構造出來。即使undo對應的page是舊的,可以通過redo構造出所需的undo page。
replica需要緩存所有未刷盤的數據變更(即RedoLog),只有primary節點把臟頁刷入盤后,replica緩存的日志才能被釋放。這是因為,如果數據未刷盤,那么只讀讀到的數據就可能是舊的,需要通過redo來重建出來,參考第一點。另外,雖然buffer pool中可能已經緩存了未刷盤的page的數據,但該page可能會被LRU替換出去,當其再次載入所以只讀節點必須緩存這些redo。
DDL問題
如果讀寫節點把一個表刪了,反映到存儲上就是把文件刪了。對於mysqld進程來說,它會確保刪除期間和刪除后不再有事務訪問該表。但是在只讀節點上,可能此時還有事務在訪問,PolarFS在完成文件系統元數據同步后,就會導致只讀節點的事務訪問存儲出錯。
PolarDB目前的解決辦法是:如果主庫對一個表進行了表結構變更操作(需要拷表),在操作返回成功前,必須通知到所有的ReadOnly節點(有一個最大的超時時間),告訴他們,這個表已經被刪除了,后續的請求都失敗。當然這種強同步操作會給性能帶來極大的影響,有進一步的優化的空間。
Change Buffer問題
Change Buffer本質上是為了減少二級索引帶來的IO開銷而產生的一種特殊緩存機制。當對應的二級索引頁沒有被讀入內存時,暫時緩存起來,當數據頁后續被讀進內存時,再進行應用,這個特性也帶來的一些問題,該問題僅存在於StandBy中。例如Primary節點可能因為數據頁還未讀入內存,相應的操作還緩存在Change Buffer中,但是StandBy節點則因為不同的查詢請求導致這個數據頁已經讀入內存,可以直接將二級索引修改合並到數據頁上,無需經過Change Buffer了。但由於復制的是Primary節點的redo,且需要保證StandBy和Primary在存儲層的一致性,所以StandBy節點還是會有Change Buffer的數據頁和其對應的redo日志,如果該臟頁回刷到存儲上,就會導致數據不一致。
為了解決這個問題,PolarDB引入shadow page的概念,把未修改的數據頁保存到其中,將cChange Buffer記錄合並到原來的數據頁上,同時關閉該Mtr的redo,這樣修改后的Page就不會放到Flush List上。也就是StandBy實例的存儲層數據跟Primary節點保持一致。
性能測試
性能評估不是本文重點,官方的性能結果也不一定是靠譜的,只有真實測試過了才算數。在此僅簡單列舉阿里雲自己的性能測試結果,權當一個參考。
PolarFS性能
不同塊大小的IO延遲
4KB大小的不同請求類型
PolarDB整體性能
使用不同底層存儲時性能表現
對外展示的性能表現
與Aurora簡單對比
阿里雲的PolarDB和AWS Aurora雖然同為基於MySQL和共享存儲的Cloud-Native Database(雲原生數據庫)方案,很多原理是相同的,包括基於redo的物理復制和計算節點間狀態同步。但在實現上也存在很大的不同,Aurora在存儲層采用日志即數據的機制,計算節點無需再將臟頁寫入到存儲節點,大大減少了網絡IO量,但這樣的機制需要對InnoDB存儲引擎層做很大的修改,難度極大。而PolarDB基本上遵從了原有的MySQL IO路徑,通過優化網絡和IO路徑來提高網絡和IO能力,相對來說在數據庫層面並未有框架性的改動,相對容易些。個人認為Aurora在數據庫技術創新上更勝一籌,但PolarDB在數據庫系統級架構優化上做得更好,以盡可能小的代價獲得了足夠好的收益。
另附PolarFS的架構師曹偉在知乎上對PolarDB和Aurora所做的對比:
在設計方法上,阿里雲的PolarDB和Aurora走了不一樣的路,歸根結底是我們的出發點不同。AWS的RDS一開始就是架設在它的虛擬機產品EC2之上的,使用的存儲是雲盤EBS。EC2和EBS之間通過網絡通訊,因此AWS的團隊認為“網絡成為數據庫的瓶頸”,在Aurora的論文中,他們開篇就提出“Instead, the bottleneck moves to the network between the database tier requesting I/Os and the storage tier that performs these I/Os.” Aurora設計於12到13年之際,當時網絡主流是萬兆網絡,確實容易成為瓶頸。而PolarDB是從15年開始研發的,我們見證了IDC從萬兆到25Gb RDMA網絡的飛躍。因此我們非常大膽的判斷,未來幾年主機通過高速網絡互聯,其傳輸速率會和本地PCIe總線存儲設備帶寬打平,網絡無論在延遲還是帶寬上都會接近總線,因此不再成為高性能服務器的瓶頸。而恰恰是軟件,過去基於內核提供的syscall開發的軟件代碼,才是拖慢系統的一環。Bottleneck resides in the software.
在架構上Aurora和PolarDB各有特色。我認為PolarDB的架構和技術更勝一籌。
1)現代雲計算機型的演進和分化,計算機型向高主頻,多CPU,大內存的方向演進;存儲機型向高密度,低功耗方向發展。機型的分化可以大大提高機器資源的使用率,降低TCO。
因此PolarStore中大量采用OS-bypass和zero-copy的技術來節約CPU,降低處理單位I/O吞吐需要消耗的CPU資源,確保存儲節點處理I/O請求的效率。而Aurora的存儲節點需要大量CPU做redolog到innodb page的轉換,存儲節點的效率遠不如PolarStore。
2)Aurora架構的最大亮點是,存儲節點具有將redolog轉換為innodb page的能力,這個改進看着很吸引眼球,事實上這個優化對關系數據庫的性能提升很有限,性能瓶頸真的不在這里:),反而會拖慢關鍵路徑redolog落地的性能。btw,在PolarDB架構下,redolog離線轉換為innodb page的能力不難實現,但我們目前不認為這是高優先級要做的。
3)Aurora的存儲多副本是通過quorum機制來實現的,Aurora是六副本,也就是說,需要計算節點向六個存儲節點分別寫六次,這里其實計算節點的網絡開銷又上去了,而且是發生在寫redolog這種關鍵路徑上。而PolarDB是采用基於RDMA實現的ParallelRaft技術來復制數據,計算節點只要寫一次I/O請求到PolarStore的Leader節點,由Leader節點保證quorum寫入其他節點,相當於多副本replication被offload到存儲節點上。
此外,在最終一致性上Aurora是用gossip協議來兜底的,在完備程度上沒有PolarDB使用的ParallelRaft算法有保證。
4)Aurora的改動手術切口太大,使得它很難后面持續跟進社區的新版本。這也是AWS幾個數據庫產品線的通病,例如Redshift,如何吸收PostgrelSQL 10的變更是他們的開發團隊很頭疼的問題。對新版本做到與時俱進是雲數據庫的一個朴素需求。怎么設計這個刀口,達到effect和cost之間的平衡,是對架構師的考驗。
總得來說,PolarDB將數據庫拆分為計算節點與存儲節點2個獨立的部分,計算節點在已有的MySQL數據庫基礎上進行修改,而存儲節點基於全新的PolarFS共享存儲。PolarDB通過計算和存儲分離的方式實現提供了即時生效的可擴展能力和運維能力,同時采用RDMA和SPDK等最新的硬件來優化傳統的過時的網絡和IO協議棧,極大提升了數據庫性能,基本上解決了使用MySQL是會遇到的各種問題,除此之外本文並未展開介紹PolarDB的ParallelRaft,其依托上層數據庫邏輯實現IO亂序提交,大大提高多個Chunk數據副本達成一致性的性能。以上這些創新和優化,都成為了未來數據庫的發展方向。
參數資料:
本文來自網易雲社區 ,經作者溫正湖授權發布。
網易雲免費體驗館,0成本體驗20+款雲產品!
更多網易研發、產品、運營經驗分享請訪問網易雲社區。
相關文章:
【推薦】 漫畫:深入淺出 ES 模塊
【推薦】 HBase – 存儲文件HFile結構解析