1 背景與簡介
能夠將事件(Event)按流水線的方式注入段存儲(Segment Store)是Pravega客戶端實現高吞吐量的關鍵技術之一,即便是處理小尺寸的寫操作也是如此。Writer在接收到事之後立即將其追加到相應的段(Segment)中,並不會等待之前的寫操作得到確認。 爲保證順序和僅一次(Exactly-Once)語義,段存儲要求所有這些追加操作都有條件地基於某個已知狀態,而該狀態對於每一個Writer都是唯一的。這一狀態存儲在每個段的段屬性(Segment Attribute)中 ,並且在每個段操作中可以被原子性地查詢或者更新。
隨着時間的推移,段屬性已經逐漸演化並支持一系列不同的用例,從記錄某個段(開啓Auto-Scaling特性)內的事件數量到存儲哈希表的索引。表段(Table Segment,一種用於保存Pravega所有流、事務和段的元信息的鍵值存儲)的引入則要求每個段具有無縫管理成千上萬條屬性的能力。
這篇文章將解釋段屬性的運作機制以及它是如何提供一個高效的鍵值存儲,成爲其它上層高級特性的基石之一的。我們先從一個概覽開始,看看Pravega Writer是如何使用段屬性來避免數據重複和丟失的。接着,我們會詳細描述段屬性是如何在二級存儲上被組織成B+樹,並使用一種創新的壓縮方法來減少寫放大效應的。
2 利用段屬性來避免數據重複或丟失
EventStreamWriter在寫入更多數據之前,必須首先知道它在服務器端已經寫入數據的狀態,即便是在某些常見的失敗場景下也是如此。它需要提供某些數值,而服務器端每次在嘗試進行修改操作時都可以原子性地檢查和更新這些數值。
在與服務器交互的過程中,大多數故障表現出來的形式都是事件沒有得到確認回覆,而在這種情況下,相關的EventStreamWriter會使用與之前相同的條件重試寫操作。
如果在之前的嘗試中事件已經被持久化了,那麼段存儲會使用ConditionalAppendFailedException異常拒絕本次重試,而EventStreamWriter則向調用者確認本次事件並繼續處理下一批事件。相反,如果事件還沒有被持久化,則段存儲會立刻進行持久化並向Writer發送確認。這種錯誤重試和有條件失敗的組合有助於避免數據丟失(事件未被持久化)和重複(事件被持久化了,但卻沒有被確認)。
每個EventStreamWriter都有一個唯一的標識符,Writer ID,並且依據當前正在處理的事件的路由鍵(Routing Key),可以同時寫入多個段存儲。它的內部狀態由一個字典結構組成,對每個交互的段記錄一個事件編號(Event Number)。每次處理一個新事件時,該內部狀態都會發生改變。每一個事件都被作爲一個條件追加(Conditional Append,以當前的事件編號和Writer ID爲條件,期望在對應的段上得到匹配)操作發往段存儲。段存儲上的注入流水線按追加操作的接收順序依次處理所有的追加,並且每一個條件追加操作都會被原子性地檢查,提交和更新,因此保證了數據存儲的一致性。
段存儲以段屬性的形式維護這一狀態。圖 1用一個示例展示了這一運作機制。
3 段屬性
每個段都有一組屬性集合,可以被獨立使用或者與不同的段操作組合使用。例如,我們可以選擇僅更新某些段屬性,也可以選擇原子性地向某個段追加並更新一些屬性。Writer ID是一個128位的UUID,而事件編號是一個64位的長整型數值,因此我們將段屬性設計爲支持16字節鍵,8字節值的鍵值對。
目前有兩種類型的屬性:核心屬性(Core Attribute),它的ID是硬編碼的,用於保存段的內部狀態(例如事件總數,擴展策略等);擴展屬性(Extended Attribute),它是從外部指定的,並無任何內部含義。兩種類型的段屬性具有相同的語義,唯一的區別是核心屬性是永久內存綁定的,而擴展屬性可以被換出。Writer ID就是使用段屬性映射到事件編號的。
段屬性可以使用如下動詞原語進行更新:
- 替換:某個屬性值被設置爲或更新爲一個新值。
- 如果大於則替換(Replace-If-Greater):某個屬性值被更新爲一個新值v,僅當存在一個當前值v’並且v>v’。
- 如果等於則替換(Replace-If-Equal):某個屬性值被更新爲一個新值v,僅噹噹前值與v’(由調用者提供)匹配。這是經典的CAS(Compare-And-Set)操作,可以用來保證屬性值在本次更新之前未被併發設置。
- 累加:某個屬性值被更新爲當前值加上一個調用者提供的值。
以下是一個從Pravega的段存儲源碼中截取的示例,展示瞭如何使用段屬性:
private CompletableFuture<Void> storeAppend(Append append) {
ArrayList<AttributeUpdate> attributes = new ArrayList<>();
// Atomically check-and-update the Writer Position while
// performing this append.
attributes.add(new AttributeUpdate(
append.getWriterId(), // Attribute Key.
AttributeUpdateType.ReplaceIfEquals, // Compare-and-set.
append.getEventNumber(), // Value to set.
append.getLastEventNumber())); // Value to compare.
// Atomically increase the number of events stored with this append.
attributes.add(new AttributeUpdate(
Attributes.EVENT_COUNT, // Core Attribute.
AttributeUpdateType.Accumulate, // Add to existing value.
append.getEventCount())); // Value to add.
// Perform the append. Pass the append payload and the attributes,
// which tells the Segment Store to apply them atomically.
return store.append(append.getSegment(), append.getData(), attributes);
}
在這段源碼中,每次追加操作涉及到兩個段屬性:將Writer的事件編號條件更新至一個新值,並且更新段中所存儲的事件總數。段存儲的注入流水線自動在本次追加操作涉及的段(append.getSegment())上進行如下操作:
- 驗證事件編號屬性值值與append.getLastEventNumber()匹配。
- 更新事件編號屬性值至append.getEventNumber()。
- 更新Attributes.EVENT_COUNT至先前值加上append.getEventCount()。
- 將append.getData()作爲一段連續字節序列追加到段尾端。
4 存儲段屬性
段屬性是每個段上的額外元信息,它可以被客戶端自由地設置或讀取。然而,段屬性沒有被任何API對外暴露。如果這是段屬性的唯一用途,那麼只要把它們存儲在一個與主段相關聯的一個獨立的內部段上就足夠了。
但是,正如上文提到的,段屬性同時也在注入流水線的內部計算中被大量使用,如果在每次需要的時候都暫時掛起流水線並從存儲中加載屬性值,必然會造成相當大的性能損失。我們需要一種方法緩存全部或者部分屬性在某種內存數據結構中,使其可以很容易地被流水線訪問和更新,但同時必須用一種高效的方式最終將這些更新持久化到二級存儲中去。
爲解決這一難題,我們設計 了一種兩層緩存方案,最終將屬性值保存到二級存儲的屬性段(Attribute Segment)上。第一層緩存是一個直接的Java哈希表(HashMap接口),用於保存最近使用的屬性。這層緩存直接進行鍵值映射,使得查找和更新操作非常高效。下層緩存以原始格式將二級存儲上的屬性段部分保存在內存中,儘管這要求反序列化以便抽取信息,但對該層緩存的訪問依然顯著快於直接從二級存儲加載。
下層緩存和二級存儲上的屬性段一同構成了所謂的屬性索引(Attribute Index),它將段屬性組織成一種適合只追加(Append-Only)存儲介質的數據結構。
我們做出的關鍵決策之一便是儘量避免在注入流水線內直接從二級存儲加載屬性值,而是儘可能在處理操作之前就進行預取。圖 2中的步驟1.1到步驟1.3展示了這一工作流程。
我們對那些已經在內存中的屬性進行元數據查詢(步驟1.1),並從屬性索引拉取其餘的屬性(步驟1.2)。爲了在那些針對相同數據的併發請求間保證一致性,我們使用條件屬性更新(Conditional Attribute Update),通過注入流水線在段的元數據中加載屬性:如果某些屬性已經被加載了,則我們不希望它們的值被某些髒狀態覆蓋。
爲了將屬性值持久化到二級存儲上去,我們使用現有的基礎設施。Storage Writer將較小的段追加操作聚合成爲較大的緩衝區一次性寫入二級存儲,同時它也能夠將多個屬性更新組合成較大的批操作,通過屬性索引持久化到二級存儲。這帶來了一些額外的好處,那些頻繁更新的屬性(例如事件數)不必每次更改後都持久化到二級存儲:我們可以將多個更新聚合起來(僅保留最新的值),如此避免了一些非必要的,昂貴的二級存儲寫操作。
5 段屬性索引
我們面臨的最大挑戰或許就是該如何將大量屬性保存到Pravega的二級存儲上。首先明確一點,我們所說的大量究竟是多大?現實一點說,通常一個段大約會有上萬個Writer,我們需要考慮比這更大的數據量嗎?
我們最初採用的方案對每個段小於10萬條屬性的場景進行了優化,方法很簡單:我們將每一個更新(24字節)追加到屬性段,在持續寫入了大約幾兆字節的數據後,再將所有數據壓縮成一個有序數組並同樣進行追加。同時維護了一個指向最後一次壓縮首部的指針,每次需要讀取時,就從該首部一直讀取到段的尾端。這個方法很簡單,沒有什麼複雜的部分,也能很好地處理我們的用例(10萬條屬性意味着二級存儲上的大約2.5MB到5MB數據,完全可以一次性讀取並緩存)。
從長遠上看,我們還發現了有關屬性的另外一些重要用途。表段,在Pravega的0.5版本中被引入,用於保存Pravega所有的元數據,並開放一個基於普通段的鍵值API。作爲一個哈希表,它將整個索引都存儲在段的屬性中,因此它比EventStreamWriters需要更多的段屬性支持。我們意識到我們需要支持每段千萬級別的屬性,這需要一種完全不同的方法將其保存到二級存儲上。我們的解決方案就是B+樹,然而,我們所選擇的實現與大多數典型的數據庫系統稍有不同。
6 只追加存儲上的B+樹
當修改操作只允許被追加到文件的尾端時,B+樹通常不會是索引結構的首選。只追加的B+樹變體已經有現有的實現,但它們都有一個顯著的缺點:寫放大(Write Amplification)。所有對該結構的修改都需要將從根節點到被更新數據節點路徑上的所有節點重新追加到文件上。
隨着時間的推移,這導致了超大的文件尺寸,其中包含了大量過期數據。週期性的全索引壓縮似乎可以解決這個問題,但這一方法對Pravega並不適用。由於我們的系統本質上是一個分佈式系統,節點失效時不可避免的,諸如壓縮操作這類的非原子性操作很難在不影響段存儲性能的前提下正確實現。
另一種方法就是使用LSM樹(Log-Structured Merge Tree),但這種數據結構也大量使用了壓縮操作。因此,我們使用了一些新技術和優化方法,讓我們可以高效地將段屬性保存到二級存儲上的B+樹上。
我們的鍵值永遠是固定長度(分別爲16字節和8字節),這讓我們得以簡化B+樹的節點結構,並允許使用較大的分叉因子(Branching Factor)。設定最大節點大小爲32KB並限制每條記錄32字節,使得我們的分叉因子可以超過1000。對於一個存儲超過10億條記錄的索引結構,我們最多只需要3到4次二級存儲上的讀操作就可以完成某個鍵的查詢。
儘管我們所要查詢的鍵的深度可能有數層,但任何B+樹的操作都需要查詢根節點,並且每一個二層節點都有大約千分之一的概率被查詢。將節點進行緩存可以極大減少對二級存儲的隨機讀取,尤其是對於深度較大的節點。對於最大節點大小32KB這樣的設置,整個二層節點只佔用大約32MB的緩存空間,我們完全可以把它們全部加載到內存中。由於訪問模式的不同,緩存命中率也會相應有很大變化,但我們的測試顯示,對於一個已經預熱的索引,當使用緩存時,定位一個鍵僅需要不超過一次的二級存儲讀操作。
較大的分叉因子也能緩解寫放大問題,但不能完全消除它。然而,一個很關鍵的事實是,儘管屬性段可能會無限增長,但活動數據(最新版本的數據)總是集中在段的尾端,而段的首部傾向於包含大部分的過期數據。通過使用與Retention相同的方法,我們可以對段進行頭部截斷,刪除那些已經被截斷的數據塊(Chunk)。段上的數據塊是一組連續的字節序列,每一個數據塊都對應二級存儲上的一個文件或者對象。因此,通過對屬性段使用現有的滾動存儲(Rolling Storage)適配器,我們已經非常接近我們的期望目標了。
然而,我們只有在確認被丟棄的數據中不再包含任何仍在使用的B+樹節點(那些在更新操作後已被重新追加的節點)後,才能進行頭部截斷。段的首部有很大概率包含過期數據,但偶爾也會包含一些仍在使用的B+樹節點。經典的壓縮操作已經考慮到這種情況了:掃描索引文件,找到最早的被過期數據包圍的節點,通過追加的方式將它們移動到文件的尾端。雖然這並不會阻塞截斷,但它還是需要同時對大量段維護一段較長的非原子性的壓縮過程。
我們採用了另一種被稱爲漸進式壓縮(Progressive Compaction)的方法。每次修改B+樹,我們都會定位屬性段上偏移最小的相關節點,通過追加的方式將其移動到段尾端(同時可能還會移動它的祖先節點)。儘管這看起來有點像我們把寫放大問題變得更糟糕了,但這確實是一個讓屬性段文件尺寸變小的折衷方案。二級存儲的寫操作通常被認爲是高吞吐量的,所以每次多寫32KB到100KB的數據不會有多少差別。而我們所得到的回報就是,我們從一開始就可以對大數據塊進行截斷,因此能夠讓屬性段的大小始終保持在合理範圍內。
爲了定位具有最小偏移的結點,每個B+樹節點保存了以它爲根的子樹中所有節點偏移的最小值。知道了這個值,我們就可以沿着根節點的路徑,定位到具有最小偏移的那個節點。
維護這個值也非常簡單:因爲每次更新都需要修改從葉節點到根節點間的所有節點,我們所要做的就是利用現有節點內編碼的信息重新爲每個節點計算該值。我們不需要額外的IO操作,因此也不會對性能造成影響。
我們看一下在圖 4中,隨着B+樹的創建,文件佈局是如何變化的。
- 起始,我們只有一棵單節點的樹,僅包含節點6。
- 無論有無漸進式壓縮,我們都對節點6進行追加。
- 節點6分裂爲6和7,節點3作爲根節點,而節點6和7爲子節點。
- 無論有無漸進式壓縮,我們都追加節點6,7和3。文件佈局爲:
6,6,7,3。 - 對於漸進式壓縮,我們跳過重新追加節點6,因爲它已經包含在更新中了。
- 無論有無漸進式壓縮,我們都追加節點6,7和3。文件佈局爲:
- 節點6分裂爲5和6。節點3的子節點爲節點5,6和7。
- 無論有無漸進式壓縮,我們都追加節點5,6和3。文件佈局爲:
6,6,7,3,5,6,3。 - 對於漸進式壓縮,我們跳過重新追加節點6,因爲它已經包含在更新中了。
- 無論有無漸進式壓縮,我們都追加節點5,6和3。文件佈局爲:
- 節點5分裂爲4和5;節點3分裂爲2和3;節點1創建爲根。節點1的子節點爲2和3,節點2的子節點爲4和5,節點3的子節點爲6和7。
- 無漸進式壓縮:我們追加節點5,4,3,2和1。文件佈局爲:
6,6,7,3,5,6,3,5,4,3,2,1。 - 有漸進式壓縮:我們追加節點7,然後是節點5,4,3,2和1。文件佈局是:
6,6,7,3,5,6,3,7,5,4,3,2,1。
- 無漸進式壓縮:我們追加節點5,4,3,2和1。文件佈局爲:
- 節點6被更新。B+樹沒有結構性變化。
- 無論有無漸進式壓縮,我們都需要追加6,3和1。
- 對於漸進式壓縮,我們跳過重新追加節點6,因爲它已經包含在更新中。
- 無漸進式壓縮的佈局:
6,6,7,3,5,6,3,5,4,3,2,1,6,3,1。 - 有漸進式壓縮的佈局:
6,6,7,3,5,6,3,7,5,4,3,2,1,6,3,1。
在這個例子中,漸進式壓縮允許我們在位置7(在無壓縮的例子中爲位置2)進行屬性段的截斷,因此幫助我們釋了放不再需要的磁盤空間。
7 實際應用中的漸進式壓縮
爲了驗證漸進式壓縮在現實場景中的運作情況,我們設計並運行了一系列測試。通過將多個更新操作組合成一個大的批操作,我們可以減少寫放大,因爲批操作越大,B+樹就越少需要重寫它的上層節點。我們使用不同的插入/更新批操作大小,並對比有壓縮和無壓縮情況下的索引(在二級存儲上)大小。在這些測試中使用的批操作尺寸反映了Storage Writer如何聚合更新操作:較小的批尺寸適用於那些具有較少併發Writer的段,而較大的批尺寸通常被用於表段。
批尺寸 | 索引大小(MB) | |
---|---|---|
無壓縮 | 漸進式壓縮 | |
10 | 3,991 | 115 (3%) |
100 | 416 | 97 (23%) |
1,000 | 60 | 54 (89%) |
表 1 順序插入1,000.000條段屬性。對較小的批尺寸,漸進式壓縮的效果最顯著(大小減少了97%),而較大的批尺寸則效果並不明顯(由於較小的寫放大)。
批尺寸 | 索引大小(MB) | |
---|---|---|
無壓縮 | 漸進式壓縮 | |
10 | 32,990 | 72 (0.22%) |
100 | 29,402 | 103 (0.35%) |
1,000 | 17,083 | 91 (0.53%) |
表 2 批量加載1,000,000條段屬性,並按隨機順序更新。無論批尺寸如何,寫放大問題都十分嚴重,漸進式壓縮也因此取得了顯著效果:索引大小被減少了至少99.5%。
8 總結
屬性在段的整個生命週期中扮演着核心角色。除了存儲段本身的元信息外,它們還大量參與到段的修改操作中去。它們被用於保存段內的統計數據(例如事件總數),允許EventStreamWriters實現僅一次語義。在傳統數據結構上使用創新的方法使得段存儲可以爲每個段有效管理10億數量級的段屬性。漸進式壓縮在不使用後臺任務和不影響性能的情況下減小了寫放大效應,爲只追加B+樹減小了99.5%的尺寸。
在下一篇文章中,我們會討論如何使用段屬性來實現一個完全基於段的可持久化哈希表:這是創建基於Pravega的大規模分佈式元數據存儲的第一步。
致謝:感謝Srikanth Satya和Flavio Junqueira爲本文提出寶貴建議。
相關閱讀:開源流存儲Pravega技術解讀系列文章