Go Flight Recorder 终于来了,线上问题可以 “回放“ 了!

大家好,我是煎鱼。

不知道大家在生产环境排查问题的时候,有没有遇到过这样的窘境:服务突然慢了,等你反应过来想抓个 trace 看看,问题已经过去了。就像开车遇到异响,等你停下来检查,声音又没了。

今天给大家分享 Go1.25 的一个重磅特性:Flight Recorder(飞行记录器)。这玩意儿真的是救命神器,能让你在问题发生后,回溯几秒钟前的执行状态。

背景

先说说为什么需要这个东西。

Go 的 execution trace 功能其实一直都有,通过runtime/trace包就能收集程序执行时的各种事件。

这对于调试延迟问题特别有用,能清楚地看到 goroutine 什么时候在执行,更重要的是,什么时候没在执行。

但问题来了。

对于短期运行的程序,比如测试、基准测试或者命令行工具,你可以从头到尾收集完整的 trace。但对于长期运行的 Web 服务,这就不现实了。服务器可能要运行好几天甚至几周,你总不能一直开着 trace 收集数据吧?那数据量得多恐怖。

更尴尬的是,往往是某个请求超时了,或者健康检查失败了,等你意识到问题,想调用trace.Start()的时候,早就晚了。

有人说,那我随机采样不就行了?这个思路是对的,但需要一大堆基础设施支撑。你得存储、分类、处理海量的 trace 数据,而且大部分数据其实都没啥用。更关键的是,当你想排查某个具体问题的时候,这种方式基本帮不上忙。

Flight Recorder 是什么

这就是 Flight Recorder 要解决的问题。

核心思路很简单:程序通常能感知到出问题了,但根因可能早就发生了。Flight Recorder 让你能收集问题发生前几秒钟的 trace 数据。

它的工作原理是这样的:正常收集 trace 数据,但不是写到文件或 socket 里,而是在内存里缓存最近几秒的数据。

一旦程序检测到问题,随时可以把缓冲区的内容快照下来,精准定位到问题窗口。

实战案例

我们用一个实际例子来看看怎么用。

假设有这么一个 HTTP 服务,实现了一个"猜数字"的游戏。它暴露了一个/guess-number端点,接收一个整数,告诉调用者猜得对不对。

同时还有个 goroutine 每分钟发送一次统计报告。

核心代码大概是这样:

type bucket struct {
    mu      sync.Mutex
    guesses int
}

func main() {
    buckets := make([]bucket, 100)

    // 每分钟发送报告
    gofunc() {
        forrange time.Tick(1 * time.Minute) {
            sendReport(buckets)
        }
    }()

    answer := rand.Intn(len(buckets))

    http.HandleFunc("/guess-number", func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()

        guess, err := strconv.Atoi(r.URL.Query().Get("guess"))
        if err != nil || !(0 <= guess && guess < len(buckets)) {
            http.Error(w, "invalid 'guess' value", http.StatusBadRequest)
            return
        }

        b := &buckets[guess]
        b.mu.Lock()
        b.guesses++
        b.mu.Unlock()

        fmt.Fprintf(w, "guess: %d, correct: %t", guess, guess == answer)

        log.Printf("HTTP request: endpoint=/guess-number guess=%d duration=%s",
            guess, time.Since(start))
    })

    log.Fatal(http.ListenAndServe(":8090", nil))
}

发送报告的函数是这样写的:

func sendReport(buckets []bucket) {
    counts := make([]int, len(buckets))

    for index := range buckets {
        b := &buckets[index]
        b.mu.Lock()
        defer b.mu.Unlock()

        counts[index] = b.guesses
    }

    b, err := json.Marshal(counts)
    if err != nil {
        log.Printf("failed to marshal report data: error=%s", err)
        return
    }

    url := "http://localhost:8091/guess-number-report"
    if _, err := http.Post(url, "application/json", bytes.NewReader(b)); err != nil {
        log.Printf("failed to send report: %s", err)
    }
}

上线后,用户开始反馈有些请求特别慢。

看日志发现,大部分请求都是微秒级的,但偶尔会有超过 100 毫秒的:

