歡迎光臨
每天分享高質量文章

Golang Failpoint 的設計與實現

對於一個大型複雜的系統來說,通常包含多個模組或多個元件構成,模擬各個子系統的故障是測試中必不可少的環節,並且這些故障模擬必須做到無侵入地整合到自動化測試系統中,透過在自動化測試中自動啟用這些故障點來模擬故障,並觀測最終結果是否符合預期結果來判斷系統的正確性和穩定性。如果在一個分散式系統中需要專門請一位同事來插拔網線來模擬網路異常,一個儲存系統中需要透過破壞硬碟來模擬磁碟損壞,昂貴的測試成本會讓測試成為一場災難,並且難以模擬一些需要精細化控制的的測試。所以我們需要一些自動化的方式來進行確定性的故障測試。

Failpoint 專案(github.com/pingcap/failpoint)就是為此而生,它是 FreeBSD failpoints 的 Golang 實現,允許在程式碼中註入錯誤或異常行為, 並由環境變數或程式碼動態啟用來觸發這些異常行為。Failpoint 能用於各種複雜系統中模擬錯誤處理來提高系統的容錯性、正確性和穩定性,比如:

  • 微服務中某個服務出現隨機延遲、某個服務不可用。

  • 儲存系統磁碟 IO 延遲增加、IO 吞吐量過低、落盤時間長。

  • 排程系統中出現熱點,某個排程指令失敗。

  • 充值系統中模擬第三方重覆請求充值成功回呼介面。

  • 遊戲開發中模擬玩家網路不穩定、掉幀、延遲過大等,以及各種異常輸入(外掛請求)情況下系統是否正確工作。

  • ……

為什麼要重覆造輪子?

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

如何使用 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,原理如下圖所示:

對於任何一個 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 stringfpblock func(val Value)) {}

  • func InjectContext(fpname stringctx context.Contextfpblock 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 () 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 () 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 專案

已同步到看一看
贊(0)

分享創造快樂