ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Rust后端框架选型实战:Actix与Axum全方位对比与决策指南

Rust后端框架选型实战:Actix与Axum全方位对比与决策指南

我这两年给团队选 Rust 后端框架,几乎每次都会被同一个问题拦住:到底用 actix 还是 axum?问的人多半已经被各种压测榜单和口号绕晕了——今天看某个排名说 actix 是地表最强,明天又看到一篇文章说 axum 才是 tokio 官方正统。作为一个两个框架都写过生产代码、也各自踩过坑的人,我可以直接告诉你:在绝大多数业务场景里,它们的性能差距你根本感受不到,真正决定体验的是设计理念、中间件生态、WebSocket 写法、错误处理范式,以及它们对团队协作方式的潜移默化。这篇文章就把我对 actix 和 axum 从选型、迁移到线上维护的完整经验拆开讲,从出身、路由、中间件聊到 WebSocket 和错误处理,最后给出具体的选型建议。

1. 两个框架的出身完全不同,理解它们才能理解所有差异

1.1 Actix:一个把性能写进基因的老牌选手

Actix 系列最初源自 actor 模型框架,Web 部分是在这套理念上长出来的。它的 HTTP 层并不是直接复用某个现成实现,而是自己维护了一套完整的请求解析、连接管理和 worker 调度逻辑,所以你在看 actix-web 源码时能明显感觉到"这是个自成体系的项目"。它早年连续霸占各类 Web 框架基准测试榜首,"性能神话"是它最亮眼的标签,也因此吸引了一大批对吞吐量极其敏感的用户。

这也直接塑造了它的气质:功能尽量内置,配置尽量集中,开箱就跑得很快。比如你新建一个HttpServer,默认就按 CPU 核数拉起 worker,每个 worker 有独立的事件循环,请求分散到多 worker 处理的模型非常清晰。对刚接触 Rust Web 开发的人来说,actix 提供的是一套"框架感"很强的 API——你按照它的规则组织App、service、middleware就行,它帮你把服务端该有的东西基本都配齐了。

1.2 Axum:tokio 官方生态里的"集成商"

Axum 登场晚得多,2021 年才出现,它的定位从一开始就很明确:不重复造轮子。底层用 tokio 做异步运行时,用 hyper 做 HTTP 实现,中间件体系直接建立在 tower 的Service/Layer抽象上,在显式持有hyper、tower-http、http-body这些 crate 的组合拳下,Axum 更像是一个"路由和提取器层的组装方案"。

这种做法的好处是生态复用率极高。你在 tower 生态里学到的Layer写法,换个项目还能用;你在其他基于 hyper 的框架里写的服务,概念的迁移成本也很低。坏处则是概念链路变长:新手经常需要同时弄懂Router、Service、Layer、Handler、Body之间的关系,不像 actix 那样"跟着框架走就行"。Axum 的设计哲学简单说是这样:框架只提供最小但完整的原语,其他能力交给配套生态。

1.3 出身带来的气质差异

这两条不同路线,最后呈现给开发者的感受差异非常大。用一个生活化的类比:actix 像一套精装修交付的房子,开发商帮你配好了厨卫、管线、门窗,你拎包入住;axum 像一套户型极佳但只做了基础硬装的房子,给了你高质量的水电走线和板材,至于用哪个牌子、怎么组合,你可以自由选,但前提是你得懂点装修。

我自己的体会是,这种气质差异会一路渗透到后面的每一个决定:写中间件、处理 WebSocket 长连接、给请求加超时、做自定义错误返回……每个环节两个框架都会给你不同的"标准答案"。这也是为什么我强烈建议大家在真正选型前,把这两个框架的核心理念先看清楚,而不是只看谁的 demo 跑得快。

2. 路由、State 与提取器:日常开发接触最多的部分

2.1 路由写法:从:id到{id}的历史账

