
我印象最深的一次是一个并发2000的查询接口用Rust异步API配合Axum框架写的服务P99稳定在30ms以内换成同样的业务逻辑旧服务已经飙到300ms。这不是玄学是异步调度、零拷贝和类型系统一起给的结果。但想拿到这些收益你需要先把Rust的异步模型、所有权规则和框架的设计哲学都搞清楚。这篇东西适合两类人一是有Go、Node或Python异步编程经验、想试试Rust写后端API的人二是已经能跑通Rust基础语法但一写异步就各种编译报错、不知道怎么组织工程结构的人。我会从环境搭建、异步模型、Axum核心概念、错误处理、中间件、测试部署这条完整链路走一遍把我实际踩过的坑和验证过的方案都放进来。1. 为什么用Rust写异步API先把异步模型想清楚1.1 从同步到 async/await 的思维方式切换许多从 C 或 Go 转过来的朋友第一次看到 Rust 的 async 函数时会下意识地把它理解成“一个会阻塞线程的函数”。这是最大的误区。Rust 的async fn并不直接执行它返回的是一个Future一个惰性的状态机。只有当你把这它交给运行时去poll里面的代码才开始真正推进。你可以把Future理解成一张“待办清单”每次被调度器轮询时它就往下执行一段直到遇到一个还没准备好的等待点比如网络 IO然后主动让出控制权。过一会儿 IO 准备好了运行时再回来继续推进这个状态机。因为中途是协作式让出的而不是靠操作系统抢占式切换线程所以一个线程上能挂几万个并发任务每个任务的切换成本远低于线程切换。这也解释了为什么 Rust 异步 API 对Send和生命周期这么敏感状态机要把所有局部变量都保存在自己内部而它可能会被运行时在不同线程间移动。如果你在一个async块里借用了某个Rc或者没有static生命周期的东西编译器立刻就会拦下你。这恰恰是 Rust 在异步领域最值钱的地方——大部分并发 bug 在编译期就暴露了而不是等到线上偶发 panic。1.2 tokio 运行时为什么它决定了API的调度模型Rust 本身不内置异步运行时生态里最主流的是 tokio。Axum 也是构建在 tokio 之上的选择了#[tokio::main]就相当于给你的程序装了一个多线程调度器。tokio 默认是多线程运行时它会创建与 CPU 核心数相当的 worker 线程每个 worker 维护自己的任务队列和 IO 驱动。当一个 Future 在某个 worker 上被 poll 到阻塞点IO 事件会通过 epoll/kqueue 注册到 IO 驱动上事件就绪后驱动再把对应的任务唤醒重新投递到某个 worker 的任务队列中。这个过程对应用层不可见你只需要保证Future满足Send static就可以放心地让它跨线程调度。有一个容易忽略的点如果你的 handler 里有 CPU 密集型的计算比如图像处理、大矩阵运算建议显式用tokio::task::spawn_blocking丢到阻塞线程池去执行否则它会长时间独占一个异步 worker导致同线程上的其他任务全部饿死。我见过一个服务因为把bcrypt哈希直接放在异步 handler 里算压测时吞吐直接掉了 70%。这不是 Axum 的问题是调度模型的使用问题。1.3 为什么最终选了 Axum 而不是 Actix-web 或 Rocket我想先给结论如果团队已经决定拥抱 tokio 生态Axum 是在类型安全、生态整合和维护成本之间最均衡的选择。维度AxumActix-webRocket底层运行时tokio自研 Actor 框架tokio提取器机制强类型 trait编译期保证也有但基于 HttpRequest 运行时提取基于 FromRequest但版本变更大中间件生态基于 tower天然契合有中间件但生态独立有但相对小众状态管理通过State提取器注入web::Data托管状态路由 DSLRouter::new().route(path, get(handler))简单直接宏和默认配置较多宏为主Axum 的Router和MethodRouter都实现了Clone你可以很容易地把路由拆成多个子模块再合并。这在项目规模变大时非常舒服。Actix-web 的 Actor 模型虽然性能同样顶级但它对“请求处理”这件事的抽象比 Axum 更重写起来心智负担高一点。Rocket 优雅但版本演进激进升级成本通常不低。如果你只是写一个内部小工具三者随便选如果我要做一个长期维护、团队多人协作的业务 APIAxum 是现在最稳的选择。2. 开发环境准备从装好Rust到跑通Axum的完整链路2.1 rustup 安装与国内源配置安装加速安装 Rust 官方推荐用 rustup而不是直接下载某个版本的编译器因为 rustup 能管理工具链、交叉编译目标、组件的切换。Windows 上最简单的办法是下载rustup-init.exe运行Linux/macOS 用curl --proto https --tlsv1.2 -sSf https://sh.rustup.rs | sh。如果不做任何配置国内网络下载工具链和 crates 依赖经常会慢到让人怀疑人生。我实测下来最有效的做法是配置两个环境变量指向国内镜像export RUSTUP_DIST_SERVERhttps://rsproxy.cn export RUSTUP_UPDATE_ROOThttps://rsproxy.cn/rustupRUSTUP_DIST_SERVER是 rustup 下载工具链的主站镜像RUSTUP_UPDATE_ROOT是更新元数据的镜像。这两个都改了安装 nightly、切换版本、添加目标组件都能走镜像。安装完成后记得检查一下cargo -V和rustc -V是否正常。如果你之前装过旧版本rustup update会升级到当前稳定版。2.2 Cargo 镜像与依赖下载加速工具链装好了crates.io 的依赖下载也要走镜像。在$CARGO_HOME默认是~/.cargo下创建config.toml[source.crates-io] replace-with rsproxy-sparse [source.rsproxy-sparse] registry sparsehttps://rsproxy.cn/index/注意sparse前缀这是 Cargo 新版的稀疏索引协议比原来的 git 索引快非常多。HTTP 库、异步运行时、框架这种重依赖第一次拉取可能涉及上百个 crate用镜像后速度会有质的提升。Windows 上如果提示缺少链接器需要装 Visual Studio Build Tools 里的“使用 C 的桌面开发”工作负载这个没法完全绕开因为 Rust 的 MSVC 工具链最后需要link.exe来完成原生链接。还有一个小技巧在config.toml里加一个[build] jobs 8把编译并行度调高一点多核机器上编译速度能快不少。2.3 编辑器与常用工具链rust-analyzer、nextest、sccache写 Rust 强烈推荐 VSCode rust-analyzer 插件。rust-analyzer 提供补全、跳转、类型标注、错误检查而且它的错误提示速度比cargo check更快因为它用了独立的增量分析机制。对于测试cargo nextest是一个很好的补充。Rust 自带的cargo test在单线程进程里跑nextest会为每个测试生成独立进程并并行调度测试数量多了以后体验好很多。编译缓存工具sccache也值得装一份。它会把编译产物缓存到本地或 S3适合多个项目共享某份依赖编译结果。配置很简单cargo install sccache echo export RUSTC_WRAPPERsccache ~/.bashrc之后cargo build在第二次构建时会明显加速。这个工具在 CI 里价值更大能省不少编译时间。3. 从零实现一个Axum服务路由、状态与提取器的落地细节3.1 最小可运行服务Cargo.toml 与 main.rs创建一个新项目cargo new axum-demo cd axum-demo在Cargo.toml里添加依赖[dependencies] axum 0.7 tokio { version 1.38, features [full] } serde { version 1.0, features [derive] } serde_json 1.0 tower-http { version 0.6, features [trace, cors] } tracing 0.1 tracing-subscriber 0.3 anyhow 1.0主程序是一个最小的 HTTP 服务use axum::{routing::get, Router}; #[tokio::main] async fn main() { let app Router::new() .route(/, get(|| async { Hello, Axum! })); let listener tokio::net::TcpListener::bind(0.0.0.0:3000).await.unwrap(); axum::serve(listener, app).await.unwrap(); }跑起来之后访问http://localhost:3000/你会看到Hello, Axum!。这里有三个关键点#[tokio::main]宏把我们的main函数包装成了 tokio 运行时入口Router::new().route()建立路由表axum::serve接收 listener 和 app 启动 HTTP 服务。3.2 路由与请求处理路径参数、查询参数、JSON提取器Axum 的提取器机制是它最核心的设计之一。handler 函数的参数可以从请求的各个部分提取且提取过程是编译期检查的顺序错误、类型错误都会直接编译失败。路径参数通过Path提取器获取use axum::extract::Path; async fn getUser(Path(id): Pathu64) - String { format!(user id {}, id) } let app Router::new() .route(/users/{id}, get(getUser));这里路径上的{id}和提取器里的Pathu64是对应关系。Axum 0.7 之后路径参数语法从:id改成了{id}如果你看旧教程会看到:id千万别混用。查询参数用Query提取器需要配合 serde 的Deserializeuse axum::extract::Query; use serde::Deserialize; #[derive(Deserialize)] struct ListParams { page: Optionu32, page_size: Optionu32, } async fn list_users(Query(params): QueryListParams) - String { format!(page{:?}, page_size{:?}, params.page, params.page_size) }JSON body 用Json提取器use axum::Json; use serde::{Deserialize, Serialize}; #[derive(Deserialize)] struct CreateUser { name: String, email: String, } #[derive(Serialize)] struct User { id: u64, name: String, email: String, } async fn create_user(Json(payload): JsonCreateUser) - JsonUser { Json(User { id: 1, name: payload.name, email: payload.email, }) }最后一个关键点提取器的顺序有讲究。路径参数、查询参数这些无歧义的提取器可以任意顺序出现但State提取器或者多个Json这种会消费 body 的提取器理论上只能出现在 handler 参数列表的所有需要提取请求体的参数之后。这个限制是合理的请求 body 只能被消费一次。3.3 状态共享Arc 与依赖注入是怎么回事几乎每个 API 服务都需要访问数据库连接池、缓存客户端、配置对象。Axum 里最常用的方式是用State提取器。use std::sync::Arc; use axum::{extract::State, routing::get, Router}; #[derive(Clone)] struct AppState { db_pool: ArcMyDbPool, // 你的连接池对象 config: ArcAppConfig, } async fn health(State(state): StateAppState) - String { format!(db ok, config version {}, state.config.version) } async fn main() { let shared_state AppState { db_pool: Arc::new(MyDbPool::new()), config: Arc::new(AppConfig { version: 1.0.into() }), }; let app Router::new() .route(/health, get(health)) .with_state(shared_state); }这里有个特别容易误解的点为什么AppState要Clone因为Router的with_state会把状态保存起来每个 handler 在调用时通过State提取器拿到的其实是这个状态的克隆。如果状态里全是Arc那 Clone 的成本就是引用计数加一非常廉价。所以状态字段里尽量都放ArcT而不是在每个 handler 里手动创建连接。对比一下 Go 的gin.Context里放全局对象的方式Axum 的State更像是显式的依赖注入handler 需要什么就声明什么不需要的完全解耦。这也让测试变得更容易后面我会专门讲。4. 错误处理与中间件让异步API具备生产级健壮性4.1 从 anyhow 到自定义 Error错误响应的类型化设计新手很容易写出handler - ResultT, anyhow::Error但anyhow::Error不能直接转换成 HTTP 响应Axum 要求错误类型必须实现IntoResponse。直接这么做会产生一个 500 错误但 body 里的信息对调用方完全没意义。工程上正确的做法是定义自己的错误枚举每一个变体对应一种 HTTP 状态和错误信息use axum::{ http::StatusCode, response::{IntoResponse, Response}, Json, }; use serde_json::json; #[derive(Debug)] enum ApiError { BadRequest(String), NotFound(String), Internal(anyhow::Error), } impl IntoResponse for ApiError { fn into_response(self) - Response { let (status, message) match self { ApiError::BadRequest(msg) (StatusCode::BAD_REQUEST, msg), ApiError::NotFound(msg) (StatusCode::NOT_FOUND, msg), ApiError::Internal(err) { tracing::error!(internal error: {:?}, err); (StatusCode::INTERNAL_SERVER_ERROR, internal server error.into()) } }; (status, Json(json!({ error: message }))).into_response() } } pub type ApiResultT ResultT, ApiError;所有 handler 统一返回ApiResultT调用方收到的响应结构就是一致的。Internal变体里用anyhow::Error包装原始错误是为了在日志里保留完整的错误上下文同时不让内部细节泄漏给客户端。有几个错误需要单独注意数据库连接超时、上游 HTTP 调用失败、反序列化失败。这些场景要在错误枚举里显式设计而不是笼统地全部归为Internal。一个经验是任何可能因为输入数据导致失败的错误都应该用BadRequest系列否则客户端很难定位问题。对比一下其他框架Express 里用中间件捕获异常再包装Go 里是if err ! nil层层返回。Axum 的这种方式更接近 Rust 的哲学把错误变成类型让编译器帮你检查有没有遗漏。4.2 中间件体系tower 中间件的构成Axum 的中间件体系是构建在 tower 之上的。tower 的Servicetrait 是异步请求处理的统一抽象中间件本质上是一种包装它接收一个内部 Service在它前后执行自定义逻辑。Axum 0.7 推荐用middleware::from_fn写简单的自定义中间件use axum::{middleware, Router}; async fn auth_middleware( req: axum::extract::Request, next: axum::middleware::Next, ) - axum::response::Response { let token req.headers() .get(Authorization) .and_then(|v| v.to_str().ok()); match token { Some(t) if t.starts_with(Bearer ) next.run(req).await, _ axum::response::Response::builder() .status(axum::http::StatusCode::UNAUTHORIZED) .body(axum::body::Body::empty()) .unwrap(), } } let app Router::new() .route(/protected, get(protected_handler)) .layer(middleware::from_fn(auth_middleware));这种写法非常适合鉴权、请求 ID 注入、简单流控等场景逻辑直观不用深入理解 tower 的底层抽象。当然如果需要更精细的控制还是要自己实现Servicetrait但 90% 的业务场景用from_fn就够了。4.3 超时、重试与请求日志三个最常用的中间件生产环境里我最先会上的是三个 tower-http 中间件TraceLayer记录每个请求的方法、路径、状态码、耗时。排查问题没有日志就像闭着眼开车。TimeoutLayer给整个请求处理设置总超时时间防止某些上游调用卡死导致连接一直挂着。ConcurrencyLimitLayer限制同时处理的请求数防止单机突发流量把自己打爆。use tower_http::{ trace::TraceLayer, limit::ConcurrencyLimitLayer, timeout::TimeoutLayer, }; let app Router::new() .route(/health, get(health)) .route(/users, get(list_users)) .layer(TimeoutLayer::new(std::time::Duration::from_secs(10))) .layer(ConcurrencyLimitLayer::new(1024)) .layer(TraceLayer::new_for_http());注意.layer的顺序会影响中间件的嵌套关系后加的 layer 会更靠近业务 handler。我的习惯是最外层放 Trace因为它要记录完整请求链路中间放 Timeout最内层放 ConcurrencyLimit。不过这个顺序也取决于业务逻辑没有绝对标准。TraceLayer 默认会用tracing输出日志所以在main函数里要初始化tracing_subscriber::fmt() .with_env_filter(axum_demodebug,tower_httpdebug) .init();tower_httpdebug这一条很关键不加上它TraceLayer 的请求日志不会打印。5. 异步API的常见坑与性能调优从能跑到跑好5.1 生命周期与 Send/Sync异步中所有权模型最容易踩的坑很多人第一次写 async block 时遇到的报错都跟生命周期和Send有关。最常见的情况是用std::sync::Mutex保护共享数据然后在异步代码里锁。std::sync::MutexGuard不是Send的在.await点持有它就会让整个 Future 变成非 Send最终编译失败。我建议优先使用tokio::sync::Mutex但要知道它也有坑如果在持有锁的时候执行.await会让其他等待锁的任务排队。更好的做法是在异步代码里尽量缩小锁的范围或者把需要跨.await的数据复制出来用不可变引用读取而不是长时间占用可变锁。另外一个高频报错是error: future cannot be sent between threads safely这个报错通常出现在tokio::spawn一个 Future 的时候。spawn要求传入的 Future 是Send static如果你的 Future 里借用了栈上的引用或者捕获了非 Send 的变量就会报这个错。解决办法是把借用改成Arc或者把static限定满足掉。5.2 连接池与资源管理数据库连接不能每请求新建新手最容易犯的错误之一就是每个请求都新建数据库连接。在同步编程里这样写虽慢但还能跑在异步环境里这会触发两次.await一次建立连接、一次查询期间连接对象如果没被正确持有还会引发生命周期问题。正确的做法是用连接池。我常用sqlx的PgPool或MySqlPool它们是Clone Send Sync的可以放进AppState里共享。连接池本质上是预建了一组连接请求到来时快速取出一个用完归还。use sqlx::postgres::{PgPool, PgPoolOptions}; let pool PgPoolOptions::new() .max_connections(20) .acquire_timeout(std::time::Duration::from_secs(3)) .connect(postgres://user:passlocalhost/db) .await?;acquire_timeout特别重要如果连接池被耗尽新请求会一直等待。没有超时的话慢查询一多整个 API 的响应时间就会同步拉高。这里把max_connections设置为核心数的 2 到 4 倍是一个保守但稳妥的起点。5.3 性能调优与编译优化sccache、release profile、内存观察性能优化首先要看测量不是猜。Rust 里常用criterion做 benchmarktracing加上耗时日志看线上表现。我一般先用tokio-console观察异步任务的调度情况看有没有任务长时间不 poll、线程饥饿等问题。编译优化方面Cargo.toml 的 release profile 也能做文章[profile.release] lto true codegen-units 1 opt-level 3lto true启用链接时优化codegen-units 1让编译器把 crate 当作一个整体优化。这两项能把运行时性能提升几个百分点代价是编译时间明显变长。本地调试用默认 dev profileCI 或发布才开这两项。再提一次sccache在多人协作的 CI 里加上它通常能把编译时间压缩到三分之一以下。6. 测试、调试与部署把异步API发布到生产环境6.1 对 handler 做集成测试tower::ServiceExt 的妙用Axum 的Router本身实现了 tower 的Service这意味着你不需要启动真实端口就能在测试里直接调用它。配合tower::ServiceExt::oneshot我们可以写出非常干净的集成测试。先在Cargo.toml的 dev-dependencies 里添加[dev-dependencies] tower { version 0.5, features [util] } http-body-util 0.1测试代码长这样use axum::{body::Body, http::{Request, StatusCode}, Router}; use tower::ServiceExt; async fn app() - Router { Router::new() .route(/hello, get(|| async { world })) } #[tokio::test] async fn test_hello() { let app app().await; let response app .oneshot(Request::builder().uri(/hello).body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); }oneshot会直接调用 Service不需要经过真实的 TCP 连接测试速度快且稳定。如果你需要测试带状态的 handler就把AppState构建好然后with_state之后再oneshot。这样每个测试都独立构造状态互不干扰。6.2 日志链路与问题定位tracing 在异步环境的价值API 服务出了线上问题最痛苦的就是日志链路线索断裂。单一请求可能经过网关、API、数据库、上游服务异步环境里任务的调度顺序又不是固定的如果没有请求 ID查问题基本靠猜。我习惯在自定义中间件里给每个请求分配一个 UUID注入到请求扩展和日志上下文中use tracing::span; use uuid::Uuid; async fn request_id_mw(req: Request, next: Next) - Response { let id Uuid::new_v4().to_string(); req.extensions_mut().insert(RequestId(id.clone())); let span span!(tracing::Level::INFO, request, request_id %id); let _enter span.enter(); next.run(req).await }配合TraceLayer和高等级的 tracing 日志你基本上能把一次请求的完整处理路径梳理出来。生产环境里 tracing 的fmt输出可以接入日志采集系统格式是纯文本做结构化解析也不难。6.3 部署方案Docker 镜像与常见问题Rust 编译出来的二进制是静态链接的理论上可以扔到任何 Linux 机器上运行但实际部署时我仍然推荐用 Docker 多阶段构建保证运行环境一致且镜像最小。FROM rust:1.80-slim AS builder WORKDIR /app COPY . . RUN cargo build --release FROM debian:bookworm-slim WORKDIR /app COPY --frombuilder /app/target/release/axum-demo . ENV RUST_LOGinfo EXPOSE 3000 CMD [./axum-demo]有几个常见问题需要提醒一下。第一如果服务里用到openssl等 C 库最终运行镜像也要装对应的.so否则启动直接报error while loading shared libraries。第二RUST_LOG环境变量要显式设置否则 tracing 不打印日志出了问题根本无从下手。第三容器里默认的listen地址一定要监听0.0.0.0不是127.0.0.1否则容器外访问不到。7. 我踩过的几个经典坑从编译错误到线上事故这一节不是理论都是我真实遇到过的场景。第一个坑是在 handler 里调用一个返回Result(), std::io::Error的函数然后妄图直接?返回给 Axum。编译器报错说std::io::Error没有实现IntoResponse我当时的反应是“哦又要写错误转换了”但直到我真正设计了自己的ApiError枚举之后才意识到错误处理是整个 API 的契约值得花时间认真设计。第二个坑是在tokio::spawn里使用了ArcMutexVecUser从多个任务并发写入。虽然Mutex保证了数据不会撕裂但锁竞争非常严重压测一上 QPS 立刻下降。后来改成tokio::sync::RwLock再后来干脆用 channel 把所有写操作串行化才彻底解决。第三个坑是连接池配置问题。线上出现过一次偶发性的 5xx 风暴排查到最后发现是数据库连接池被慢查询占满了新请求 acquire 超时直接失败。从那以后我处理的每个 API 服务都会严格配置acquire_timeout和max_connections还会加一个监控指标观察连接池的等待时间。这类问题在开发环境永远模拟不出来必须从架构层面预防。