PolarDB及其分佈式文件系統PolarFS的架構實現

PolarDB及其分佈式文件系統PolarFS的架構實現

PolarDB是阿里雲基於MySQL推出的新一代雲原生(Cloud Native)數據庫產品,所謂雲原生數據庫,指的是一種融合了衆多創新技術而跨界的雲數據庫服務,它可以更好地服務於雲環境下的應用場景,本質上是雲的能力和SQL能力的融合。

因爲PolarDB並不開源,所以我們不能從代碼層面進行解讀,只能從阿里雲公開的技術資料以及他們在VLDB 2018上發表的分佈式文件系統PolarFS(PolarDB的共享存儲文件系統)的論文基礎上進行解讀和分析。

雲原生數據庫出現的背景

在雲原生數據庫出現之前,傳統的MySQL RDS已經在雲服務上有了嘗試,但是在嘗試過程中,出現了很多傳統MySQL RDS無法解決的痛點問題,比如說:

  • 擴展困難。傳統MySQL不管是縱向擴展機器硬件,還是橫向增加備庫都需要遷徙數據,擴展週期比較長,無法從容應對突如其來的業務高峯,這使得數據庫服務不能服務於那些負載不確定的應用。
  • 成本浪費。傳統MySQL的只讀庫需要擁有一份與主庫完全相同的數據副本,用戶想要增加只讀庫的數目,不僅要增加計算成本還要增加存儲成本。
  • 主從切換時間長。傳統MySQL通常採用主從異步複製的HA架構,主從切換後從庫可能需要重建,導致切換時間太長,數據庫系統長時間不可用。
  • 複製延遲高。傳統MySQL的讀寫分離通常採用redolog來進行主從複製,但是這種邏輯複製延遲較高,備庫常常會出現讀延遲。
  • 存儲不均衡。在傳統的MySQL雲服務上,有些機器上可能是大實例,而有些機器上是小實例,往往大實例導致會存儲資源不夠用,而小實例又浪費了很多存儲資源。
  • binlog日誌。傳統的MySQL爲了兼容多種存儲引擎,記錄了兩種事務日誌(binlog和redolog),binlog 邏輯複製性能較差,不同日誌間的一致性管理又會影響系統的性能。

有了上面這些痛點,傳統的MySQL RDS漸漸不能滿足應用的需求,雲原生數據庫就應運而生了。

說到雲原生數據庫,就不得不提到AWS的Aurora數據庫。其在2014年下半年發佈後,轟動了整個數據庫領域。Aurora對MySQL存儲層進行了大刀闊斧的改造,將其拆爲獨立的存儲節點(主要做數據塊存儲,數據庫快照的服務器)。上層的MySQL計算節點(主要做SQL解析以及存儲引擎計算的服務器)共享同一個存儲節點,可在同一個共享存儲上快速部署新的計算節點,高效解決服務能力擴展和服務高可用問題。基於日誌即數據的思想,大大減少了計算節點和存儲節點間的網絡IO,進一步提升了數據庫的性能。再利用存儲領域成熟的快照技術,解決數據庫數據備份問題。被公認爲關係型數據庫的未來發展方向之一。

毫無疑問,其後推出的雲原生數據庫產品多多少少受收到了Aurora的影響,這其中就包括本文介紹的PolarDB數據庫,它也是借鑑了很多Aurora的技術實現,採用了計算和存儲分離和全用戶態的架構,並且大量使用新硬件。

PolarDB架構

PolarDB在進行架構設計時,遵循四個原則:

  1. 計算和存儲分離;
  2. 全用戶態,零拷貝;
  3. ParellelRaft,支持亂序確認、亂序提交。
  4. 大量使用新硬件:RDMA、NVMe、SPDK等等。
    PolarDB架構

上圖給出了PolarDB的架構概覽,可以看到PolarDB的上層是計算層,下層是存儲層,存儲和計算是分離的,中間通過RDMA高速網絡相連接。在計算層有一個主節點,這個主節點負責處理讀寫請求,其餘是備節點,備節點是隻讀節點。主節點個所有備節點之間採用的是Shared Everything架構,即共享存儲層數據和日誌文件。

存儲和計算分離之後,相當於將數據庫系統一體化的架構做了水平的切分。這樣做主要以下幾點優勢:

  • 可以根據計算層和存儲層不同的特點去配置不同的硬件和資源。在計算節點,我們更關注的是CPU和內存,而存儲層更關注的則是IO的響應時間和成本。
  • 存儲和計算分離之後,數據庫應用的持久狀態下沉到存儲層,計算層不持有數據,所以數據庫實例可以在計算節點上靈活地做各種遷移和擴展。在存儲層,PolarDB的存儲是一個共享的分佈式文件系統,它可以有自己的複製策略來提供較高的數據可用性和可靠性。
  • 存儲分離之後,存儲層上的多個節點的存儲資源就可以形成單一的存儲池,存儲資源池化能夠解決傳統數據庫中存儲碎片、節點間負載不均衡以及存儲空間浪費等問題。