路由是每个项目第一天就要写的代码,所以我把对比重点放在这里。actix-web 的路径参数一直使用{id}这种花括号语法,例如/user/{id};Axum 在 0.7 及以前的版本用的是/:id冒号语法,到了 0.8 大版本才改成和 actix 一致的/{id}。

也就是说,如果你在网上看到大量 Axum 教程里写route("/users/:id"),那很可能是 0.7 时代的旧写法,在 0.8 上编译会直接报错。这一点是很多人从旧教程学习时踩到的最大的暗坑。

先看 actix-web 4 的典型写法:

use actix_web::{get, web, App, HttpResponse, HttpServer, Responder}; #[get("/user/{id}")] async fn get_user(id: web::Path<i32>) -> impl Responder { HttpResponse::Ok().body(format!("user id = {}", id.into_inner())) } #[actix_web::main] async fn main() -> std::io::Result<()> { HttpServer::new(|| App::new().service(get_user)) .bind(("127.0.0.1", 8080))? .workers(4) .run() .await }

再看 Axum 0.8 的等价实现:

use axum::{extract::Path, routing::get, Router}; async fn get_user(Path(id): Path<i32>) -> String { format!("user id = {}", id) } #[tokio::main] async fn main() { let app = Router::new().route("/user/{id}", get(get_user)); let listener = tokio::net::TcpListener::bind("127.0.0.1:8080") .await .expect("bind failed"); axum::serve(listener, app).await.expect("server error"); }

两种写法的可读性都不错,参数提取也都是类型安全的:路径里写的必须是能解析成i32的值,解析失败会得到 400 而不是 panic。从这一点看,两者没有明显的高下之分,更多是团队习惯问题。

2.2 State:依赖注入的两种姿势

真实项目里几乎不可能没有全局共享状态:数据库连接池、配置对象、外部服务客户端。两个框架都提供了依赖注入式的状态管理,但细节上有个容易被忽略的差异。

actix 用web::Data<T>,你把它注册到App::app_data(...),在 handler 里直接声明参数即可:

#[derive(Clone)] struct AppState { db: String, // 替换成你的连接池 } async fn get_user(id: web::Path<i32>, state: web::Data<AppState>) -> HttpResponse { let s = state.db.as_str(); HttpResponse::Ok().body(format!("user {} from {}", id, s)) } // 初始化时: // App::new().app_data(web::Data::new(AppState { db: "...".into() }))

web::Data<T>内部本身就是Arc<T>,所以不用担心克隆开销,handler 里直接拿来用就行。

Axum 用的是State<T>提取器,但Router::with_state(state)要求状态类型T: Clone,而且每次提取都会 clone 一份。如果你存的是连接池这类"克隆成本高"的对象,通常得手动包一层Arc:

#[derive(Clone)] struct AppState { db: std::sync::Arc<String>, // 实践中一般是 Arc<Pool> } async fn get_user( Path(id): Path<i32>, State(state): State<AppState>, ) -> String { format!("user {} from {}", id, state.db) } // 初始化时: // let app = Router::new() // .route("/user/{id}", get(get_user)) // .with_state(AppState { db: std::sync::Arc::new("...".into()) });

这里我见过不少新人踩坑:直接往State<T>里塞一个没有实现Clone的类型,结果启动后第一次请求就 panic。解决方案也很简单,要么让状态类型实现Clone,要么统一包成Arc<T>。另外在多个模块同时需要状态时,Axum 更推荐用Arc包大对象然后针对每个子路由再次共享,actix 则天然没有这个心智负担。

2.3 提取器顺序与 body 消费

提取器是这两个框架日常开发的"手感"所在。actix 和 axum 都支持从Path、Query、Json、Form、HeaderMap等来源提取数据,也都遵循同一个铁律:会消费请求体的提取器必须放在参数最后。actix 里web::Json<T>、web::Payload这类属于末端提取器;Axum 里Json<T>、String、Bytes、multipart::Multipart也是一样,必须先提取完所有非 body 参数,再提取 body。

只讲一句"放最后"显然不够,我举个具体例子。Axum 中这样写是合法的:

