概述

这篇博文是针对那些希望改善其服务的可观察性的 GoLang 开发者。它略过了基础知识,直接跳到了高级主题,例如异步结构化日志,使用 sample 的度量,使用 TraceQL 的跟踪,聚合 pprof 和连续 pprof,使用 benchstat 的微基准和基本统计,黑盒性能测试,以及用于确定系统最大负载的基本 PID 控制器。我们还将简要地谈一谈当前在可观察性领域的研究,包括主动的随意剖析和被动的关键部分检测。

可观察性的三大支柱。日志、度量、跟踪

如果你正在读这篇文章,你很可能不需要对可观察性的基础知识进行复习。让我们深入了解那些不显眼的东西,并专注于使其尽可能容易在三个主要的可观察性表面之间移动。我们还将讨论如何将追踪加入到这个组合中,这样 pprof 数据就可以和追踪联系起来,并返回。

如果你正在寻找一个简短明了的监控基础知识介绍,以及快速将基本的可观察性引入你的服务的方法,Cindy Sridharan 的《分布式系统可观察性》是一个不错的开始。

结构化的日志记录

如果你没有使用一个零分配的日志库,那么日志可能会成为一个瓶颈。如果你还没有,可以考虑使用 zap 或 zerolog —两者都是不错的选择。

zerolog 767 ns/op 552 B/op 6 allocs/op
zap 848 ns/op 704 B/op 2 allocs/op
go-kit 3614 ns/op 2895 B/op 66 allocs/op
logrus 5661 ns/op 6092 B/op 78 allocs/op

Golang也有一个正在进行的关于引入结构化日志的提议: slog. 请务必查看 proposal 并提供反馈!

最近的 benchmark 可以看: Logbench

结构化日志对于从日志中提取数据至关重要。采用json或logfmt格式可以简化临时性的故障排除,并允许快速和肮脏的图表/警报,同时你要研究适当的指标。大多数日志库也有现成的钩子用于gRPC/HTTP客户端和服务器,以及常见的数据库客户端,这大大简化了它们在现有代码库中的引入。

如果你觉得基于文本的格式效率不高,你可以在很大程度上优化你的日志。例如,zerolog 支持二进制 CBOR 格式Envoy 有 protobufs,用于他们的结构化访问日志。

在某些情况下,日志本身会成为性能瓶颈。你不希望你的服务因为 Docker 在启用 DEBUG 日志时无法快速从 stderr 管道中提取事件而被卡住。

一个解决方案就是对你的日志取样:

  1. sampled := log.Sample(zerolog.LevelSampler{
  2. DebugSampler: &zerolog.BurstSampler{
  3. Burst: 5,
  4. Period: 1*time.Second,
  5. NextSampler: &zerolog.BasicSampler{N: 100},
  6. },
  7. })

或者,你可以让它们的排放完全异步化,所以它们永远不会阻塞:

  1. wr := diode.NewWriter(os.Stdout, 1000, 10*time.Millisecond, func(missed int) {
  2. fmt.Printf("Logger Dropped %d messages", missed)
  3. })
  4. log := zerolog.New(wr)

对于那些使用Grafana和Loki的人来说,还有一点要注意:你很可能想设置派生字段。这样,你可以从日志中提取字段,并把它们放到任意的URL中。

一键切换 logs, straces 和第三方资源的一个例子

如果上下文表明应该启用追踪,请考虑在每条日志消息中包括一个追踪ID,你以后会为此感谢自己的。

度量衡

让我们假设你已经在你的服务中使用了 Prometheus 风格的度量。但是,当你在图表上看到一个峰值,并且需要弄清楚是什么导致了速度减慢(剧透:可能是数据库),你会怎么做?从指标直接跳到慢速请求的追踪上不是很好吗?如果是这样,ExemplarAdderExemplarObserver 就是为你准备的。

  1. ctx := r.Context()
  2. traceID := trace.SpanContextFromContext(ctx).TraceID.String()
  3. requestDurations.(prometheus.ExemplarObserver).ObserveWithExemplar(
  4. time.Since(now).Seconds(), prometheus.Labels{"traceID": traceID}
  5. })

请注意,你不仅可以把 trace_id,还可以把任意的键值数据放入标签中,这对多租户环境特别有用,你可以把 user_id 或 team_id 包括进去。这可以成为解决高 cardinality 度量问题的一个经济有效的方案。

