Rust 后端可观测性:用 Tracing 让服务不再“黑盒”
前几篇我们把 Rust 后端从内存服务做到了持久化、认证和权限控制。但真正部署上线后,还有一个很容易被忽视的问题:服务出问题时,你能多快定位?
日志、链路追踪、指标,这些听起来像运维的事,但开发阶段就应该考虑。Rust 生态里最成熟的方案是 tracing:它不只是“打印日志”,而是把事件、上下文、异步任务链路串在一起,让异步服务也能被看清楚。
为什么 Rust 后端需要 tracing?
Rust 异步服务通常是多任务并发的。一个请求进来,可能会经过路由、认证、数据库查询、外部 HTTP 调用、缓存命中判断等多个环节。如果只用 println! 或简单日志,很容易遇到这些问题:
- 多个请求日志混在一起,分不清哪条属于哪个请求
- 异步任务切换后,上下文丢失
- 错误只打印了
Err(...),但不知道触发路径 - 线上问题只能靠猜,缺少结构化信息
tracing 的优势在于:它天然适配异步 Rust,每个 Span 都可以携带上下文,请求 ID、用户 ID、数据库耗时、错误原因都能被结构化地记录下来。
安装依赖
在之前的 todo_service 里新增:
cargo add tracing tracing-subscriber tracing-appender
cargo add tower-http --features trace
tracing 负责事件和 Span,tracing-subscriber 负责输出格式,tracing-appender 支持日志文件滚动,tower-http 可以把 HTTP 请求日志自动接入 tracing。
第一步:初始化 Subscriber
在 main 函数里初始化日志系统:
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
fn init_tracing() {
let fmt_layer = tracing_subscriber::fmt::layer()
.with_target(true)
.with_thread_ids(true)
.with_file(true)
.with_line_number(true);
tracing_subscriber::registry()
.with(fmt_layer)
.init();
}
然后在 main 开头调用:
#[tokio::main]
async fn main() {
init_tracing();
// 初始化数据库、路由、启动服务
}
这样所有 tracing::info!、tracing::warn!、tracing::error! 都会被正确输出。
第二步:记录结构化日志
不要只写:
println!("login failed");
可以改成:
tracing::info!(
username = %payload.username,
ip = %client_ip,
"user login attempt"
);
登录失败时:
tracing::warn!(
username = %payload.username,
reason = "invalid password",
"login failed"
);
这样日志不再是纯文本,而是带有字段的结构化事件。后续如果接入 JSON 输出、日志平台或告警系统,会方便很多。
第三步:用 Span 包裹请求
tracing 最强大的地方是 Span。它可以把一组相关事件归到同一个上下文中。
use tracing::Instrument;
async fn list_todos(
State(state): State<AppState>,
auth: AuthUser,
) -> Result<Json<Vec<Todo>>, AppError> {
let span = tracing::info_span!(
"list_todos",
user_id = auth.user_id
);
list_todos_inner(state, auth)
.instrument(span)
.await
}
在内部函数里,你可以继续记录子事件:
async fn list_todos_inner(
state: AppState,
auth: AuthUser,
) -> Result<Json<Vec<Todo>>, AppError> {
tracing::debug!("querying todos from database");
let todos = sqlx::query_as!(
Todo,
"SELECT id, title, completed FROM todos WHERE user_id = $1 ORDER BY id DESC",
auth.user_id
)
.fetch_all(&*state.db)
.await?;
tracing::info!(count = todos.len(), "todos fetched");
Ok(Json(todos))
}
这样日志输出时,querying todos 和 todos fetched 都会挂在同一个 list_todos Span 下。排查问题时,你能清楚看到一个请求经历了哪些步骤。
第四步:自动记录 HTTP 请求
手动打日志当然可以,但每个 handler 都写一遍会很累。tower-http 提供了现成的 Trace 层:
use tower_http::trace::TraceLayer;
let app = Router::new()
.route("/register", post(register))
.route("/login", post(login))
.route("/todos", get(list_todos))
.layer(TraceLayer::new_for_http())
.with_state(state);
它会自动记录:
- 请求方法、路径
- 响应状态码
- 请求耗时
- 错误信息
如果想进一步记录请求 ID,可以结合 tower-http 的 RequestId 层,把 ID 注入 tracing 上下文。这样每个请求日志都会带上同一个 request_id,多行日志也能串起来。
第五步:日志分级要克制
tracing 支持不同级别:
trace:最细粒度,通常用于调试debug:开发阶段查看流程info:关键业务事件,比如登录、创建资源warn:可疑情况,比如密码错误、参数异常error:明确失败,比如数据库连接断开
生产环境通常只输出 info 及以上,避免日志过多。开发阶段可以开到 debug,方便看流程。
可以通过环境变量控制:
RUST_LOG=todo_service=debug,tower_http=info cargo run
第六步:日志落盘和滚动
线上服务不能只依赖控制台输出。可以用 tracing-appender 把日志写入文件:
use tracing_appender::rolling;
let file_appender = rolling::daily("./logs", "todo_service.log");
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
let file_layer = tracing_subscriber::fmt::layer()
.with_writer(non_blocking)
.with_ansi(false);
这样每天会生成一个新的日志文件,避免单个文件无限增长。_guard 需要保持存活,否则日志可能丢失。
真实项目里建议这样接
一个比较实用的落地顺序是:
- 先接
tracing-subscriber,把println!替换掉 - 给关键接口加上
info_span - 接入
tower-httpTraceLayer,自动记录 HTTP 请求 - 给登录、注册、创建资源等业务动作打
info日志 - 给失败路径打
warn或error - 日志落盘,并设置合理的滚动策略
- 后续再考虑接入 OpenTelemetry、Prometheus、Jaeger 等系统
Rust 后端开发到这一步,会明显感觉服务从“能跑”变成了“可维护”。尤其是异步服务,请求并发上来之后,没有结构化日志和 Span 上下文,排查问题会非常痛苦。tracing 的好处是它不要求你一开始就搭建复杂的监控系统,哪怕只是把日志打清楚,也能让调试和运维轻松很多。
- 点赞
- 收藏
- 关注作者
评论(0)