async fn create_user( Path(team_id): Path<i32>, Query(params): Query<HashMap<String, String>>, Json(payload): Json<CreateUserReq>, ) -> impl IntoResponse { // ... }

但如果你把Json(payload)挪到中间,编译器会直接拒绝,因为Json<T>实现了FromRequest而不是FromRequestParts,它必须处于提取链末尾。actix 也有类似限制,只不过报错方式略有不同。对于这种设计,我一开始也觉得是约束,后来反而觉得是好事:它从类型层面杜绝了"先消费 body 再读 query"这种容易出 bug 的写法。

自定义提取器方面,actix 需要实现FromRequesttrait,Axum 0.8 引入了带M标记的FromRequest<S, M>和FromRequestParts<S>两套 trait,主体逻辑虽然没变,但签名调整导致很多网上的老代码需要跟着改。如果你计划深度定制提取器,建议直接看当前版本文档,别拿两年前的帖子当标准。

3. 中间件与生态:Tower 的"降维打击"到底体现在哪里

3.1 两种中间件的写法对比

中间件是另一个日常每天都碰的东西。actix 有一套基于Transform和Service的中间件体系,老派写法需要定义一个结构体、实现Transform,格式比较仪式化;不过它也提供了wrap_fn这种匿名闭包形式,日常记日志、计时这类逻辑用闭包最省事:

use actix_web::{App, HttpServer}; HttpServer::new(|| { App::new() .wrap_fn(|req, srv| { let start = std::time::Instant::now(); let fut = srv.call(req); async move { let resp = fut.await?; println!("actix: {} {:?}", resp.status(), start.elapsed()); Ok(resp) } }) }) .bind(("127.0.0.1", 8080)) .unwrap() .run() .await

Axum 的等价格式则完全建立在 tower 的Layer和Service之上。用middleware::from_fn可以不用了解Service内部细节,直接把一个"接收 Request 和 Next、返回 Response"的平铺函数变成中间件:

use axum::{ middleware::{self, Next}, response::Response, routing::get, Router, }; use axum::extract::Request; async fn timing_middleware(req: Request, next: Next) -> Response { let start = std::time::Instant::now(); let resp = next.run(req).await; println!("axum: {} {:?}", resp.status(), start.elapsed()); resp } let app = Router::new() .route("/", get(|| async { "hello" })) .layer(middleware::from_fn(timing_middleware));

这里正是两个框架的哲学分界点。actix 的中间件系统是"自研私有协议",你想在 actix 里复用一个 tower 生态组件,基本要写适配层;axum 则直接把中间件抽象交给了 tower,于是 tower 生态里所有现成组件,比如超时、重试、限流、追踪、CORS,都能以统一方式接入。对于需要通过 ServiceBuilder 做中间件编排的场景,axum 的组合表达能力明显更强,这也是很多人说的"axum 在中间件上是降维打击"的真正含义。

3.2 现成组件生态对比

光说理论不够,我列一个日常项目里最常用的中间件对照表:

需求actix Web 生态Axum 生态
访问日志 / 链路追踪actix 自带 Logger,集成 tracing 稍显费劲tower_http::trace::TraceLayer,和 tracing 无缝衔接
CORSactix-corstower_http::cors::CorsLayer
压缩actix-web提供 Compress 中间件tower_http::compression::CompressionLayer
超时自己包Future或用 actix-timeout 类三方库tower::timeout::TimeoutLayer
静态文件actix-filestower-http::services::ServeDir
会话actix-sessionaxum 需要自己接三方 session crate

所以如果你做的是一个强依赖中间件编排的业务(例如网关、BFF 层),Axum 的体验会顺畅得多。反过来,如果项目只是常规 CRUD 加几个接口,actix 自带能力基本够用,你甚至不需要去第三方生态翻找。

3.3 中间件作用范围的差异

还有一个细节非常影响排障:中间件的作用范围。axum 的layer既可以挂在整个Router上,也可以只挂在某个具体路由上,甚至可以通过route_layer只对某条路径生效。这种粒度控制对"部分接口需要额外校验、其余接口不动"的场景特别有用。

actix 的中间件挂在App层面,是对全局 worker 生效;想针对单个接口做差异化逻辑,通常需要在 handler 内部处理或者在中间件里自己判断路径。不能说谁更优,毕竟全局挂载更符合"统一的横切逻辑应该全局生效"的直觉,但在路由级中间件这个长尾需求上,axum 确实更细。

4. WebSocket、流式响应与错误处理:直面真实业务场景

4.1 WebSocket:actor 风格与简单升级回调

如果你做实时推送、在线聊天、协作编辑这类功能,WebSocket 是躲不掉的,而这一块两个框架的语义差别最大,选错会直接影响团队开发效率。

actix 的 WebSocket 实现和它的 actor 模型深度绑定:你为每个连接定义一个 actor 结构体,让它实现Actor和StreamHandler,然后在WebSocketContext里维护状态、发消息、处理断连。这种模型的优点是每个连接的状态被封装得很干净,适合"每个客户端一个独立会话"的场景;缺点是理解门槛高,你要先弄懂 actor 的生命周期、消息类型、ctx怎么用,新手往往在这一步劝退。

一个最小可用的 actix WebSocket 长这样:

use actix::{Actor, AsyncContext, StreamHandler}; use actix_web::{web, HttpRequest, HttpResponse}; use actix_web_actors::ws::{self, WebSocketContext}; struct WsSession; impl Actor for WsSession { type Context = WebSocketContext<Self>; } impl StreamHandler<Result<ws::Message, ws::ProtocolError>> for WsSession { fn handle(&mut self, msg: Result<ws::Message, ws::ProtocolError>, ctx: &mut Self::Context) { if let Ok(ws::Message::Text(text)) = msg { ctx.text(format!("echo: {}", text)); } } } async fn ws_route(req: HttpRequest, stream: web::Payload) -> HttpResponse { ws::start(WsSession, &req, stream) }

Axum 的 WebSocket 走的是"升级回调"路线:用WebSocketUpgrade提取器接收升级请求,然后on_upgrade挂上一个异步闭包,闭包里直接读写 WebSocket 消息,异步循环自己掌控。不需要引入 actor 概念,心智模型就是"一个长连接的异步任务":

use axum::{ extract::ws::{Message, WebSocket, WebSocketUpgrade}, response::Response, }; async fn ws_route(ws: WebSocketUpgrade) -> Response { ws.on_upgrade(handle_socket) } async fn handle_socket(mut socket: WebSocket) { while let Some(Ok(msg)) = socket.recv().await { if let Message::Text(text) = msg { let _ = socket.send(Message::Text(text)).await; } } }

两者我都写过多套线上代码,客观来说:连接数少、逻辑简单时 axum 这种"一个 async 函数搞定"的写法明显更顺手;连接数非常大、状态机复杂、需要超时重连等行为时,actix 的 actor 模型能逼你把逻辑拆得更规整,反而利于后期维护。但如果你团队里没有熟悉 actor 模型的人,我建议先选 axum,因为从 async 思维自然迁移过来的成本最低。

4.2 流式响应与 SSE

除了 WebSocket,流式场景也能看出两者底子。axum 的响应体类型Body基于http-body,天然支持从异步流构造 body,所以做 SSE 或者大文件流式返回非常顺手。SSE 甚至有专门的Sse响应类型,配合Sse::keep_alive可以维护心跳:

use axum::response::sse::{Event, KeepAlive, Sse}; use tokio_stream::wrappers::ReceiverStream; use futures_util::stream::StreamExt; let stream = /* 某异步流,产出 Result<Event, Infallible> */; Sse::new(stream).keep_alive(KeepAlive::default())

actix 做流式响应也不差,底层有HttpResponse::streaming,传入一个产出Bytes的异步流即可;但 SSE 这种"文本事件 + 心跳"的结合需要自己拼事件格式、自己发注释行维持连接。如果你的接口清单里 SSE 占了相当比例,axum 确实省事不少。

use actix_web::{HttpResponse, web}; use futures_util::stream::{once, StreamExt}; async fn sse_handler() -> HttpResponse { let stream = once(async { Ok(web::Bytes::from("data: hello\n\n")) }); HttpResponse::Ok() .content_type("text/event-stream") .streaming(stream) }

4.3 错误处理的哲学差异

错误处理是最能体现两个框架"范式"的场景之一。actix 要求你实现ResponseErrortrait,把业务错误映射为某一种 HTTP 响应;Axum 的思路更现代——任何实现了IntoResponse的类型都可以直接作为 handler 的返回错误,于是你可以把Result<T, E>里的E定义成业务错误类型,写起来非常自然。

actix 的典型做法:

use actix_web::{http::StatusCode, HttpResponse, ResponseError}; use serde_json::json; #[derive(Debug)] struct BizError(String); impl ResponseError for BizError { fn status_code(&self) -> StatusCode { StatusCode::BAD_REQUEST } fn error_response(&self) -> HttpResponse { HttpResponse::build(self.status_code()).json(json!({"message": self.0})) } }

axum 的写法:

use axum::{http::StatusCode, response::{IntoResponse, Response}, Json}; struct BizError(String); impl IntoResponse for BizError { fn into_response(self) -> Response { (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "message": self.0 }))).into_response() } }

