用 LevelDB 的小夥伴請注意,你的 LevelDB 可能存在數據不一致的風險。

最近,我司分散式存儲團隊遇到了一個詭異的問題。現象很簡單,我們發現從 LevelDB 中讀取的數據是一個過期的(Deprecated)數據。例如,我們依次執行了 Put(Key1, Value1),Put(Key1, Value2),但是 Get(Key1) 時返回的是 Value1,而不是 Value2。

一開始,我們懷疑是不是自己程序的問題,畢竟 LevelDB 是一個廣泛被採用的項目,如果有這麼嚴重的 Bug,應該早就被修復了才對。但經過反覆的檢查,我們最終還是把懷疑的目標放在了 LevelDB 上。

於是我們瀏覽了 Github 上 LevelDB 項目的所有 Issue,發現了這樣一個問題:

Compaction causes data inconsistency when using snapshots。

順著這個線索,我們對問題進行了驗證,並最終證實了我們遇到的情況是由這個 Bug 引起的。不過很遺憾,這個 Bug 對應的 PR 到目前還沒有被 Merge 到主線。也就是說,大部分正在被使用的 LevelDB 代碼中,都隱藏著這麼一個非常嚴重的 Bug。

在這裡,我們詳細講解一下這個關於 LevelDB Compaction 的 Bug,幫助大家更好的理解 LevelDB 的實現細節。

LevelDB 背景

關於 LevelDB 的基礎背景,有很多文章進行過詳細的介紹,這裡我們只簡單帶過一下。

LevelDB 中,每一個 Record 由 <Key, SN(Sequence Number), ValueType, Value> 四部分組成。SN 是 LevelDB 在寫入數據時生成的一個全局遞增的整數,ValueType 表明這個 Record 是否為有效的 Record。

在 LevelDB 中,Record 分多層保存,從 Level 0 開始到 Level N。除 Level 0 外,每一個 Level 內的 Record 都是有序的,且被分別保存在多個文件中,每個文件被稱作一個 SStable。

Put 的流程

  • 生成一個新的 SN
  • 將 Record 寫入 WAL,用於保證 Crash Consistency
  • 將 Record 寫入內存中的 Memtable

當內存中的 Memtable 超過一定大小時,被 Dump 到 Level 0,作為一個數據文件。

Delete 的過程

在 LevelDB 中,Delete 操作並不是通過直接在文件中刪除一個 Record 的方式來實現的。實際上 Delete 的過程和 Put 的過程相似,也是寫入一個 Record,唯一的區別是,Record 中的 ValueType 為 DELETE。 這樣當執行 Get 操作時,如果發現 Record 中的 ValueType 為 DELETE, 則認為這個 Record 已經被刪除掉了。而 Record 實際上從文件中被清除是在 Compaction 階段完成的。

Compaction 的流程

以 Level 0 的 Compaction 舉例,當 Level 0 的文件數據超過一定限制時,則會觸發 Compaction:

  • 將 Record 從 Level 0 中讀出,並獲取這組 Record 的 Key Range
  • 掃描 Level 1 中的文件,如果文件的中保存的 Record 和內存中的 Record 的 Key Range 有重疊,則將這個文件中的 Record 也載入到內存,以保證 Level 1 的 Record 在 Compaction 結束後依然是有序的
  • 將所有載入到內存的 Record 按照 <Key, SN> 進行排序
  • 如果發現有相同 Key 的 Record,則只保留 SN 最大的 Record
  • 將排序並清理後的 Record 拆分成多個文件,並寫入磁碟,作為 Level 1 的文件
  • 將 Level 0 和 Level 1 中被 Compaction 的文件從磁碟上刪除

每一層的文件超過一定數量時,都會觸發 Compaction 操作。非 Level 0 層的 Compaction 的流程和 Level 0 層的 Compaction 類似。區別在於,非 Level 0 層進行 Compaction 時,不一定將這一層的所有文件都 Compact 到下一層,而是可以選擇指定的幾個文件,或一個特定的 Key Range 進行 Compaction。

Get 的流程

從 Level 0 層開始逐層掃描,尋找這一層中 Key 相同且 SN 最大的 Record,如果這個 Record 的 ValueType 為有效,則返回這個 Record 的 Value,如果 ValueType 為無效,則意味著這個 Key 已經被刪除了,返回 NotFound 錯誤。

如果在某一層中找到了一個 Key 相同的 Record,則不會再向下一層尋找。

由於每一層的 Record 是有序的,所以 LevelDB 在內存中保存了每個文件的 Key Range,作為索引,這樣可以加速查找的過程。

以上是 LevelDB 最基礎的邏輯。更詳細的介紹,建議參考 leveldb-handbook - leveldb-handbook 文檔,裡面有非常詳細的介紹。

LevelDB Snapshot

在 LevelDB 中,還提供了 Snapshot 的功能。當執行 Snapshot 時,LevelDB 返回一個 Snapshot 對象。通過這個對象,我們可以獲得快照時刻的資料庫內容。

在 LevelDB 中,Snapshot 的實現非常精妙。Snapshot 並不是一個實體,只是記錄了 Snapshot 發生時的 SN。當 LevelDB 中存在未被釋放的 Snapshot 時,Compaction 流程將有所變化。而這裡面的細節是很少被大家注意到的。