追踪

追踪对于当今世界的性能分析至关重要,所以大多数服务都启用了追踪功能。业界的追踪之路崎岖不平,从 OpenTracing 到 OpenCensus 再到现在的 OpenTelemetry 。我们使用 OTEL 库作为追踪排放的通用设置,Grafana Tempo 作为后端。

Flow 的区块链追踪排放与典型的网络后端不同:我们不依赖信任边界之间传递的追踪上下文,所以我们不是传播追踪 ID,而是根据正在处理的对象—区块或交易的哈希值—确定地构建它们。

追踪的问题不在于数据的排放,而在于定位 “有趣 “数据的能力。默认的 Grafana 的搜索能力是相当有限的,有时需要几分钟才能找到想要的追踪。

标准的 Trace 搜索面板

TraceQL通过引入一种搜索跟踪的便捷方式解决了这个问题。现在找到一个特定的跟踪是一件很容易的事。下面是参考文档中的几个例子:

  1. # A trace has INSERTs that on average are longer than 1s:
  2. { span.db.statement =~ "INSERT.*"} | avg(duration) > 1s
  3. # A trace has over 5 spans with http.status = 200 in any given namespace:
  4. { span.http.status = 200 } | by(resource.namespace) | count() > 5
  5. # A trace passed through two regions (in any order):
  6. { resource.region = "eu-west-0" } && { resource.region = "eu-west-1" }

如果你喜欢视频格式,这里是 Joe Elliott’sTraceQL from GrafanaCon 2022 的介绍。

现在,有了TraceQL,我们可以按照我们的愿望来具体化:

{.network = “mainnet20” && span.transaction.ContractFunctionCall = “e467b9dd11fa00df.FlowStorageFees.calculateAccountsCapacity” && duration > 1ms }

如果你使用 Grafana,别忘了把你的日志数据源和你的追踪器联系起来。这将使你很容易在 Trace 和日志之间进行导航:

链接 Traces 和 Logs (以及可能的 Metrics)

优化 TraceQL 查询可以通过在你的 “资源 “中拥有共同的每个进程属性来实现。这比搜索要快得多,而且可以产生巨大的差异。例如,在上面的截图中,我们有一个错误,即 env 和 network 应该作为资源而不是属性被排放出来。

Profiling