看着差不多,但 axum 的优势在于"什么都能转 Response"。你甚至可以把StatusCode、字符串、Result<T, E>直接作为 handler 返回值,框架自动帮你做转换。这一点在快速原型阶段非常有生产力,少写很多样板代码。当然,反面影响是有的:因为返回类型太自由,团队如果没有约定,错误响应格式会变得五花八门。我见过不少 axum 项目最后不得不用统一的ApiError类型把范式重新收紧,而 actix 因为 API 更"重",反而从一开始就统一了格式。

5. 性能、编译体验与版本节奏:别被压测数字带偏

5.1 性能:同一梯队里的细微差别

必须先把话说清楚:这几年公开的基准测试里,actix 和 axum 都处在头部梯队,actix 做一些纯短连接压测时偶尔领跑,axum 在带中间件链路的长请求场景上差距也小到可以忽略。真到生产环境,瓶颈几乎都会转移到数据库、Redis、外部 RPC 和序列化上,框架层那点差距对用户响应时间的影响通常是零点几毫秒级。

有一个更值得关注的点是连接处理模型。actix 默认多 worker + 自研 HTTP 层,在某些高并发短连接场景下确实有一点点优势;axum 则完全站在 hyper 肩膀上,hyper 本身也是大厂反复优化过的 HTTP 实现,两边都不是等闲之辈。因此我的建议是:如果性能是你的唯一考量,直接抛硬币,然后花时间把数据库访问和序列化优化好,收益远大于纠结这两个框架。

