bigywater.tech
微服务 · 77

可观测性的开销:日志同步写与异步写,指标热路径上每次记录多少纳秒

记一个指标、一行日志、一个 span 到底要花多少纳秒?带标签的指标比不带标签慢多少?日志同步写会不会拖慢请求?异步写又会怎样?放进完整的请求里占多少?这篇用 Go 基准和 Python 计时逐项量出来。

3.9 ns vs 35.1 ns
Go 里 Prometheus Counter.Inc():缓存 child 与每次 WithLabelValues 查标签的耗时
4.525 ms vs 0.117 ms
日志目的地每次写入 3 ms 时,同步与异步队列 logger.info 的 p50 耗时(Python)
52 µs(5.2%)
1 ms 的请求加上 2 次指标、3 行 JSON 日志和 1 个 span 后多出的耗时(Python)

Go 热路径

实测 · 本机执行输出
  操作                                         | ns/op(中位数) | allocs/op
  -------------------------------------------+------------+----------
  atomic.Int64.Add(1)                        | 4.0        | 0        
  sync.Mutex 加一                              | 7.7        | 0        
  time.Now()                                 | 32.5       | 0        
  Prometheus Counter.Inc()                   | 3.9        | 0        
  CounterVec.WithLabelValues(…).Inc()(每次查标签) | 35.1       | 0        
  CounterVec 缓存 child 后 .Inc()               | 3.9        | 0        
  Gauge.Set()                                | 1.9        | 0        
  Histogram.Observe()(默认 11 个桶)              | 18.9       | 0        
  HistogramVec.WithLabelValues(…).Observe()  | 33.8       | 0        
  Counter.Inc() 10 个 goroutine 并发(每次操作的均摊耗时) | 60.0       | 0        
  atomic.Int64.Add(1) 10 个 goroutine 并发      | 66.1       | 0        
  slog.Info 级别被关掉                            | 5.9        | 0        
  slog.Info(文本)写到 io.Discard                 | 595.9      | 0        
  slog.Info(JSON)写到 io.Discard               | 636.3      | 2        
  一次不带标签的 Counter.Inc 和一次 atomic 加一差不多(都是几纳秒);带标签的 WithLabelValues 每次要把标签值做哈希查表,约十倍;Histogram 要按桶二分查找再做几次原子操作;日志一行是几百纳秒,而且和字段个数、编码方式有关;关掉的日志级别几乎免费。并发写同一个计数器时,缓存行争用让每次操作慢了十几倍(atomic 本身也一样)

无标签的 Counter 与 atomic 加一一样快,查标签约十倍,一行 slog 是几百纳秒;并发写同一个计数器会因争用慢十几倍。

Python 热路径

实测 · 本机执行输出
  操作                              | ns/op(中位数) | µs/op
  --------------------------------+------------+------
  Counter.inc()(无标签)              | 199        | 0.20 
  Counter.labels(…).inc()(每次查标签)  | 707        | 0.71 
  缓存 child 后 .inc()               | 212        | 0.21 
  Gauge.set()                     | 207        | 0.21 
  Histogram.observe()(无标签)        | 539        | 0.54 
  Histogram.labels(…).observe()   | 967        | 0.97 
  span(no-op,没配 SDK)              | 2016       | 2.02 
  span(SDK + SimpleSpanProcessor) | 8701       | 8.70 
  span(SDK + BatchSpanProcessor)  | 9092       | 9.09 
  span(SDK + Batch,5 个属性)         | 12269      | 12.27
  logging.info,级别被关掉              | 94         | 0.09 
  logging.info → NullHandler      | 2630       | 2.63 
  logging.info → 文件(格式化 + 写入)     | 8835       | 8.84 
  同样是 Counter.inc():Python 199 ns,Go 3.9 ns,Python 是 Go 的 51 倍(算出来的比值);Python 里指标操作是零点几到一微秒,一个 span 是 9.1 到 12.3 µs,一行日志写文件是 8.8 µs(NullHandler 是 2.6 µs,说明大部分在格式化和写文件);给 span 加属性、指标带标签的查询都会增加耗时

