摘要:因為同一個包下面的所有都在同一個命名空間,所以需要小心命名來避免命名沖突,這里有一些推薦的規(guī)則來改善這種情況保證名字在包內(nèi)是唯一的。
作者:龍恒
對于一個大型復雜的系統(tǒng)來說,通常包含多個模塊或多個組件構成,模擬各個子系統(tǒng)的故障是測試中必不可少的環(huán)節(jié),并且這些故障模擬必須做到無侵入地集成到自動化測試系統(tǒng)中,通過在自動化測試中自動激活這些故障點來模擬故障,并觀測最終結(jié)果是否符合預期結(jié)果來判斷系統(tǒng)的正確性和穩(wěn)定性。如果在一個分布式系統(tǒng)中需要專門請一位同事來插拔網(wǎng)線來模擬網(wǎng)絡異常,一個存儲系統(tǒng)中需要通過破壞硬盤來模擬磁盤損壞,昂貴的測試成本會讓測試成為一場災難,并且難以模擬一些需要精細化控制的的測試。所以我們需要一些自動化的方式來進行確定性的故障測試。
Failpoint 項目 就是為此而生,它是 FreeBSD failpoints 的 Golang 實現(xiàn),允許在代碼中注入錯誤或異常行為, 并由環(huán)境變量或代碼動態(tài)激活來觸發(fā)這些異常行為。Failpoint 能用于各種復雜系統(tǒng)中模擬錯誤處理來提高系統(tǒng)的容錯性、正確性和穩(wěn)定性,比如:
微服務中某個服務出現(xiàn)隨機延遲、某個服務不可用。
存儲系統(tǒng)磁盤 IO 延遲增加、IO 吞吐量過低、落盤時間長。
調(diào)度系統(tǒng)中出現(xiàn)熱點,某個調(diào)度指令失敗。
充值系統(tǒng)中模擬第三方重復請求充值成功回調(diào)接口。
游戲開發(fā)中模擬玩家網(wǎng)絡不穩(wěn)定、掉幀、延遲過大等,以及各種異常輸入(外掛請求)情況下系統(tǒng)是否正確工作。
……
為什么要重復造輪子?Etcd 團隊在 2016 年開發(fā)了 gofail 極大地簡化了錯誤注入,為 Golang 生態(tài)做出了巨大貢獻。我們在 2018 年已經(jīng)引入了 gofail 進行錯誤注入測試,但是我們在使用中發(fā)現(xiàn)了一些功能性以及便利性的問題,所以我們決定造一個更好的「輪子」。
如何使用 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 轉(zhuǎn)換后的代碼:
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 實現(xiàn)應該是什么樣子?理想中的 failpoint 應該是使用代碼定義并且對業(yè)務邏輯無侵入,如果在一個支持宏的語言中 (比如 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 也不能提供一個比較優(yōu)雅的實現(xiàn) (go build --tag="enable-failpoint-a")。
Failpoint 設計準則使用 Golang 代碼定義 failpoint,而不是注釋或其他形式。
Failpoint 代碼不應該有任何額外開銷:
不能影響正常功能邏輯,不能對功能代碼有任何侵入。
注入 failpoint 代碼之后不能導致性能回退。
Failpoint 代碼最終不能出現(xiàn)在最終發(fā)行的二進制文件中。
Failpoint 代碼必須是易讀、易寫并且能引入編譯器檢測。
最終生成的代碼必須具有可讀性。
生成代碼中,功能邏輯代碼的行號不能發(fā)生變化(便于調(diào)試)。
支持并行測試,可以通過 context.Context 控制一個某個具體的 failpoint 是否激活。
Golang 如何實現(xiàn)一個類似 failpoint 宏?宏的本質(zhì)是什么?如果追本溯源,發(fā)現(xiàn)其實可以通過 AST 重寫在 Golang 中實現(xiàn)滿足以上條件的 failpoint,原理如下圖所示:
對于任何一個 Golang 代碼的源文件,可以通過解析出這個文件的語法樹,遍歷整個語法樹,找出所有 failpoint 注入點,然后對語法樹重寫,轉(zhuǎn)換成想要的邏輯。
相關概念 FailpointFailpoint 是一個代碼片段,并且僅在對應的 failpoint name 激活的情況下才會執(zhí)行,如果通過 failpoint.Disable("failpoint-name-for-demo") 禁用后, 那么對應的的 failpoint 永遠不會觸發(fā)。所有 failpoiint 代碼片段不會編譯到最終的二進制文件中,比如我們模擬文件系統(tǒng)權限控制:
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 函數(shù)
AST 重寫階段標記需要被重寫的部分,主要有以下功能:
提示 Rewriter 重寫為一個相等的 IF 語句。
標記函數(shù)的參數(shù)是重寫過程中需要用到的參數(shù)。
標記函數(shù)是一個空函數(shù),編譯過程會被 inline,進一步被消除。
標記函數(shù)中注入的 failpoint 是一個閉包,如果閉包訪問外部作用于變量,閉包語法允許捕獲外部作用域變量,不會出現(xiàn)編譯錯誤, 同時轉(zhuǎn)換后的的代碼是一個 IF 語句,IF 語句訪問外部作用域變量不會產(chǎn)生任何問題,所以閉包捕獲只是為了語法合法,最終不會有任何額外開銷。
簡單、易讀、易寫。
引入編譯器檢測,如果 Marker 函數(shù)的參數(shù)不正確,程序不能通過編譯的,進而保證轉(zhuǎn)換后的代碼正確性。
目前支持的 Marker 函數(shù)列表:
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 在調(diào)用的地方注入一個 failpoint,最終 failpoint.Inject 調(diào)用會重寫為一個 IF 語句, 其中 mock-io-error 用來判斷是否觸發(fā),failpoint-closure 中的邏輯會在觸發(fā)后執(zhí)行。 比如我們在一個讀取文件的函數(shù)中注入一個 IO 錯誤:
failpoint.Inject("mock-io-error", func(val failpoint.Value) error { return fmt.Errorf("mock error: %v", val.(string)) })
最終轉(zhuǎn)換后的代碼如下:
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)"),目的是在注入點休眠一秒,不需要執(zhí)行額外的邏輯。
failpoint.Inject("mock-delay", nil) failpoint.Inject("mock-delay", func(){})
最終會產(chǎn)生以下代碼:
failpoint.Eval(_curpkg_("mock-delay")) failpoint.Eval(_curpkg_("mock-delay"))
如果我們只想在 failpoint 中執(zhí)行一個 panic,不需要接收 failpoint.Value,則我們可以在閉包的參數(shù)中忽略這個值。 例如:
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 中包含一個回調(diào)函數(shù),用于精細化控制 failpoint 的激活與關閉 :
failpoint.InjectContext(ctx, "failpoint-name", func(val failpoint.Value) { fmt.Println("unit-test", val) })
轉(zhuǎn)換后的代碼:
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)) }
如果我們在循環(huán)中使用 failpoint,可能我們會使用到其他的 Marker 函數(shù):
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 函數(shù)保持疑問,為什么不直接使用關鍵字?
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 只能在循環(huán)上下文中使用,在閉包中使用。
一些復雜的注入示例示例一:在 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 來動態(tài)控制某個 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") } }
示例三:動態(tài)注入 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") }
除了上面的例子之外,還可以寫的更加復雜的情況:
循環(huán)的 INITIAL 語句, CONDITIONAL 表達式,以及 POST 語句
FOR RANGE 語句
SWITCH INITIAL 語句
Slice 的構造和索引
結(jié)構體動態(tài)初始化
……
實際上,任何你可以調(diào)用函數(shù)的地方都可以注入 failpoint,所以請發(fā)揮你的想象力。
Failpoint 命名最佳實踐上面生成的代碼中會自動添加一個 _curpkg_ 調(diào)用在 failpoint-name 上,是因為名字是全局的,為了避免命名沖突,所以會在最終的名字包包名,_curpkg_ 相當一個宏,在運行的時候自動使用包名進行展開。你并不需要在自己的應用程序中實現(xiàn) _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 都在同一個命名空間,所以需要小心命名來避免命名沖突,這里有一些推薦的規(guī)則來改善這種情況:
保證名字在包內(nèi)是唯一的。
使用一個自解釋的名字。
可以通過環(huán)境變量來激活 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 提供最初實現(xiàn),給我們提供了靈感,讓我們能站在巨人的肩膀上對 failpoint 進行迭代。
感謝 FreeBSD 定義 語法規(guī)范。
最后,歡迎大家和我們交流討論,一起完善 Failpoint 項目。
文章版權歸作者所有,未經(jīng)允許請勿轉(zhuǎn)載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉(zhuǎn)載請注明本文地址:http://specialneedsforspecialkids.com/yun/17999.html
摘要:作者張博康本文為源碼解析系列的第五篇,為大家介紹在測試中使用的周邊庫。而對于行為的情況會特殊一些,在中并不做實際的動作,而是返回并通過傳參給閉包產(chǎn)生自定義的返回值。 作者:張博康 本文為 TiKV 源碼解析系列的第五篇,為大家介紹 TiKV 在測試中使用的周邊庫 fail-rs。 fail-rs 的設計啟發(fā)于 FreeBSD 的 failpoints,由 Rust 實現(xiàn)。通過代碼或者環(huán)...
閱讀 3156·2023-04-25 18:22
閱讀 2390·2021-11-17 09:33
閱讀 3307·2021-10-11 10:59
閱讀 3237·2021-09-22 15:50
閱讀 2809·2021-09-10 10:50
閱讀 859·2019-08-30 15:53
閱讀 448·2019-08-29 11:21
閱讀 2908·2019-08-26 13:58