5.2 编译时间与依赖体积

编译体验是很多人容易忽略的隐性成本。axum 的依赖链路包含 tokio、hyper、tower、tower-http、http-body 这一大套东西,特别是开启全部 feature 后,冷编译时间明显偏长;actix 的依赖也不少,但它的 HTTP 层是自研的,没有 hyper 那套庞大的泛型体系,冷编译感觉上会轻一些。我这里说的"轻"是相对而言,两者首次全量编译在普通笔记本上都是几分钟起步,CI 上如果没有缓存构建,每次全量编译都是一笔不小的时间成本。

小团队持续集成频繁时,我建议在 CI 里把编译缓存和 sccache 尽早配上,尤其是在快速迭代的 Axum 项目里,这比纠结框架本身更实在。开发期也一样,热重载工具配合 cargo watch 使用,能显著减轻编译等待带来的烦躁感。

5.3 版本节奏:稳定 vs 快速迭代

actix-web 的 4.x 已经稳定了相当长一段时间,核心 API 很少有大变动,网上资料基本不过期,这是它对团队非常友好的地方。axum 则相反,目前仍处于 0.x 快速迭代期,几乎每年都有大版本发布:0.6 改路由嵌套和状态管理、0.7 改Server绑定方式、0.8 又改了路径参数语法并调整了FromRequest。跟版本升级对每个依赖方来说都是一次不小的迁移工作。

