Go 1.25 Flight Recorder:慢请求来了再截最近几秒 trace
长跑的 Go HTTP 服务出事时,最尴尬的是:你知道「刚才那个请求慢了」,却拿不到慢之前几秒里运行时到底在干什么。runtime/trace.Start / Stop 适合测试和 CLI。对已经跑了几天的进程再 Start,要么太晚,要么整段 trace 大到没法存。Go 1.25 把 Flight Recorder 送进标准库:内存里只留最近几秒的执行轨迹,异常时再 WriteTo 打快照。和同版本的 GOMAXPROCS 容器感知一样,它是 Go 1.25 里偏生产环境的能力,不算「演示用」开关。
排障时容易有个误会:把 flight recorder 当成「永远在线的全量调试器」。别这么指望它。环形缓冲会丢掉窗口外的事件,WriteTo 对时效也只是尽力保证「尽量新到调用时刻」。你拿到的是足够定位一类延迟问题的切片,算不上法庭级完整录音。预期摆正,落地时就不会在「为什么快照里没有三分钟前的某次 GC」上空耗。
一、为什么 Start/Stop 扛不住长跑服务
官方博文先把问题说透:execution trace 能告诉你 goroutine 何时在跑、何时没在跑 ,对延迟类问题极有用。trace.Start(w) 把窗口内事件写到 io.Writer,在单测、微基准、短生命周期命令行工具里很好用,端到端收得完。
Web 服务往往连续跑几天甚至几周。若对整个生命周期开 trace:
- 数据量会膨胀到难以传输和筛查;
- 真正出问题的常常是「某一个超时 / 某一次健康检查失败」。等你反应过来再调用
Start,根因已经过去了。
随机在集群里采样 trace 理论上可行,但要自建存储、分流和检索,成本高,而且对「正在查的那一次慢请求」帮助有限。Flight Recorder 的定位更窄、也更贴运维直觉:程序自己知道出事了,把出事前后那一小段缓冲抠出来。

二、FlightRecorder API:缓冲、截取、停掉
runtime/trace @ go1.25.0 增加了 FlightRecorder。核心类型与方法,都是文档已列出的:
trace.NewFlightRecorder(cfg trace.FlightRecorderConfig) *trace.FlightRecorderStart() error/Stop()/Enabled() boolWriteTo(w io.Writer) (n int64, err error)- 配置字段:
MinAge time.Duration、MaxBytes uint64
文档约束值得先记住:
- 同一进程当前只能有一个 flight recorder 处于 active(未来可能放开);
- 它可以和
trace.Start同时存在; MaxBytes优先于MinAge;任一为0则由实现定义(文档说MinAge为 0 时可按「大约秒级」理解;Go 1.25 源码里目前分别是 10 秒和 10 MiB,属实现细节,以所用版本为准);WriteTo同一时刻只允许一个 goroutine 执行;recorder 未启动或已有 WriteTo 在进行会返回 error。
博文对两个旋钮的建议很实用:
MinAge:希望窗口里稳定保留多久的数据。调试「约 5 秒超时」时,建议设到大约 2×,例如 10 秒,给前后文留余量。MaxBytes:窗口大小的上限,用来避免缓冲把内存打爆。注意文档称它只是提示(hint),不保证WriteTo写出的字节数、也不保证进程内存开销一定不超过它。量级上,博文给的是平均每秒几 MB,忙服务约 10 MB/s。按你能接受的内存与「要留几秒」一起估。
下面是一份可单独编译的最小骨架:进程启动时打开 recorder,业务侧在「慢请求」条件满足时截一次快照。
go
package main
import (
"log"
"net/http"
"os"
"sync"
"time"
"runtime/trace"
)
func main() {
fr := trace.NewFlightRecorder(trace.FlightRecorderConfig{
MinAge: 200 * time.Millisecond, // 博文示例取值,约为下面 100ms 阈值的 2×
MaxBytes: 1 << 20, // 1 MiB,演示用;生产按窗口重估
})
if err := fr.Start(); err != nil {
log.Fatal(err)
}
http.HandleFunc("/guess-number", func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// ... 业务逻辑 ...
_, _ = w.Write([]byte("ok"))
if fr.Enabled() && time.Since(start) > 100*time.Millisecond {
go captureSnapshot(fr)
}
})
log.Fatal(http.ListenAndServe(":8090", nil))
}
var once sync.Once
func captureSnapshot(fr *trace.FlightRecorder) {
once.Do(func() {
f, err := os.Create("snapshot.trace")
if err != nil {
log.Printf("create snapshot: %v", err)
return
}
defer f.Close()
if _, err := fr.WriteTo(f); err != nil {
log.Printf("WriteTo: %v", err)
return
}
fr.Stop()
log.Printf("captured flight recorder snapshot to %s", f.Name())
})
}
思路和官方示例一致:sync.Once 保证快照函数只执行一次,慢请求再多也只写一份文件;截完后 Stop,释放 recorder。Stop 只在这里调用一次:ListenAndServe 正常不会返回,main 里的 defer fr.Stop() 根本没机会执行,所以示例里不写。生产里也可以改成「截完写对象存储再决定是否重启 recorder」(实测 Stop 之后可以再次 Start),但 API 边界仍是这几个方法。
这段代码在 Go 1.25.0 与 1.25.14 上编译、go vet 都通过;给请求人为加 150ms 延迟后实际跑过,当前目录生成了 snapshot.trace(约 20 KB,go tool trace 能解析)。Go 1.24 上编译会报 undefined: trace.NewFlightRecorder,因为这个 API 是 1.25 才加的。
生产里常见的接法是:在超时中间件或统一的 access log 收尾处判断耗时,别在每个 handler 里复制一份 Once。中间件能拿到路径、状态码和耗时,便于把「只截 5xx」或「只截特定路由」写成策略。注意 WriteTo 是同步把快照写进 io.Writer 的,写完才返回;博文示例用 go captureSnapshot 放到后台 goroutine 执行,放后台能避免在请求路径上等写盘,这是我的理解,文档没有这么写。Once(或限流令牌)则让尖刺期不会反复写文件。
Enabled() 适合在热路径上做廉价判断:已经 Stop 之后再进截取逻辑没有意义。文档写明它在 Start 成功且尚未 Stop 时为 true,可以被多个 goroutine 同时调用。要留意的是,Go 1.25 的实现里它只是读一个普通布尔字段:多个 Enabled() 之间没问题,但与另一个 goroutine 里的 Stop() 同时发生时,-race 会报数据竞争(本地在 Go 1.25.0、1.25.14 上复现)。所以别把它当严格的同步手段,它只是个廉价的快速判断。别把它理解成「缓冲区里是否已有有趣事件」,那是分析阶段的事。

