Golang Failpoint 的設計與實現

做者:龍恆git

對於一個大型複雜的系統來講,一般包含多個模塊或多個組件構成,模擬各個子系統的故障是測試中必不可少的環節,而且這些故障模擬必須作到無侵入地集成到自動化測試系統中,經過在自動化測試中自動激活這些故障點來模擬故障,並觀測最終結果是否符合預期結果來判斷系統的正確性和穩定性。若是在一個分佈式系統中須要專門請一位同事來插拔網線來模擬網絡異常,一個存儲系統中須要經過破壞硬盤來模擬磁盤損壞,昂貴的測試成本會讓測試成爲一場災難,而且難以模擬一些須要精細化控制的的測試。因此咱們須要一些自動化的方式來進行肯定性的故障測試。github

Failpoint 項目 就是爲此而生,它是 FreeBSD failpoints 的 Golang 實現,容許在代碼中注入錯誤或異常行爲, 並由環境變量或代碼動態激活來觸發這些異常行爲。Failpoint 能用於各類複雜系統中模擬錯誤處理來提升系統的容錯性、正確性和穩定性,好比:express

  • 微服務中某個服務出現隨機延遲、某個服務不可用。
  • 存儲系統磁盤 IO 延遲增長、IO 吞吐量太低、落盤時間長。
  • 調度系統中出現熱點,某個調度指令失敗。
  • 充值系統中模擬第三方重複請求充值成功回調接口。
  • 遊戲開發中模擬玩家網絡不穩定、掉幀、延遲過大等,以及各類異常輸入(外掛請求)狀況下系統是否正確工做。
  • ……

爲何要重複造輪子?

Etcd 團隊在 2016 年開發了 gofail 極大地簡化了錯誤注入,爲 Golang 生態作出了巨大貢獻。咱們在 2018 年已經引入了 gofail 進行錯誤注入測試,可是咱們在使用中發現了一些功能性以及便利性的問題,因此咱們決定造一個更好的「輪子」。bash

如何使用 gofail

  • 使用註釋在程序中注入一個 failpoint:網絡

    // gofail: var FailIfImportedChunk int
    // if merger, ok := scp.merger.(*ChunkCheckpointMerger); ok && merger.Checksum.SumKVS() >= uint64(FailIfImportedChunk) {
    // rc.checkpointsWg.Done()
    // rc.checkpointsWg.Wait()
    // panic("forcing failure due to FailIfImportedChunk")
    // }
    // goto RETURN1
    
    // gofail: RETURN1:
    
    // gofail: var FailIfStatusBecomes int
    // if merger, ok := scp.merger.(*StatusCheckpointMerger); ok && merger.EngineID >= 0 && int(merger.Status) == FailIfStatusBecomes {
    // rc.checkpointsWg.Done()
    // rc.checkpointsWg.Wait()
    // panic("forcing failure due to FailIfStatusBecomes")
    // }
    // goto RETURN2
    
    // gofail: RETURN2:
    複製代碼
  • 使用 gofail enable 轉換後的代碼:閉包

    if vFailIfImportedChunk, __fpErr := __fp_FailIfImportedChunk.Acquire(); __fpErr == nil { defer __fp_FailIfImportedChunk.Release(); FailIfImportedChunk, __fpTypeOK := vFailIfImportedChunk.(int); if !__fpTypeOK { goto __badTypeFailIfImportedChunk} 
        if merger, ok := scp.merger.(*ChunkCheckpointMerger); ok && merger.Checksum.SumKVS() >= uint64(FailIfImportedChunk) {
            rc.checkpointsWg.Done()
            rc.checkpointsWg.Wait()
            panic("forcing failure due to FailIfImportedChunk")
        }
        goto RETURN1; __badTypeFailIfImportedChunk: __fp_FailIfImportedChunk.BadType(vFailIfImportedChunk, "int"); };
    
    /* gofail-label */ RETURN1:
    
    if vFailIfStatusBecomes, __fpErr := __fp_FailIfStatusBecomes.Acquire(); __fpErr == nil { defer __fp_FailIfStatusBecomes.Release(); FailIfStatusBecomes, __fpTypeOK := vFailIfStatusBecomes.(int); if !__fpTypeOK { goto __badTypeFailIfStatusBecomes} 
        if merger, ok := scp.merger.(*StatusCheckpointMerger); ok && merger.EngineID >= 0 && int(merger.Status) == FailIfStatusBecomes {
            rc.checkpointsWg.Done()
            rc.checkpointsWg.Wait()
            panic("forcing failure due to FailIfStatusBecomes")
        }
        goto RETURN2; __badTypeFailIfStatusBecomes: __fp_FailIfStatusBecomes.BadType(vFailIfStatusBecomes, "int"); };
    
    /* gofail-label */ RETURN2:
    複製代碼

