譯者注:在分散式領域中存在著三種型別的訊息投遞語義,分別是:最多一次(at-most-once)、至少一次(at-least-once)和恰好一次(exactly-once)。本文作者介紹了一個利用Kafka和RocksDB來構建的“恰好一次”訊息去重系統的實現原理。以下是譯文。
對任何一個資料流水線的唯一要求就是不能丟失資料。資料通常可以被延遲或重新排序,但不能丟失。
為了滿足這一要求,大多數的分散式系統都能夠保證“至少一次”的投遞訊息技術。實現“至少一次”的投遞技術通常就是:“重試、重試、再重試”。在你收到消費者的確認訊息之前,你永遠不要認為訊息已經投遞過去。
但“至少一次”的投遞並不是使用者想要的。使用者希望訊息被投遞一次,並且僅有一次。
然而,實現“恰好一次”的投遞需要完美的設計。每種投遞失敗的情況都必須認真考慮,並設計到架構中去,因此它不能在事後“掛到”現有的實現上去。即使這樣,“只有一次”的投遞訊息幾乎是不可能的。
在過去的三個月裡,我們構建了一個全新的去重系統,以便在面對各種故障時能讓系統儘可能實現“恰好一次”的投遞。
新系統能夠跟蹤舊系統100倍的訊息數量,並且可靠性也得到了提高,而付出的代價卻只有一點點。下面我們就開始介紹這個新系統。
問題所在
Segment內部的大部分系統都是通過重試、訊息重新投遞、鎖定和兩階段提交來優雅地處理故障。但是,有一個特例,那就是將資料直接傳送到公共API的客戶端程式。
客戶端(特別是移動客戶端)經常會發生網路問題,有時候傳送了資料,卻沒有收到API的響應。
想象一下,某天你乘坐公共汽車,在iPhone上使用HotelTonight軟體預訂房間。該應用程式將資料上傳到了Segment的伺服器上,但汽車突然進入了隧道並失去了網路連線。你傳送的某些資料在伺服器上已經被處理,但客戶端卻無法收到伺服器的響應訊息。
在這種情況下,即使伺服器在技術上已經收到了這些確切的訊息,但客戶端也會進行重試並將相同的訊息重新傳送給Segment的API。
從我們伺服器的統計資料來看,在四個星期的視窗時間內,大約有0.6%的訊息似乎是我們已經收到過的重複訊息。
這個錯誤率聽起來可能並不是很高。但是,對於一個能創造數十億美元效益的電子商務應用程式來說,0.6%的出入可能意味著盈利和數百萬美元損失之間的差別。
對訊息進行去重
現在,我們認識到問題的癥結了,我們必須刪除傳送到API的重複訊息。但是,該怎麼做呢?
最簡單的思路就是使用針對任何型別的去重系統的高階API。在Python中,我們可以將其表示為:
1 2 3 4 5 6 |
def dedupe(stream): for message in stream: if has_seen(message.id): discard(message) else: publish_and_commit(message) |
對於資料流中的每個訊息,首先要把他的id(假設是唯一的)作為主鍵,檢查是否曾經見過這個特定的訊息。如果以前見過這個訊息,則丟棄它。如果沒有,則是新的,我們應重新發布這個訊息並以原子的方式提交訊息。
為了避免儲存所有的訊息,我們會設定“去重視窗”這個引數,這個引數定義了在訊息過期之前key儲存的時長。只要訊息落在視窗時間之外,我們就認為它已過期失效。我們要保證在視窗時間內某個給定ID的訊息只傳送一次。
這個行為很容易描述,但有兩個方面需要特別注意:讀/寫效能和正確性。
我們希望系統能夠低延遲和低成本的對通過流水線的數十億個事件進行去重。更重要的是,我們要確保所有的事件都能夠被持久化,以便可以從崩潰中恢復出來,並且不會輸出重複的訊息。
架構
為了實現這一點,我們建立了一個“兩階段”架構,它讀入Kafka的資料,並且在四個星期的時間視窗內對接收到的所有事件進行去重。
Kafka的拓撲結構
要了解其工作原理,首先看一下Kafka的流拓撲結構。所有傳入訊息的API呼叫都將作為單獨的訊息進行分離,並讀入到Kafka輸入主題(input topic)中。
首先,每個傳入的訊息都有一個由客戶端生成的具有唯一性的messageId標記。在大多數情況下,這是一個UUIDv4(我們考慮切換到ksuids)。 如果客戶端不提供messageId,我們會在API層自動分配一個。
我們不使用向量時鐘或序列號,因為我們希望能降低客戶端的複雜性。使用UUID可以讓任何人輕鬆地將資料傳送到我們的API上來,因為幾乎所有的主要語言都支援它。
1 2 3 4 5 6 |
{ "messageId": "ajs-65707fcf61352427e8f1666f0e7f6090", "anonymousId": "e7bd0e18-57e9-4ef4-928a-4ccc0b189d18", "timestamp": "2017-06-26T14:38:23.264Z", "type": "page" } |
為了能夠將訊息持久化,並能夠重新傳送,一個個的訊息被儲存到Kafka中。訊息以messageId進行分割槽,這樣就可以保證具有相同messageId的訊息能夠始終由同一個消費者處理。
這對於資料處理來說是一件很重要的事情。我們可以通過路由到正確的分割槽來查詢鍵值,而不是在整個中央資料庫的數百億條訊息中查詢,這種方法極大地縮小了查詢範圍。
去重“worker”(worker:工人。譯者注,這裡表示的是某個程式。為防止引起歧義,下文將直接使用worker)是一個Go程式,它的功能是從Kafka輸入分割槽中讀入資料,檢查訊息是否有重複,如果是新的訊息,則傳送到Kafka輸出主題中。
根據我們的經驗,worker和Kafka拓撲結構都非常容易掌握。我們無需使用一組遇到故障時需要切換到副本的龐大的Memcached例項。相反,我們只需使用零協同的嵌入式RocksDB資料庫,並以非常低的成本來獲得持久化儲存。
RocksDB的worker程式
每一個worker都會在本地EBS硬碟上存放了一個RocksDB資料庫。 RocksDB是由Facebook開發的嵌入式鍵值儲存系統,它的效能非常高。
每當從輸入主題中過來的訊息被消費時,消費者通過查詢RocksDB來確定我們之前是否見過該事件的messageId。
如果RocksDB中不存在該訊息,我們就將其新增到RocksDB中,然後將訊息釋出到Kafka輸出主題。
如果訊息已存在於RocksDB,則worker不會將其釋出到輸出主題,而是更新輸入分割槽的偏移,確認已處理過該訊息。
效能
為了讓我們的資料庫獲得高效能,我們必須對過來的每個事件滿足三種查詢模式:
- 檢測隨機key的存在性,這可能不存在於我們的資料庫中,但會在key空間中的任何地方找到。
- 高速寫入新的key
- 老化那些超出了“去重視窗”的舊的key
實際上,我們必須不斷地檢索整個資料庫,追加新的key,老化舊的key。在理想情況下,這些發生在同一資料模型中。
一般來說,這些效能大部分取決於我們資料庫的效能,所以應該瞭解一下RocksDB的內部機制來提高它的效能。
RocksDB是一個日誌結構合併樹(log-structured-merge-tree, 簡稱LSM)資料庫,這意味著它會不斷地將新的key附加到磁碟上的預寫日誌(write-ahead-log)中,並把排序過的key存放在記憶體中作為memtable的一部分。
key存放在記憶體中作為memtable的一部分
以下是在我們的生產日誌中寫入的示例:
1 2 3 4 5 |
[JOB 40] Syncing log #655020 [default] [JOB 40] Flushing memtable with next log file: 655022 [default] [JOB 40] Level-0 flush table #655023: started [default] [JOB 40] Level-0 flush table #655023: 15153564 bytes OK [JOB 40] Try to delete WAL files size 12238598, prev total WAL file size 24346413, number of live WAL files 3. |
每個SSTable是不可變的,一旦建立,永遠不會改變。這是什麼寫入新的鍵這麼快的原因。無需更新檔案,無需寫入擴充套件。相反,在帶外壓縮階段,同一級別的多個SSTable可以合併成一個新的檔案。
當在同一級別的SSTables壓縮時,它們的key會合並在一起,然後將新的檔案升級到下一個更高的級別。
看一下我們生產的日誌,可以看到這些壓縮作業的示例。在這種情況下,作業41正在壓縮4個0級檔案,並將它們合併為單個較大的1級檔案。
1 2 3 4 5 6 7 8 |
/data/dedupe.db$ head -1000 LOG | grep "JOB 41" [JOB 41] Compacting 4@0 + 4<a href="http://www.jobbole.com/members/yaowei729">@1</a> files to L1, score 1.00 [default] [JOB 41] Generated table #655024: 1550991 keys, 69310820 bytes [default] [JOB 41] Generated table #655025: 1556181 keys, 69315779 bytes [default] [JOB 41] Generated table #655026: 797409 keys, 35651472 bytes [default] [JOB 41] Generated table #655027: 1612608 keys, 69391908 bytes [default] [JOB 41] Generated table #655028: 462217 keys, 19957191 bytes [default] [JOB 41] Compacted 4@0 + 4<a href="http://www.jobbole.com/members/yaowei729">@1</a> files to L1 => 263627170 bytes |
壓縮完成後,新合併的SSTables將成為最終的資料庫記錄集,舊的SSTables將被取消連結。
如果我們登入到生產例項,我們可以看到正在更新的預寫日誌以及正在寫入、讀取和合並的單個SSTable。
下面生產的SSTable統計資料中,可以看到一共有四個“級別”的檔案,並且一個級別比一個級別的檔案大。
RocksDB儲存了索引和儲存在SSTable的特定SSTables的布隆過濾器,並將這些載入到記憶體中。通過查詢這些過濾器和索引可以找到特定的key,然後將完整的SSTable作為LRU基礎的一部分載入到記憶體中。
在絕大多數情況下,我們就可以看到新的訊息了,這使得我們的去重系統成為教科書中的布隆過濾器案例。
布隆過濾器會告訴我們某個鍵“可能在集合中”,或者“絕對在集合中”。要做到這一點,布隆過濾器儲存了已經見過的任何元素的多種雜湊函式的設定位。如果設定了雜湊函式的所有位,則過濾器將返回訊息“可能在集合中”。
我們的集合包含{x,y,z},在布隆過濾器中查詢w,則布隆過濾器會返回“不在集合中”,因為其中有一位沒有設定。
如果返回“可能在集合中”,則RocksDB可以從SSTables中查詢到原始資料,以確定該項是否在該集合中實際存在。但在大多數情況下,我們不需查詢任何SSTables,因為過濾器將返回“絕對不在集合”的響應。
在我們查詢RocksDB時,我們會為所有要查詢的相關的messageId發出一個MultiGet。基於效能考慮,我們會批量地釋出出去,以避免太多的併發鎖定操作。它還允許我們批量處理來自Kafka的資料,這是為了實現順序寫入,而不是隨機寫入。
以上回答了為什麼讀/寫工作負載效能這麼好的問題,但仍然存在如何老化資料這個問題。
刪除:按大小來限制,而不是按時間來限制
在我們的去重過程中,我們必須要確定是否要將我們的系統限制在嚴格的“去重視窗”內,或者是通過磁碟上的總資料庫大小來限制。
為了避免系統突然崩潰導致去重系統接收到所有客戶端的訊息,我們決定按照大小來限制接收到訊息數量,而不是按照設定的時間視窗來限制。這允許我們為每個RocksDB例項設定最大的大小,以能夠處理突然的負載增加。但是其副作用是可能會將去重視窗降低到24小時以下。
我們會定期在RocksDB中老化舊的key,使其不會增長到無限大小。為此,我們根據序列號保留key的第二個索引,以便我們可以先刪除最早接收到的key。
我們使用每個插入的key的序列號來刪除物件,而不是使用RocksDB TTL(這需要在開啟資料庫的時候設定一個固定的TTL值)來刪除。
因為序列號是第二索引,所以我們可以快速地查詢,並將其標記為已刪除。下面是根據序列號進行刪除的示例程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 |
func (d *DB) delete(n int) error { // open a connection to RocksDB ro := rocksdb.NewDefaultReadOptions() defer ro.Destroy() // find our offset to seek through for writing deletes hint, err := d.GetBytes(ro, []byte("seek_hint")) if err != nil { return err } it := d.NewIteratorCF(ro, d.seq) defer it.Close() // seek to the first key, this is a small // optimization to ensure we don't use `.SeekToFirst()` // since it has to skip through a lot of tombstones. if len(hint) > 0 { it.Seek(hint) } else { it.SeekToFirst() } seqs := make([][]byte, 0, n) keys := make([][]byte, 0, n) // look through our sequence numbers, counting up // append any data keys that we find to our set to be // deleted for it.Valid() && len(seqs) < n { k, v := it.Key(), it.Value() key := make([]byte, len(k.Data())) val := make([]byte, len(v.Data())) copy(key, k.Data()) copy(val, v.Data()) seqs = append(seqs, key) keys = append(keys, val) it.Next() k.Free() v.Free() } wb := rocksdb.NewWriteBatch() wo := rocksdb.NewDefaultWriteOptions() defer wb.Destroy() defer wo.Destroy() // preserve next sequence to be deleted. // this is an optimization so we can use `.Seek()` // instead of letting `.SeekToFirst()` skip through lots of tombstones. if len(seqs) > 0 { hint, err := strconv.ParseUint(string(seqs[len(seqs)-1]), 10, 64) if err != nil { return err } buf := []byte(strconv.FormatUint(hint+1, 10)) wb.Put([]byte("seek_hint"), buf) } // we not only purge the keys, but the sequence numbers as well for i := range seqs { wb.DeleteCF(d.seq, seqs[i]) wb.Delete(keys[i]) } // finally, we persist the deletions to our database err = d.Write(wo, wb) if err != nil { return err } return it.Err() } |
為了保證寫入速度,RocksDB不會立即返回並刪除一個鍵(記住,這些SSTable是不可變的!)。相反,RocksDB將新增一個“墓碑”,等到壓縮時再進行刪除。因此,我們可以通過順序寫入來快速地老化,避免因為刪除舊項而破壞記憶體資料。
確保正確性
我們已經討論瞭如何確保數十億條訊息投遞的速度、規模和低成本的搜尋。最後一個部分將講述各種故障情況下我們如何確保資料的正確性。
EBS快照和附件
為了確保RocksDB例項不會因為錯誤的程式碼推送或潛在的EBS停機而損壞,我們會定期儲存每個硬碟驅動器的快照。雖然EBS已經在底層進行了複製,但是這一步可以防止資料庫受到某些底層機制的破壞。
如果我們想要啟用一個新例項,則可以先暫停消費者,將相關聯的EBS驅動器分開,然後重新附加到新的例項上去。只要我們保證分割槽ID相同,重新分配磁碟是一個輕鬆的過程,而且也能保證資料的正確性。
如果worker發生崩潰,我們依靠RocksDB內建的預寫日誌來確保不會丟失訊息。訊息不會從輸入主題提交,除非RocksDB已經將訊息持久化在日誌中。
讀取輸出主題
你可能會注意到,本文直到這裡都沒有提到“原子”步驟,以使我們能夠確保只投遞一次訊息。我們的worker有可能在任何時候崩潰,不如:寫入RocksDB時、釋出到輸出主題時,或確認輸入訊息時。
我們需要一個原子的“提交”點,並覆蓋所有這些獨立系統的事務。對於輸入的資料,需要某個“事實來源”:輸出主題。
如果去重worker因為某些原因發生崩潰,或者遇到Kafka的某個錯誤,則系統在重新啟動時,會首先查閱這個“事實來源”,輸出主題,來判斷事件是否已經發布出去。
如果在輸出主題中找到訊息,而不是RocksDB(反之亦然),則去重worker將進行必要的修復工作以保持資料庫和RocksDB之間的同步。實際上,我們使用輸出主題作為我們的預寫入日誌和最終的事實來源,讓RocksDB進行檢查和校驗。
在生產環境中
我們的去重系統已經在生產執行了3個月,對其執行的結果我們感到非常滿意。我們有以下這些資料:
- 在RocksDB中,有1.5TB的key儲存在磁碟上
- 在老化舊的key之前,有一個四個星期的去重視窗
- RocksDB例項中儲存了大約600億個key
- 通過去重系統的訊息達到2000億條
該系統快速、高效、容錯性強,也非常容易理解。
特別是我們的v2版本系統相比舊的去重系統有很多優點。
以前我們將所有的key儲存在Memcached中,並使用Memcached的原子CAS(check-and-set)操作來設定key。 Memcached起到了提交點和“原子”地釋出key的作用。
雖然這個功能很好,但它需要有大量的記憶體來支撐所有的key。此外,我們必須能夠接受偶爾的Memcached故障,或者將用於高速記憶體故障切換的支出加倍。
Kafka/RocksDB的組合相比舊系統有如下幾個優勢:
資料儲存在磁碟上:在記憶體中儲存所有的key或完整的索引,其代價是非常昂貴的。通過將更多的資料轉移到磁碟,並利用多種不同級別的檔案和索引,能夠大幅削減成本。對於故障切換,我們能夠使用冷備(EBS),而不用執行其他的熱備例項。
分割槽:為了縮小key的搜尋範圍,避免在記憶體中載入太多的索引,我們需要保證某個訊息能夠路由到正確的worker。在Kafka中對上游進行分割槽可以對這些訊息進行路由,從而更有效地快取和查詢。
顯式地進行老化處理:在使用Memcached的時候,我們在每個key上設定一個TTL來標記是否超時,然後依靠Memcached程式來對超時的key進行處理。這使得我們在面對大量資料時,可能會耗盡記憶體,並且在丟棄大量超時訊息時,Memcached的CPU使用率會飆升。而通過讓客戶端來處理key的刪除,使得我們可以通過縮短去重視窗來優雅地處理。
將Kafka作為事實來源:為了真正地避免對多個提交點進行訊息去重,我們必須使用所有下游消費者都常見的事實來源。使用Kafka作為“事實來源”是最合適的。在大多數失敗的情況下(除了Kafka失敗之外),訊息要麼會被寫入Kafka,要麼不會。使用Kafka可以確保按順序投遞訊息,並在多臺計算機之間進行磁碟複製,而不需要在記憶體中保留大量的資料。
批量讀寫:通過Kafka和RocksDB的批量I/O呼叫,我們可以通過利用順序讀寫來獲得更好的效能。與之前在Memcached中使用的隨機訪問不同,我們能夠依靠磁碟的效能來達到更高的吞吐量,並只在記憶體中保留索引。
總的來說,我們對自己構建的去重系統非常滿意。使用Kafka和RocksDB作為流媒體應用的原語開始變得越來越普遍。我們很高興能繼續在這些原語之上構建新的分散式應用程式。