同样的操作比 Go 慢一到两个数量级:指标零点几微秒,span 约 9~12 µs,一行日志写文件约 9 µs。

同步与异步

实测 · 本机执行输出
  目的地每次写入耗时 | 方式      | logger.info 耗时 p50(ms) | p99(ms) | 最大(ms) | 2000 次调用用的总时间(s) | 目的地实际写入的行数 | 被丢弃的行数
  ----------+---------+------------------------+---------+--------+------------------+------------+-------
  3 ms      | (a)同步   | 4.525                  | 4.768   | 5.3    | 10.0             | 2000       | 0     
  3 ms      | (b)异步队列 | 0.117                  | 0.371   | 1.1    | 10.0             | 2000       | 0     
  8 ms      | (a)同步   | 11.326                 | 12.216  | 14.2   | 21.8             | 2000       | 0     
  8 ms      | (b)异步队列 | 0.125                  | 0.352   | 1.5    | 10.0             | 1934       | 66    
  同步写时,目的地的耗时全部加在请求路径上:3 ms 的目的地让每次调用多 3 ms;8 ms 的目的地让 200 个/秒的请求跟不上(调用被拖慢,总时间变长)。异步队列把调用的耗时降到微秒级,代价是目的地跟不上时队列满了就只能丢日志(或者阻塞调用方)

同步写把慢目的地的耗时全部加在请求路径上;异步队列把调用降到微秒级,但目的地跟不上时会丢日志。

完整请求

实测 · 本机执行输出
  配置                  | 每秒处理的请求数(3 轮中位数) | 每个请求的平均耗时(ms) | 比 (a) 多出的耗时(µs/请求)
  --------------------+------------------+---------------+-------------------
  (a)什么都不记录           | 999              | 1.001         | 0                 
  (b)+ 2 次指标          | 998              | 1.002         | 1                 
  (c)+ 3 行 JSON 日志写文件 | 965              | 1.036         | 36                
  (d)+ 1 个 span       | 950              | 1.052         | 52                
  每一档比上一档多出的耗时:指标 1 µs,再加 3 行日志 35 µs,再加 1 个 span 16 µs;全部加上比什么都不记录多 52 µs,占 1 ms 请求的 5.2%(算出来的)。请求本身越短,这个占比越大
ERRORS {}

指标几乎免费,日志和 span 才是大头,占 1 ms 请求的几个百分点。

分步演示

可观测性的开销
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8 / Go / nginx / Envoy / etcd / Consul / Nacos / OpenTelemetry / Prometheus(按各篇注明用到哪些及版本);所有服务都是本机自己起的进程,端口 9150 以上,只监听 127.0.0.1,不连任何外部服务,不碰本机已有的实例;故障用 kill -9 进程、让连接断开或在代码里注入延迟与错误来制造;计时用 time.perf_counter,数字每次运行会有小幅抖动。Go 部分:go1.27.1、Apple M2 Pro、prometheus/client_golang v1.25.0,每个基准 count=3 取中位数;Python 部分:Python 3.14、prometheus_client 0.26.0、OpenTelemetry SDK 1.45.1,time.perf_counter 循环计时(5 轮中位数);慢日志目的地用 sleep 模拟(sleep 的粒度会让 3 ms 变成约 4.5 ms);完整请求用 1 ms 的忙等代替真实业务;所有数字只反映这台机器、这一组库版本,不同语言、不同硬件差别很大
    • 没有验证:Go 的 OpenTelemetry SDK 创建 span 的耗时;Java 的 Logback 异步 appender 与 Log4j2 的无垃圾模式;日志落盘的 fsync 开销(Python 的 FileHandler 只 flush 不 fsync);采样之后 span 的开销(未采样的 span 更便宜);高并发下的争用(只测了 Go 的 10 个 goroutine 写同一个计数器);指标 Exporter 暴露 /metrics 时的抓取开销(见基数爆炸一篇)
    京ICP备2026067502号-1京公网安备11010502063487号