事務前沿研究丨事務測試體系解析

作者介紹:童牧。

緒論

在程序員的生涯中,bug 一直伴隨着我們,雖然我們期望寫出完美的程序,但是再優秀的程序員也無法保證自己能夠不寫出 bug。因此,我們爲程序編寫測試,通過提前發現 bug 來提高最終交付程序的質量。我從在 PingCAP 的工作中感受到,做好數據庫和做好數據庫測試是密不可分的,本次分享,我們將在第一講的事務隔離級別的基礎上,對數據庫事務的測試進行研究,主要講述,在 PingCAP 我們是如何保證事務的正確性的。

因爲我們保證事務正確性的方法比較多,所以本次我們會着重講解 Jepsen 和 Elle,而其他方法則是作爲補充,我也會簡單說明他們的做法和優缺點。我將事務測試的方法劃分爲以下幾個類別:

  • 理論正確性的驗證

  • 基於不變量的正確性驗證

  • 對執行歷史進行檢查的驗證

  • 輔助測試手段

回顧 Percolator 提交協議

Percolator

在開始講述測試方法前,我們先對 Percolator 提交協議進行回顧,感受一下這一協議下的複雜性。Percolator 提交協議使用 2PC 的提交方式來保證事務的原子性,但是在 shared-nothing 架構下,沒有一個節點有全局的事務信息,事務的狀態信息被打散到了每個 Key 上,使得對於 Key 狀態的處理變得更加複雜。

圖 1 - Percolator 下的兩階段提交

圖 1 是 Percolator 下的兩階段提交的流程,在 Prewrite 階段,將數據寫入到存儲引擎中,這些鍵值對可能存儲在不同的實例上,其中 Primary Key(PK) 是這個事務中的第一個 Key,是事務成功的原子性標誌。Prewrite 階段寫入的數據包含了事務實際要寫入的數據和事務的 PK(圖中爲 x),所以一旦 Prewrite 階段完成,則說明這個事務已經寫入成功。但是我們還需要 Commit 階段來使得這一成功的事務對外可見,在 Commit 階段中,會先 Commit PK,這一操作將事務標記爲成功狀態;然後 Commit 其他的 Key,讓整個事務都可以被直接讀取。

圖 2 - Percolator 下的讀寫關係處理

但是在這一提交協議下,另一個事務可能從任意一個時間點對正在提交的事務進行讀取,圖 2 給出了從 4 個時間點對事務進行讀取的情況。read1 時,事務還沒有 prewrite,所以讀取到舊的值即可;read2 時,讀事務看到了 Prewrite 的結果,但是對應的 Key 還沒有被 Commit,這時候會查詢這一事務對應的 PK 來確認狀態,但此時 PK 也還未提及,因此需要判斷所讀取到的事務是否過期,如果沒有過期,那麼等待一段時間後重試,如果已經過期,則會將這個事務 Rollback 掉;read3 和 read2 的過程相似,但是因爲查到了 PK 是已經提交的狀態,所以會讀取到這個事務的內容,並且把這個事務中讀取到的未提交的 Key 提交掉;read4 讀到的 Key 都是已經提交狀態的,直接讀取到數據即可。

一個破壞原子性的 bug

原子性是事務正確性的重要保證之一,比如在一個轉賬事務中,如果一個事務只有一半成功了,可能就會出現一個賬號扣了錢,而另一個賬號沒有收到錢的情況。

圖 3 - 原子性被破壞 bug

圖 3 是一個原子性被破壞的 bug,是三個事務併發執行產生的情況。Txn1 執行一條語句嘗試鎖住 x-z 這 3 個 Key,但是語句運行失敗,其中已經上鎖的 x-y 需要被異步撤銷,隨後第二條語句會重新選出一個主鍵,這裏假設新主鍵是 a,同時也嘗試對 y 加鎖,此時會阻止異步清鎖繼續執行。Txn3 對 y 加鎖時讀到了 stmt1 加的鎖,resolve 時發現 pk 已經被 rollback,因爲錯誤讀取了 Txn2 產生的緩存,Txn3 誤 rollback 掉了 stmt2 加上的 y,導致了事務原子性的破壞。在原本的 Percolator 提交協議中,並沒有包含主鍵更換的邏輯,而爲了避免在加鎖失敗時重啓事務,我們在實現中是有這一優化的,也因此使得所實現的事務模型更加複雜。