顯然,採用存儲和計算分離的架構設計可以完美解決上述傳統MySQL RDS的絕大多數痛點問題。雖然存儲和計算分離的好處多多,但是目前可用的分佈式共享文件系統卻寥寥無幾,不論是Hadoop生態圈佔統治地位的HDFS,還是在通用存儲領域風生水起的Ceph,都不能滿足數據庫系統的性能需求,並且還存在大量與現有數據庫系統的適配問題。

因此,想要讓數據庫系統實現存儲和計算的分離的同時還能夠具備良好的性能和可靠性,還需要針對數據庫來設計專門的分佈式文件系統。因此,PolarDB還專門設計了PolarFS(共享的分佈式文件系統)來實現上述的存儲和計算分離的架構設計。

分佈式共享文件系統PolarFS

PolarFS
上面這張圖給出了從PolarFS視角下看到的PolarDB的實現架構。簡單點來看,PolarDB可以分兩個部分:

  1. 第一部分爲數據庫服務(上圖中的POLARDB),這部分主要是負責客戶端SQL請求解析、事務處理、查詢優化等數據庫服務的計算操作。
  2. 第二部分爲分佈式文件系統PolarFS(上圖中的libpfs、PolarCtrl、PolarSwitch、ChunkServers),主要負責數據存儲、數據IO、數據一致性、元數據管理等數據庫服務的存儲操作。

第一部分的數據庫服務是完全兼容MySQL的,這是傳統數據庫的知識,我們這裏就不做介紹了。第二部分的分佈式文件系統PolarFS則是PolarDB數據庫實現存儲和計算分離最重要的特性和基礎,下面會針對其每一個組件進行單獨介紹。

PolarFS存儲資源的組織方式

在介紹PolarFS各組件的功能之前,我們先介紹一下PolarFS存儲資源的組織方式。PolarFS將存儲資源分爲三層來進行封裝和管理,分別爲:Volume(卷)、Chunk(區)、Block(塊)。

Volume

當用戶申請創建PolarDB數據庫實例時,系統就會爲該實例分配一個Volume(卷),每一個數據庫實例就對應一個Volume。每一個Volume由多個Chunks組成,一個Volume的容量大小範圍是10GB至100TB,數據庫系統可以通過向Volume添加Chunks來按需擴展數據庫實例的容量。這也就是說PolarDB支持用戶創建的實例大小範圍爲10GB至100TB,這滿足了絕大多數雲數據庫實例的容量要求。

Volume上也存放了文件系統的元數據,這些元數據包括:

  1. directory entry:目錄項,一個目錄項保存了一個文件的路徑,一個目錄項中同時也包含一個inode的引用。所有目錄項被組織成了一棵目錄樹(directory tree)。
  2. inode:一個inode描述的是一個常規文件或者是一個目錄,這表示每一個文件名對應一個indode,不論其表示的是常規文件還是目錄。對於常規文件來說,這個inode保存了一組塊標記(block tag)的引用,用來指示這個文件存儲在哪些塊上。而對於一個目錄來說,這個inode保存了該父目錄中的子目錄項。
  3. block tag:每一個塊標記描述了一個文件塊號(file block number)到一個卷塊號(volume block number)的映射。

在PolarFS中,這三種元數據被抽象爲一種叫做元對象(metaobject)的數據類型,用這個公共的數據類型可以用來訪問磁盤和內存中的元數據。PolarFS用一個MySQL數據庫實例來存儲文件系統的元數據,並且在各節點的內存中也緩存這些元數據信息。

由於PolarFS是一種共享訪問分佈式文件系統,因此還要保證文件系統元數據在各節點之間的一致性,比如一個節點增加、刪除了文件,或者是改變了文件的大小,這些更改元數據的操作都要持久化到磁盤中並及時同步到各節點上。爲了實現元數據的一致性,PolarFS中每個文件系統實例都有相應的Journal文件和Paxos文件來管理元數據的更新。關於PolarFS元數據的更新、協調和同步會在後面做更加詳細地描述。

Chunk

一個Volume分成了多個Chunks,這些Chunks則分佈在各個ChunkServers上。Chunk是數據分發的最小單位了,單個Chunk只會存放於存儲節點的一個NVMe SSD盤上,它不會跨越ChunkServer出現在多個盤上,並且其副本默認複製到不同機器的三個ChunkServer上,這樣做的目的是利於數據高可靠和高可用的管理。使用ParallelRaft(變種的Raft協議,後面會介紹)在保證副本的一致性的同時,還最大化了IO的吞吐量。

