做者:龍恆git
對於一個大型複雜的系統來講,一般包含多個模塊或多個組件構成,模擬各個子系統的故障是測試中必不可少的環節,而且這些故障模擬必須作到無侵入地集成到自動化測試系統中,經過在自動化測試中自動激活這些故障點來模擬故障,並觀測最終結果是否符合預期結果來判斷系統的正確性和穩定性。若是在一個分佈式系統中須要專門請一位同事來插拔網線來模擬網絡異常,一個存儲系統中須要經過破壞硬盤來模擬磁盤損壞,昂貴的測試成本會讓測試成爲一場災難,而且難以模擬一些須要精細化控制的的測試。因此咱們須要一些自動化的方式來進行肯定性的故障測試。github
Failpoint 項目 就是爲此而生,它是 FreeBSD failpoints 的 Golang 實現,容許在代碼中注入錯誤或異常行爲, 並由環境變量或代碼動態激活來觸發這些異常行爲。Failpoint 能用於各類複雜系統中模擬錯誤處理來提升系統的容錯性、正確性和穩定性,好比:express
Etcd 團隊在 2016 年開發了 gofail 極大地簡化了錯誤注入,爲 Golang 生態作出了巨大貢獻。咱們在 2018 年已經引入了 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:
// goto RETURN2
和 // gofail: RETURN2:
,而且中間必須添加一個空行,至於緣由能夠看 generated code 邏輯。理想中的 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); } })
可是咱們遇到了一些問題:微服務
go build --tag="enable-failpoint-a"
)。Failpoint 代碼不該該有任何額外開銷:性能
context.Context
控制一個某個具體的 failpoint 是否激活。宏的本質是什麼?若是追本溯源,發現其實能夠經過 AST 重寫在 Golang 中實現知足以上條件的 failpoint,原理以下圖所示:測試
對於任何一個 Golang 代碼的源文件,能夠經過解析出這個文件的語法樹,遍歷整個語法樹,找出全部 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) }) }
AST 重寫階段標記須要被重寫的部分,主要有如下功能:
提示 Rewriter 重寫爲一個相等的 IF 語句。
目前支持的 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.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) })
示例一:在 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") }
除了上面的例子以外,還能夠寫的更加複雜的狀況:
實際上,任何你能夠調用函數的地方均可以注入 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 都在同一個命名空間,因此須要當心命名來避免命名衝突,這裏有一些推薦的規則來改善這種狀況:
使用一個自解釋的名字。
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)"
最後,歡迎你們和咱們交流討論,一塊兒完善 Failpoint 項目。