這裏的問題是沒有考慮到執行失敗的語句可能會造成事務中選取出新的主鍵,但原始的 Percolator 提交協議並沒有包括主鍵的更換,即是說我們在實現分佈式事務的實現中所做的優化,使得這一模型變得更加複雜了。

理論正確性的驗證

我們使用 TLA+ 對理論正確性進行驗證,TLA+ 是爲並行和分佈式系統設計的建模語言,能夠模擬出所有可能發生的情況,來保證理論的正確性。

圖 4 - 形式化驗證過程

使用 TLA+ 對模型進行形式化驗證需要先定義初始狀態,然後定義 Next 過程和驗證正確性的 THEOREM。Next 指的是可能會發生的過程,在一個並行系統中,雖然串行過程會逐個發生,但是並行過程發生的先後則不可被預測。圖 4 是形式化驗證的運行過程,執行一次 Next 過程,然後使用定義的 THEOREM 驗證狀態是否出現問題。因爲 Next 是可能會發生的過程,所有一次 Next 調用可能會產生多種執行路徑,TLA+ 會搜索所有可能的路徑,確保在這個並行系統中,任意一種順序都不會違反我們所定義的約束。

雖然 TLA+ 能夠從理論上驗證正確性,但這一方法也有着各種限制:

  • 複雜度隨過程量指數級提示,如果搜索路徑過於複雜,要完成期望深度的搜索可能需要消耗大量的時間。

  • 理論上的正確不能防止實現上的錯誤。

線性一致性和 Snapshot Isolation

首先我們需要明白,線性一致性和事務的隔離性是不相關的兩個概念。

圖 5 - 非可線性化

可線性化(Linearizability)原本是多處理器並行操作內存時的概念,後續被引入到數據庫中來,對單個元素的事務提出了兩點要求:

  • 單個元素上的事務可串行化;

  • 單個元素上,如果 Txn2 開啓的時間點晚於 Txn1 提交完成的時間點,Txn2 在串行化的隊列中一定在 Txn1 之後。

圖 5 是一個可串行化但是不可線性化的例子,可串行化的執行順序是 Txn2 -> Txn1。雖然 Txn2 開啓的時間點晚於 Txn1 提交的時間點,但是在可串行化的隊列中,Txn2 在 Txn1 之前。Spanner 提出了外部一致性的概念,並且認爲外部一致性是強於可線性化的一致性標準,因爲其對事務的先後約束能夠拓展到多元素的操作上,外部一致性也被理解爲一種可線性化的定義,他們約束的效果大體上相同。當我們綜合考慮隔離性和一致性時,就會發現可串行化並不是理想中的完美的隔離與一致性級別,例如圖 5 中,Txn1 是一個進行消費的事務,在進行消費後,還有事務讀取到了消費前的餘額,顯然這在很多場景下是無法被接受的。Jepsen 的一致性模型中,在可串行化之上又設定了一個隔離與一致性級別。當一個數據庫同時滿足可串行化和可線性化時,將其稱爲嚴格可串行化(Strict Serializable)。

圖 6 - 可線性化與外部一致性

如圖 6 所示,在可線性化的執行下,Txn3 將 x 從 1 改寫成 2,並且提交時間是從 ts1 到 ts2,那麼對於客戶端而言,早於 ts1 的時間點和晚於 ts2 的時間點是狀態確認的,而在 ts1 和 ts2 之中,因爲不能確定 Txn3 實際生效的時間點,所以 x 的值處於不可知的狀態,讀取到 1 或 2 都是允許的。

Snapshot Isolation(SI) 是一個被廣泛採用的隔離級別,同時 TiDB 也是一個 SI 隔離級別的數據庫,所以我們也在談論事務測試之前,需要搞清楚 SI 隔離級別是如何使用事務間依賴進行定義的。需要注意的是,SI 是一個先射箭再畫靶的隔離級別,所以我們對其的定義的目標是避免如“SI 就是事務從一個快照讀”這種模糊的語言,而是要給出相對客觀的靶子。

圖 7 - 偏序依賴