在PolarFS中,Chunk的大小被置爲10G。選擇這麼大的容量作爲文件系統最小的存儲單元可以大大減少元數據的數量,並且還能夠簡化元數據的管理。同時,這樣做可以使元數據方面的緩存在各節點的內存中,從而有效避免了關鍵IO路徑上額外的元數據訪問開銷。但這樣做也有不足,比如當上層應用出現區域級的熱點訪問時,Chunk內的熱點數據無法進一步打散。但由於每個存儲節點提供的Chunk數量往往遠大於節點數量(節點:Chunk在1:1000量級),同時PolarFS可以支持Chunk的在線遷移,因此可以將熱點Chunk分佈到不同節點上以獲得整體的負載均衡。

Block

在ChunkServers內,Chunk進一步被劃分爲Block,每個Block的大小爲64KB。PolarFS可以根據按需分配Block,並將其映射到Chunk中,以此來實現精簡配置。Chunk至Block的映射信息由ChunkServer自行管理和保存,除數據Block之外,每個Chunk還包含一些額外Block用來實現Write Ahead Log。我們也將本地映射元數據全部緩存在ChunkServer的內存中,使得用戶數據的I/O訪問能夠全速推進。

PolarFS的各個組件

介紹完PolarFS存儲資源的組織方式後,下面我們來詳細介紹PolarFS各組件的功能。

libpfs

libpfs是一個輕量級的用戶空間庫,PolarFS採用了編譯到數據庫的形態,替換標準的文件系統接口,這使得全部的I/O路徑都在用戶空間中,數據處理在用戶空間完成,儘可能減少數據的拷貝。這樣做的目的是避免傳統文件系統從內核空間至用戶空間的消息傳遞開銷,尤其數據拷貝的開銷,比較典型的例子是:數據庫系統向磁盤寫入數據之前,需要先寫操作系統的文件系統緩存,然後纔會真正的寫入到磁盤中。這一點對於PolarFS採用的大量低延遲新硬件來說至關重要。

其提供了類POSIX的文件系統接口API(見下圖)來訪問底層的存儲,因而付出很小的修改代價即可完成數據庫的用戶空間化。

當數據庫節點啓動時,會調用pfs_mount()掛載其對應的volume,並且初始化文件系統狀態,該操作會加載文件系統的元數據並將其緩存在本地計算節點上。數據庫系統銷燬期間調用pfs_umount()來分離volume並釋放資源。在對捲進行擴容後,通過調用pfs_mount_growfs()函數來識別新分配的塊,同時增加其空間映射到文件系統中。

PolarSwitch

PolarSwitch是部署在計算節點的Daemon,它負責將I/O請求映射到具體的存儲節點上。數據庫(POLARDB)通過libpfs將I/O請求發送給PolarSwitch,每個請求包含了數據庫實例所在的Volume id、起始偏移和長度。在某些情況下,一個I/O請求可能會需要跨越訪問多個Chunks,這時PolarSwitch會進一步將I/O請求劃分爲多個子請求。最終,PolarSwitch將這些請求發往Chunk的Leader(三副本一致性協議中的主)所屬的ChunkServer完成訪問。

具體來說,PolarSwitch根據自己緩存的Volume id到Chunk的映射表,知道該I/O請求屬於那個Chunk。PolarSwitch還緩存了該Chunk的三個副本分別屬於哪幾個ChunkServer以及哪個ChunkServer是當前的Leader節點,PolarSwitch只將請求發送給Leader節點。

ChunkServer

ChunkServer部署在存儲節點上,一個存儲節點可以有多個ChunkServer。每個ChunkServer綁定到一個CPU核,並管理一個獨立的NVMe SSD盤,因此ChunkServers之間沒有資源爭搶。

ChunkServer負責Chunk內的資源映射和讀寫。每個Chunk都包括一個Write Ahead Log(WAL),對Chunk的修改會先寫Log再修改,保證數據的原子性和持久性。ChunkServer使用了一塊固定大小的3D XPoint SSD buffer作爲WAL的寫緩存,Log會優先存放到更快的3D XPoint SSD中,如果3D XPoint buffer不夠用,Log纔會寫入NVMe SSD中。對Chunk中數據的修改都是直接寫到NVMe SSD中。

ChunkServer會複製寫請求到對應的Chunk副本(其他機器的ChunkServer)上,PolarFS通過自己定義的Parallel Raft一致性協議保證了在各類故障狀況下數據的正確同步並且保障已Commit數據不丟失。與此同時,Parallel Raft一致性協議能夠更好的支持高併發的I/O。

PolarCtrl

