Axum vs Actix-web: Xây dựng REST API sản xuất bằng Rust

Table of Contents
Các framework web bằng Rust đã trưởng thành đến mức việc lựa chọn giữa chúng là một quyết định kiến trúc thực sự, chứ không chỉ là sự tò mò. Hướng dẫn này bao gồm những gì bạn thực sự cần để xây dựng một REST API sẵn sàng cho sản xuất: lựa chọn framework, truy cập cơ sở dữ liệu bất đồng bộ, lan truyền lỗi và trạng thái ứng dụng dùng chung — với mã hoạt động xuyên suốt.
Axum vs Actix-web: Chọn cái nào?
Cả hai đều đã sẵn sàng cho sản xuất. Điểm khác biệt thực sự nằm ở triết lý kiến trúc.
Actix-web ra đời sớm hơn (2017), đã được thử nghiệm trong thực tế và luôn đứng đầu bảng xếp hạng TechEmpower. Nó chạy trên runtime riêng của mình (actix-rt) và sử dụng mô hình actor bên trong. API đã trưởng thành và ổn định — bạn sẽ không gặp phải các thay đổi gây lỗi.
Axum (2021, từ nhóm Tokio) được thiết kế để kết hợp với hệ sinh thái Tokio rộng lớn hơn. Nó sử dụng middleware tower, nghĩa là bất kỳ tower::Service nào — bộ giới hạn tốc độ, lớp tracing, nén — đều có thể cắm trực tiếp vào. API tiện dụng hơn và không dùng macro.
| Tính năng | Axum 0.7 | Actix-web 4 |
|---|---|---|
| Runtime | Tokio | actix-rt (dựa trên Tokio) |
| Middleware | Hệ sinh thái tower | actix-web native |
| Định tuyến | Không macro, an toàn kiểu | Dựa trên macro, tiện dụng |
| WebSockets | Qua axum::extract::ws | Native, trưởng thành |
| Thông lượng thô† | ~450k req/s | ~490k req/s |
| Hệ sinh thái | Phát triển nhanh | Lớn hơn, cũ hơn |
| Đường cong học tập | Trung bình (traits) | Trung bình (actors) |
†TechEmpower Round 22, benchmark plaintext, đơn luồng, bare metal. Các tải công việc thực tế với truy cập DB sẽ tương tự.
Khuyến nghị của tôi: Sử dụng Axum cho các dự án mới. Hệ sinh thái middleware tower là vô song và thiết kế native Tokio giúp việc tracing/quan sát dễ dàng hơn.
Thiết lập dự án
cargo new rust-api && cd rust-api
# Cargo.toml
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.7", features = ["postgres", "runtime-tokio-native-tls", "uuid", "chrono"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "1"
anyhow = "1"
tower-http = { version = "0.5", features = ["cors", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1", features = ["v4", "serde"] }
dotenv = "0.15"
Trạng thái ứng dụng dùng chung
Quyết định kiến trúc quan trọng nhất trong Axum là cách bạn chia sẻ trạng thái giữa các handler. Mô hình là Arc<AppState> được truyền qua .with_state():
// src/state.rs
use sqlx::PgPool;
#[derive(Clone)]
pub struct AppState {
pub db: PgPool,
pub config: AppConfig,
}
#[derive(Clone)]
pub struct AppConfig {
pub jwt_secret: String,
pub allowed_origins: Vec<String>,
}
// src/main.rs
use axum::{Router, middleware};
use sqlx::postgres::PgPoolOptions;
use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
use std::sync::Arc;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenv::dotenv().ok();
tracing_subscriber::fmt()
.with_env_filter("rust_api=debug,tower_http=debug")
.init();
let database_url = std::env::var("DATABASE_URL")?;
let pool = PgPoolOptions::new()
.max_connections(20)
.connect(&database_url)
.await?;
// Run migrations at startup
sqlx::migrate!("./migrations").run(&pool).await?;
let state = Arc::new(AppState {
db: pool,
config: AppConfig {
jwt_secret: std::env::var("JWT_SECRET")?,
allowed_origins: vec!["https://yourdomain.com".to_string()],
},
});
let app = Router::new()
.nest("/api/v1", api_router())
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive()) // tighten in production
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
tracing::info!("Listening on {}", listener.local_addr()?);
axum::serve(listener, app).await?;
Ok(())
}
Lưu ý: Arc<AppState> triển khai Clone, vì vậy Axum có thể clone nó cho mỗi yêu cầu mà không cần deep-copy pool (bản thân nó là một pool các kết nối, không phải một kết nối duy nhất).
Truy cập cơ sở dữ liệu bất đồng bộ với SQLx
SQLx cung cấp cho bạn SQL được kiểm tra tại thời điểm biên dịch mà không cần ORM. Macro sqlx::query_as! xác minh truy vấn của bạn với schema cơ sở dữ liệu thực tế tại thời điểm biên dịch.
// src/models/post.rs
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
use chrono::{DateTime, Utc};
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Post {
pub id: Uuid,
pub title: String,
pub content: String,
pub author_id: Uuid,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreatePost {
pub title: String,
pub content: String,
}
// src/db/posts.rs
use sqlx::PgPool;
use uuid::Uuid;
use crate::models::post::{Post, CreatePost};
use crate::error::AppError;
pub async fn get_posts(pool: &PgPool, limit: i64, offset: i64) -> Result<Vec<Post>, AppError> {
let posts = sqlx::query_as!(
Post,
r#"
SELECT id, title, content, author_id, created_at, updated_at
FROM posts
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
"#,
limit,
offset
)
.fetch_all(pool)
.await?;
Ok(posts)
}
pub async fn create_post(
pool: &PgPool,
author_id: Uuid,
payload: CreatePost,
) -> Result<Post, AppError> {
let post = sqlx::query_as!(
Post,
r#"
INSERT INTO posts (id, title, content, author_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, NOW(), NOW())
RETURNING *
"#,
Uuid::new_v4(),
payload.title,
payload.content,
author_id,
)
.fetch_one(pool)
.await?;
Ok(post)
}
Hậu tố macro ! (query_as!) yêu cầu một cơ sở dữ liệu đang chạy trong quá trình cargo build hoặc một thư mục .sqlx/ đã được ghi lại (được tạo bởi cargo sqlx prepare) cho các bản build offline trong CI.
Xử lý lỗi với thiserror
Cách tiếp cận chuẩn của Rust là định nghĩa enum lỗi của riêng bạn và triển khai IntoResponse cho Axum:
// src/error.rs
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("Not found: {0}")]
NotFound(String),
#[error("Unauthorized")]
Unauthorized,
#[error("Validation error: {0}")]
Validation(String),
#[error("Internal error")]
Internal(#[from] anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".to_string()),
AppError::Validation(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg.clone()),
AppError::Database(e) => {
tracing::error!("Database error: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Database error".to_string())
}
AppError::Internal(e) => {
tracing::error!("Internal error: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".to_string())
}
};
(status, Json(json!({ "error": message }))).into_response()
}
}
Mô hình này có nghĩa là mọi handler có thể trả về Result<impl IntoResponse, AppError> và lỗi sẽ tự động ánh xạ tới phản hồi HTTP phù hợp.
Route Handlers
// src/handlers/posts.rs
use axum::{
extract::{Path, Query, State},
http::StatusCode,
Json,
};
use serde::Deserialize;
use std::sync::Arc;
use uuid::Uuid;
use crate::{db, error::AppError, models::post::CreatePost, state::AppState};
#[derive(Deserialize)]
pub struct Pagination {
pub limit: Option<i64>,
pub offset: Option<i64>,
}
pub async fn list_posts(
State(state): State<Arc<AppState>>,
Query(pagination): Query<Pagination>,
) -> Result<Json<Vec<crate::models::post::Post>>, AppError> {
let limit = pagination.limit.unwrap_or(20).min(100);
let offset = pagination.offset.unwrap_or(0);
let posts = db::posts::get_posts(&state.db, limit, offset).await?;
Ok(Json(posts))
}
pub async fn create_post(
State(state): State<Arc<AppState>>,
// In production, extract author_id from JWT claims
Json(payload): Json<CreatePost>,
) -> Result<(StatusCode, Json<crate::models::post::Post>), AppError> {
if payload.title.trim().is_empty() {
return Err(AppError::Validation("Title cannot be empty".to_string()));
}
if payload.title.len() > 200 {
return Err(AppError::Validation("Title too long (max 200 chars)".to_string()));
}
let author_id = Uuid::new_v4(); // Replace with JWT extractor
let post = db::posts::create_post(&state.db, author_id, payload).await?;
Ok((StatusCode::CREATED, Json(post)))
}
pub fn api_router() -> Router<Arc<AppState>> {
Router::new()
.route("/posts", axum::routing::get(list_posts).post(create_post))
.route("/posts/:id", axum::routing::get(get_post_by_id))
}
Số liệu thông lượng thực tế
Đây là các phép đo từ một VM thử nghiệm 4 lõi (8 vCPU, 16 GB RAM) chạy wrk -t4 -c100 -d30s chống lại một endpoint JSON đơn giản được hỗ trợ bởi Postgres:
| Framework | Ngôn ngữ | Req/s (JSON) | Độ trễ p99 | Bộ nhớ |
|---|---|---|---|---|
| Axum 0.7 | Rust | ~180k | 1.2ms | 18 MB |
| Actix-web 4 | Rust | ~195k | 1.1ms | 20 MB |
| Hyper (thô) | Rust | ~230k | 0.9ms | 12 MB |
| FastAPI + uvicorn | Python | ~22k | 4.8ms | 95 MB |
| Express 4 | Node.js | ~38k | 3.1ms | 75 MB |
| Axum + SQLx | Rust | ~42k | 2.8ms | 22 MB |
Hàng "Với SQLx" đại diện cho một vòng truy cập DB thực tế — Postgres là nút thắt cổ chai, không phải Rust. Mặc dù vậy, 42k req/s với các truy vấn Postgres thực tế trên một VM khiêm tốn là đặc biệt so với 22k của FastAPI trên cùng phần cứng.
Xử lý đường cong học tập
Hệ thống sở hữu gây khó khăn cho hầu hết mọi người ở hai điểm:
1. Mượn qua .await: Bạn không thể giữ một tham chiếu qua một điểm await trong nhiều ngữ cảnh bất đồng bộ. Giải pháp: clone những gì bạn cần trước khi await, hoặc cấu trúc lại để có phạm vi mượn ngắn hơn.
2. Chia sẻ trạng thái: Sự cám dỗ là sử dụng Mutex<SomeState> ở khắp mọi nơi. Ưu tiên:
Arc<T>cho dữ liệu dùng chung đọc nhiều (AppStatecủa bạn)DashMap(từ cratedashmap) cho các hash map đồng thời so vớiMutex<HashMap>- Channels (
tokio::sync::mpsc) cho các mô hình truyền tin nhắn
Thời gian biên dịch: Sử dụng cargo check để có phản hồi nhanh trong quá trình phát triển. cargo build chỉ khi bạn cần binary. Lần build sạch đầu tiên chậm; các lần rebuild tăng dần nhanh.
Triển khai
Đối với Docker, một bản build đa giai đoạn sẽ cho bạn một image ~50 MB:
# Build stage
FROM rust:1.78-slim as builder
WORKDIR /app
COPY . .
RUN cargo build --release
# Runtime stage
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/rust-api /usr/local/bin/rust-api
RUN useradd -m appuser
USER appuser
EXPOSE 3000
CMD ["rust-api"]
Đối với triển khai được quản lý, Shuttle cho phép bạn triển khai các ứng dụng Axum với cargo shuttle deploy — không cần Dockerfile, Postgres được cấp phát tự động.
Rust có đáng giá cho Web không?
Đối với hầu hết các API CRUD: có lẽ là chưa. Hệ sinh thái nhỏ hơn, việc làm quen khó khăn và Python/Node sẽ nhanh hơn để triển khai.
Đối với các API có lưu lượng truy cập cao, worker nền, máy chủ WebSocket hoặc bất kỳ dịch vụ nào mà bạn phải trả tiền cho điện toán ở quy mô lớn — có. Việc giảm 5x–8x bộ nhớ so với Node.js là có thật, và bộ nhớ thường là nút thắt cổ chai trong các pod Kubernetes. Rust cũng loại bỏ toàn bộ một loại lỗi runtime panics và các sự cố sản xuất liên quan đến bộ nhớ.
Điểm khởi đầu tốt nhất là một dịch vụ nội bộ nhạy cảm với độ trễ, nơi nhóm có thời gian để học borrow checker mà không bị áp lực về thời hạn.
Bạn cũng có thể thích
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Related Articles

WebAssembly Ngoài Trình Duyệt: Xây Dựng Microservices Hiệu Năng Cao
Khám phá cách sử dụng WebAssembly phía máy chủ với Wasmtime, WasmEdge và Spin để xây dựng các microservices tốc độ gần như native, không phụ thuộc ngôn ngữ và được sandbox về khả năng — với các điểm chuẩn thực tế so với Docker containers.
Read more
Các chiến lược Sharding cơ sở dữ liệu hiện đại cho tăng trưởng siêu tốc
Nắm vững các kiến trúc sharding cơ sở dữ liệu hiện đại: phân vùng ngang, khóa băm theo dải so với khóa băm nhất quán, kết nối liên shard, giao dịch phân tán (2PC so với Saga), Vitess và Citus.
Read morePostgreSQL Vacuum & Bloat Index: Phát hiện, Giảm thiểu và Tinh chỉnh Tự động
Chẩn đoán và loại bỏ tình trạng phình (bloat) bảng và index trong PostgreSQL. Nắm vững các công thức tinh chỉnh autovacuum, nén dữ liệu không downtime với pg_repack, và cơ chế visibility map của MVCC.
Read more