爲了定義 SI,需要引入了一個新的事務依賴,叫事務開始依賴,這一依賴反映了一個事務提交時間點和另一個事務開啓時間點之間的偏序關係,偏序關係往往和其他依賴同時發生,並且具有傳遞性。如圖 7 所示,Txn2 讀取到了 Txn1 的寫入,既可以說明 Txn2 的開始時間點晚於 Txn1 提交生效的時間點。需要注意的是,這裏的偏序指的是數據庫內部的時間點,需要和線性一致性級別綜合考慮,才能從外部觀測到的順序推斷出數據庫內部的時間點的偏序關係,即如果圖 7 中,Txn2 沒有讀取到 Txn1 的寫入,即使連接數據庫的客戶端能夠肯定 Txn2 是在 Txn1 完全提交後纔開啓的,也不能得到 c1 ≺t s2 的結論,這也有可能是數據庫不提供可線性化的線性一致性。

Adya 將 SI 的隔離級別稱爲 PL-SI,是在 PL-2 上附加了對數據庫內部時間點的約束,其包括事務開始依賴和一部分讀後寫依賴。

圖 8 - G-SIa 異常

SI 的通俗理解是,一個事務會取有一個快照,其讀操作在這個快照上進行,讀取的作用範圍是時間點小於等於這個快照時間點的所有寫入。那麼對於兩個存在偏序關係的事務,如果 c1 ≺t s2,那麼 Txn2 則需要讀取到 Txn1 的修改,同時 Txn1 不應該讀取到 Txn2 的寫入內容(從偏序關係的傳遞性可以推導 s1 ≺t c1 ≺t s2 ≺t c2),但是圖 8 中的 Txn1 卻讀取到了 Txn2 的寫入內容,從而破壞了 SI 的語義。從另一個角度來理解,事務開始依賴和 WR 依賴和 WW 依賴一樣,反映的是事務間的先後關係,當這些關係出現環的時候,他們的先後關係就無法被確認了,G-SIa(Interference) 異常指的是就是 WR, WW 和 S 依賴之間形成了環。

圖 9 - G-SIb 異常

圖 9 展示了 G-SIb(Missed Effects) 的異常現象,指的是 WR, WW, RW 和 S 依賴形成了環,但是其中只允許有一個 RW 依賴。這一現象可以理解爲,一個事務沒有完整的讀取到另一個事務的寫入,圖中 Txn2 寫入了兩個值,但是 Txn1 只讀取到一個值,另一個值讀取的是舊版本,產生了 RW 依賴。之所以只允許有一個 RW 依賴,是因爲一個 RW 依賴就足以檢查住這種問題,而兩個 RW 則會帶來如 Write Skew 的誤判。

PL-SI 需要在 PL-2 的基礎上防止 G-SIa 和 G-SIb 的發生,這點和對標可重複讀的 PL-2.99 是有些細微的差別的,請小心對待。

Jepsen

提到事務測試,就不得不提 Jepsen。Jepsen 是 TiDB 質量保證的重要一環,除了每一次發版,在日常測試中,Jepsen 也在不間斷的運行。

圖 10 - 約束檢查的思想

什麼是 Jepsen,爲什麼 Jepsen 是有效、高效的?圖 10 是作者的一些想法,如果我們要驗證由 ① 和 ② 組成的方程組的求解是否正確,我們可以仔細地檢查求解過程,也可以將結果代入到原式中,檢查是否符合給定條件。我相信大部分人在檢查方程結果的時候,都會選擇後者,因爲後者能夠簡單有效的檢查出錯誤。而 Jepsen 的思想,就是通過設計一些“方程”,將求解交給數據庫,最後通過一些約束條件檢查數據庫是否求解的正確。

Bank

圖 10 - Jepsen Bank

Jepsen Bank 是一個非常經典的測試,其模擬的情況也很簡單,圖 10 是這個用例運行的方式,在一張表中有許多用戶和他們的餘額紀錄,同時會有許多事務併發的進行轉賬操作。Bank 的檢查條件也非常簡單,就是所有用戶的賬戶總餘額不變。並且在 SI 隔離級別中,任意一個快照都應該滿足這一約束,如果某個快照不符合這一約束,則說明可能出現了 G-SIb(Missed Effects) 的異常現象,讀者可以思考一下原因。Jepsen 會在運行過程中,定時開啓事務,查詢總餘額,檢查是否破壞約束。

Bank 是一個比較接近現實業務的測試場景,邏輯理解簡單,但是因爲併發構造,在實際運行過程中可能會造成大量的事務衝突,Bank 並不關心數據庫如何處理這些衝突,會不會帶來事務失敗,大部分錯誤最終都會反應到餘額之上。

