TiKV 原始碼解析(五)fail-rs 介紹

PingCAP發表於2019-04-01

作者:張博康

本文為 TiKV 原始碼解析系列的第五篇,為大家介紹 TiKV 在測試中使用的周邊庫 fail-rs

fail-rs 的設計啟發於 FreeBSD 的 failpoints,由 Rust 實現。通過程式碼或者環境變數,其允許程式在特定的地方動態地注入錯誤或者其他行為。在 TiKV 中通常在測試中使用 fail point 來構建異常的情況,是一個非常方便的測試工具。

Fail point 需求

在我們的整合測試中,都是簡單的構建一個 KV 例項,然後傳送請求,檢查返回值和狀態的改變。這樣的測試可以較為完整地測試功能,但是對於一些需要精細化控制的測試就鞭長莫及了。我們當然可以通過 mock 網路層提供網路的精細模擬控制,但是對於諸如磁碟 IO、系統排程等方面的控制就沒辦法做到了。

同時,在分散式系統中時序的關係是非常關鍵的,可能兩個操作的執行順行相反,就導致了迥然不同的結果。尤其對於資料庫來說,保證資料的一致性是至關重要的,因此需要去做一些相關的測試。

基於以上原因,我們就需要使用 fail point 來複現一些 corner case,比如模擬資料落盤特別慢、raftstore 繁忙、特殊的操作處理順序、錯誤 panic 等等。

基本用法

示例

在詳細介紹之前,先舉一個簡單的例子給大家一個直觀的認識。

還是那個老生常談的 Hello World:

#[macro_use]
extern crate fail;

fn say_hello() {
    fail_point!(“before_print”);
    println!(“Hello World~”);
}

fn main() {
    say_hello();
    fail::cfg("before_print", "panic");
    say_hello();
}
複製程式碼

執行結果如下:

Hello World~
thread 'main' panicked at 'failpoint before_print panic' ...
複製程式碼

可以看到最終只列印出一個 Hello World~,而在列印第二個之前就 panic 了。這是因為我們在第一次列印完後才指定了這個 fail point 行為是 panic,因此第一次在 fail point 不做任何事情之後正常輸出,而第二次在執行到 fail point 時就會根據配置的行為 panic 掉!

Fail point 行為

當然 fail point 不僅僅能注入 panic,還可以是其他的操作,並且可以按照一定的概率出現。描述行為的格式如下:

[<pct>%][<cnt>*]<type>[(args...)][-><more terms>]
複製程式碼
  • pct:行為被執行時有百分之 pct 的機率觸發
  • cnt:行為總共能被觸發的次數
  • type:行為型別
    • off:不做任何事
    • return(arg):提前返回,需要 fail point 定義時指定 expr,arg 會作為字串傳給 expr 計算返回值
    • sleep(arg):使當前執行緒睡眠 arg 毫秒
    • panic(arg):使當前執行緒崩潰,崩潰訊息為 arg
    • print(arg):列印出 arg
    • pause:暫停當前執行緒,直到該 fail point 設定為其他行為為止
    • yield:使當前執行緒放棄剩餘時間片
    • delay(arg):和 sleep 類似,但是讓 CPU 空轉 arg 毫秒
  • args:行為的引數

比如我們想在 before_print 處先 sleep 1s 然後有 1% 的機率 panic,那麼就可以這麼寫:

"sleep(1000)->1%panic"
複製程式碼

定義 fail point

只需要使用巨集 fail_point! 就可以在相應程式碼中提前定義好 fail point,而具體的行為在之後動態注入。

fail_point!("failpoint_name");
fail_point!("failpoint_name", |_| { // 指定生成自定義返回值的閉包,只有當 fail point 的行為為 return 時,才會呼叫該閉包並返回結果
    return Error
});
fail_point!("failpoint_name", a == b, |_| { // 當滿足條件時,fail point 才被觸發
    return Error
})
複製程式碼

動態注入

環境變數

通過設定環境變數指定相應 fail point 的行為:

FAILPOINTS="<failpoint_name1>=<action>;<failpoint_name2>=<action>;..."
複製程式碼

注意,在實際執行的程式碼需要先使用 fail::setup() 以環境變數去設定相應 fail point,否則 FAILPOINTS 並不會起作用。