gofail 使用中遇到的問題

  • 使用註釋的方式在代碼中注入 failpoint,代碼容易出錯,而且沒有編譯器檢測。
  • 只能全局生效,大型項目爲了縮短自動化測試的時間會引入並行測試,不一樣並行任務之間會存在干擾。
  • 須要寫一些 hack 代碼來避免一些沒必要要的錯誤日誌,好比如上代碼,必需要寫 // goto RETURN2// gofail: RETURN2:,而且中間必須添加一個空行,至於緣由能夠看 generated code 邏輯。

咱們要設計一個什麼樣子的 failpoint?

理想的 failpoint 實現應該是什麼樣子?

理想中的 failpoint 應該是使用代碼定義而且對業務邏輯無侵入,若是在一個支持宏的語言中 (好比 Rust),咱們能夠定義一個 fail_point 宏來定義 failpoint:分佈式

fail_point!("transport_on_send_store", |sid| if let Some(sid) = sid {
    let sid: u64 = sid.parse().unwrap();
    if sid == store_id {
        self.raft_client.wl().addrs.remove(&store_id);
    }
})
複製代碼

可是咱們遇到了一些問題:函數

  • Golang 並不支持 macro 語言特性。
  • Golang 不支持編譯器插件。
  • Golang tags 也不能提供一個比較優雅的實現 (go build --tag="enable-failpoint-a")。

Failpoint 設計準則

  • 使用 Golang 代碼定義 failpoint,而不是註釋或其餘形式。
  • Failpoint 代碼不該該有任何額外開銷:
    • 不能影響正常功能邏輯,不能對功能代碼有任何侵入。
    • 注入 failpoint 代碼以後不能致使性能回退。
    • Failpoint 代碼最終不能出如今最終發行的二進制文件中。
  • Failpoint 代碼必須是易讀、易寫而且能引入編譯器檢測。
  • 最終生成的代碼必須具備可讀性。
  • 生成代碼中,功能邏輯代碼的行號不能發生變化(便於調試)。
  • 支持並行測試,能夠經過 context.Context 控制一個某個具體的 failpoint 是否激活。

Golang 如何實現一個相似 failpoint 宏?

宏的本質是什麼?若是追本溯源,發現其實能夠經過 AST 重寫在 Golang 中實現知足以上條件的 failpoint,原理以下圖所示:微服務

image

對於任何一個 Golang 代碼的源文件,能夠經過解析出這個文件的語法樹,遍歷整個語法樹,找出全部 failpoint 注入點,而後對語法樹重寫,轉換成想要的邏輯。性能

相關概念

Failpoint

Failpoint 是一個代碼片斷,而且僅在對應的 failpoint name 激活的狀況下才會執行,若是經過 failpoint.Disable("failpoint-name-for-demo") 禁用後, 那麼對應的的 failpoint 永遠不會觸發。全部 failpoiint 代碼片斷不會編譯到最終的二進制文件中,好比咱們模擬文件系統權限控制:

func saveTo(path string) error {
    failpoint.Inject("mock-permission-deny", func() error {
         // It's OK to access outer scope variable
         return fmt.Errorf("mock permission deny: %s", path)
    })
}
複製代碼

Marker 函數

AST 重寫階段標記須要被重寫的部分,主要有如下功能:

  • 提示 Rewriter 重寫爲一個相等的 IF 語句。
    • 標記函數的參數是重寫過程當中須要用到的參數。
    • 標記函數是一個空函數,編譯過程會被 inline,進一步被消除。
    • 標記函數中注入的 failpoint 是一個閉包,若是閉包訪問外部做用於變量,閉包語法容許捕獲外部做用域變量,不會出現編譯錯誤, 同時轉換後的的代碼是一個 IF 語句,IF 語句訪問外部做用域變量不會產生任何問題,因此閉包捕獲只是爲了語法合法,最終不會有任何額外開銷。
  • 簡單、易讀、易寫。
  • 引入編譯器檢測,若是 Marker 函數的參數不正確,程序不能經過編譯的,進而保證轉換後的代碼正確性。

