名词 | 说明 |
插桩 | 在源代码的一些关键节点,插入一些探针之类的代码片段,以此收集代码运行过程中的数据。插桩可以在编译期进行,也可以在运行时。 |
调用级 | 业务代码某次触发的一次调用,来源可以是一个网络请求、单元测试、自动化用例等 |
覆盖率 | 描述代码执行情况的指标,常见的有增量覆盖率、全量覆盖率、分支覆盖率等等 |
调用链 | 方法调用链路,记录调用请求处理过程中,走过的所有方法。本质是一个有向图,节点表示函数,边表示调用关系,可能存在环。 |
在应用程序并行执行的情况下,精确获取单个用例、流量、单元测试走过的方法链(有向图)、出入参数、行覆盖等运行时数据,经过一定的加工之后,应用在覆盖率、影响面评估、流量观测、精准测试、流量回放、风险分析等研发效能相关场景。
常见的覆盖率工具,原理都是通过在代码关键节点,插入全局探针数组来实现覆盖状态的采集。伪代码如下
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 源码中看到
插桩完成后的代码如下
//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
},
}
核心逻辑跟 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
},
}
$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 命令行工具,在编译期修改目标业务代码,在函数、代码块入口等关键位置,植入 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
如果采集每个调用的全量链路的所有数据,存储开销必然是非常巨大的,可以考虑采样,并且只采集关键的出入参数据,或者仅采集方法链路和代码块覆盖数据。同时可以考虑冷热分离,全量数据采用廉价的离线存储,而实时数据则限定有效时间,定期清理和压缩归档。也可以考虑只存储聚合计算之后的数据,而不存储每一个调用的明细数据,比如汇总和去重存储调用链。具体策略可以根据应用场景调整。
除了能支持基本的增量、全量、分支覆盖率之外,还可以实现更为精细化的覆盖数据采集。比如
链路覆盖率
分场景覆盖率
影响面评估的核心基础是 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 实现精准可以更精确,并且很容易可以做到代码块级别,可以获得远高于方法级精准的裁剪率。
Ellyn 插桩的本质是在所有方法内插入语句,因此可以拦截方法的执行。插桩过程会遍历项目,获取项目中的所有方法标识、参数类型列表等元数据,可以进一步实现一个基于方法标识+实际参数匹配的规则引擎,在任意方法维度配置 mock 规则,插桩代码检查是否命中 mock 规则,命中则直接返回。可以实现方法级 mock,比服务粒度的 mock 灵活度更高。
可以基于插桩采集的链路数据,分析程序中潜在的风险,包括但不限于稳定性、资损防控、隐私合规等。
比如稳定性方面
Ellyn 采集的链路数据包含链路是否有异步调用,以及各异步链路的出入参,通过分析异步链路的出参结果是否影响主链路的出参结果,可以识别该异步链路是否为弱依赖。
可以基于动态采集的链路数据结合静态分析数据得到一份非常完整的流量(链路)大图,可以应用在容量治理、红蓝攻防的爆炸半径分析等。
再比如,可以收集运行时的 panic 信息,包括 panic 发生时的堆栈信息,调用链信息、出入参数等等,帮助研发定位 panic 根因,降低线上 panic 风险。
https://github.com/lvyahui8/ellyn