Ellyn-Golang 呼叫級覆蓋率&方法呼叫鏈插樁採集方案

lvyahui8發表於2025-01-13

Ellyn-Golang 呼叫級覆蓋率&方法呼叫鏈插樁採集方案

詞語解釋

名詞 說明
插樁 在原始碼的一些關鍵節點,插入一些探針之類的程式碼片段,以此收集程式碼執行過程中的資料。插樁可以在編譯期進行,也可以在執行時。
呼叫級 業務程式碼某次觸發的一次呼叫,來源可以是一個網路請求、單元測試、自動化用例等
覆蓋率 描述程式碼執行情況的指標,常見的有增量覆蓋率、全量覆蓋率、分支覆蓋率等等
呼叫鏈 方法呼叫鏈路,記錄呼叫請求處理過程中,走過的所有方法。本質是一個有向圖,節點表示函式,邊表示呼叫關係,可能存在環。

Ellyn 要解決什麼問題?

在應用程式並行執行的情況下,精確獲取單個用例、流量、單元測試走過的方法鏈(有向圖)、出入引數、行覆蓋等執行時資料,經過一定的加工之後,應用在覆蓋率、影響面評估、流量觀測、精準測試、流量回放、風險分析等研發效能相關場景。

常見的覆蓋率工具實現

常見的覆蓋率工具,原理都是透過在程式碼關鍵節點,插入全域性探針陣列來實現覆蓋狀態的採集。虛擬碼如下

var flags []bool

func method() {
     flags[0] = true
     n := 100
     k := 10 
     sum := 0 
     for i := 0 ; i < n ; i ++ {
         if i % k == 0  {
             flags[1] = true
             sum += i
         } else {
             flags[2] = true
             sum += 1
         }
     }
     flags[3] = true
     println(sum)
}

這裡為了可讀性調整了格式,實際生成的程式碼,一般追加在檔案或者已有行的尾部,不會影響原始碼的行號。

以上面的虛擬碼為例,我們看看各開源實現是怎麼對程式碼插樁的。

Go test cover 實現方案

Go test cover 插樁邏輯可以在 go 原始碼中看到

插樁完成後的程式碼如下

//line api.go:1
package cover_example                              

import "fmt"                                       

func method() {GoCover.Count[0]++;                 
        n := 100                                   
        k := 10                                    
        sum := 0                                   
        for i := 0; i < n; i++ {GoCover.Count[2]++;
                if i%k == 0 {GoCover.Count[3]++;   
                        sum += i                   
                } else{ GoCover.Count[4]++;{       
                        sum += 1                   
                }}                                 
        }                                          
        GoCover.Count[1]++;fmt.Println(sum)        
}                                                  

var GoCover = struct {                             
        Count     [5]uint32                        
        Pos       [3 * 5]uint32                    
        NumStmt   [5]uint16                        
} {                                                
        Pos: [3 * 5]uint32{                        
                5, 9, 0x19000f, // [0]
                16, 16, 0x120002, // [1]
                9, 10, 0xf0019, // [2]
                10, 12, 0x4000f, // [3]
                12, 14, 0x40009, // [4]
        },
        NumStmt: [5]uint16{
                4, // 0
                1, // 1
                1, // 2
                1, // 3
                1, // 4
        },
}

七牛雲 GOC

https://github.com/qiniu/goc

核心邏輯跟 Go test cover 類似的,部分程式碼也是複用的 go test cover 原始碼:pkg/cover/internal/tool/cover.go。

七牛雲 cover 需要有 main package 才能插樁,實現原理與 go test cover 一致。不過七牛雲將探針陣列生成到了一個單獨的檔案,而不是像 go test coverI 具追加到原始碼檔案尾部

  goc-build-4fa554c51f8f ls
api.go  api_test.go  go.mod  http_cover_apis_auto_generated.go  src
  goc-build-4fa554c51f8f cat api.go                           
//line /tmp/goc-build-4fa554c51f8f/api.go:1
package main; import . "cover_example/src/gocbuild4fa554c51f8f"

import "fmt"