目前支持的 Marker 函數列表:

  • func Inject(fpname string, fpblock func(val Value)) {}
  • func InjectContext(fpname string, ctx context.Context, fpblock func(val Value)) {}
  • func Break(label ...string) {}
  • func Goto(label string) {}
  • func Continue(label ...string) {}
  • func Fallthrough() {}
  • func Return(results ...interface{}) {}
  • func Label(label string) {}

如何在你的程序中使用 failpoint 進行注入?

最簡單的方式是使用 failpoint.Inject 在調用的地方注入一個 failpoint,最終 failpoint.Inject 調用會重寫爲一個 IF 語句, 其中 mock-io-error 用來判斷是否觸發,failpoint-closure 中的邏輯會在觸發後執行。 好比咱們在一個讀取文件的函數中注入一個 IO 錯誤:

failpoint.Inject("mock-io-error", func(val failpoint.Value) error {
    return fmt.Errorf("mock error: %v", val.(string))
})
複製代碼

最終轉換後的代碼以下:

if ok, val := failpoint.Eval(_curpkg_("mock-io-error")); ok {
    return fmt.Errorf("mock error: %v", val.(string))
}
複製代碼

經過 failpoint.Enable("mock-io-error", "return("disk error")") 激活程序中的 failpoint,若是須要給 failpoint.Value 賦一個自定義的值,則須要傳入一個 failpoint expression,好比這裏 return("disk error"),更多語法能夠參考 failpoint語法

閉包能夠爲 nil ,好比 failpoint.Enable("mock-delay", "sleep(1000)"),目的是在注入點休眠一秒,不須要執行額外的邏輯。

failpoint.Inject("mock-delay", nil)
failpoint.Inject("mock-delay", func(){})
複製代碼

最終會產生如下代碼:

failpoint.Eval(_curpkg_("mock-delay"))
failpoint.Eval(_curpkg_("mock-delay"))
複製代碼

若是咱們只想在 failpoint 中執行一個 panic,不須要接收 failpoint.Value,則咱們能夠在閉包的參數中忽略這個值。 例如:

failpoint.Inject("mock-panic", func(_ failpoint.Value) error {
    panic("mock panic")
})
// OR
failpoint.Inject("mock-panic", func() error {
    panic("mock panic")
})
複製代碼

最佳實踐是如下這樣:

failpoint.Enable("mock-panic", "panic")
failpoint.Inject("mock-panic", nil)
// GENERATED CODE
failpoint.Eval(_curpkg_("mock-panic"))
複製代碼

爲了能夠在並行測試中防止不一樣的測試任務之間的干擾,能夠在 context.Context 中包含一個回調函數,用於精細化控制 failpoint 的激活與關閉

failpoint.InjectContext(ctx, "failpoint-name", func(val failpoint.Value) {
    fmt.Println("unit-test", val)
})
複製代碼

轉換後的代碼:

if ok, val := failpoint.EvalContext(ctx, _curpkg_("failpoint-name")); ok {
    fmt.Println("unit-test", val)
}
複製代碼

使用 failpoint.WithHook 的示例

func (s *dmlSuite) TestCRUDParallel() {
    sctx := failpoint.WithHook(context.Backgroud(), func(ctx context.Context, fpname string) bool {
        return ctx.Value(fpname) != nil // Determine by ctx key
    })
    insertFailpoints = map[string]struct{} {
        "insert-record-fp": {},
        "insert-index-fp": {},
        "on-duplicate-fp": {},
    }
    ictx := failpoint.WithHook(context.Backgroud(), func(ctx context.Context, fpname string) bool {
        _, found := insertFailpoints[fpname] // Only enables some failpoints.
        return found
    })
    deleteFailpoints = map[string]struct{} {
        "tikv-is-busy-fp": {},
        "fetch-tso-timeout": {},
    }
    dctx := failpoint.WithHook(context.Backgroud(), func(ctx context.Context, fpname string) bool {
        _, found := deleteFailpoints[fpname] // Only disables failpoints. 
        return !found
    })
    // other DML parallel test cases.
    s.RunParallel(buildSelectTests(sctx))
    s.RunParallel(buildInsertTests(ictx))
    s.RunParallel(buildDeleteTests(dctx))
}
複製代碼