PolarCtrl是PolarFS集羣的控制平臺,他被部署在一組專用的機器上(至少三個)去提供高可用服務。PolarCtrl還是集羣的控制中心,主要負責集羣的節點管理、Volume管理、資源分配、元數據同步、監控負載等等。更具體的說,其主要的職責包括:

  • 跟蹤存儲節點上所有ChunkServers的活躍度和將康狀況,包括剔除出現故障的ChunkServer,遷移負載過高的ChunkServer上的部分Chunk等;
  • 維護元數據庫中所有Volume和Chunk位置的狀態;
  • 創建Volume及Chunk的佈局管理,比如Volume上的Chunks應該分配到哪些ChunkServers上;
  • Volume至Chunks的元數據信息維護,並將元數據信息同步到PolarSwitch中;
  • 監控Volume和Chunk的IO性能,沿着IO路徑收集和跟蹤數據;
  • 定期發起副本內和副本間的CRC數據校驗。

PolarCtrl用一個MySQL數據庫實例來存儲和管理上述文件系統的元數據。

中心統控,局部自治的分佈式管理

分佈式文件系統的設計有兩種範式:中心化和去中心化。中心化的系統包括GFS和HDFS,其包含單中心點,負責維護元數據和集羣成員管理。這樣的系統實現相對簡單,但從可用性和擴展性的角度而言,單中心可能會成爲全系統的瓶頸。去中心化的系統如Dynamo完全相反,節點間是對等關係,元數據被切分並冗餘放置在所有的節點上。去中心化的系統被認爲更可靠,但設計和實現會更復雜。

PolarFS在這兩種設計方式上做了一定權衡,採用了中心統控,局部自治的方式:PolarCtrl是一箇中心化的控制平臺,其負責集羣的管理任務。ChunkServer負責Chunk和Bolck內部映射的管理,以及Chunk間的數據複製。當ChunkServer彼此交互時,通過ParallelRaft一致性協議來處理故障並自動發起Leader選舉,這個過程無需PolarCtrl參與。

PolarCtrl服務由於不直接處理高併發的IO流,其狀態更新頻率相對較低,因而可採用典型的多節點高可用架構來提供PolarCtrl服務的持續性,當PolarCtrl因崩潰恢復出現的短暫故障間隙,由於PolarSwitch的緩存以及ChunkServer數據層的局部元數據管理和自主leader選舉的緣故,PolarFS能夠儘量保證絕大部分數據IO仍能正常服務。

PolarFS的IO流程

當上層的數據庫服務(POLARDB)需要訪問下層數據的時候,它將通過調用libpfs的API接口將IO請求下發到下層的文件系統,通常調用的是pfs_pread()和pfs_pwrite()這兩個接口。對於寫請求來說,幾乎是不需要修改文件系統的元數據,這是因爲在此之前libpfs已經調用pfs_posix_fallocate()接口預分配了足夠的存儲Chunks給了相關文件,從而避免了讀寫節點和只讀節點之間昂貴的元數據同步,這是數據庫系統中常見的優化措施。

在大多數常見情況下,libpfs只是根據啓動掛載時已經構建的索引表將文件偏移量映射到塊的偏移量,並將IO請求切分爲一個或者多個更小的塊IO請求。在切分後,這些塊IO請求由libpfs通過共享內存發送到PolarSwitch。此共享內存的構造爲多個環形的緩衝區(Ring Buffer)。在環形緩衝區的一端,libpfs將塊IO請求加入這個緩衝區內,並等待此IO請求的完成;在另一端,PolarSwitch用專用線程去不斷輪詢所有的環形緩衝區,一旦其發現了新的IO請求,它就會將它們從緩衝區中取出,並根據本地緩存的路由信息(PolarCtrl同步的)將它們轉發到對應的ChunkServer上。

寫IO請求流程

上圖顯示了一個寫IO請求是如何在PolarFS內部執行的。具體的步驟爲:

  1. POLARDB通過libpfs發送一個寫IO請求,經由ring buffer發送到PolarSwitch;
  2. PolarSwitch根據本地緩存的元數據,將請求發送至對應Chunk的Leader節點(ChunkServer1);
  3. 請求到達ChunkServer1後,節點上的RDMA NIC將此請求放到一個預分配好的內存buffer中,並將該請求對象加到請求隊列中。一個IO輪詢線程不斷輪詢這個請求隊列,一旦發現有新請求則立即開始處理;
  4. IO處理線程通過異步調用將此請求通過SPDK寫到Chunk對應的WAL日誌塊上(通常是 3D XPoint SSD buffer),同時將該請求通過RDMA異步發給Chunk的Follower節點(ChunkServer2、ChunkServer3)。由於都是異步調用,所以數據傳輸是併發進行的;
  5. 當請求到達ChunkServer2、ChunkServer3後,同樣通過RDMA NIC將其放到預分配好的內存buffer並加入到複製隊列中;
  6. Follower節點上的IO輪詢線程被觸發,該請求通過SPDK異步地寫入該節點的Chunk副本對應的WAL日誌塊;
  7. 當Follower節點的寫請求成功後,會在回調函數中通過RDMA向Leader節點發送一個確認響應;
  8. Leader節點收到ChunkServer2、ChunkServer3任一節點成功的應答後,即形成Raft組的majority。主節點通過SPDK將該請求寫到指定的數據塊上;
  9. 隨後,Leader節點通過RDMA NIC向PolarSwitch返回請求處理結果;
  10. PolarSwitch標記請求成功並通知上層的POLARDB返回客戶端。