Long Fork

圖 11 - Jepsen Long Fork

Long Fork 是一個爲 SI 隔離級別設計的事務測試場景,其中有兩種事務,寫事務會對一個寄存器進行賦值,而讀事務則會查詢多個寄存器,最後分析這些讀事務是否讀到了破壞 PL-SI 的情況。圖 11 中,Txn1 和 Txn2 是寫事務,Txn3 和 Txn4 是讀事務,圖 11 中存在 G-SIa,破壞了 PL-SI,但是在這裏我們需要做一些假設才能發現環。

圖 12 - Jepsen Long Fork

圖 12 是對圖 11 例子的分析,根據 WR 依賴,我們可以確定 c2 ≺t s3 和 c1 ≺t s4。但是因爲我們不知道 Txn3 和 Txn4 的開始時間點,所以我們需要進行假設,如果 s3 ≺t s4,則如圖中左側的假設,從偏序的傳遞性,可以推導出 c2 ≺t s4,於是就能發現一個由 S 依賴和 RW 依賴組成的環;如果 s4 ≺t s3,則如圖中右側所示,也能發現一個 G-SIb 異常。如果 s3 和 s4 相等,那麼說明 Txn3 和 Txn4 是從會讀取到同樣的內容,但是實際讀取到的內容卻出現了矛盾,也存在異常,具體找到環的步驟就留給讀者自行推導了。

小結

Jepsen 提供了一些通過約束檢查來發現異常的方法,並且設計了一系列測試場景。有比較好的覆蓋率,其優點有:

  • 着眼於約束條件,簡化了正確性的驗證;

  • 測試的效率高。

圖 13 - Missing Lost Update

但是 Jepsen 也有他的不足之處,圖 13 表達了一個在 Bank 下的 Lost Update 異常,T2 的轉賬丟失了,但是最後並不能從結果上檢查出這個異常,因爲餘額的總和沒有變。

History Check

通過 BFS 進行檢測

有一類測試方法是通過 history check 來進行的,其目的是爲了尋找到更多的異常現象,儘可能的挖掘執行歷史中的信息。「On the Complexity of Checking Transactional Consistency」這篇論文就研究了從執行歷史分析事務一致性和其複雜度。

圖 14 - 可序列化的檢測

可串行化的檢測遵循其字面意思,我們只需要找到一個執行序列,能夠讓所有的事務串行執行的即可,那麼很自然的,只需要採用一個廣度優先搜索(BFS),就可以檢查是否是串行的。但是問題在於,這種方法在假設事務執行滿足 Sequential Consistency 的前提下,複雜度爲 O(deep^(N+3)),其中 deep 爲每個線程執行事務的數量,N 爲線程數。

圖 15 - 通過 guard 變量轉化 SI 檢測

檢測可串行化是簡單的,但是要檢測一個歷史序列是否符合 SI 隔離級別就無法直接進行,如圖 15 所示,論文通過添加 guard 變量,從而可以通過上文提到的對可串行化的檢測來判斷 SI 的讀寫關係是否合理。以 Lost Update 爲例,兩個事務都對 x 進行了修改,分別寫入了第一個版本和第二個版本,因此插入兩個 guard 變量,將這兩個事務對 x 的這兩個修改串聯起來,不允許其中插入對 x 的修改,只要驗證圖中 (b) 的執行歷史能夠滿足可串行化的要求,就可以說明 (a) 的執行歷史滿足 SI 的要求。

Elle

爲了解決理解困難和複雜度高等問題,Jensen 的作者 Kyle Kingsbury 發表了名爲 Elle 的事務一致性檢測方法。

Elle 通過環檢測和異常檢測的方式,來驗證事務的一致性和隔離性是否達到我們所指定的要求,它的工作方式是儘可能的找出執行歷史中的異常,而非嘗試找出一個可串行的執行序列,因此在效率是有着明顯的優勢,但相對的,這一測試方法的過程較爲複雜。

圖 16 - 簡單的 G1c 檢測

圖 16 就是檢測出 G1c 的例子,根據我們在第一講所討論的理論,我們不需要按照可串行化的字面定義,去尋找一個執行序列,而只需要檢測是否出現某些不允許出現的情況。雖然從直覺上,我們並不能肯定不出現某些異常就等同於字面上的可串行化,但是從在隔離級別定義的研究基礎上來說,通過檢查異常來判斷是否可串行化是合理的。此外,對於事務測試而言,找到異常已經是我們所期望的結果了。