func method() {GoCover_0_396638376133663931613965.Count[0]++;
        n := 100
        k := 10
        sum := 0
        for i := 0; i < n; i++ {GoCover_0_396638376133663931613965.Count[2]++;
                if i%k == 0 {GoCover_0_396638376133663931613965.Count[3]++;
                        sum += i
                } else{ GoCover_0_396638376133663931613965.Count[4]++;{
                        sum += 1
                }}
        }
        GoCover_0_396638376133663931613965.Count[1]++;fmt.Println(sum)
}

func main() {GoCover_0_396638376133663931613965.Count[5]++;
        method()
}

  goc-build-4fa554c51f8f cat src/gocbuild4fa554c51f8f/cover.go
package gocbuild4fa554c51f8f


var GoCover_0_396638376133663931613965 = struct {
        Count     [6]uint32
        Pos       [3 * 6]uint32
        NumStmt   [6]uint16
} {
        Pos: [3 * 6]uint32{
                5, 9, 0x19000f, // [0]
                16, 16, 0x120002, // [1]
                9, 10, 0xf0019, // [2]
                10, 12, 0x4000f, // [3]
                12, 14, 0x40009, // [4]
                19, 21, 0x2000d, // [5]
        },
        NumStmt: [6]uint16{
                4, // 0
                1, // 1
                1, // 2
                1, // 3
                1, // 4
                1, // 5
        },
}

Jacoco 方案

$jacocoInit 方法將按照 class+method 維度獲取相應的全域性探針陣列,原理其實與 go 的類似。得益於 java 語言的動態能力,jacoco 不僅支援編譯期插樁,也支援執行時插樁。另外,Go 是原始碼插樁,所見即所得,而 Jacoco 是位元組碼插樁,插入的是位元組碼指令,下面的程式碼是插樁完的位元組碼反編譯之後的原始碼。

package testapp;

public class Application {
    public Application() {
        boolean[] var1 = $jacocoInit();
        super();
        var1[0] = true;
    }

    public static void method() {
        boolean[] var0 = $jacocoInit();
        int n = 100;
        int k = 10;
        int sum = 0;
        int i = 0;

        for(var0[1] = true; i < n; var0[4] = true) {
            if (i % k == 0) {
                sum += i;
                var0[2] = true;
            } else {
                ++sum;
                var0[3] = true;
            }

            ++i;
        }

        System.out.println(sum);
        var0[5] = true;
    }
}

全域性探針方案的優劣

這類方案的優勢是,實現簡單,並且效能影響極小(特別在客戶端大規模程式碼插樁時)。但最明顯的缺點是隻能收集全域性粒度的資料,無法細分單個呼叫的覆蓋和鏈路資料。

要細分單個呼叫資料,折中的方案是透過求快照差來近似獲取覆蓋資料,比如在做流量回放時,要收集單個回放流量的覆蓋資料:在回放之前,先清空全部的覆蓋資料;在回放完成後,記錄一次最新的覆蓋率資料,以這份資料作為流量的覆蓋資料。這麼做會有兩個很明顯的問題

  • 回放流量\用例執行等只能序列執行,否則因為併發影響,無法透過快照差來求覆蓋率資料,回放效率低下。

  • 單個流量\用例的覆蓋資料依然可能存在噪音,比如一些旁路的非同步邏輯(定時器、MQ 消費等)造成的覆蓋資料,也會統計到當前流量\用例上。

另外基於全域性探針採集的方案,還有兩個明顯的缺點,即使透過快照差也無法解決:

  • 雖然採集了覆蓋率或者已覆蓋的方法,但無法還原呼叫鏈/控制流圖。

  • 資料無法全鏈路串聯起來,流量很可能經過了多個後端服務,每個服務收集的覆蓋資料是孤立且不繫結請求資訊的,因此無法串聯起來

Ellyn 實現方案

Ellyn 命令列工具,在編譯期修改目標業務程式碼,在函式、程式碼塊入口等關鍵位置,植入 SDK 呼叫,並將 SDK 原始碼複製到目標專案,跟隨目標專案一起編譯。