讀IO請求流程

讀IO請求無需這麼複雜的步驟,lipfs發起的讀IO請求直接通過PolarSwitch路由到數據對應Chunk的Leader節點(ChunkServer1),從其中讀取對應的數據返回即可。需要說明的是,在ChunkServer上有個子模塊叫IoScheduler,用於保證發生併發讀寫訪問時,讀操作能夠讀到最新的已提交數據。

全用戶態的IO處理

PolarFS用全用戶態的設計來處理IO請求,任何邏輯都跑在用戶態。一個請求從數據庫引擎發出,然後libpfs會把它映射成一組IO請求,之後PolarSwitch會把這些請求通過RDMA發送到存儲層,隨後ParellelRaft 的leader節點處理這些請求並通過SPDK持久化到日誌磁盤上,同時再通過RDMA同步兩份數據到兩個followers,最後leader將這些請求通過SPDK寫到數據磁盤上。在這整個過程上全都是應用調用,並沒有系統調用,這也就沒有上下文切換,同時也沒有多餘的數據拷貝,沒有上下文切換加零拷貝使得POLARDB擁有了很好的性能。

當然,實現全用戶態的IO處理離不了大量新硬件的加持,可以說POLARDB性能優勢基本上就是來自於全用戶態的架構和大量新硬件的加持,而這點也是相輔相成的。

基於ParallelRaft的一致性模型

PolarFS設計了基於Raft的ParallelRaft一致性協議來適應更大規模的容錯和分佈式文件系統。相對Raft協議,ParallelRaft不僅能夠保證在極端情況下數據的可靠性,還能夠提供更高的IO併發處理。

Raft協議的關鍵特性

  • Leader選舉安全特性:Raft 協議保證了在任意一個任期(term)內,最多隻有一個 leader,新leader節點擁有以前term的所有已提交的日誌,保證選舉之後不會丟失已提交的數據。
  • 日誌匹配原則:Raft協議保證每個副本日誌append的連續性;當leader發送一條log 給follower,follower需要返回ack來確認該log項已經被收到且持久化,同時也隱式地表明所有之前的log項均已收到且持久化。
  • Leader Append-only 原則:Raft協議中leader日誌的提交(commit)也是連續的;當leader commit一條log項並廣播至所有follower,它也同時確認了所有之前的log項都已被提交了。

Raft 的這些關鍵特性時相互關聯和滿足的,比如說這裏的Leader選舉安全特性是由日誌匹配原則和Leader Append-only 原則保證的,如果這兩個特性被破壞,Raft協議的Leader選舉安全性也就被破壞,這可能會導致選主後數據的不一致。

Raft協議的缺陷

爲了選舉的安全性和易於理解,Raft設計成了高度串行化的協議。Leader和Follower的日誌都不允許出現空洞,這也就是說所有的Log都是由follower按序確認、leader按序提交併按序應用(apply)於所有副本,所有這些操作都是順序的。這樣當寫請求並行執行的時候,也要按照順序提交。隊列前面的請求還沒有處理,在隊列尾部的請求就不能提交和確認,這樣就增大了平均延遲和吞吐。

整個過程的按序串行的操作導致了Raft協議不適合多連接並行場景,比如當Leader和Follower之間有多個連接,如果某個連接卡住了或者變慢了,備機就會收到亂序的日誌,此時提前到達的後序Log也不能及時確認,一直要到那些先前丟失的Log到達纔可以按序應答。此外,當大多數Follower因爲一些丟失的Log被阻塞時,Ledar上就不能提交了。

在實際場景中,在高併發環境下,使用多個連接並行的方式來提升併發吞吐量是很常見的。ParallelRaft就是在Raft協議的基礎上解決了這個問題。

ParallelRaft的亂序日誌複製

爲了消除Raft協議的性能瓶頸,就要打破它的有序原則,也就是要打破Raft協議的日誌匹配原則原則和Leader Append-only原則,但是還不能破壞Leader選舉的安全性。ParallelRaft做到了日誌的亂序複製,即當某條log項成功提交時,並不意味着它之前的所有日誌項都已經成功提交。