维度actix-web 4.xAxum 0.8
API 稳定性高,长期稳定中,0.x 阶段仍可能有破坏性变更
文档时效性老资料多数可用教程和帖子容易过期,务必查新版文档
版本升级工作量小大,升级时建议逐条过 changelog

我并不是说 axum 快速迭代是缺点——它代表了生态还在积极演化,很多设计决策也在吸收社区反馈。但如果你是给企业做多年维护的内部系统,"依赖一个还在快速变动的框架"是需要认真权衡的风险。锁定版本、细化依赖、把升级列入技术债日程,这才是负责任的做法。

6. 选型建议:先写一个小 Demo,再谈生产力

6.1 两张表看清各自适合什么

把前面对比抽象成一张决策表,可以直接贴在团队文档里:

对比维度actix-webaxum
性能微基准略占优同一梯队,差距可忽略
HTTP 实现自研 HTTP 层,多 worker 模型基于 hyper
中间件生态自成一派,够用但封闭tower / tower-http 生态,复用面广
WebSocketactor 模型,适合复杂连接状态升级回调,简洁直观
SSE / 流式可做但 SSE 需自己拼内置 Sse 支持,更顺手
错误处理ResponseError范式统一IntoResponse灵活自由
API 稳定性4.x 长期稳定0.x 快速迭代
学习曲线需要理解 actor 模型需要理解 tower 抽象

再从业务视角看一遍:你要做的如果是实时推送、在线协作、游戏服务这类"长连接 + 每连接独立状态"的系统,actix 的 actor 模型天然契合,能很好地约束并发状态;如果你要做的是微服务网关、BFF、REST API、SSE 聚合这类"中间件链复杂、请求响应为主"的系统,axum 的 tower 生态会让你少写大量胶水代码。

6.2 我给两类项目的实际选择

举两个我个人的实际案例,避免纸上谈兵。一个做实时通知中心的项目,每个客户端连接要保持状态、要心跳、要根据连接状态广播事件,我最终选了 actix。原因很简单:团队里最熟悉的那套模式就是 actor 风格,把每个连接建模成一个 actor,消息收发、超时清理都变得有章法;用 axum 的 async 循环写法也能实现,但需要自己管理很多"如果服务端要主动推消息给指定客户端"的状态注册表,反而容易乱。

另一个做内部 API 聚合层的项目,我选了 axum。因为它需要把多个下游服务的输出协议统一、统一加 trace 和超时、部分路由要做白名单校验,这套东西用 tower 的ServiceBuilder分层组合非常合理,而且IntoResponse可以让我快速把各种原始 RPC 响应转成统一的 JSON 错误结构,开发效率高了一大截。

这两个案例想说明的不是"某某场景只能用某某框架",而是选型的决定性因素往往是"团队心智模型最契合哪边的范式"。两个框架都能完成同样的事,差别在于哪种写法在你们的项目里崩溃概率更低、交接成本更小。

6.3 最后一个建议

如果你看完这篇还是举棋不定,我的建议很具体:挑一个周五下午,用两个框架分别写同一个最小业务 Demo,包含两个带路径参数的路由、一个数据库查询、一个 WebSocket 回显、一个自定义错误返回。写完之后不看跑分,只看"哪份代码你在三个月后再打开时更愿意继续维护"。多数人做完这个小实验就有了答案。我这几年围观过不少团队选型,最后决定性的往往不是技术参数,而是这种最朴素的后端工程师直觉——代码是不是顺手,是不是敢放心交给下一个接手的人。选一个能让你长期待下去的开发范式,比选一个今天快 3% 的框架重要得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进