圖 17 - Elle 所設計的模型

如圖 17 所示,Elle 設計了四個模型,分別是寄存器、加法計數器、集合和列表,其對執行歷史的檢測的方法大同小異,後面會以 List Append 作爲對象展開講解。

Elle 中的事務有生成和執行兩個階段,在生成階段,Elle 會隨機產生這個事務需要讀寫的內容,這些預生成好的讀寫會在執行階段得到結果。即一個事務在歷史中會存在兩條記錄,生成階段的 :invoke 和執行階段的 :ok/:fail/:info 中的一個。

  • :invoke,事務被生成,之後會被執行。

  • :ok,事務執行並確定提交成功。

  • :fail,確定事務沒有被提交。

  • :info,事務狀態不一定(例如提交時出現連接錯誤)。

爲了分析線性一致性,Elle 在分析過程中,考慮 Adya 所定義的事務間讀寫關係的依賴的同時,還給出了和時間相關的依賴,時間上的依賴在滿足對應的線性一致性的前提下也能夠反應事務的先後關係。

  • Process Depend,一個線程中事務執行的先後嚴格遵循執行順序,在支持 Sequential Consistency 的系統中可以使用。

  • Realtime Depend,所有線程中事務執行的先後嚴格遵循執行時間,在支持 Linearizability 的系統中可以使用。

圖 18 - 對時間依賴的環檢測

圖 18 展示了一個符合 Sequential Consistency 但不符合 Linearizability 的例子,客戶端分兩個線程執行事務,但是客戶端知道不同線程間事務的嚴格順序,注意在分佈式數據庫中,這些線程可能連接到的是不同的數據庫節點。如果系統只滿足 Sequential Consistency,那麼對應的依賴圖應該如左下方所示,其中並沒有出現環;但是如果系統是滿足 Linearizability 的,那麼依賴圖就會變成右下方所示,Txn3 和 Txn4 之間形成了環,換句話說,Txn4 發生在 Txn3 之前,但是卻讀取到了 Txn3 的寫入,並且這一異常,單純從 Adya 所定義的讀寫關係中是發現不了的。

接下來我們會通過幾個例子來講解,Elle 具體是如何工作和發現數據庫在事務處理上的異常的。

{:type :invoke, :f :txn, :value [[:a :x [1 2]] [:a :y [1]]], :process 0, :time 10, :index 1}
{:type :invoke, :f :txn, :value [[:r :x nil] [:a :y [2]]], :process 1, :time 20, :index 2}
{:type :ok, :f :txn, :value [[:a :x [1 2]] [:a :y [1]]], :process 0, :time 30, :index 3}
{:type :ok, :f :txn, :value [[:r :x []] [:a :y [2]]], :process 1, :time 40, :index 4}
{:type :invoke, :f :txn, :value [[:r :y nil]], :process 0, :time 50, :index 5}
{:type :ok, :f :txn, :value [[:r :y [1 2]]], :process 0, :time 60, :index 6}

例 1 - 包含 G-SIb 的執行歷史

圖 19 - G-SIb 的依賴圖