Go 运行时提供了出色的剖析能力。如果你想对你的代码进行 net/http/pprof 以外的分析,我们强烈推荐你阅读[DataDog的 “The Busy Developer’s Guide to Go Profiling, Tracing and Observability”](https://github.com/DataDog/go-profiler-notes/blob/main/guide/README.md)。 它详细介绍了所有的剖析器类型(CPU、内存、块等),并涵盖了低级别的实现细节,如堆栈跟踪内部 pprof 格式

许多剖析功能需要一个新的 Go 运行时来进行快速准确的操作。如果你打算在生产中依赖剖析数据,特别是如果你打算使用连续剖析,请升级到 Go 1.19。

一旦你有了原始的 pprof 文件,你会想要分析它。然而,Go 工具 pprof -http 5000 有其局限性。理想的解决方案是将配置文件存储在一个支持基本查询和过滤的数据库中。我们使用谷歌的云分析器,但不是依赖他们有点有限的客户端库,而是利用他们的 “离线API”,允许我们将现有的 .prof 文件运送到谷歌:

  1. profileBytes, _ := os.ReadFile(filename)
  2. client.CreateOfflineProfile(ctx, &pb.CreateOfflineProfileRequest{
  3. Parent: projectId,
  4. Profile: &pb.Profile{
  5. ProfileType: profileType,
  6. Deployment: deployment,
  7. ProfileBytes: profileBytes,
  8. },
  9. })

与简单的本地存储相比,将配置文件发送到远程存储的主要好处是能够将多个配置文件汇总到一个视图中:

继承 profile 查看 UI

此外,它使我们有能力查看一段时间内的概况趋势。

历史 topN 趋势图的例子

遗憾的是,谷歌的 “离线 “API有严重的速率和大小限制,而且似乎普遍不受支持,所以我们正在积极探索替代方案。由于我们已经在使用Grafana栈,我们正在关注Phlare的发展;它看起来是一个非常有能力的替代品:

Phlare github 上的一个例子.

将 profile 与 trace 联系起来

我们在性能分析的用户体验方面投入了大量的精力,确保日志、Trace 和 Metric 之间的平滑过渡。然而,profile 数据目前是一个独特的挑战。为了解决这个问题,我们正在探索pprof.Do(或者在较低层次上的pprof.SetGoroutineLabels )。这将使我们能够在 profile 和 tracce 之间建立一种目前还没有的联系。

  1. pprof.Do(
  2. ctx,
  3. pprof.Labels(
  4. "span", fmt.Sprintf("%s", span)
  5. ),
  6. func(ctx context.Context) {
  7. doWork(ctx)
  8. },
  9. )

标签有一些缺点:它们并不支持所有的配置文件类型,而且会增加配置文件的大小,所以要注意标签的数量。

你可以在这里添加任意的标签,这在多租户环境中特别有用。例如,你可以用租户的 UserID 和 TeamID 来注解 pprofs。即使是单租户设置,用 EndpointPath 注释配置文件也可以提供更多关于 CPU 使用的信息。

fgprof

Go 的默认剖析器有一个缺点,即只能查看 On-CPU 或 Off-CPU 时间。 Felix Geisendörfer 的剖析器,fgprof,通过提供一个单一的视图来解决这个问题。

持续剖析

剖析现在已经足够便宜,许多公司提供了用于生产中的连续剖析的库,这已经成为一种趋势。著名的例子包括 PyroscopeDataDogGoogle

Grafana Phlare 没有将剖析器嵌入代码库中,而是采用了一个代理模型,定期抓取位于 /debug/pprof/ 的 Go 的 pprof HTTP 端点。

  1. scrape_configs:
  2. - job_name: 'default'
  3. scrape_interval: 10s
  4. profiling_config:
  5. path_prefix: "/debug/pprof"
  6. pprof_config:
  7. memory:
  8. enabled: true
  9. path: "/allocs"
  10. delta: true
  11. # ...

基于eBPF的剖析

上述所有的 pprof 功能都需要二进制中的某种工具,无论是 HTTP 端点还是一个持续的 pprof 库。最近,在可观察性方面出现了一种趋势,即通过 eBPF 实现无工具的 pprof。

例如,Parca 使你能够观察到无仪表的 C、C++、Rust、Go等

Parca UI Demo

如果你想对 Go 使用与uprobe / uretprobe 用于 C/C++ 代码相同的 eBPF pprof 方法,请注意,由于 Go 的堆栈增长和复制,你可能遇到 SIGBUS。 此外,goroutines 是动态映射到线程的,因此不可能使用 tid 来识别代码流(同样的情况也可能发生在使用 coroutines 的 C++ 中)。幸运的是,在相应的 bccbpftrace 问题中,有一些解决方法。

微观基准测试

Go 中的微基准测试是一种众所周知的做法,所以没有什么可说的。然而,有几件事情值得一提。首先,建议在运行微观基准时使用 test.benchmem 并运行 '^$'

第二,只有当 -count 大于或等于 10 的时候,基准测试结果才应该被认为是有效的。

  1. $ go test -count 10 -bench 'Benchmark.*TokenTransfer' -benchmem -run '^$' ./
  2. goos: darwin
  3. goarch: arm64
  4. pkg: github.com/onflow/cadence/runtime
  5. BenchmarkFungibleTokenTransfer-8 6638 179964 ns/op 104511 B/op 1966 allocs/op
  6. BenchmarkFungibleTokenTransfer-8 6458 179890 ns/op 103890 B/op 1966 allocs/op
  7. BenchmarkFungibleTokenTransfer-8 6853 180334 ns/op 104513 B/op 1966 allocs/op
  8. ...

最后,在分析单个基准结果时,应始终采用 benchstat 工具(或类似工具),以提高可读性并保证环境中没有噪音。

  1. $ benchstat go1.20rc1
  2. name time/op
  3. FungibleTokenTransfer-8 180µs ± 0%
  4. name alloc/op
  5. FungibleTokenTransfer-8 105kB ± 1%
  6. name allocs/op
  7. FungibleTokenTransfer-8 1.97k ± 0%

在基准测试环境中,噪声可能是一个严重的问题。LLVM 项目在如何调整 Linux 系统以使基准的变化小于 0.1% 方面有很好的文档。

此外,在进行比较时,特别是在宣称性能改进时,必须显示统计数字。

  1. $ benchstat go1.19 go1.20rc1
  2. name old time/op new time/op delta
  3. FungibleTokenTransfer-8 182µs ± 2% 180µs ± 0% -1.33% (p=0.021 n=10+8)
  4. name old alloc/op new alloc/op delta
  5. FungibleTokenTransfer-8 105kB ± 1% 105kB ± 1% ~ (p=0.363 n=10+10)
  6. name old allocs/op new allocs/op delta
  7. FungibleTokenTransfer-8 1.97k ± 0% 1.97k ± 0% -0.10% (p=0.000 n=10+10)

不要羞于在你的测试中添加自定义指标,以便从关键部分获得更多的性能报告的洞察力。利用 b.ReportMetricb.ResetTimer 来优化它们。

如果有一个开源或 SaaS 工具可以收集和跟踪 Go 仓库的微观基准测试结果,那就更好了;但是,我们没有找到。

黑盒性能测试

虽然有各种工具可以对网络应用进行端到端的性能测试(例如,我们使用 grafana/k6 来测试我们的应用),但是像编译器或数据库这样的复杂系统需要定制的黑盒测试,也就是所谓的宏观基准测试。这里有几个大型开源项目中自动化宏观基准的好例子(基准框架本身也是开源的)。

Vitess,一个分布式数据库,有一个非常全面的(尽管有点嘈杂)夜间性能测试,跟踪 micro- 和 macro-benchmark 结果。该测试框架在 https://github.com/vitessio/arewefastyet 上开放源代码。

Rust是另一个优秀基准框架的好例子,用于在每个提交的基础上进行各种特定的编译器测试。它也是开源的,网址是: https://github.com/rust-lang/rustc-perf.

与这两个相比,我们的端到端设置相对微不足道,但仍有几个有趣的细节。其中之一是检测系统的过载点。由于区块链是异步的,在我们的端到端宏观基准中可以在多层排队交易,我们需要找到区块链在没有过度延迟的情况下可以处理的最大交易/秒值。我们曾经使用 TCP 的和性增/乘性减(AIMD)算法 来实现,但它的收敛速度相当慢。最近,我们改用PID控制器(确切地说,是PD控制器),它有一个很好的特性,可以快速收敛到所需的队列大小,同时也不会超调太厉害。

我们的令牌传输基准收敛到稳定状态的一个例子。

如果你对在你自己的系统中引入 PID 控制器感兴趣,Philipp K. Janert 的书《计算机系统的反馈控制》。向企业程序员介绍控制理论》,是一个很好的开始。如果你更喜欢视觉学习,”了解PID控制 “中的前几个讲座也是很好的介绍。YouTube 播放列表也是一个很好的介绍。请记住,这是 MATLAB 频道,所以它很快就会深入下去。你已经被警告了 =)

未来的工作

在这里,我们将介绍一下我们计划在今年添加到性能观察工具包中的东西。

通过减慢速度进行瓶颈检测

加速是很困难的,但减速是相对直接的。因此,识别瓶颈的一种方法是尝试将系统组件放慢 1ms、5ms、50ms、250ms等,并测量基准结果。然后,将函数推断回-1ms、-5ms、-50ms等。这种方法是近似的,但对小数值来说效果很好。

惰性 profile

惰性 profile 是另一种积极的方法,是对前一种方法更精确的概括。它不是放慢感兴趣的组件的速度,而是放慢它周围的其他东西的速度,从而模拟被测试组件的速度。

如果你对随意剖析感兴趣,在”Coz:用因果剖析寻找有价值的代码“(SOSP’15)中有一个很好的视频介绍。

自动关键路径分析

分析一个大规模分布式系统的关键路径可能是一个挑战。在他们的 OSDI’14 论文中,”神秘的机器。大型互联网服务的端到端性能分析,密歇根大学和Facebook提出了一种通过观察日志(现在可能更类似于跟踪)来被动地识别关键路径的方法。这种方法的好处是可以很好地与追踪和剖析的基础设施堆叠在一起。

Ref