若是咱們在循環中使用 failpoint,可能咱們會使用到其餘的 Marker 函數

failpoint.Label("outer")
for i := 0; i < 100; i++ {
    inner:
        for j := 0; j < 1000; j++ {
            switch rand.Intn(j) + i {
            case j / 5:
                failpoint.Break()
            case j / 7:
                failpoint.Continue("outer")
            case j / 9:
                failpoint.Fallthrough()
            case j / 10:
                failpoint.Goto("outer")
            default:
                failpoint.Inject("failpoint-name", func(val failpoint.Value) {
                    fmt.Println("unit-test", val.(int))
                    if val == j/11 {
                        failpoint.Break("inner")
                    } else {
                        failpoint.Goto("outer")
                    }
                })
        }
    }
}

複製代碼

以上代碼最終會重寫爲以下代碼:

outer:
    for i := 0; i < 100; i++ {
    inner:
        for j := 0; j < 1000; j++ {
            switch rand.Intn(j) + i {
            case j / 5:
                break
            case j / 7:
                continue outer
            case j / 9:
                fallthrough
            case j / 10:
                goto outer
            default:
                if ok, val := failpoint.Eval(_curpkg_("failpoint-name")); ok {
                    fmt.Println("unit-test", val.(int))
                    if val == j/11 {
                        break inner
                    } else {
                        goto outer
                    }
                }
            }
        }
    }
複製代碼

對於爲何會有 label, break, continue 和 fallthrough 相關 Marker 函數保持疑問,爲何不直接使用關鍵字?

  • Golang 中若是某個變量或則標籤未使用,是不能經過編譯的。

    label1: // compiler error: unused label1
        failpoint.Inject("failpoint-name", func(val failpoint.Value) {
            if val.(int) == 1000 {
                goto label1 // illegal to use goto here
            }
            fmt.Println("unit-test", val)
        })
    
    複製代碼
  • break 和 continue 只能在循環上下文中使用,在閉包中使用。

一些複雜的注入示例

示例一:在 IF 語句的 INITIAL 和 CONDITIONAL 中注入 failpoint

if a, b := func() {
    failpoint.Inject("failpoint-name", func(val failpoint.Value) {
        fmt.Println("unit-test", val)
    })
}, func() int { return rand.Intn(200) }(); b > func() int {
    failpoint.Inject("failpoint-name", func(val failpoint.Value) int {
        return val.(int)
    })
    return rand.Intn(3000)
}() && b < func() int {
    failpoint.Inject("failpoint-name-2", func(val failpoint.Value) {
        return rand.Intn(val.(int))
    })
    return rand.Intn(6000)
}() {
    a()
    failpoint.Inject("failpoint-name-3", func(val failpoint.Value) {
        fmt.Println("unit-test", val)
    })
}
複製代碼

上面的代碼最終會被重寫爲:

if a, b := func() {
    if ok, val := failpoint.Eval(_curpkg_("failpoint-name")); ok {
        fmt.Println("unit-test", val)
    }
}, func() int { return rand.Intn(200) }(); b > func() int {
    if ok, val := failpoint.Eval(_curpkg_("failpoint-name")); ok {
        return val.(int)
    }
    return rand.Intn(3000)
}() && b < func() int {
    if ok, val := failpoint.Eval(_curpkg_("failpoint-name-2")); ok {
        return rand.Intn(val.(int))
    }
    return rand.Intn(6000)
}() {
    a()
    if ok, val := failpoint.Eval(_curpkg_("failpoint-name-3")); ok {
        fmt.Println("unit-test", val)
    }
}
複製代碼

示例二:在 SELECT 語句的 CASE 中注入 failpoint 來動態控制某個 case 是否被阻塞