帶有 Snapshot 的 Compaction

我們在這裡再回顧一遍 Compaction 流程:

  • 將 Record 從 Level 0 中讀出,並獲取這組 Record 的 Key Range
  • 掃描 Level 1 中的文件,如果文件的中保存的 Record 和內存中的 Record 的 Key Range 有重疊,則將這個文件中的 Record 也載入到內存,以保證 Level 1 的 Record 在 Compaction 結束後依然是有序的
  • 將所有載入到內存的 Record 按照 <Key, SN> 進行排序
  • 如果發現有相同 Key 的 Record,則只保留 SN 最大的 Record
  • 將排序並清理後的 Record 拆分成多個文件,並寫入磁碟,作為 Level 1 的文件
  • 將 Level 0 和 Level 1 中被 Compaction 的文件從磁碟上刪除

按照這種方式進行 Compaction,可以保證除了 Level 0 層外,每一層中相同的 Key 只會出現一次。

Compaction 的一個很重要的目的,就是對 LevelDB 中保存的『無效』 Record 進行清理。然而,當 LevelDB 中存在 Snapshot 時,為了保證 Snapshot 對應的數據可以被訪問到,需要調整對『無效』的定義,避免屬於某個 Snapshot 的 Record 被清理 掉。因此 Compaction 流程中的第四步被調整為:

  • 如果發現有相同 Key 的 Record
    • 如果有 Snapshot,則保留大於 Snapshot SN 的所有 Record,以及一個小於 Snapshot SN 的 Record 中,SN 最大的 Record
    • 如果沒有 Snapshot,則只保留 SN 最大的 Record

這樣,Snapshot 時 Record 的狀態,就通過保留最接近 Snapshot SN 的 Record 的方式保存了下來。當通過 Get 操作讀取 Snapshot 時,只會查找 SN 小於 Snapshot SN 的 Record。SN 大於 Snapshot SN 的 Record 都被視為是無效的。

隨著 Compaction 流程的變更,在存在 Snapshot 的情況下,Compaction 的會導致每一層中可能會保存有多個具有相同 Key 的 Record,他們按照 SN 被排序。如果這些 Record 比較多,或者 Value 比較大,則這些 Record 可能會被分別保存在不同的文件中。這就為這個 Bug 埋下了隱患。

Compaction 的 Bug

在 LevelDb 中,觸發 Compaction 的條件有很多。對於非 Level 0 層來說,可能會觸發對某一個單獨文件的 Compaction 操作。

而這裡面的問題在於,當觸發對某個文件的 Compaction 時,LevelDB 只在下一層中查找 Key Range 存在 Overlap 的文件,而忽略了在同一層中,也可能存在有 Key Range Overlap 的情況。例如:

| level n | file1 | file2 |
| | <k1 sn1>, <k1 sn2> | <k1 sn3>, <k1 sn4> |

當對 file2 進行 Compaction 時,file1 中的兩個記錄 <k1 sn1>, <k1, sn2> 有可能會被保留下來。這就會導致當 Get(k1) 的請求查詢到 level n 時,會獲取到一個過期的 Record <k1, sn2>。而實際上真正有效的 Record 是被保存在 Level N+1 層。

我們可以通過這段代碼復現這個問題。你也可以在自己的環境上編譯並執行一下看看。

[kyle@localhost test]$ g++ test_leveldb.cc -lleveldb && ./a.out
modified=388
modified=236
...
modified=38
modified=368
Value MISMATCH key=2776

解決方法很簡單,在對某個文件做 Compaction 時,先在同層的文件中查找是否有 Key Range Overlap 的情況,如果有的話,則需要把對應的文件也加入到 Compaction 的過程中,例如在上面的例子中,對 file2 的 Compaction 會觸發把 file1 也一起進行 Compaction,這樣就避免了問題發生。

具體代碼可以參考這個 PR。

關於 RocksDB

RocksDB 是源自 LevelDB 的項目,不過貌似 RocksDB 很早就已經把這個問題解決了。至於為什麼沒有 backport 到 LevelDB 就不得而知了。

關於問題的思考

LevelDB 被應用的太廣泛了,而且有 Sanjay 和 Jeff Dean 這樣的大神加持,以至於我們很難想像會存在這麼嚴重的 Bug。這不得不讓我們面對這樣一個事實:魔鬼在細節中,devil is in the detail。

Jeff Dean compiles and runs his code before submitting, but only to check for compiler and CPU bugs.

像這種關於 Jeff Dean 的傳說,無非只是些段子罷了。如果不重視細節,必將被魔鬼吞噬。

相信這個 Bug 影響的範圍還是很大的,看了一下 Chromium 的 IndexedDB,也用到了 Snapshot 的功能,用於提供 Transaction 語義。而這個 Bug 最致命的地方還在於很難被察覺到,它並不會產生程序崩潰,也不會導致 DB 打開失敗。

如果你的項目裡面用到了 LevelDB,同時也用到了 Snapshot 功能,那麼趕緊打上這個 Patch 吧。

作者介紹

@張凱(Kyle Zhang),SmartX 聯合創始人 & CTO。SmartX 擁有國內最頂尖的分散式存儲和超融合架構基礎設施研發團隊,是國內超融合領域的技術領導者。


推薦閱讀:
相关文章