图注:图中最右的「MaxBytes 上限」表示窗口大小的上限,并不是时间轴上的一段数据。它优先于 MinAge,但文档称只是提示,不保证内存开销或 WriteTo 写出的字节数一定不超过它;MinAge 是尽力保留的下限,窗口里仍可能见到更老的事件。
三、官方反例:defer Unlock 如何拖住猜数字请求
博文用「猜数字」HTTP 服务说明机制。别抄成自家事故报告,重点看 flight recorder 怎样把「锁持有过久」从时间线上钉死。
服务为每个可猜数字准备一把锁保护计数;另有 goroutine 每分钟把计数汇总 POST 到报表服务。错误写法(下面是节选,省略了后面的 json.Marshal 和 http.Post,完整代码见博文)是在循环里:
go
package report
import "sync"
type bucket struct {
mu sync.Mutex
guesses int
}
// 反例(节选):defer 到函数返回才 Unlock,锁会跨过后面的 HTTP POST
func sendReport(buckets []bucket) []int {
counts := make([]int, len(buckets))
for index := range buckets {
b := &buckets[index]
b.mu.Lock()
defer b.mu.Unlock() // 问题点:defer 绑定的是函数,不是循环体
counts[index] = b.guesses
}
// 随后还有 json.Marshal + http.Post......
return counts
}
意图是「读完计数就放锁」,实际却是:所有 bucket 的锁一直持有到 sendReport 返回,而返回发生在远端 HTTP 完成之后。猜数字接口要涨计数时抢同一把锁,就被拖到百毫秒级。博文日志里多数请求是纳秒~微秒,偶发超过 100ms。这是示例现象,用来说明「为何值得在阈值处触发截取」。
修好很直接:在循环体内立刻 Unlock,或把「加锁---拷贝---解锁」收进小函数,让 defer 作用域变短。
go
package report
import "sync"
type bucket struct {
mu sync.Mutex
guesses int
}
func sendReportOK(buckets []bucket) []int {
counts := make([]int, len(buckets))
for index := range buckets {
b := &buckets[index]
b.mu.Lock()
counts[index] = b.guesses
b.mu.Unlock()
}
return counts
}
Flight recorder 在这里的价值是:你不必先猜是锁、是 GC 还是网络。阈值触发 WriteTo 后,用工具看 flow,会看到大量 goroutine 的边指向那个迟迟 Unlock 的报表 goroutine。
四、拿到 snapshot 之后看什么
截取完成后:
bash
go tool trace snapshot.trace
按博文路径:打开本地 UI → View trace by proc。关注:
- 时间线上的大空隙:示例里约 100ms 几乎没有有效推进;
- flow events:谁 unblock 了谁;慢恢复后是否大量边汇聚到同一 goroutine;
- 栈 :该 goroutine 开始时在等 HTTP,结束时已回到 ticker,中间的 outgoing flow 指向
Unlock。
这和「开着全量 trace 再人工翻几天日志」完全不是一个量级的活。Flight recorder 给的是事后几秒的手术刀,别拿它当长期审计流水。
落地时可以约定很薄的一层策略:只在明确故障信号上触发(超时、错误预算、健康检查失败);用 Once 或令牌限制截取频率;MinAge/MaxBytes 按问题窗口与内存预算联调;快照文件进已有的诊断桶,别堆在容器可写层里过夜。
第一次看 trace UI 的人,容易在海量事件里迷路。一个可重复的手法是:先按时间对齐「日志里的慢请求时刻」,再在空隙右侧找「突然恢复执行」的 goroutine,打开 flow,沿 outgoing / incoming 边往回点。官方例子里,边把你带到 sendReport 的 Unlock,故事就闭环了。若 flow 指向的是 channel 发送、syscall 或 GC 相关事件,结论会不同,但手法相同:空隙 → 恢复点 → flow → 栈。
把这一步写进团队 runbook 很有用:截取文件命名带实例 ID 与时间;分析时先回答三个问题:空隙多长,谁在空隙后唤醒了大量 goroutine,该 goroutine 的起止栈各是什么。答完再谈改代码。否则容易对着彩色时间线截图,却说不出根因句。
五、什么时候用、什么时候别用
适合:
- 线上偶发延迟尖刺,日志只能告诉你「慢了」,看不到调度与同步关系;
- 故障可被程序自己检测到(超时中间件、熔断器、错误计数);
- 你愿意为「最近几秒」付一点持续的 trace 开销(Go 1.21 起 trace 开销已明显下降,但零成本不存在)。
不太适合:
- 需要完整端到端轨迹的短任务:继续
trace.Start/Stop更简单; - 还没定义「何为异常」就常开
WriteTo:会制造噪音文件; - 指望它替代 metrics / 分布式 tracing:它只管单进程运行时叙事,跨服务因果不归它。
配置上再强调一次文档语义:MaxBytes 是提示性上限,不保证 WriteTo 写出的字节数或进程内存开销永远低于该值;把它当预算,别当硬 SLA。MinAge 也是「尽力保留」,快照里仍可能见到更老的事件。
和采样式全链路 trace 相比,flight recorder 更像「黑匣子最后几秒」。你仍然需要 metrics 告诉你尖刺是否存在、告警是否该响;flight recorder 回答的是尖刺发生时这台进程内部的调度故事。两者叠用时,常见顺序是:告警或慢日志 → 拉取该实例近期 snapshot(若已自动落盘)→ go tool trace 看锁与阻塞 → 再决定要不要加大 MinAge 复现窗口。
若你已经在用 net/http/pprof 的 /debug/pprof/trace,那是「现在开始录一段时间」的拉取模型,和飞行记录仪的「始终转着、事后截取」不同。后者不要求你在故障瞬间还能成功打到管理端口并猜对时长。文档也允许 flight recorder 与 trace.Start 并存,但多数服务选一个主路径即可,避免自己都说不清当前到底在往哪里写。
关于开销:博文回顾了 Go 1.21 大幅降低了 trace 的运行时开销,1.22 起 trace 格式更稳健、可分割,并说这才促成了 flight recorder 这类功能。即便如此,忙服务按约 10 MB/s 估算,十秒窗口加安全余量,内存与写盘都要进容量规划。MaxBytes 优先于 MinAge,到了上限会先牺牲更旧事件,这是用空间换「别把进程撑死」。
六、小结
Go 1.25 的 Flight Recorder 把「事后想起来再 Start」改成「事先转着环形缓冲,出事再截」。API 面很窄:NewFlightRecorder → Start → 条件满足时 WriteTo → Stop;用 MinAge(约 2× 问题窗口)和 MaxBytes(窗口大小上限,仅作提示)控制窗口与内存预算;用 go tool trace 看 proc 与 flow。官方猜数字例子里的 defer Unlock 说明:同步错误可以表现为「偶发慢请求」,而 flight recorder 擅长把这种错误从时间线上挖出来。先把触发条件和快照去向设计清楚,再把它接进你现有的超时中间件,收益通常比再加一套全量采样基建来得快。快照总是「不够看」时,优先调 MinAge/MaxBytes 和触发阈值,别改回全量 Start。
封面建议:白底示意图,左侧是被划叉的「Start/Stop 全量轨迹」,右侧是环形缓冲,旁边标注 MinAge / MaxBytes,箭头指向 snapshot.trace 与 go tool trace;整体蓝绿配色,标题文字为「Go 1.25 Flight Recorder」。