遍歷程式碼並在關鍵位置插入程式碼,則是基於 GO AST API,讀取每一個原始碼檔案,解析並遍歷 AST(抽象語法樹),在函式和程式碼塊的開始位置植入程式碼,函式植入非常容易,函式有很直接的分隔符,AST 可以直接遍歷單個函式,因此很容易在函式開始位置植入程式碼。比較麻煩的是程式碼塊,這裡程式碼塊可以理解為一段在不發生異常的情況下,可以連續執行的一段程式碼,一直到控制語句或者程式碼塊結束符(go 為}')為止,跟靜態分析中的 Basic Block 很相似,程式並沒有直接的、固定的塊分隔符,AST 也沒有抽象的 Block 節點,因此需要自行遍歷所有 Statement,尋找控制語句和結束符,自行記錄開始結束位置,手動劃分 Block。遍歷完所有檔案後,將方法、Block 等後設資料透過 go embed 壓縮整合到目標程式中。

插樁完的目的碼編譯執行後,SDK 將按照協程粒度收集資料,模擬函式彈棧入棧的操作,當函式彈空時,說明當前協程呼叫結束了,可以將當前協程資料放入本地的 RingBuffer 佇列,等待後續的加工、上報等處理。如果是同一個呼叫(流量)觸發的多個非同步覆蓋,則將多個協程的資料透過鏈路 ID 關聯起來,這個關聯合並的動作可以放在上報後端實現,進一步降低對本地的效能影響。

程式架構

原理圖

工具能力

支援呼叫級鏈路資料採集,鏈路資料包括方法鏈路(堆疊)、方法出入參、方法耗時、異常、error、行覆蓋等。並且工具內部預設整合了一個簡單的 web 頁面,可以在本地視覺化檢視鏈路資料

難點和挑戰

Ellyn 插樁是對程式碼有侵入的,因此需要充分考慮穩定性和效能方面的影響,並且由於採集的是呼叫級的資料,資料量巨大,資料儲存本身也是一大挑戰。

穩定性

  • 避免插樁之後目標專案無法執行,插樁工具可以支援回滾。同時插樁工具的準出應該配套豐富的自動化和灰度流程。

  • 避免自身的異常丟擲給業務,導致業務程式碼異常。對 Go 語言即應該 recover 自身所有可能的 panic(當然 fatal error 是無法 recover 的,此類問題可以依賴自動化、灰度等在準出階段發現)。

  • 可以進一步支援動態關閉、自動降級等能力

  • 可以監控 CPU、記憶體使用情況,自動暫停恢復採集能力

  • 可以增加監控埋點,結合監控系統進行告警

  • 插樁採集邏輯可以增加限流,降低在大流量場景下對目標專案的影響。

效能影響

插樁程式碼要避免對業務程式碼造成明顯的效能影響。

技術上

  • 避免加重量級鎖,確保每個協程只操作當前協程的資料

  • 需要高頻建立使用的物件,考慮池化,減少 GC 的壓力。

  • 插樁到業務程式碼的方法要確保每一個方法都是 O(1)時間複雜度的操作。

  • 高頻訪問的欄位進行快取行填充,避免偽共享。

  • 整數索引的場景,儘量考慮用 bitmap(bitset)或者陣列,而非 map。

能力上

  • 支援多種取樣策略

  • 引數採集涉及序列化,對效能影響較大,支援不同的引數採集策略,必要時可以關閉引數採集

當然,即使經過各種最佳化,由於插樁語句做了更多的操作,即使是 0(1)級別的無鎖操作,依然比傳統方案僅一次陣列訪問的指令要更多。在一些 CPU 敏感型場景,Ellyn 插樁效能損耗依然比傳統方案要高。

基準測試

以下對比了排序、搜尋、壓縮、加密、檔案讀寫、網路請求等場景下的效能影響,在涉及有 IO 操作的情況下,Elyn 影響可以忽略不計,在純 CPU 密集型場景,有一定效能損失。實際上,網際網路業務大部分場景都是有 IO 操作的,比如讀寫 DB、RPC 呼叫,甚至於僅列印日誌(非非同步寫),因此效能影響基本可以忽略。