func (s *StoreService) ExecuteStoreTask() {
    select {
    case <-func() chan *StoreTask {
        failpoint.Inject("priority-fp", func(_ failpoint.Value) {
            return make(chan *StoreTask)
        })
        return s.priorityHighCh
    }():
        fmt.Println("execute high priority task")

    case <- s.priorityNormalCh:
        fmt.Println("execute normal priority task")

    case <- s.priorityLowCh:
        fmt.Println("execute normal low task")
    }
}
複製代碼

上面的代碼最終會被重寫爲:

func (s *StoreService) ExecuteStoreTask() {
    select {
    case <-func() chan *StoreTask {
        if ok, _ := failpoint.Eval(_curpkg_("priority-fp")); ok {
            return make(chan *StoreTask)
        })
        return s.priorityHighCh
    }():
        fmt.Println("execute high priority task")

    case <- s.priorityNormalCh:
        fmt.Println("execute normal priority task")

    case <- s.priorityLowCh:
        fmt.Println("execute normal low task")
    }
}
複製代碼

示例三:動態注入 SWITCH CASE

switch opType := operator.Type(); {
case opType == "balance-leader":
    fmt.Println("create balance leader steps")

case opType == "balance-region":
    fmt.Println("create balance region steps")

case opType == "scatter-region":
    fmt.Println("create scatter region steps")

case func() bool {
    failpoint.Inject("dynamic-op-type", func(val failpoint.Value) bool {
        return strings.Contains(val.(string), opType)
    })
    return false
}():
    fmt.Println("do something")

default:
    panic("unsupported operator type")
}
複製代碼

以上代碼最終會重寫爲以下代碼:

switch opType := operator.Type(); {
case opType == "balance-leader":
    fmt.Println("create balance leader steps")

case opType == "balance-region":
    fmt.Println("create balance region steps")

case opType == "scatter-region":
    fmt.Println("create scatter region steps")

case func() bool {
    if ok, val := failpoint.Eval(_curpkg_("dynamic-op-type")); ok {
        return strings.Contains(val.(string), opType)
    }
    return false
}():
    fmt.Println("do something")

default:
    panic("unsupported operator type")
}
複製代碼

除了上面的例子以外,還能夠寫的更加複雜的狀況:

  • 循環的 INITIAL 語句, CONDITIONAL 表達式,以及 POST 語句
  • FOR RANGE 語句
  • SWITCH INITIAL 語句
  • Slice 的構造和索引
  • 結構體動態初始化
  • ……

實際上,任何你能夠調用函數的地方均可以注入 failpoint,因此請發揮你的想象力。

Failpoint 命名最佳實踐

上面生成的代碼中會自動添加一個 _curpkg_ 調用在 failpoint-name 上,是由於名字是全局的,爲了不命名衝突,因此會在最終的名字包包名,_curpkg_ 至關一個宏,在運行的時候自動使用包名進行展開。你並不須要在本身的應用程序中實現 _curpkg_,它在 failpoint-ctl enable 的自動生成以及自動添加,並在 failpoint-ctl disable 的時候被刪除。

package ddl // ddl’s parent package is `github.com/pingcap/tidb`

func demo() {
	// _curpkg_("the-original-failpoint-name") will be expanded as `github.com/pingcap/tidb/ddl/the-original-failpoint-name`
	if ok, val := failpoint.Eval(_curpkg_("the-original-failpoint-name")); ok {...}
}
複製代碼

由於同一個包下面的全部 failpoint 都在同一個命名空間,因此須要當心命名來避免命名衝突,這裏有一些推薦的規則來改善這種狀況:

  • 保證名字在包內是惟一的。

  • 使用一個自解釋的名字。

    • 能夠經過環境變量來激活 failpoint:
    GO_FAILPOINTS="github.com/pingcap/tidb/ddl/renameTableErr=return(100);github.com/pingcap/tidb/planner/core/illegalPushDown=return(true);github.com/pingcap/pd/server/schedulers/balanceLeaderFailed=return(true)"
    複製代碼

致謝

  • 感謝 gofail 提供最初實現,給咱們提供了靈感,讓咱們能站在巨人的肩膀上對 failpoint 進行迭代。
  • 感謝 FreeBSD 定義 語法規範

最後,歡迎你們和咱們交流討論,一塊兒完善 Failpoint 項目

相關文章
相關標籤/搜索