ParallelRaft亂序日誌複製的主要特性:

  • 亂序確認(Out-of-Order Acknowledge):一旦log項持久化成功,follower不用等它之前的log持久化完成就可以立即給leader確認應答,這樣減少了平均延遲時間。
  • 亂序提交(Out-of-Order Commit):某條log項收到大多數follower確認應答之後,leader不用等它之前的log的多數應答完成就可以立即提交這條log項。

ParallelRaft做到了日誌的亂序複製,即當某條log項成功提交時,並不意味着它之前的所有日誌項都已經成功提交。雖然ParallelRaft通過日誌的亂序確認和提交大大提升了數據庫的吞吐量,但是沒有這些串行化的限制也會引入兩個新的問題:

  1. 應用帶空洞的日誌:因爲log不是按序確認提交的,所以log也不是按序寫入的,會出現帶有空洞的日誌(後序的log先寫入)。面對帶空洞的日誌,怎麼保證日誌應用的正確性?這也就是說應用亂序提交的日誌(有空洞的日誌)也要保證數據庫存儲的數據和狀態是正確的,不能因爲亂序提交就出現數據庫語義的不一致。
  2. Leader的選舉安全特性被破壞:因爲Leader的日誌是亂序提交的,會出現帶空洞的日誌。這樣就保證不了選主後新Leader節點擁有以前任期內的所有已提交的日誌,這破壞了Raft協議的Leader選舉安全特性,所以在選主後可能會丟失已提交的數據。
應用帶空洞的日誌(Apply with Holes in the Log)

爲了解決應用帶空洞的日誌的問題,ParallelRaft引入了一個新的數據結構:look behind buffer,每一條日誌項都會包含該數據結構。look behind buffer包含前面N條日誌修改的邏輯塊地址(Logical Block Address),這使得look behind buffer就像是一座架在日誌空洞上的橋,N是這個橋的長度,就是允許出現的最大空洞長度。

雖然日誌中可能存在多個空洞,但是所有日誌項的邏輯塊修改地址的彙總信息總是完整的,有了look behind buffer就可以擁有完整序列化話的邏輯塊修改地址信息,除非有空洞的長度比N大。這個N可以根據實際使用場景做配置,PolarDB測試結果顯示N設置爲2就已經完全能夠滿足它的IO併發度。

在應用空洞日誌的時候,就可以根據look behind buffer確定先後提交的日誌是否會產生衝突,這保證了當前應用的日誌修改的邏輯地址不會與某些先序日誌後提交的日誌修改的邏輯塊地址產生重疊。這樣,與先序日誌無衝突的日誌可以安全的應用,否則就加到一個pending列表中,稍後等先序日誌到達後再做應用。

綜上所述,ParallelRaft利用look behind buffer完美解決了空洞日誌的應用問題。

Leader選舉的安全

跟Raft的選舉模式一樣,ParallelRaft也是選擇擁有最新任期(term)和最多日誌項的節點爲主。但由於ParallelRaft採用亂序的日誌複製,導致了選出來的新主的日誌可能會存在空洞,這使得選舉的安全性被破壞,即不能保證新Leader節點擁有以前任期內的所有已提交的日誌,至於爲什麼要保證選舉的安全性,可以去看看Raft的論文,主要就是爲了在複製(實現主備一致性)的時候不丟失已經提交的數據。

爲了解決Leader的選舉安全特性被破壞的問題,ParallelRaft在選主的過程中增加Merge階段來填補新Leader日誌的空洞。

上圖顯示ParallelRaft選主的過程。首先,Follower Candidate會將自己本地的日誌項發送給Leader Candidate,Leader Candidate接收到這些日誌項後與自己本地的日誌項合併(merge)。其次,Leader Candidate將合併後的狀態與Follower Candidate進行同步。之後,Leader Candidate可以提交所有的合併後的日誌項,並通知Follower Candidate提交日誌。最後,Leader Candidate正式升級爲Leader,與此同時Follower Candidate也升級爲Follower。

在合併階段,並不是簡單的把日誌的空洞給補上就可以了,這裏還需要一套完善的機制來保證填補的日誌空洞必須是在前一任期內已經持久化到大多數節點上的,這代表這些日誌是已經有效的數據。這套機制包括:

  1. 如果Leader Candidate上的日誌空洞缺失的是已經提交的日誌項(該日誌被舊主提交),那麼一定可以從當前的Follower Candidate中找回來,因爲已提交的日誌項總是已經被集羣中大多數節點持久化,而多數派與Follower Candidate一定存在交集,所以一定可以找回來填補這個空洞。
  2. 如果Leader Candidate上的日誌空洞缺失的是未提交的日誌項(該日誌未被舊主提交),並且這個日誌項也沒有在任何一個Follower Candidate中持久化,則可以直接跳過這個日誌項。因爲該條日誌在選主前並沒有被大多數節點持久化,認爲是無效數據。
  3. 如果Leader Candidate上的日誌空洞缺失的是未提交的日誌項(該日誌未被舊主提交),而這個日誌項在某些Follower Candidate中被持久化,則Leader Candidate就選擇最高任期的日誌項(index號是一樣的)填補這個空洞,認爲此日誌項是有效的。