無插樁(基準)

goos: linux
goarch: arm64
pkg: benchmark
BenchmarkQuickSort-4                        137570             42951 ns/op            4088 B/op               9 allocs/op
BenchmarkBinarySearch-4                   228617994                26.24 ns/op               0 B/op               0 allocs/op
BenchmarkBubbleSort-4                        44918            133785 ns/op            4088 B/op               9 allocs/op
BenchmarkShuffle-4                          330484             18054 ns/op               0 B/op               0 allocs/op
BenchmarkStringCompress-4                     3034           1903760 ns/op          876214 B/op              33 allocs/op
BenchmarkEncryptAndDecrypt-4                590178              9990 ns/op            1312 B/op              10 allocs/op
BenchmarkWrite2DevNull-4                   1428777              4202 ns/op             304 B/op               5 allocs/op
BenchmarkWrite2TmpFile-4                    535009             10967 ns/op             128 B/op               1 allocs/op
BenchmarkLocalPipeReadWrite-4               265272             21792 ns/op            2176 B/op              18 allocs/op
BenchmarkSerialNetRequest-4                    387          15407760 ns/op           40489 B/op             480 allocs/op
BenchmarkConcurrentNetRequest-4               1713           3576828 ns/op          136009 B/op             990 allocs/op
PASS
ok          benchmark        76.928s

0.0001 取樣(萬分之一)

goos: linux
goarch: arm64
pkg: benchmark
BenchmarkQuickSort-4                        107018             55802 ns/op            4089 B/op               9 allocs/op
BenchmarkBinarySearch-4                   81365398                72.32 ns/op               0 B/op               0 allocs/op
BenchmarkBubbleSort-4                        33294            182848 ns/op            4093 B/op               9 allocs/op
BenchmarkShuffle-4                          320906             18466 ns/op               0 B/op               0 allocs/op
BenchmarkStringCompress-4                     2468           3261636 ns/op          876280 B/op              35 allocs/op
BenchmarkEncryptAndDecrypt-4                563416             10802 ns/op            1344 B/op              12 allocs/op
BenchmarkWrite2DevNull-4                   1368524              4353 ns/op             304 B/op               5 allocs/op
BenchmarkWrite2TmpFile-4                    521224             11328 ns/op             128 B/op               1 allocs/op
BenchmarkLocalPipeReadWrite-4               272166             20679 ns/op            2193 B/op              18 allocs/op
BenchmarkSerialNetRequest-4                    435          13852948 ns/op           40875 B/op             494 allocs/op
BenchmarkConcurrentNetRequest-4               1730           3471552 ns/op          136226 B/op             992 allocs/op
PASS
ok          benchmark        77.277s

資料儲存

如果採集每個呼叫的全量鏈路的所有資料,儲存開銷必然是非常巨大的,可以考慮取樣,並且只採集關鍵的出入引數據,或者僅採集方法鏈路和程式碼塊覆蓋資料。同時可以考慮冷熱分離,全量資料採用廉價的離線儲存,而實時資料則限定有效時間,定期清理和壓縮歸檔。也可以考慮只儲存聚合計算之後的資料,而不儲存每一個呼叫的明細資料,比如彙總和去重儲存呼叫鏈。具體策略可以根據應用場景調整。

主要應用場景

覆蓋率採集

除了能支援基本的增量、全量、分支覆蓋率之外,還可以實現更為精細化的覆蓋資料採集。比如

  • 鏈路覆蓋率

    • 與傳統的分支覆蓋率不同的是,分支覆蓋率分母是所有條件的笛卡爾積,實際其中很多分支鏈路是不可達的,因此無法準確給分支覆蓋率一個合理的目標。而鏈路覆蓋率,分母可以是線上環境和測試環境累積的所有可達鏈路,分子是本次迴歸覆蓋的所有鏈路,因此可以以 100%為近似的覆蓋目標(有可能無法達到 100%是因為分母中的部分鏈路可能已經失效,這裡就需要考慮資料的保鮮策略了)。
  • 分場景覆蓋率

    • 比如區分自動化還是手工測試的覆蓋,甚至可以二次開發,將覆蓋資料與測試賬號繫結,明確具體是哪個場景、哪個使用者造成的覆蓋。

