返回文章列表

Rust 可观测性实战:别再 println 了

1127·7 分钟阅读
Rust可观测性tracingMetricsOpenTelemetry

"线上 P99 延迟飙了,日志里什么都没有。"

这句话我听过不下十次。每次排查下来,原因都一样——要么是日志不够,出了问题两眼一抹黑;要么是日志太多,关键信息被淹没在 debug 输出的海洋里。

更常见的情况是:本地跑得好好的,上了生产就开始出幺蛾子,但你根本看不到哪里出了问题。

先说结论:可观测性 = 日志(Logs)+ 指标(Metrics)+ 链路追踪(Traces)。 三者缺一不可。Rust 生态在这三个维度上都有成熟工具,而且性能开销可以做到接近零——字面意义上的零。

这篇文章不是 API 文档搬运。我想讲的是:怎么从 println!("请求进来了") 进化到生产级可观测性,中间踩了哪些坑,以及哪些东西其实不需要。


告别 println,拥抱 tracing

Rust 的可观测性基本只有一条路:tokio 的 tracing crate。

不是说 log 不好,而是 tracinglog 的基础上加了一个关键能力:Span(跨度)

Event vs Span:一张图说清楚

时间轴 ─────────────────────────────────────►

Span:  ┌──────────── HTTP POST /users ────────────┐
       │                                            │
       │  Event: 收到请求                            │
       │       ┌── Span: 数据库查询 ──┐              │
       │       │  Event: SELECT ...   │              │
       │       │  Event: 返回 1 行    │              │
       │       └──────────────────────┘              │
       │  Event: 返回 201                            │
       └────────────────────────────────────────────┘
  • Event = 一条日志记录,一个时间点。info!("收到请求") 就是一个 Event。
  • Span = 一个有开始和结束的时间段,可以嵌套。一次 HTTP 请求、一次数据库查询,都是 Span。

Event 发生在 Span 的上下文里。这意味着你不需要在每条日志里手动写 request_id=xxx——只要在 Span 里记录一次,所有子 Event 自动继承。

这就是 tracinglog 强大的根本原因:结构化上下文,而不是平面日志。

最简配置

# Cargo.toml
[dependencies]
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
 
fn init_tracing() {
    tracing_subscriber::registry()
        .with(EnvFilter::from_default_env())  // 读 RUST_LOG 环境变量
        .with(tracing_subscriber::fmt::layer())  // 输出到终端
        .init();
}

然后用环境变量控制日志级别:

RUST_LOG=info cargo run           # 只看 info 及以上
RUST_LOG=my_crate=debug cargo run # 只有你的 crate 输出 debug
RUST_LOG=debug,hyper=warn cargo run # 混合配置

一行代码替代所有的 println!env_logger::init()

#[instrument]:一行代码搞定函数级追踪

这是 tracing 最好用的宏,没有之一:

use tracing::{info, instrument};
 
#[instrument]
async fn create_user(name: &str, email: &str) -> Result<User, Error> {
    info!("开始创建用户");
    // ...
}

调用时自动创建 Span,记录函数名和参数,函数结束时自动关闭 Span。对 async fn 也正确处理了 poll/yield 边界(后面会讲为什么这很重要)。

但默认行为有个问题:它会记录所有参数。 如果参数里有密码、大 JSON body、或者数据库连接池,你就踩坑了。

// ❌ 千万别这样写
#[instrument]
async fn login(user: &str, password: &str) -> Result<Token, Error> {
    // password 会被记录到日志里!
}
 
// ✅ 正确写法
#[instrument(skip(password), fields(user_id = %user))]
async fn login(user: &str, password: &str) -> Result<Token, Error> {
    // password 被跳过,user_id 显式记录
}

#[instrument] 的常用参数:

参数 作用 示例
skip(...) 跳过敏感/大参数 skip(password, body)
fields(...) 显式记录字段 fields(user_id = %user.id)
level = ... 设置 Span 级别 level = "debug"
name = "..." 自定义 Span 名称 name = "process_payment"
err 记录 Err 返回值 #[instrument(err)]
ret 记录 Ok 返回值 #[instrument(ret)]