2025/09/19 16:52:02 HTTP request: endpoint=/guess-number guess=69 duration=625ns
2025/09/19 16:52:02 HTTP request: endpoint=/guess-number guess=42 duration=1.417µs
2025/09/19 16:52:02 HTTP request: endpoint=/guess-number guess=86 duration=115.186167ms
2025/09/19 16:52:02 HTTP request: endpoint=/guess-number guess=0 duration=127.993375ms

问题来了,能看出哪里有 bug 吗?

用 Flight Recorder 排查

先别急着看答案,我们用 Flight Recorder 来排查。

首先,在 main 函数里配置并启动 recorder:

// 配置Flight Recorder
fr := trace.NewFlightRecorder(trace.FlightRecorderConfig{
    MinAge:   200 * time.Millisecond,
    MaxBytes: 1 << 20, // 1 MiB
})
fr.Start()

这里 MinAge 设置为 200 毫秒,大概是问题窗口的 2 倍。

MaxBytes 限制缓冲区大小,避免内存爆炸。一般来说,每秒会产生几 MB 的 trace 数据,繁忙的服务可能达到 10MB/s。

接下来写个辅助函数来捕获快照:

var once sync.Once

func captureSnapshot(fr *trace.FlightRecorder) {
    once.Do(func() {
        f, err := os.Create("snapshot.trace")
        if err != nil {
            log.Printf("opening snapshot file %s failed: %s", f.Name(), err)
            return
        }
        defer f.Close()

        _, err = fr.WriteTo(f)
        if err != nil {
            log.Printf("writing snapshot to file %s failed: %s", f.Name(), err)
            return
        }

        fr.Stop()
        log.Printf("captured a flight recorder snapshot to %s", f.Name())
    })
}

然后在请求处理函数里,当响应时间超过 100 毫秒时触发快照:

if fr.Enabled() && time.Since(start) > 100*time.Millisecond {
    go captureSnapshot(fr)
}

重新运行服务,等到触发慢请求,我们就能拿到快照文件了。

分析 trace

拿到 trace 文件后,用 Go 自带的工具分析:

go tool trace snapshot.trace

这个工具会启动一个本地 Web 服务器,然后在浏览器里打开。点击"View trace by proc"可以看到时间线视图。

在这个视图里,我们能看到 goroutine 的执行情况。重点关注右侧那个巨大的空白期——大概 100 毫秒,啥都没干!

放大这个区域后,可以看到很多 goroutine 都在等待一个特定的 goroutine。点击这个 goroutine,查看它的栈信息,发现它在执行sendReport函数。

再仔细看那些"Outgoing flow"事件,它们都指向了sendReport里的Unlock操作。

问题找到了!

看这段代码:

for index := range buckets {
    b := &buckets[index]
    b.mu.Lock()
    defer b.mu.Unlock()

    counts[index] = b.guesses
}

我们本想给每个 bucket 加锁,拷贝完值就解锁。但defer的执行时机是函数返回时,不是循环结束时。

所以这些锁一直被持有,直到整个 HTTP 请求完成后才释放。

这就是典型的 defer 误用场景。正确的写法应该是:

for index := range buckets {
    b := &buckets[index]
    b.mu.Lock()
    counts[index] = b.guesses
    b.mu.Unlock()
}

总结

Flight Recorder 真的是个好东西。它让我们能在问题发生后,回过头去看发生了什么,而不需要一直开着 trace 收集海量数据。

简单来说,它就像是给你的程序装了个行车记录仪,出了事故可以回放录像。比起传统的 trace 方式,既节省资源,又能精准定位问题。

这个特性在 Go1.25 正式可用了,配合之前几个版本对 tracing 的优化(Go1.21 降低了开销,Go1.22 改进了 trace 格式),整个诊断工具链越来越成熟了。

如果你经常需要排查生产环境的性能问题,强烈建议试试这个新特性。

关注和加煎鱼微信,

一手消息和知识,拉你进技术交流群👇

你好,我是煎鱼,出版过 Go 畅销书《Go 语言编程之旅》,再到获得 GOP(Go 领域最有观点专家)荣誉,点击蓝字查看我的出书之路

日常分享高质量文章,输出 Go 面试、工作经验、架构设计,加微信拉读者交流群,和大家交流!

原创不易 点赞支持

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值