目录
Go API 通常会在几毫秒内响应,但有些请求却需要 40 毫秒。查看日志可以确认延迟,却无法得知这些时间究竟花在了数据库查询、渲染还是外部调用上。
一种办法是引入链路追踪基础设施。不过,如果问题局限在单个进程内,一个 SQLite 文件或许就足够了。
akmadian/gospan 可以记录嵌套的跨度(span),并将其写入 SQLite 或 log/slog。为 net/http 处理器添加埋点、运行工作负载,再查询生成的文件,就能找出缓慢的操作及其相关输入。
它的实用之处在于,将父子关系、耗时和属性组合起来,存入可用普通 SQL 查询的表中。不过,由于记录是异步进行的,事件可能会被丢弃。
本文基于所提供的 gospan v0.1.0 调研资料,其验证日期为 2026 年 10 月 7 日。下文的测量数据均来自该调研,并非为撰写本文另行运行所得。README 将文件格式定义为保持不变的契约,但 Go API 在 1.0 之前仍可能发生变化。
适用于调查范围局限在单个进程内的情况#
gospan 适合用于范围明确的调查,例如:“这个程序中的哪项操作拖慢了请求?”
其核心部分使用 Go 标准库。SQLite 接收端使用纯 Go 驱动 modernc.org/sqlite。在所提供的验证中,示例使用 CGO_ENABLED=0 构建,无需收集器或代理。
它不具备跨进程传播上下文的功能。因此,对其他服务的 HTTP 调用不会自动延续同一条追踪。如果你想跨多个服务追踪请求,这一限制就很重要。
与 OpenTelemetry 的比较也需要准确。OpenTelemetry SDK 同样可以在没有收集器的情况下运行,所需的基础设施取决于导出器和部署配置。这里 gospan 的吸引力在于,它可以将本地埋点直接衔接到可查询的 SQLite 文件生成流程。
创建文件,并在请求入口添加埋点#
验证时使用的模块如下:
github.com/akmadian/gospan v0.1.0
github.com/akmadian/gospan/sqlite v0.1.0验证所用的 gospan 模块要求 Go 1.25.0。复现本例时,请固定版本。
在应用启动时,仅初始化一次接收端。
sink, err := sqlite.New("traces")
if err != nil {
log.Fatal(err)
}
tracer, err := gospan.New(sink)
if err != nil {
log.Fatal(err)
}
gospan.SetDefault(tracer)默认情况下,接收端会为每次运行创建单独的文件。文件位置可通过 sink.Path() 获取。
在请求入口创建根跨度,并将返回的上下文传给处理器。
func traced(route string, next http.HandlerFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, span := gospan.Start(
r.Context(),
"http.request",
slog.String("http.method", r.Method),
slog.String("http.route", route),
)
defer span.End()
next(w, r.WithContext(ctx))
})
}调用 r.WithContext(ctx),会为处理器内部的操作设置父跨度。如果使用原始请求上下文启动子跨度,就会丢失这一父子关系。
请保持跨度名称固定。例如,名称使用 db.query,将表名、标识符和查询类别存入属性。如果把每个用户的 ID 都放进名称,就会让内存聚合中的类别数量无谓增加。
为有优化价值的操作添加跨度#
在数据库辅助函数中,可以记录输入和错误。
func loadUser(ctx context.Context, id int) error {
ctx, span := gospan.Start(
ctx,
"db.query",
slog.String("db.table", "users"),
slog.Int("db.id", id),
)
defer span.End()
err := queryUser(ctx, id)
if err != nil {
span.Fail(err)
}
return err
}Fail 只记录结果,不会结束跨度。请保留通过 defer 调用的 End。
通过 errors.Is 匹配到 context.Canceled 或 context.DeadlineExceeded 的错误会被标记为 canceled,其他错误则标记为 error。因此,超时的依赖调用和包含该调用的 HTTP 请求可以记录不同的结果。例如,子跨度被视为已取消,而请求由于返回了 502,被记录为错误。
对于行数等事后才知道的信息,请使用 SetAttrs。在 End 之后进行的修改不会生效。
对于没有子跨度的小型操作,可以使用写法更简短的 Track。
func checkCache(ctx context.Context) {
defer gospan.Track(ctx, "cache.lookup")()
// キャッシュを検索する。
}Track 不会返回包含该跨度的上下文。如果需要让后续操作成为子跨度,请使用 Start。
关闭应用时,应先停止接收请求,等待正在运行的处理器完成,再使用合适的上下文调用 tracer.Close。关闭过程会处理队列中的事件,并执行最后一次刷新。
准备一个原因已知的工作负载#
在所提供的实验中,使用了 8 个并发工作协程,向以下端点共发送了 300 个请求。
| 端点 | 刻意设置的行为 | |---|---| | GET /users/{id} | 模拟数据库操作:当 id % 10 == 7 时耗时 40 ms,否则耗时 2 ms | | GET /report | 依次执行 3 次数据库调用后,渲染耗时 15 ms | | POST /checkout | 支付调用的超时时限为 30 ms,每 8 个输入中有 1 个会尝试执行耗时 50 ms 的操作 |
这些延迟都是刻意加入的,目的是展示能否通过追踪找出已知原因,并不表明实际数据库存在性能问题。
在记录的这次运行中,287 个请求成功,13 个请求返回状态码 502。追踪器的统计如下:
started=1100 completed=1100 written=2500
dropped=0 write_errors=0这 1,100 个跨度包括请求根跨度和子操作。Written 统计的是事件,而非已完成的跨度:1,100 个开始事件、1,100 个结束事件和 300 个属性更新事件,共计 2,500 个事件。
在解读文件之前,请同时检查丢弃数量和写入错误数量。即使查询结果看起来正常,记录也可能并不完整。
查看已完成操作中最慢的那些#
SQLite 接收端提供了 spans_named 视图,其中包含易读的名称和计算好的 duration_ns 列。属性则存储在单独的 attrs 表中。
以只读方式打开已完成记录的文件。
sqlite3 -readonly traces/your-run.sqlite首先,按耗时从长到短查看已完成的跨度。
SELECT
trace_id,
id,
parent_id,
name,
ROUND(duration_ns / 1e6, 2) AS duration_ms
FROM spans_named
WHERE end_ns IS NOT NULL
ORDER BY duration_ns DESC
LIMIT 20;慢请求的根跨度可以帮助确定调查对象。查看其子跨度,就能知道哪些已记录的操作造成了延迟。
将 123 替换为查到的追踪 ID,进一步查看某条追踪。
SELECT
id,
parent_id,
name,
ROUND(duration_ns / 1e6, 2) AS duration_ms,
status,
error
FROM spans_named
WHERE trace_id = 123
ORDER BY start_ns;不要把所有行的耗时相加后当作请求耗时。父跨度的时间包含子操作的耗时,而且并发执行的子操作在时间上可能重叠。请结合层级关系和时间戳来解读各个时间区间。
在报表端点中,可以分别看到依次执行的数据库调用和渲染操作。由此便能提出具体的后续问题:“能否合并数据库操作?”“大部分延迟是否来自渲染?”
将慢跨度与输入关联起来#
耗时能帮助确定哪些操作缓慢,属性则能帮助查找触发条件。
以下查询会找出耗时超过 20 毫秒的数据库跨度,并列出记录的 ID。
SELECT
t.value AS db_table,
COUNT(*) AS slow_calls,
group_concat(i.value, ',') AS ids
FROM spans_named AS s
JOIN attrs AS t
ON t.span_id = s.id AND t.key = 'db.table'
JOIN attrs AS i
ON i.span_id = s.id AND i.key = 'db.id'
WHERE s.name = 'db.query'
AND s.end_ns IS NOT NULL
AND s.duration_ns > 20e6
GROUP BY t.value;在这个条件受控的工作负载中,预期会变慢的用户 ID 满足 id % 10 == 7。在实际服务中,同样形式的查询也可能揭示:延迟集中在某类查询或某种输入大小上。
不过,仅凭相关性并不能解释原因。如果怀疑问题出在数据库,请通过查询计划和数据库侧的测量加以确认。
对于失败或未完成的操作,可以使用以下查询。
SELECT
CASE
WHEN end_ns IS NULL THEN 'incomplete'
WHEN status = 1 THEN 'error'
ELSE 'canceled'
END AS outcome,
name,
COUNT(*) AS spans,
MIN(error) AS sample_error
FROM spans_named
WHERE end_ns IS NULL OR status <> 0
GROUP BY outcome, name
ORDER BY spans DESC;结束时间为 null,意味着结束事件尚未持久化。可能的原因包括:操作尚未完成、事件被丢弃,或进程在刷新前就已退出。
将记录的可靠性也视为结果的一部分#
gospan 通过容量有限的通道,将事件发送给单个负责写入的 goroutine。默认容量为 8,192 个事件。通道满时,默认策略会丢弃新到达的事件,并增加 Stats().Dropped。
也可以改用 WithBlockingPolicy(),等待通道腾出空间。这样可以让事件进入队列,但会给被测操作增加等待时间。
在所提供的高频循环基准测试中,受测配置下的 slog 接收端丢弃了约 80% 的事件。这说明,评估开销时不能忽略数据缺失情况。
SQLite 还存在另一层边界。Written 表示接收端已接受事件,并不保证事件已经保存到持久存储中。接收端会缓冲事件,并在 Flush 时提交。如果刷新失败,待处理的数据会被丢弃,不会重试,失败会计入 WriteErrors。
正常关闭应用很重要。强制终止可能导致队列中的事件或尚未提交的事件丢失。
对于本地调查,只要明确检查这些限制,有时就可以接受。先选一个缓慢的端点,为请求添加跨度,并为开销较大的操作添加包围它们的跨度,然后运行具有代表性的工作负载。确认 Dropped=0 和 WriteErrors=0,查看耗时最长的追踪,再根据其属性选择下一步的测量对象。
来源: https://zenn.dev/nonejp/articles/c7dd2859872f9d
目录
