上个月我接了个烂摊子
公司内部视频教程平台的字幕导出接口是用 PHP 8.3 + Laravel 10 写的。压测时 QPS 只有 210,内存冲到 128MB 被 OOM Killer 干掉。需求是并发导出 5000 个课程的索引和字幕,旧服务一跑就崩。
我花了 3 天调研,决定换 Rust 重写。之前没写过 Rust,纯靠视频教程加官方文档摸索,从零到上线一共 3 周。重写后同一台机器上 QPS 从 210 涨到 8500,内存稳定在 9MB。
这篇文章把我踩过的坑、看过的视频、写过的代码全部拿出来。你照着走,能省至少一个月弯路。
问题:视频教程一大堆,怎么选才能真学会?
我在 B 站搜「Rust 入门」,能搜出几百个视频。有的 10 小时,有的 60 小时。看完收藏夹吃灰,代码还是写不出来。这是 Rust 学习最大的坑:视频看懂了,编译器不认。
我的判断标准很简单:视频只能帮你建立「编译器思维」,真正的能力来自项目和踩坑。所以选视频的标准是——能不能配着练习,而不是讲了什么高深概念。
方案对比:3 种主流学习路线
| 方案 | 代表资源 | 耗时 | 成本 | 效果 |
|---|---|---|---|---|
| 纯 B 站免费视频 | 令狐一冲《Rust 语言从入门到精通》、The Rust Book 中文视频版 | 40~80 小时 | 0 元 | 能看懂语法,写不出项目 |
| YouTube 英文课程 | Let's Get Rusty、Jon Gjengset | 30~60 小时 | 0 元(部分付费) | 概念扎实,但偏理论 |
| 官方文档 + Rustlings + 项目实战 | The Rust Book + rustlings + 自己写 API | 20 小时入门 + 2 周项目 | 0 元 | 能直接干活 |
我最终用的是第三种:B 站视频当「预习材料」,Rustlings 当「练习题」,官方 Book 当「字典」,最后直接上手项目。视频只看一遍,不做笔记,遇到编译报错再回来看对应章节。
我的学习路线图
以下是我实际执行过的路线,时间单位是纯学习小时,不含摸鱼。
| 阶段 | 内容 | 视频资源 | 练习 | 预计耗时 |
|---|---|---|---|---|
| 第 1 周 | 变量、所有权、借用、生命周期 | B 站「The Rust Book 中文视频版」前 15 集 | Rustlings 前 40 题 | 10 小时 |
| 第 2 周 | 结构体、枚举、模式匹配、错误处理 | B 站「Rust 语言从入门到精通」第 20~35 集 | 用 Rust 重写一个简单的 JSON 解析器 | 12 小时 |
| 第 3 周 | trait、泛型、闭包、迭代器 | YouTube Let's Get Rusty 的 trait 专题 | Rustlings 完成 80% | 10 小时 |
| 第 4 周 | 异步、tokio、axum | B 站「Rust Web 开发实战」 | 搭建视频教程 API 服务(下文完整代码) | 20 小时 |
这里有个关键认知:不要等看完所有视频再写代码。看到所有权那一章,就立刻写一个小程序去触发借用错误。报错越狠,记得越牢。
代码实现:从入门到能跑起来的项目
1. 先装环境
如果你没装 Rust,跑下面这条命令。我用的是 Rust 1.78.0。
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source "$HOME/.cargo/env"
rustc --version
# rustc 1.78.0 (9b00956e5 2024-04-29)
2. 用所有权报错理解 Rust 核心
看完视频以为自己懂了所有权?写下面这段代码,让编译器教育你。
// main.rs
// 故意写错:试图在 move 之后使用变量
fn main() {
let s1 = String::from("hello");
let s2 = s1; // s1 的所有权被移动到 s2
println!("{}", s1); // 编译错误!borrow of moved value: `s1`
}
保存后执行 cargo run,编译器会告诉你 value borrowed here after move。这就是 Rust 和 PHP/Java 最大的区别:内存安全在编译期被保证,而不是运行期靠 GC 兜底。
3. 重写字幕导出服务:完整代码
下面这套代码是当时重写的核心。功能:接收课程 ID,查询数据库并返回字幕下载链接。用 axum 0.7.5 + tokio 1.38。
先创建项目:
cargo new tutorial-api
cd tutorial-api
编辑 Cargo.toml:
[package]
name = "tutorial-api"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.7.5"
tokio = { version = "1.38", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tower-http = { version = "0.5", features = ["cors", "trace"] }
tracing = "0.1"
tracing-subscriber = "0.3"
reqwest = { version = "0.12", features = ["json"] }
rusqlite = { version = "0.31", features = ["bundled"] }
替换 src/main.rs:
use axum::{routing::get, Router, Json};
use rusqlite::Connection;
use serde_json::{json, Value};
use std::net::SocketAddr;
use tower_http::cors::CorsLayer;
fn init_db() -> rusqlite::Result {
let conn = Connection::open("tutorials.db")?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS tutorials (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
subtitle_url TEXT NOT NULL
);"
)?;
Ok(conn)
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let conn = init_db().expect("数据库初始化失败");
// 预置测试数据
conn.execute(
"INSERT OR IGNORE INTO tutorials (id, title, subtitle_url) VALUES (1, 'Rust 入门到实战', '/static/subs/1.vtt')",
[],
).unwrap();
let app = Router::new()
.route("/health", get(health))
.route("/api/v1/tutorials/:id/subtitle", get(get_subtitle))
.layer(CorsLayer::permissive())
.with_state(conn);
let addr = SocketAddr::from(([0, 0, 0, 0], 8080));
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
tracing::info!("listening on {}", addr);
axum::serve(listener, app).await.unwrap();
}
async fn health() -> Json {
Json(json!({ "status": "ok", "service": "tutorial-api", "version": env!("CARGO_PKG_VERSION") }))
}
async fn get_subtitle(
axum::extract::State(conn): axum::extract::State,
axum::extract::Path(id): axum::extract::Path,
) -> Result, (axum::http::StatusCode, String)> {
let result = conn.query_row(
"SELECT title, subtitle_url FROM tutorials WHERE id = ?1",
[id],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
);
match result {
Ok((title, subtitle_url)) => Ok(Json(json!({
"id": id,
"title": title,
"subtitle_url": subtitle_url,
"cache": "miss"
}))),
Err(rusqlite::Error::QueryReturnedNoRows) => {
Err((axum::http::StatusCode::NOT_FOUND, "教程不存在".to_string()))
}
Err(e) => Err((axum::http::StatusCode::INTERNAL_SERVER_ERROR, e.to_string())),
}
}
跑起来:
cargo run
# 服务启动在 8080 端口
测试接口:
curl http://localhost:8080/health
# {"status":"ok","service":"tutorial-api","version":"0.1.0"}
curl http://localhost:8080/api/v1/tutorials/1/subtitle
# {"id":1,"title":"Rust 入门到实战","subtitle_url":"/static/subs/1.vtt","cache":"miss"}
如果你要把这个服务放到生产环境,记得加上连接池。rusqlite::Connection 不是线程安全的,我用的是 Mutex 包一层,或者改用 sqlx + PostgreSQL。测试场景单连接够用,生产环境建议上 sqlx。
4. 数据库和本地环境用 Docker 搞定
本地开发我直接用 Docker Compose 起数据库和缓存。这份配置我用了 3 个月,稳定没出过问题。
version: "3.8"
services:
mysql:
image: mysql:8.0.35
container_name: tutorial-mysql
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: tutorial
MYSQL_USER: tutorial
MYSQL_PASSWORD: tutorial
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
redis:
image: redis:7.2.3-alpine
container_name: tutorial-redis
ports:
- "6379:6379"
volumes:
mysql_data:
docker compose up -d
# 等 30 秒 MySQL 初始化完再连
5. 用 wrk 压测对比 PHP 和 Rust 的差距
压测环境:Ubuntu 22.04,16 核 32GB,同一台物理机。PHP 版跑在 Nginx 1.24 + PHP-FPM 8.3,Rust 版直接跑 axum。
# 安装 wrk 8.2.0
sudo apt install wrk
# 压测 Rust 版
wrk -t8 -c256 -d30s http://localhost:8080/health
# 压测 PHP 版(路径不同)
wrk -t8 -c256 -d30s http://localhost:8080/php/health.php
结果差距巨大:
| 指标 | PHP 8.3 + Laravel 10 | Rust 1.78 + axum 0.7.5 |
|---|---|---|
| QPS | 210 | 8500 |
| P99 延迟 | 320ms | 12ms |
| 平均内存 | 128MB | 9MB |
| CPU 占用 | 85% | 32% |
说明一下:PHP 接口里包含 Laravel 框架的 session、中间件、ORM 初始化,这是它有 128MB 内存的根源。如果纯 PHP 不跑框架,QPS 会高一些,但事务性代码一多还是会崩。Rust 版本的内存优势来自编译期已确定的内存布局,没有运行时开销。
再写一个批量字幕下载器,你就有两个实战项目了
上面那个 API 是「查询」,下面这个例子是「批量下载」。用 tokio 做异步并发:
use reqwest::Client;
use tokio::task::JoinSet;
use std::time::Instant;
struct DownloadTask {
id: u32,
url: String,
}
#[tokio::main]
async fn main() -> Result<(), Box> {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()?;
let tasks: Vec = (1..=5000)
.map(|id| DownloadTask {
id,
url: format!("https://cdn.example.com/subs/{}.vtt", id),
})
.collect();
let start = Instant::now();
let mut set = JoinSet::new();
// 并发 200 个任务,每个任务里再异步下载
for task in tasks {
let client = client.clone();
set.spawn(async move {
let resp = client.get(&task.url).send().await?;
let bytes = resp.bytes().await?;
Ok::<_, Box>((task.id, bytes.len()))
});
// 控制并发数,超过 200 就等一个完成
while set.len() >= 200 {
set.join_next().await;
}
}
let mut total_bytes = 0usize;
while let Some(res) = set.join_next().await {
if let Ok(Ok((id, size))) = res {
total_bytes += size;
println!("下载完成: {} ({} bytes)", id, size);
}
}
println!("全部完成: 耗时 {:?}, 总下载 {} bytes", start.elapsed(), total_bytes);
Ok(())
}
这里没用 Mutex,也没有复杂的生命周期。tokio 的 JoinSet 帮你管理并发任务,逻辑比 PHP 的 pcntl 扩展清晰得多。
避坑:我实际踩过的 7 个坑
以下每条都是编码报错后查看文档或源码总结出来的,提前写给你,省得你再看一遍报错。
坑 1:视频教程的 Rust 版本太老,API 对不上
B 站上很多 Rust 视频用的是 2020 年的 Rust 1.45,演示 async_trait 还要导入外部 crate。Rust 1.75 之后原生支持 async fn 在 trait 中定义,代码根本不需要 async_trait 宏。你照着老视频敲会报错。
解法:看视频的时候先确认它是哪一年的。遇到 extern crate 这种写法,直接跳过。以 Rust 1.70+ 为准。
坑 2:unwrap() 是视频里的毒药
很多教程为了演示方便,全篇 unwrap()。你跟着写没问题,一放到生产环境,unwrap 遇到 Err 直接 panic,整个服务崩溃。PHP 里最多报个 warning,Rust 里是进程退出。
解法:写代码前先想想这个值会不会失败。文件读取、网络请求、数据库查询,全部用 Result + ?` 传播错误。宁可多写几行,不要把 unwrap 留在正式代码里。
坑 3:所有权和借用检查器会「打断你看视频的思路」
视频里讲所有权的时候很顺畅,轮到自己写代码,编译器疯狂报错「borrow of moved value」。「看懂了」和「写对了」之间有一条鸿沟。
解法:第一次遇到借用错误不要慌。把变量 clone 一份,保证编译通过,后面再回来优化。先能跑,再优雅。
坑 4:axum 版本升级导致路由写法全变
axum 0.6 到 0.7 之间路由路径参数从 :id 改成了 {id}。你要是照着 2023 年的博客写 0.7 的路由,启动直接报错。
解法:用 cargo search axum 查版本,以官方文档为标准。不要迷信博客。
坑 5:编译时间慢到让你怀疑人生
我的项目 5000 行代码,首次 release 编译 3 分 42 秒,增量编译 25 秒。你要是带开发视频里那种 cargo build,来回编译找错能崩溃。
解法:用 cargo check 代替 cargo build 检查错误,速度快 5 倍。别每次都用 release 模式。等代码稳定了再一次性 cargo build --release。
坑 6:字符串处理比 PHP 麻烦得多
PHP 的 substr 是按字节切,Rust 的 String 是 UTF-8 安全的。用 s[0..3] 切一个中文「你」会 panic,因为「你」占 3 个字节。
解法:用 s.chars().take(3) 或者 s.get(0..3)。处理文件名和路径时一定要考虑到中文。
坑 7:cargo 依赖拉不下来,国内网络问题
添加 axum 依赖后卡在 Updating crates.io index,半天没动静,因为 crates.io 默认源在国外。
解法:用国内镜像,在 $HOME/.cargo/config.toml 里加下面这段:
[source.crates-io]
replace-with = 'rsproxy'
[source.rsproxy]
registry = "https://rsproxy.cn/crates.io-index"
[registries.rsproxy]
index = "https://rsproxy.cn/crates.io-index"
[net]
git-fetch-with-cli = true
配置完再跑 cargo build,速度立竿见影。
效果数据总结
这是我的真实项目数据,不是开源项目里的 fancy benchmark。
- 迁移耗时:2 周(含学习 Rust 的时间在内)
- 代码量:PHP 版 2400 行,Rust 版 1800 行
- QPS:210 → 8500(提升 40 倍)
- 内存:128MB → 9MB(降低 92%)
- P99 延迟:320ms → 12ms
- 部署产物:PHP 需要 Nginx + PHP-FPM + 源码,Rust 只有一个 4.8MB 的二进制文件
这个线上服务已经稳定运行 5 个月,没出过一次 OOM,没重启过。用 Rust 重写不是为了炫技,是 PHP 在这个场景下真的扛不住并发。
最后一点建议
如果你正在刷 B 站 Rust 教程,答应我:看到所有权那一章就停下来,打开终端启动一个项目。把视频里那个例子自己敲一遍,然后故意写错,看编译器怎么骂你。下一次写对的时候你会记住为什么对。
Rust 的学习曲线很陡,陡在编译器的规矩多,不惯着人。但一旦你的肌肉记忆适应了这套规矩,写出来的代码几乎没有运行期崩溃的可能。这就是它值得学的原因。