Merge過程的核心就是要確定本機的空洞日誌狀態是如何的。對於被舊主提交過的日誌,我們要保證其不能丟。而對於被舊主提交過的日誌,只要是其被集羣中某些節點持久化了,我們就可以認爲其是有效日誌,這不會影響數據庫語義的一致性。

ParallelRaft的選主過程在經過了Merge階段之後,新Leader節點就擁有了之前任期的所有已提交的日誌項,可以看出來這個過程其實就是一個basic paxos。其實,Raft協議的設計初衷就是利用日誌的連續性簡化Leader的選舉過程,犧牲了一定的性能換來協議的簡潔性,看來還是魚和熊掌不能兼得啊!

PolarFS元數據的協調和同步

在前面介紹PolarFS存儲資源的組織方式和各組件功能的時候我們提到過,PolarFS將三種元數據(directory entry、inode、block tag)被抽象爲一種叫做元對象(metaobject)的數據類型,這個公共的數據類型可以用來訪問磁盤和內存中的元數據。PolarFS用一個MySQL數據庫實例來存儲文件系統的元數據,並且在各節點的內存中也緩存這些元數據信息。

由於PolarFS是一種共享訪問分佈式文件系統,因此還要保證文件系統元數據在各節點之間的一致性,比如一個節點增加、刪除了文件,或者是改變了文件的大小,這些更改元數據的操作都要持久化到磁盤中並及時同步到各節點上。

更新元數據和更新普通數據一樣,我們也要保證修改數據的事務性。所以,在PolarFS中元數據的更新都被視爲事務操作,事務操作中保存了修改對象的舊值和新值,在完成所有更新即可提交事務。爲了在分佈式環境下實現元數據更新一致性,PolarFS在提交過程中還需要一個合理的協調和同步機制。如果提交失敗,則可以讓元數據事務回滾到舊值的狀態。

PolarFS的每個數據庫實例都有相應的Journal文件和與之對應的Paxos文件。Journal文件記錄了文件系統元數據的修改歷史(相當於元數據修改的日誌),作爲共享實例的各個計算節點之間元數據同步的中心。Journal文件邏輯上是一個固定大小的循環buffer,數據庫的只讀節點都會輪詢該buffer來獲取新的元數據更新事務,一旦在Journal中發現了新的元數據更新,各個只讀節點就會應用這個事務日誌來更新本地緩存的元數據。

由於Journal文件對於PolarFS非常關鍵,它們的修改必需被Paxos互斥鎖保護。正常情況下,只有一個讀寫節點會去寫Journal文件,而其他的只讀節點只會去讀它。但是在網絡分區管理的情況下,可能會有多個節點去寫Journal文件。在這種情況下,就需要一個合理的機制去協調針對Journal文件的寫入。PolarFS在Paxos文件中繼承了Disk Paxos算法,這使得它可以被原子地進行讀和寫,從而實現了Journal分佈式互斥訪問。如果一個節點希望在journal中追加項,其必須使用Disk Paxos算法來獲取Paxos文件中的鎖。通常,鎖的持有者會在記錄持久化後馬上釋放鎖。但是一些故障情況下(比如讀寫節點crash)持有者不釋放鎖,爲此在Paxos互斥鎖上還維持有一個租約lease,租約過期後其他競爭者可以重啓鎖競爭過程。

上圖用一個例子展示了PolarFS元數據的更新和同步的事務提交流程:

  1. Node 1是讀寫節點,其調用pfs_fallocate()接口將Volume的第201個block分配給FileID爲316的文件後,通過Paxos文件請求互斥鎖,並順利獲得鎖;
  2. Node 1開始記錄事務至Journal中。最後寫入的項標記爲pending tail,當所有的項記錄完成之後,pending tail變成Journal的有效tail。
  3. Node1更新superblock,記錄修改的元數據,此過程相當於寫數據而不是寫日誌,寫Journal文件相當於寫日誌。與此同時,node2嘗試獲取訪問互斥鎖,由於此時node1擁有的互斥鎖,Node2會失敗並在稍後重試。
  4. Node2在Node1釋放lock後拿到鎖(可能此時產生網絡分區,Node2也成爲了讀寫節點),但Journal中Node1追加的新項決定了Node2的本地緩存的元數據是過時的。
  5. Node2掃描新項後釋放lock。然後Node2回滾這個未記錄的事務並更新本地的元數據,最後Node2進行事務重試。
  6. Node3是隻讀節點並開始同步元數據,其只需要加載Journal文件中的增量項並在本地內存中重放即可。

