我们的后端服务跑在一个 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 的核心原因是:

业务背景

服务是一个实时风控规则引擎:接收事件流(每秒 ~2 万条),对每条事件跑 40+ 条规则匹配,命中则落库并推送告警。规则匹配是纯 CPU 计算,IO 只占请求总时长的 15% 左右——这正是 Rust 最能发挥的场景。

技术栈对照

迁移并非逐行翻译,而是在保留业务语义的前提下重新设计架构。两侧的技术栈对应关系如下:

Python 栈
FastAPI · SQLAlchemy · uvicorn · asyncio
Rust 栈
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 模型定义:

Python
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,这是编译期生成的,没有任何运行时反射:

Rust
// 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 的查询构建器是强类型的——如果你把字符串字段和数字比较,编译直接失败:

Python
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()
Rust
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")) 会直接编译失败,因为 amountFloat8"100"&str。这种类型错误在 Python 端只有运行时才会暴露。

Web 框架迁移:FastAPI → Actix-Web

FastAPI 的优雅之处在于装饰器 + 类型注解自动生成文档和校验;Actix-Web 走的是显式 App 定义 + actor 模型路线。下面看路由、中间件、序列化和错误处理四个维度的迁移。

路由定义

Python
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}
Rust
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 响应:

Rust
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 开发者最容易踩的坑:

Rust
// 编译失败: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,且内部可变性需要 MutexRwLock

3. 生命周期与借用

Python 没有显式生命周期,引用由 GC 托管。Rust 的 async fn 借用局部变量时,变量的生命周期必须覆盖整个 Future——这会导致编译器要求把借用的数据提升为 'static 或克隆。一个常见模式是用 tokio::spawn 启动后台任务时,捕获的变量必须是 owned 的:

Rust
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 文档生成能力——用 utoipa crate 给 Actix handler 加 #[utoipa::path] 注解,自动生成与 FastAPI 兼容的 schema,前端无感切换。

最终架构图

迁移后的系统架构如下,所有组件均为 Rust 实现,单二进制部署:

Client
Actix-Web
(HTTP/TLS)
serde
反序列化
规则引擎
(rayon 并行)
bb8 连接池
PostgreSQL

规则引擎部分用 rayon 做数据并行——把每条事件的 40 条规则拆分到多核并行执行,最后归约风险分。这是性能提升 40 倍的关键之一:Python 端受 GIL 限制只能单核跑规则,Rust 端 8 核全开。

总结:什么时候该迁移,什么时候不该

迁移历时 6 周(2 人),产出 1.4 万行 Rust 代码。性能提升显著,但成本不低。我的建议是:

迁移成本评估的核心是看瓶颈在哪。如果 profiler 显示你的服务 80% 时间在等数据库,换语言最多提升 1.2 倍;如果 80% 时间在 CPU 计算,那 Rust 能给你数量级的提升。先 profile,再决定——这是这次迁移教会我最重要的一课。

后续计划

下一步计划把规则引擎的 DSL 解析器从手写递归下降迁移到 nom,并引入 tonic gRPC 支持内部服务间通信。完整的迁移 PR 和基准测试脚本已开源在 GitHub