坑!(重头戏来了)

span.enter().await——最经典的异步陷阱

我第一次写 tracing 的时候,这么写的:

// ❌ 这是一个 BUG
let span = tracing::info_span!("process_request");
let _guard = span.enter();
let result = db.query("SELECT ...").await;  // 💥

看起来没问题对吧?Span 进入了,查询做了,guard 离开作用域时自动退出 Span。

问题在于:.await 会让出当前线程。 你的 task 可能被调度到另一个线程上继续执行,但 _guard 还绑定在原来的线程上。结果就是:

  • Span 在线程 A 上"进入"了,但在线程 B 上"退出"
  • 线程 A 上的 Span 永远不会退出,污染了后续所有请求的上下文
  • 线程 B 上根本看不到这个 Span,链路追踪直接断掉

表现: 日志里偶尔出现莫名其妙的嵌套关系,或者某个请求的 Span 突然消失了。

正确写法:

// ✅ 方法一:用 .instrument()
use tracing::Instrument;
 
let result = db.query("SELECT ...")
    .instrument(tracing::info_span!("process_request"))
    .await;
 
// ✅ 方法二:用 #[instrument](推荐)
#[instrument]
async fn process_request() {
    let result = db.query("SELECT ...").await;  // Span 自动正确管理
}

.instrument() 方法会把 Span 绑定到 Future 上,而不是线程上。每次 poll 时自动进入,yield 时自动退出。

教训:在异步代码里,永远不要手动 span.enter() 然后 .await。用 #[instrument].instrument()

Clippy 从 1.61 开始自带 clippy::await_holding_span_guard 检查,建议直接开成 deny。

测试里日志全丢了

写测试的时候加了一堆 info!debug!,跑测试时终端一片空白。

原因很简单:没有安装 subscriber,所有事件被静默丢弃。

// ❌ 测试里看不到任何日志
#[tokio::test]
async fn test_create_user() {
    info!("开始测试");  // 你看不到这行
    // ...
}
// ✅ 在测试里初始化 subscriber
#[tokio::test]
async fn test_create_user() {
    let _ = tracing_subscriber::fmt()
        .with_test_writer()  // 关键:输出到测试的 stdout
        .try_init();
    info!("开始测试");  // 现在能看到了
    // ...
}

try_init() 而不是 init(),因为多个测试并行时 init() 会 panic(subscriber 只能初始化一次)。

更省事的做法是用 test-log crate:

// 自动初始化,自动输出到测试 stdout
#[test_log::test(tokio::test)]
async fn test_create_user() {
    info!("自动可见");
}

教训:测试里看不到日志,99% 是因为没装 subscriber。用 try_init()test-log

生产环境开了 debug 级别

microservices 文章里已经提过这个,但我想展开讲一下——因为很多人觉得"开 debug 级别只是多点日志"。

不是的。开 debug 级别的代价比你想的大得多:

  1. 磁盘 I/O 暴增:debug 日志量可能是 info 的 10-100 倍
  2. CPU 开销:即使没有 subscriber,tracing 也要评估 Interest(虽然很快,但不是零)
  3. 内存压力:JSON 格式化、大字段序列化都在堆上分配
  4. 关键信息被淹没:出了问题你要在海量 debug 输出里找 error

编译时直接砍掉 debug/trace 级别,零成本:

# Cargo.toml
[dependencies]
tracing = { version = "0.1", features = ["max_level_info"] }

这样 debug!()trace!() 在编译时就被移除了,连 Interest 检查都不会执行。真正的零成本。

过滤方式 时机 开销 适用场景
RUST_LOG 环境变量 运行时 微小(原子读取) 开发/调试
EnvFilter 运行时 每次事件评估一次 生产环境动态调整
max_level_info 编译时 生产发布版本

教训:发布版本用 max_level_info 编译,开发时再开 debug。两个世界的好处都要。


指标(Metrics)