PolarFS的元數據更新機制非常適合PolarDB一寫多讀的典型應用擴展模式。正常情況下一寫多讀模式沒有鎖爭用開銷,只讀實例可以通過原子IO無鎖獲取Journal信息,從而使得PolarDB可以提供近線性的QPS性能擴展。

由於PolarFS支持了基本的多寫一致性保障,當讀寫實例出現故障時,POLARDB能夠方便地將只讀實例升級爲讀寫實例,而不必擔心底層存儲產生不一致問題,因而方便地提供了數據庫實例Failover的功能。

PolarDB的物理複製

從上面的介紹來看,PolarFS採用了計算和存儲分離的架構,上層的計算節點共享下層的存儲池裏的日誌和數據。這也就是說,計算層的讀寫節點和只讀節點共享了存儲層的數據和日誌。那麼是不是隻要將只讀節點配置文件中的數據目錄換成讀寫節點的數據目錄,數據庫系統就可以直接工作了呢?

實際上,PolarFS在和MySQL做適配的時候還會存在一系列的問題,比如:

  1. 緩存一致性問題。如果在讀寫節點上修改數據,由於InnoDB buffer pool的緩存機制,修改的髒數據頁還沒有被flush到底層磁盤存儲中,而這時如果只讀節點上的查詢就看不到這些髒數據;即使讀寫節點將髒數據刷到了磁層磁盤,只讀節點也會有優先查詢本地buffer pool中緩存的舊數據。
  2. 多版本一致性讀問題。InnoDB通過Undo日誌來實現事務的MVCC,讀寫節點在進行Undo日誌Purge的時候並不會考慮此時在只讀節點上是否還有事務要訪問即將被刪除的Undo Page,這就會導致記錄舊版本被刪除後,只讀節點上事務讀取到的數據是錯誤的。
  3. DDL問題。讀寫實例上執行DDL語句改變了底層存儲的文件狀態,會造成只讀節點訪問底層存儲出錯。比如,只讀節點刪除一個表,反映到底層存儲上就是刪除相關的表文件,而此時只讀節點上如果有針對這個表的事務操作,就會因爲訪問不到這個表的底層文件而出錯。

因此,多個計算節點共享同一份數據並不是一件容易的事情。爲了解決上述問題,PolarDB顯然還需要有一套完善的主從同步複製機制。在MySQL中,原先是通過Binlog同步的方式來實現主從的邏輯複製。然而在PolarDB中,爲了提升數據庫本身和主備複製的性能,放棄了binlog的邏輯複製,取而代之的是基於Redo log的物理複製機制。

有了基於Redo log的物理複製,不僅可以解決緩存的一致性問題,還大大提升了複製的性能。一方面,得益於計算和存儲分離的架構,在複製的時候只需要更新只讀節點緩存中的數據頁(過濾了很多沒有緩存的數據頁),並且物理複製的速度也比邏輯複製快很多。因此,PolarDB可以大大降低主備之間的複製延遲,甚至在某些情況下可以做到主備之間的強同步。

但針對一些特殊的問題,還需要一些額外的處理,比如上述的多版本一致性讀問題和DDL問題:

  • 針對多版本一致性讀問題,有兩種處理方式:一種是所有隻讀節點定期向讀寫節點彙報自己的最大能刪除的Undo數據頁,由讀寫節點統籌安排Purge操作;另外一種是當讀寫節點刪除Undo數據頁時候,只讀節點接收到Redo log同步日誌後,會判斷即將被刪除的數據頁是否還在被使用,如果在使用則等待,超過一個時間後直接給客戶端報錯。

  • 針對DDL問題,如果主庫對一個表進行了表結構變更操作(DDL操作),在操作返回成功前,必須通知所有的只讀節點(有一個最大的超時時間),這個表的結構已經發生了變化。當然,這種強同步操作會給性能帶來極大的影響,有進一步的優化的空間。

雖然物理複製能帶來性能上的巨大提升,但是邏輯日誌由於其良好的兼容性也並不是一無是處,所以PolarDB依然保留了Binlog的邏輯,方便用戶開啓。

參考

  1. PolarFS: An Ultra-low Latency and Failure Resilient Distributed File System for Shared Storage Cloud Database
  2. 阿里雲PolarDB及其共享存儲PolarFS技術實現分析(上)
  3. 阿里雲PolarDB及其共享存儲PolarFS技術實現分析(下)
  4. PolarDB · 新品介紹 · 深入瞭解阿里雲新一代產品 PolarDB
  5. 從架構深度解析阿里雲自研數據庫POLARDB
  6. 面向雲數據庫,超低延遲文件系統PolarFS誕生了
  7. PolarFS的ParallelRaft
  8. PolarFS中的ParallelRaft
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章