影響面評估

影響面評估的核心基礎是 callgraph 或控制流圖。主流的方案是基於靜態分析,但靜態分析除了演算法本身準確性之外,一些執行時決策的呼叫,比如反射,比如將一組方法放在 slice、map 中,執行時計算 key 進行的呼叫,靜態分析是完全無法分析出來的,此時基於 Ellyn 動態收集的鏈路資料可以作為有效補充。基於動靜結合的方式可以有效提升影響面評估的準確率(查準率)和召回率(查全率)。

鏈路觀測

支援採集單個單元測試\自動化測試\流量的呼叫鏈明細資料,包括函式呼叫鏈、方法出入參、耗時、error/panic、行覆蓋等資訊,並將其繫結到一個鏈路 ID 上(可以是 logid/traceid 等)。進一步可以基於鏈路 ID 將全鏈路的資料串聯起來。

最直接的應用場景就是基於視覺化頁面,幫助研發和 QA 同學在測試環境定位聯調測試問題。相對於基於日誌定位問題更加直觀。

單測生成

由於可以全量採集所有方法的出入參和方法呼叫鏈,因此可以基於累積的資料,輔助生成單元測試。比如按照單測試 AAA 模式

·Arrange(準備)

。基於採集的入參,構造請求引數

。基於方法呼叫鏈以及下游函式的出入參,生成下游函式呼叫的 mock

·Act(執行):執行單測

·Assert(斷言)

。基於採集的出參,對返回結果生成斷言語句

精準測試

Ellyn 可以將單個用例的覆蓋資料繫結到一個鏈路 ID 上(logid/traceid 等),因此,只需要進一步建立鏈路 ID 和用例的關係,就可以間接建立用例與程式碼方法或程式碼塊的對映關係(知識庫)。在用例推薦時,只需要對變更版本和線上版本進行 Function Diff 或者 Block Diff,再基於 Diff 結果反查知識庫,即可實現函式級精準(成本更低)或者程式碼塊級(裁剪率更高)精準。

而建立用例和鏈路 ID 的關係往往很容易做到,如自動化用例、單元測試等,在執行前後我們都可以很容易從上下文拿到鏈路 ID,而對於手工用例,則可以透過錄制工具來繫結這個關係。

與基於傳統覆蓋率方案實現的精準方案不同的是,Ellyn 實現精準可以更精確,並且很容易可以做到程式碼塊級別,可以獲得遠高於方法級精準的裁剪率。

Mock 平臺

Ellyn 插樁的本質是在所有方法內插入語句,因此可以攔截方法的執行。插樁過程會遍歷專案,獲取專案中的所有方法標識、引數型別列表等後設資料,可以進一步實現一個基於方法標識+實際引數匹配的規則引擎,在任意方法維度配置 mock 規則,插樁程式碼檢查是否命中 mock 規則,命中則直接返回。可以實現方法級 mock,比服務粒度的 mock 靈活度更高。

風險分析

可以基於插樁採集的鏈路資料,分析程式中潛在的風險,包括但不限於穩定性、資損防控、隱私合規等。

比如穩定性方面

  • Ellyn 採集的鏈路資料包含鏈路是否有非同步呼叫,以及各非同步鏈路的出入參,透過分析非同步鏈路的出參結果是否影響主鏈路的出參結果,可以識別該非同步鏈路是否為弱依賴。

  • 可以基於動態採集的鏈路資料結合靜態分析資料得到一份非常完整的流量(鏈路)大圖,可以應用在容量治理、紅藍攻防的爆炸半徑分析等。

再比如,可以收集執行時的 panic 資訊,包括 panic 發生時的堆疊資訊,呼叫鏈資訊、出入引數等等,幫助研發定位 panic 根因,降低線上 panic 風險。

專案地址

https://github.com/lvyahui8/ellyn

相關文章