日志告诉你"发生了什么",指标告诉你"有多严重"。

两条路:metrics crate vs OpenTelemetry Metrics

metrics crate OpenTelemetry Metrics
定位 轻量级 facade,类似 log 的思路 统一可观测性的一部分
API 简单直观 稍重,但和 Traces/Logs 共享 Context
学习曲线
适用场景 只需要 Prometheus 指标 已经在用 OTel 做链路追踪
维护 metrics-rs 团队 OpenTelemetry 官方

建议:如果你已经在用 OpenTelemetry 做链路追踪,指标走同一条管线更干净。如果只需要简单的 Prometheus 指标,metrics crate 更省事。

metrics + metrics-exporter-prometheus 两个 crate 就够了。初始化只需一行:

PrometheusBuilder::new()
    .with_http_listener(([0, 0, 0, 0], 9090))
    .install()
    .expect("failed to install Prometheus exporter");

然后在代码里用 counter!histogram!gauge! 三个宏记录指标,Grafana 配一个 Prometheus 数据源就能画图了。

关键:指标命名要规范。 我见过太多项目用 request_countreq_cntrequests 混着来,最后 Grafana dashboard 一塌糊涂。建议一开始就定好命名规范,比如 http_requests_totalhttp_request_duration_seconds

三种指标类型

类型 用途 示例
Counter 只增不减的计数器 请求总数、错误总数
Gauge 可增可减的瞬时值 当前连接数、队列长度
Histogram 值的分布 请求延迟(P50/P99/P999)

教训:不要只看平均值。P99 和 P999 才是真正告诉你"用户在经历什么"的指标。

microservices 文章里那个 P99 vs P999 的例子很好——P99 正常但 P999 飙了,说明有少量请求在做全表扫描。Histogram 就是为这个场景而生的。


链路追踪(Distributed Tracing)

这是可观测性里最有价值但也最复杂的部分。

为什么需要链路追踪?

假设你的微服务架构是这样的:

用户请求 → API Gateway → User Service → PostgreSQL
                       ↘ Email Service → SMTP

一个请求经过了 4 个服务。如果延迟飙了,是哪个服务的问题?

没有链路追踪: 你只能一个一个服务翻日志,靠时间戳猜。 有链路追踪: 一张图看到整个调用链,每个环节花了多少时间。

Trace: abc-123
├── API Gateway (120ms)
│   ├── User Service (80ms)
│   │   └── PostgreSQL query (45ms)  ← 这里慢了
│   └── Email Service (30ms)
│       └── SMTP send (25ms)

OpenTelemetry 的现状

先说一个很多人不知道的事:OpenTelemetry Rust 的 Traces API/SDK 到现在还是 Beta 状态。

信号 API 状态 SDK 状态
Logs Stable Stable
Metrics Stable Stable
Traces Beta ⚠️ Beta ⚠️

但别被"Beta"吓到。Jaeger、Grafana Tempo 都原生支持 OTLP 协议,生态已经成熟。只是说 API 可能还会有小调整,升级时需要注意。

重要的事情说三遍:旧的 opentelemetry-jaeger 已经废弃了,用 OTLP。旧的 opentelemetry-jaeger 已经废弃了,用 OTLP。旧的 opentelemetry-jaeger 已经废弃了,用 OTLP。

tracing-opentelemetry:一行代码把 tracing 导出到 OTLP

这个 crate 是整个链路追踪的关键——它是一个 tracing::Layer,把你的 tracing Span 自动转成 OpenTelemetry Span 并导出。

核心代码只有三步:

// 1. 创建 OTLP exporter
let exporter = opentelemetry_otlp::SpanExporter::builder()
    .with_http().build().expect("failed to build exporter");
 
// 2. 创建 TracerProvider
let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder()
    .with_batch_exporter(exporter)
    .with_resource(opentelemetry_sdk::Resource::builder()
        .with_service_name("my-service").build())
    .build();
 