#[macro_use]
extern crate fail;

fn main() {
    fail::setup(); // 初始化 fail point 設定
    do_fallible_work();
    fail::teardown(); // 清除所有 fail point 設定,並且恢復所有被 fail point 暫停的執行緒
}
複製程式碼

程式碼控制

不同於環境變數方式,程式碼控制更加靈活,可以在程式中根據情況動態調整 fail point 的行為。這種方式主要應用於整合測試,以此可以很輕鬆地構建出各種異常情況。

fail::cfg("failpoint_name", "actions"); // 設定相應的 fail point 的行為
fail::remove("failpoint_name"); // 解除相應的 fail point 的行為
複製程式碼

內部實現

以下我們將以 fail-rs v0.2.1 版本程式碼為基礎,從 API 出發來看看其背後的具體實現。

fail-rs 的實現非常簡單,總的來說,就是內部維護了一個全域性 map,其儲存著相應 fail point 所對應的行為。當程式執行到某個 fail point 時,獲取並執行該全域性 map 中所儲存的相應的行為。

全域性 map 其具體定義在 FailPointRegistry

struct FailPointRegistry {
    registry: RwLock<HashMap<String, Arc<FailPoint>>>,
}
複製程式碼

其中 FailPoint 的定義如下:

struct FailPoint {
    pause: Mutex<bool>,
    pause_notifier: Condvar,
    actions: RwLock<Vec<Action>>,
    actions_str: RwLock<String>,
}
複製程式碼

pausepause_notifier 是用於實現執行緒的暫停和恢復,感興趣的同學可以去看看程式碼,太過細節在此不展開了;actions_str 儲存著描述行為的字串,用於輸出;而 actions 就是儲存著 failpoint 的行為,包括概率、次數、以及具體行為。Action 實現了 FromStr 的 trait,可以將滿足格式要求的字串轉換成 Action。這樣各個 API 的操作也就顯而易見了,實際上就是對於這個全域性 map 的增刪查改:

  • fail::setup() 讀取環境變數 FAILPOINTS 的值,以 ; 分割,解析出多個 failpoint name 和相應的 actions 並儲存在 registry 中。
  • fail::teardown() 設定 registry 中所有 fail point 對應的 actions 為空。
  • fail::cfg(name, actions)name 和對應解析出的 actions 儲存在 registry 中。
  • fail::remove(name) 設定 registryname 對應的 actions 為空。

而程式碼到執行到 fail point 的時候到底發生了什麼呢,我們可以展開 fail_point! 巨集定義看一下:

macro_rules! fail_point {
    ($name:expr) => {{
        $crate::eval($name, |_| {
            panic!("Return is not supported for the fail point \"{}\"", $name);
        });
    }};
    ($name:expr, $e:expr) => {{
        if let Some(res) = $crate::eval($name, $e) {
            return res;
        }
    }};
    ($name:expr, $cond:expr, $e:expr) => {{
        if $cond {
            fail_point!($name, $e);
        }
    }};
}
複製程式碼

現在一切都變得豁然開朗了,實際上就是對於 eval 函式的呼叫,當函式返回值為 Some 時則提前返回。而 eval 就是從全域性 map 中獲取相應的行為,在 p.eval(name) 中執行相應的動作,比如輸出、等待亦或者 panic。而對於 return 行為的情況會特殊一些,在 p.eval(name) 中並不做實際的動作,而是返回 Some(arg) 並通過 .map(f) 傳參給閉包產生自定義的返回值。

pub fn eval<R, F: FnOnce(Option<String>) -> R>(name: &str, f: F) -> Option<R> {
    let p = {
        let registry = REGISTRY.registry.read().unwrap();
        match registry.get(name) {
            None => return None,
            Some(p) => p.clone(),
        }
    };
    p.eval(name).map(f)
}
複製程式碼

小結

至此,關於 fail-rs 背後的祕密也就清清楚楚了。關於在 TiKV 中使用 fail point 的測試詳見 github.com/tikv/tikv/t…,大家感興趣可以看看在 TiKV 中是如何來構建異常情況的。

同時,fail-rs 計劃支援 HTTP API,歡迎感興趣的小夥伴提交 PR。

TiKV 原始碼解析(五)fail-rs 介紹

相關文章