我们的后端服务跑在一个 FastAPI + SQLAlchemy + uvicorn 的经典 Python 异步栈上,业务逻辑以 CPU 密集的数据聚合和规则引擎为主。随着流量翻倍,单实例 QPS 在 800 附近撞上了天花板——CPU 长期 90%+,P99 延迟突破 500ms。本文记录我把整套服务用 Rust(Actix-Web + Diesel + Tokio) 重写后,QPS 跃升到 32000、P99 降到 12ms 的完整迁移过程。
引言:为什么是 Rust,而不是 Go
迁移前我们认真评估了三个方向:继续优化 Python(换 orjson、上 Cython、引入 Redis 缓存)、迁移到 Go、迁移到 Rust。继续优化 Python 的天花板我们已经摸到了——GIL 限制了真正的多核并行,CPU 密集场景下增加 worker 数量只能线性堆机器。
Go 是个务实的选择,但有两个顾虑:一是 GC 在我们这种长连接 + 高频小对象的场景下会带来尾部延迟抖动;二是业务里有大量数值计算和位运算,Go 的抽象控制力不如 Rust。最终选 Rust 的核心原因是:
- 零成本抽象:泛型和 trait 在编译期单态化,运行时无虚函数开销,适合密集计算。
- 无 GC 的内存安全:所有权系统在编译期保证无数据竞争,没有 STW 停顿。
- 极致的 async 运行时:Tokio 的无栈协程在百万连接下内存占用远低于 Go 的 goroutine。
- 生态成熟度:Actix-Web 连续多年在 TechEmpower 基准测试中位列前茅,Diesel 提供编译期 SQL 校验。
服务是一个实时风控规则引擎:接收事件流(每秒 ~2 万条),对每条事件跑 40+ 条规则匹配,命中则落库并推送告警。规则匹配是纯 CPU 计算,IO 只占请求总时长的 15% 左右——这正是 Rust 最能发挥的场景。
技术栈对照
迁移并非逐行翻译,而是在保留业务语义的前提下重新设计架构。两侧的技术栈对应关系如下:
FastAPI · SQLAlchemy · uvicorn · asyncio
Actix-Web · Diesel · Tokio · async/await
| 分层 | Python(迁移前) | Rust(迁移后) |
|---|---|---|
| Web 框架 | FastAPI 0.110 | Actix-Web 4.5 |
| ASGI / 运行时 | uvicorn + asyncio | Tokio(多线程调度器) |
| ORM | SQLAlchemy 2.0 (async) | Diesel 2.1 |
| 序列化 | Pydantic v2 | serde + serde_json |
| 数据校验 | Pydantic 装饰器 | serde + validator crate |
| 部署 | Docker + gunicorn | 静态编译二进制 + systemd |
一个关键的设计差异:Python 端依赖 Pydantic 在运行时做校验,每次请求都要解析一次模型;Rust 端的 serde 在编译期生成反序列化代码,零反射开销。这是后续性能差距的重要来源之一。
ORM 迁移:SQLAlchemy → Diesel
Diesel 与 SQLAlchemy 的哲学截然不同。SQLAlchemy 是运行时 ORM,查询在运行时拼装;Diesel 是编译期 ORM——schema 用宏定义,查询构建器通过类型系统在编译期校验 SQL 正确性。
Schema 定义对比
先看 Python 端的 SQLAlchemy 模型定义:
from sqlalchemy import Column, Integer, String, Float, DateTime
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
class Event(Base):
__tablename__ = "events"
id = Column(Integer, primary_key=True)
user_id = Column(String(64), index=True)
amount = Column(Float)
risk_score = Column(Float, default=0.0)
created_at = Column(DateTime)
Rust 端用 Diesel 的 table! 宏定义 schema,这是编译期生成的,没有任何运行时反射:
// schema.rs —— 由 `diesel migration run` 自动生成
diesel::table! {
events (id) {
id -> Int4,
user_id -> Varchar,
amount -> Float8,
risk_score -> Float8,
created_at -> Timestamptz,
}
}
// 对应的可查询结构体
use diesel::prelude::*;
#[derive(Queryable, Selectable)]
#[diesel(table_name = events)]
pub struct Event {
pub id: i32,
pub user_id: String,
pub amount: f64,
pub risk_score: f64,
pub created_at: chrono::DateTime<chrono::Utc>,
}
查询构建对比
同样一个"查询某用户最近 10 条金额大于 100 的事件",两侧写法差异明显。Diesel 的查询构建器是强类型的——如果你把字符串字段和数字比较,编译直接失败:
from sqlalchemy import select
from sqlalchemy.orm import Session
async def get_risky_events(db: Session, user_id: str):
stmt = (
select(Event)
.where(Event.user_id == user_id)
.where(Event.amount > 100)
.order_by(Event.created_at.desc())
.limit(10)
)
result = await db.execute(stmt)
return result.scalars().all()
use crate::schema::events::dsl::*;
use diesel::prelude::*;
pub fn get_risky_events(
conn: &mut PgConnection,
uid: &str,
) -> QueryResult<Vec<Event>> {
events
.filter(user_id.eq(uid))
.filter(amount.gt(100.0))
.order(created_at.desc())
.limit(10)
.select(Event::as_select())
.load(conn)
}
Diesel 的filter(amount.gt("100"))会直接编译失败,因为amount是Float8而"100"是&str。这种类型错误在 Python 端只有运行时才会暴露。
Web 框架迁移:FastAPI → Actix-Web
FastAPI 的优雅之处在于装饰器 + 类型注解自动生成文档和校验;Actix-Web 走的是显式 App 定义 + actor 模型路线。下面看路由、中间件、序列化和错误处理四个维度的迁移。
路由定义
from fastapi import FastAPI, Depends
from pydantic import BaseModel
app = FastAPI()
class EventIn(BaseModel):
user_id: str
amount: float
@app.post("/events")
async def create_event(payload: EventIn):
score = evaluate_rules(payload)
return {"risk_score": score}
use actix_web::{web, App, HttpServer, HttpResponse, Responder};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct EventIn {
user_id: String,
amount: f64,
}
#[derive(Serialize)]
struct ScoreOut {
risk_score: f64,
}
async fn create_event(payload: web::Json<EventIn>) -> impl Responder {
let score = evaluate_rules(&payload);
HttpResponse::Ok().json(ScoreOut { risk_score: score })
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
App::new().service(
web::resource("/events").route(web::post().to(create_event))
)
})
.bind("0.0.0.0:8080")?
.run()
.await
}
中间件与错误处理
FastAPI 用异常类 + 全局 exception handler;Actix-Web 推荐把错误实现为 ResponseError trait,框架会自动调用 error_response() 转换为 HTTP 响应:
use actix_web::{HttpResponse, ResponseError};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ApiError {
#[error("event not found")]
NotFound,
#[error("invalid amount: {0}")]
BadRequest(String),
#[error("db error: {0}")]
Db(#[from] diesel::result::Error),
}
impl ResponseError for ApiError {
fn error_response(&self) -> HttpResponse {
match self {
ApiError::NotFound => HttpResponse::NotFound().finish(),
ApiError::BadRequest(m) => HttpResponse::BadRequest().json(serde_json::json!({"error": m})),
ApiError::Db(_) => HttpResponse::InternalServerError().finish(),
}
}
}
这样新增错误类型只需要加一个 enum 变体,? 运算符会自动把 diesel::result::Error 转成 ApiError::Db——比 Python 端的 try/except 散落各处要整洁得多。
异步模型差异:asyncio vs Tokio
这是迁移中最需要思维转换的部分。两者的表面语法相似(都是 async/await),但底层模型差异巨大。
1. 协程实现:有栈 vs 无栈
Python 的协程是有栈协程,每个协程有独立的栈帧;Rust 的 async fn 返回的是一个状态机(Future),编译期生成,没有独立栈。这意味着 Rust 的每个 Future 内存占用是定长的(等于状态机大小),通常几十到几百字节;Python 每个协程至少几 KB。
2. Send / Sync 约束
Tokio 的多线程调度器要求跨 .await 的 Future 必须是 Send——即不能在线程间传递非 Send 的类型。这是 Python 开发者最容易踩的坑:
// 编译失败:Rc 不是 Send,不能跨 await 持有
async fn bad() {
let shared = Rc::new(5); // Rc<i32>
some_async_op().await; // ✗ Future 不是 Send
println!("{}", shared);
}
// 正确:用 Arc 替代 Rc
async fn good() {
let shared = Arc::new(5); // Arc<i32>: Send + Sync
some_async_op().await; // ✓
println!("{}", shared);
}
在 Python 里Rc对应的概念不存在——GIL 保证了一切引用计数的线程安全。迁移到 Rust 后,跨.await持有的共享数据必须用Arc,且内部可变性需要Mutex或RwLock。
3. 生命周期与借用
Python 没有显式生命周期,引用由 GC 托管。Rust 的 async fn 借用局部变量时,变量的生命周期必须覆盖整个 Future——这会导致编译器要求把借用的数据提升为 'static 或克隆。一个常见模式是用 tokio::spawn 启动后台任务时,捕获的变量必须是 owned 的:
async fn handle_event(ev: Event, pool: Arc<bb8::Pool<DieselConnectionManager<PgConnection>>>) {
// tokio::spawn 要求 Future: Send + 'static
// 因此 pool 必须是 Arc(owned),不能是 &Pool
tokio::spawn(async move {
let mut conn = pool.get().await.unwrap();
diesel::insert_into(events::table)
.values(&ev)
.execute(&mut conn)
.ok();
});
}
基准测试
迁移完成后,我用 wrk 对同一组接口做压测。测试环境:单台 c6i.2xlarge(8 vCPU / 16GB),PostgreSQL 单独部署在相邻可用区。所有接口逻辑等价,规则引擎跑同样的 40 条规则。
| 场景 | Python QPS | Rust QPS | P99 延迟 | 内存占用 |
|---|---|---|---|---|
| 纯 JSON 序列化 | 12,400 | 198,000 | 2.1ms → 0.4ms | 320MB → 28MB |
| 规则匹配(CPU 密集) | 810 | 32,600 | 512ms → 11ms | 540MB → 45MB |
| DB 读 + 序列化 | 1,520 | 18,400 | 88ms → 6ms | 410MB → 38MB |
| DB 写 + 规则匹配 | 640 | 9,800 | 240ms → 18ms | 580MB → 52MB |
| 长连接(10k 并发) | —(OOM) | 稳态 14,200 | — → 9ms | — → 120MB |
规则匹配场景下 Rust 的 QPS 是 Python 的 40.3 倍,P99 从 512ms 降到 11ms——这正是文章标题的来源。需要注意的是,DB 读场景的提升只有 12 倍,因为这里瓶颈部分转移到 PostgreSQL,Rust 优化的是应用层开销。长连接场景 Python 直接 OOM,而 Rust 在 10k 并发下内存仅 120MB,得益于 Tokio 无栈协程的极低开销。
压测命令:wrk -t8 -c200 -d30s http://host:port/endpoint。每个场景预热 10 秒后采集 30 秒数据,取 3 次中位数。Python 端 uvicorn 启动 8 worker,Rust 端 Tokio 多线程运行时使用全部 8 核心。
踩坑记录
1. Diesel 编译时间爆炸
Diesel 的宏在编译期展开大量代码,首次编译我们的服务耗时 4 分 20 秒。优化手段:把 schema 拆分到独立 crate 并开启 incremental compilation;用 cargo nextest 替代 cargo test 并行跑测试;对 diesel::table! 生成的代码开启 #[cfg_attr 条件编译减少 release 构建量。优化后增量编译降到 18 秒。
2. 生命周期地狱(Lifetime Hell)
把一个返回 &str 的 Python 函数迁移过来,如果它内部拼接字符串,&str 的生命周期会引发连锁编译错误。解法是统一返回 String(owned),只在热路径用 Cow<str> 兼顾借用和 owned。不要为了"零拷贝"过早优化——先让代码跑起来,再针对性优化热点。
3. serde 序列化陷阱
一个反直觉的坑:Option<f64> 默认在 None 时序列化成 null,但我们前端要求省略字段。解法是给字段加 #[serde(skip_serializing_if = "Option::is_none")]。另一个坑是 chrono::DateTime 需要 #[serde(with = "serde_with::chrono::rfc3339")] 才能正确序列化时区,否则会 panic。
4. 部署复杂度:交叉编译
Python 部署是 Docker 镜像一把梭;Rust 的目标是静态编译单二进制,但 OpenSSL / libpq 这类 C 依赖让交叉编译变复杂。最终方案是用 cargo build --target x86_64-unknown-linux-musl 配合 rustls(替代 OpenSSL)和 libpq 的 musl 静态链接。产物 14MB 单文件,scp 上去 systemctl restart 即可。
迁移过程中我保留了 Python 端的 OpenAPI 文档生成能力——用utoipacrate 给 Actix handler 加#[utoipa::path]注解,自动生成与 FastAPI 兼容的 schema,前端无感切换。
最终架构图
迁移后的系统架构如下,所有组件均为 Rust 实现,单二进制部署:
(HTTP/TLS)
反序列化
(rayon 并行)
规则引擎部分用 rayon 做数据并行——把每条事件的 40 条规则拆分到多核并行执行,最后归约风险分。这是性能提升 40 倍的关键之一:Python 端受 GIL 限制只能单核跑规则,Rust 端 8 核全开。
总结:什么时候该迁移,什么时候不该
迁移历时 6 周(2 人),产出 1.4 万行 Rust 代码。性能提升显著,但成本不低。我的建议是:
- 该迁移:CPU 密集型服务、对尾部延迟敏感(无 GC)、需要支撑高并发长连接、对单实例成本敏感(Rust 单机可替代 10+ Python 实例)。
- 不该迁移:IO 密集型 CRUD 服务(瓶颈在 DB,语言差异不大)、快速迭代的 MVP、团队没有 Rust 经验储备、业务逻辑频繁变更(Rust 的编译时间和类型约束会拖慢迭代)。
迁移成本评估的核心是看瓶颈在哪。如果 profiler 显示你的服务 80% 时间在等数据库,换语言最多提升 1.2 倍;如果 80% 时间在 CPU 计算,那 Rust 能给你数量级的提升。先 profile,再决定——这是这次迁移教会我最重要的一课。
下一步计划把规则引擎的 DSL 解析器从手写递归下降迁移到 nom,并引入 tonic gRPC 支持内部服务间通信。完整的迁移 PR 和基准测试脚本已开源在 GitHub。