// 3. 加一层 OpenTelemetryLayer,完事
tracing_subscriber::registry()
    .with(EnvFilter::from_default_env())
    .with(tracing_subscriber::fmt::layer())
    .with(OpenTelemetryLayer::new(provider.tracer("my-service")))
    .init();

你的 #[instrument]info!()error!() 不用改一行代码,Span 自动导出到 Jaeger/Grafana Tempo。

坑:OTel 的依赖树很重。 opentelemetry-otlp 默认拉 grpc-tonic(gRPC),如果你只需要 HTTP 导出,记得 default-features = false,只开 http-proto。不然编译时间多 30 秒都是轻的。

跨服务传播 Context

链路追踪的核心是 Context Propagation——把 trace_id 通过 HTTP 请求头传给下游服务,让所有 Span 挂在同一个 Trace 下。

axum 的 tower-http 提供了 TraceLayer + PropagateRequestIdLayer,两行代码搞定:

Router::new()
    .route("/api/users/{id}", get(get_user))
    .layer(TraceLayer::new_for_http())  // 自动为每个请求创建 Span
    .layer(PropagateRequestIdLayer::x_request_id())  // 自动注入/提取 x-request-id

TraceLayer 自动为每个 HTTP 请求创建 Span,记录 method、uri、status_code。PropagateRequestIdLayer 自动在请求头里注入/提取 x-request-id

下游服务用 reqwest + opentelemetry-http 的 propagator 自动提取 traceparent header,Span 就串起来了。

坑:TraceLayer 默认不记录请求体和响应体。 这是好事——记录请求体会严重影响性能,而且可能泄露敏感数据。如果你确实需要,自己在 handler 里用 debug!() 记录关键字段。


生产环境完整配置

把上面所有东西串起来,一个 axum 微服务的完整可观测性配置只需要:

  1. 初始化init_tracing() 返回 SdkTracerProvider(前面已经给过代码)
  2. 路由层:加上 TraceLayer + PropagateRequestIdLayer
  3. 优雅关闭provider.shutdown() flush 遥测数据
#[tokio::main]
async fn main() {
    let provider = init_tracing();  // 前面给过的代码,加个 .json() 就行
 
    let app = Router::new()
        .route("/health", get(|| async { "ok" }))
        .route("/api/users/{id}", get(get_user))
        .layer(TraceLayer::new_for_http())
        .layer(PropagateRequestIdLayer::x_request_id());
 
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
 
    axum::serve(listener, app)
        .with_graceful_shutdown(shutdown_signal())
        .await
        .unwrap();
 
    provider.shutdown().expect("failed to flush traces");
}

这十几行代码给你: 结构化 JSON 日志、链路追踪导出到 OTLP、每个 HTTP 请求自动创建 Span、Request ID 自动传播、环境变量控制日志级别、优雅关闭时 flush 遥测数据。

坑:别忘了 provider.shutdown() 不 flush 就退出进程,最后一批遥测数据就丢了。我有一次排查线上问题,发现 Jaeger 里总是缺最后几秒的 Span,就是这个原因。


OTel 的"坑"——升级前请看

OpenTelemetry Rust 的 API 一直在演进,每个 minor 版本都可能有 breaking changes。 如果你从旧版本升级,大概率会遇到编译错误。

常见的变更模式

旧 API 新 API 备注
TracerProvider SdkTracerProvider 重命名
LoggerProvider SdkLoggerProvider 重命名
MeterProvider SdkMeterProvider 重命名
Resource::new(...) Resource::builder().build() Builder 模式
PeriodicReader::builder(exporter, runtime::Tokio) PeriodicReader::builder(exporter) 不再需要传 runtime
OTLP 默认 gRPC OTLP 默认 HTTP 不装 grpc-tonic

好消息:processor 不再依赖异步运行时了。 以前必须传 runtime::Tokio,现在用自带的后台线程。这解决了一个长期痛点——不是所有项目都用 Tokio。

教训:升级 OTel 之前,一定看 migration guide。锁死版本,测试后再升。 这个库的演进速度很快,API 不稳定是常态,不是你的问题。