例 1 是一個出現 G-SIb 異常的歷史,這裏用 Clojure 的 edn 的格式來表示,通過 :process 屬性,可以推斷出第一行和第三行是 Txn1,第二行和第四行是 Txn2,第五行和第六行是 Txn3。從 Txn1 和 Txn2 從 :invoke 到 :ok 狀態的 :time 屬性來看,他們可能是並行的。而 Txn3 則是在這兩個事務都成功提交之後纔開啓的,從 Txn3 讀取到的 [[:r :y [2 1]] 來看,因爲 List 是按照 Append 順序排列的,所以可以判斷 Txn2 發生在 Txn1 之前,但是 Txn2 又讀到了 Txn1 寫入之前的 x,這裏產生了 RW 依賴,出現了環。在這個例子中,Elle 利用 List 的特性,找出了原本不容易判斷的 WW 依賴。

{:type :invoke, :f :txn, :value [[:a :x [1 2]] [:a :y [1]]], :process 0, :time 10, :index 1}
{:type :invoke, :f :txn, :value [[:a :x [3]] [:a :y [2]]], :process 1, :time 20, :index 2}
{:type :ok, :f :txn, :value [[:a :x [1 2]] [:a :y [1]]], :process 0, :time 30, :index 3}
{:type :ok, :f :txn, :value [[:a :x [3]] [:a :y [2]]], :process 1, :time 40, :index 4}
{:type :invoke, :f :txn, :value [[:r :x nil]], :process 2, :time 50, :index 5}
{:type :ok, :f :txn, :value [[:r :x [1 2 3]]], :process 2, :time 60, :index 6}
{:type :invoke, :f :txn, :value [[:r :x nil]], :process 3, :time 70, :index 7}
{:type :ok, :f :txn, :value [[:r :x [1 2]]], :process 3, :time 80, :index 8}

例 2 - 可能破壞 Linearizability 的執行歷史

圖 20 - 可能破壞 Linearizability 的依賴圖

例 2 是一個可能破壞 Linearizability 的例子,從圖 20 上方的依賴圖來看,出現了 RW,WR 和 Realtime 依賴組成的環,即 G-SIb 現象,但是這個環裏有一個 Realtime 依賴,這個系統還有可能是因爲破壞了 Linearizability 而產生這個環的。破壞 Linearizability 的情況可以從圖的下面更加清晰的發現,當遇到這種情況時,因爲不能斷言是出現了哪一種異常,Elle 會彙報可能產生的異常種類。

圖 21 - Elle 論文中的例子

圖 21 是 Elle 的論文中給出的例子,令第一行爲 Txn1,第二行爲 Txn2,第三行爲 Txn3。Txn1 和 Txn2 之間存在 Realtime 關係,而 Txn2 對 Key 爲 255 的 List 的讀取中沒有 Txn3 的寫入,說明其中存在一個 RW 依賴,而 Txn1 則讀取到了 Txn3 的寫入,於是出現了和圖 20 中類似的情況,這裏不再展開分析了。

MIKADZUKI

Elle 展示了依賴圖在測試中的巨大作用,在 PingCAP 內部,我們嘗試通過另一種方式來通過依賴圖對數據庫進行測試。回顧 Elle 的流程,是執行後分析執行歷史,將其轉化爲依賴圖後,判斷其是否符合某個隔離級別或一致性級別。MIKADZUKI 的做法正好相反,嘗試在有依賴圖的情況下,生成出執行歷史,對比生成的執行歷史和數據庫實際執行的表現,就可以發現數據庫是否正常。

圖 22 - MIKADZUKI 依賴圖層次

圖 22 是 MIKADZUKI 內部的圖的層次,Process 中的事務會串行執行,而 Process 間的事務會並行執行。同一個 Process 下的事務,首尾之間存在 Realtime 的關係,而 Process 間的事務會生成 Depend,Depend 和 Realtime 都代表了事務執行的先後關係,所以在生成時,這兩類依賴不會讓事務形成環。

圖 23 - MIKADZUKI 的執行流程

圖 23 是 MIKADZUKI 的執行流程,一輪有四個階段:

  • 生成一張沒有環的 Graph;

  • 爲 Graph 中的寫請求填充隨機生成的讀寫數據,數據以 KV 形式表達,其中 Key 是主鍵索引或唯一索引,Value 是整行數據;

  • 從寫請求根據事務間的依賴,推測出讀請求應當讀取到的結果;

  • 按照圖的事務依賴描述,並行執行事務,如果讀取到與第三步預測中不同的結果,則說明結果有誤。

這一測試方法幫助我們發現過一些問題,在實驗後期,我們嘗試添加製造成環的依賴,WW 依賴所成的環,在正常執行下會出現死鎖,而死鎖檢測則是以往的測試方法不容易發現的,因爲死鎖檢測卡住不會導致任何異常。

小結

通過對執行歷史的檢查,我們能夠儘可能的發現異常,得益於對隔離級別和一致性的學術研究,對歷史的檢查複雜度大幅降低。Elle 進而設計了一些模型,能夠爲分析事務間關係提供線索,從而使得對完整歷史的檢查變得可能且有效。

錯誤注入

墨菲法則聲稱——任何可能發生錯誤的地方都會發生錯誤,即再小的發生錯誤的概率,也總有一天會發生。另一方面,測試環境的規模和數量又遠小於生產環境,正常情況下,絕大部分的錯誤都將發生在生產環境,那麼幾乎所有因此引發的罕見的系統的 bug 也將發生在生產環境下,這顯然是我們所不期望發生的。

爲了將 bug 止於測試環境,我們會使用一些方法進行故障模擬,主要包括:

  • Failpoint,爲進程注入錯誤。

  • Chaos Test,模擬外界產生的故障,更接近真實情況。

Failpoint

Failpoint 是用於向進程中注入一些問題的測試手段,可以在編譯期決定是否打開,正常發佈的版本是關閉 Failpoint 的。TiDB 通過摺疊代碼來控制 Failpoint,TiKV 則通過宏和編譯時的環境變量進行控制。通過 Failpoint,我們可以高效的模擬一些平時罕見但又存在發生可能的情況:

  • 存在一些難以被訪問到的代碼路徑,並且可能是正確性的重要保障;

  • 程序可能在任意節點被 kill;

  • 代碼執行可能在任意一個節點 hang 住。

// disable
failpoint.Inject("getMinCommitTSFromTSO", nil)

// enable
failpoint.Eval(_curpkg_("getMinCommitTSFromTSO"))

例3 - 打開 Failpoint

例 3 是一個簡單的打開 Failpoint 的例子,在關閉狀態下,Inject 函數不會做任何事情,而當 Failpoint 打開後,Inject 函數就會變成 Eval 函數,此時我們可以使用 HTTP 請求去控制 Failpoint 的行爲,包括:

  • 人爲添加 sleep;

  • 讓 goroutine panic;

  • 暫停這個 goroutine 的執行;

  • 進入 gdb 中斷。

// disable
failpoint.Inject("beforeSchemaCheck", func() {
    c.ttlManager.close()
    failpoint.Return()
})

// enable
if _, _err_ := failpoint.Eval(_curpkg_("beforeSchemaCheck"));
    _err_ == nil {
    c.ttlManager.close()
    return
}

例 4 - 利用 Failpoint 注入變量

在例 4 中,TiDB 利用 Failpoint 做了更多的注入,在其中關閉了 TTL Manager,這將導致悲觀鎖的快速過期,並且中斷事務的提交。此外,還可以藉助 Failpoint 修改當前作用域下的變量。如果沒有 Failpoint,這些故障情況可能極少發生,而通過 Failpoint,我們就可以快速的測試當發生故障時,是否會產生如破壞一致性的異常現象。

圖 24 - 對提交 Secondary Keys 的 Failpoint 注入

圖 24 展示了在兩階段提交中的提交階段下,通過注入來達到延遲或者跳過 Secondary Keys 的效果,而這些情況在通常情況下是幾乎不會出現的。

Chaos Test

在一個分佈式系統中,我們難以要求開發人員總是寫出正確的代碼,事實上大部分時候我們都不能做到完全的正確實現。如果說 Failpoint 是細粒度的控制某段代碼可能會出現的現象,是演習;那麼 Chaos Test 就是無差別的對系統進行破壞,是真正的戰場。

圖 25 - Chaos Test 概念圖

圖 25 是 Chaos Test 的概念圖,Chaos Test 和 Failpoint 最大的區別在於,Failpoint 仍然是開發人員所設想的可能出現異常的地方,那麼開發人員在絕大多數情況下,是無法將 Failpoint 設計的全面的,Chaos Test 爲這一紕漏加上了一層額外的保險。

在開發 Chaos Mesh 的過程中,我們也做了諸多嘗試,例如:

  • kill node

  • 物理機的斷電測試

  • 網絡延遲與丟包

  • 機器的時間漂移

  • IO 性能限制

Chaos Mesh 在正式發佈前,Chaos Test 就在 PingCAP 被證明是有效的,我們將這些測試心得通過 Chaos Mesh 分享給社區。

總結

尚未提筆寫這篇文章的時候,我也曾反覆思考過,關於事務測試,究竟能夠分享些什麼?並且一度陷入覺得沒有東西好說的困境。然而當我嘗試說明白一些測試方法時,才後知後覺的意識到,測試是一門很深奧也容易被忽視的學問,我們在開發數據庫的過程中花費了不少的心思在設計和運行測試上,本文所提及的,也只是事務測試體系的冰山一角。所有的測試,都是爲了更好的產品質量,事務作爲數據庫的核心特性之一,更應該受到關注。Through the fire and the flames, we carry on.

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章