错误上下文传播:SpanTrace

光有日志和链路追踪还不够。当错误发生时,你想知道的不只是"哪里报错了",还想知道"报错时程序在做什么上下文"。

tracing-error crate 的 SpanTrace 可以在错误被捕获时自动记录当前的 Span 链路。用法很简单:在你的错误类型里加一个 SpanTrace 字段,构造时调用 SpanTrace::capture() 就行。

错误信息输出效果:

database connection failed

Span trace:
HTTP request (method=POST uri=/users)
  └── create_user (user_id=42)
       └── db::query (query="INSERT INTO users ...")

一眼就能看到:这个错误是在处理 POST /users 请求时,创建用户 42 时,执行数据库插入时发生的。

教训:SpanTraceanyhow::backtrace 有用得多。backtrace 给你的是函数调用栈,SpanTrace 给你的是业务上下文。 你不需要知道是哪个函数调了哪个函数,你需要知道"这个请求在做什么"。


可观测性架构全景

最后画一张图,把所有组件串起来:

┌─────────────────────────────────────────────────────┐
│                    你的 Rust 服务                     │
│                                                     │
│  ┌──────────┐  ┌──────────┐  ┌──────────────────┐   │
│  │ tracing  │  │ metrics  │  │ tracing-error    │   │
│  │(Events + │  │(Counter/ │  │ (SpanTrace)      │   │
│  │ Spans)   │  │Histogram)│  │                  │   │
│  └────┬─────┘  └────┬─────┘  └────────┬─────────┘   │
│       │              │                 │             │
│  ┌────┴──────────────┴─────────────────┴──────────┐  │
│  │         tracing-subscriber (Layers)            │  │
│  │  ┌─────────┐ ┌──────────┐ ┌─────────────────┐  │  │
│  │  │fmt Layer│ │OTel Layer│ │metrics-tracing  │  │  │
│  │  │ (JSON)  │ │          │ │  -context       │  │  │
│  │  └────┬────┘ └────┬─────┘ └───────┬─────────┘  │  │
│  └───────┼───────────┼───────────────┼────────────┘  │
│          │           │               │               │
└──────────┼───────────┼───────────────┼───────────────┘
           │           │               │
     ┌─────┴───┐  ┌────┴────┐   ┌─────┴──────┐
     │  stdout │  │ OTLP    │   │ Prometheus │
     │ (日志)  │  │Exporter │   │  /metrics  │
     └─────────┘  └────┬────┘   └────────────┘
                       │
              ┌────────┴────────┐
              │  OTel Collector  │
              │  (or 直连)       │
              └───┬────────┬────┘
                  │        │
           ┌──────┴──┐ ┌───┴──────────┐
           │  Jaeger │ │Grafana Tempo │
           │(链路追踪)│ │ (链路追踪)    │
           └─────────┘ └──────────────┘

数据流:

  • 日志 → stdout → 你的日志系统(Loki/ELK/CloudWatch)
  • 链路追踪 → OTLP → OTel Collector → Jaeger/Grafana Tempo
  • 指标 → Prometheus scrape → Grafana

三条管线,一个 subscriber,零侵入。


总结

你要做什么 用什么 一句话
结构化日志 tracing + tracing-subscriber 替代所有 println!
函数级追踪 #[instrument] 一行代码,自动创建 Span
日志级别控制 EnvFilter + max_level_info 运行时灵活 + 编译时零成本
Prometheus 指标 metrics + metrics-exporter-prometheus 最轻量的方案
链路追踪 tracing-opentelemetry + opentelemetry-otlp 三层代码导出到 OTLP
错误上下文 tracing-error + SpanTrace 比 backtrace 有用 10 倍
生产配置 axum + TraceLayer + provider.shutdown() 十几行搞定

可观测性不是"锦上添花",而是"雪中送炭"。你的服务出了问题,能不能在 5 分钟内定位到原因,就取决于你今天花了多少时间在这上面。

Rust 的 tracing 生态给了你一个编译时零成本的工具。不用白不用。