本文永久链接 – https://tonybai.com/2026/05/27/migrate-go-to-rust

大家好,我是Tony Bai。

在现代后端系统编程领域,Go 和 Rust 无疑是最耀眼的两大双子星。它们都拥有静态类型、编译型、单二进制文件分发等优异特性。然而,这两门语言在底层的设计哲学、运行时权衡以及开发者体验上,走向了截然不同的方向。

Matthias Endler(Corrode 咨询公司创始人)撰写的《从 Go 迁移到 Rust》(Migrating from Go to Rust)是近年来系统编程领域极具深度的一篇迁移指南。作为在生产环境中同时大规模部署过 Go 和 Rust 系统的资深架构师,Matthias 并没有陷入单纯的“谁比谁快”的无意义争论,而是从正确性保证、运行时权衡、工程重构成本等多个维度,客观地为准备进行语言迁移的团队提供了一份极其务实的工程路线图。

以下是该迁移指南的完整简体中文译文,以及技术社区对于此文的精彩技术辩论与观点。


在我协助团队进行的所有迁移中,从 Go 到 Rust 的迁移是一个特例。

这并不是“Rust 会更快吗?”或“Go 是否拥有类型系统?”的问题,因为 Go 在这些方面已经做得很好了。这里的讨论主要围绕正确性保证运行时权衡以及开发人员体验展开。

在开始之前,先做一个简短的免责声明:本指南高度侧重于后端。后端服务是 Go 的强项所在——小巧的静态二进制文件、专注于网络连接的标准库,以及用于 HTTP 服务器、gRPC、数据库等的庞大生态系统。

这也是大多数考虑使用 Rust 的团队的来源(至少是那些联系我的团队),因此我认为这是在实践中最有用的对比。如果你正在编写命令行工具(CLI)、嵌入式固件或游戏引擎,本文中的一些内容仍然适用,但老实说,我恐怕这不是最适合你的资源。

作为背景,我之前曾写过关于 Go 和 Rust 对比的文章,比如 2017 年的《Go vs Rust?选择 Go》,以及后来与 Shuttle 团队合作撰写的《Go vs Rust:实操对比》,后者通过一个小型后端服务展示了两种语言的具体差异。

你将在本文中学到什么

  • Go 与 Rust 的重叠点和分歧点。
  • Go 的模式如何映射到 Rust。
  • 你能从借用检查器中获得什么。
  • 我在什么情况下会建议人们保留 Go,以及在什么情况下 Rust 值得进行迁移。
  • 如何渐进式地迁移 Go 服务。

我的背景与立场

坦白说:我不是 Go 的粉丝。我认为它是一门设计糟糕的语言,尽管它非常成功。它混淆了简单性(simplicity)与易用性(easiness),并且它的几个核心设计折中——无处不在的 nil、作为纪律规则而非类型的错误处理、长期缺失的泛型——都将设计引向了我所不认同的方向。尽管如此,成功才是硬道理!Go 已经捕获了庞大且持久的活跃开发者份额,在 JetBrains 开发者生态系统调查中一直维持在 17-19% 左右。Rust 正在稳步增长,但目前仍然只占一小部分:


图:2017-2024 年开发者中 Go 和 Rust 的使用情况

Go 显然对很多人都非常适用,而一个假装其不适用的指南是毫无帮助的。因此,在这份指南中,我将尽最大努力保持客观,而不是去重新争论那些老问题。但你应该了解我的先验立场,以便进行校准。

另一个值得披露的前提是:我运行着一家 Rust 咨询公司;所以,我当然是有偏见的!更多人使用 Rust 对我的业务是有利的。但我也在专业领域中使用过这两门语言,并曾将 Go 服务推向生产环境。

本指南适用于那些希望诚实对比迁移到 Rust 时会有什么变化的 Go 开发者。

如果想看一个故意持相反立场的观点,我推荐阅读 Blain Smith 的《就用 Go语言好了,别他妈的废话了!》(Just Fucking Use Go)。在脑海中同时保留这两种观点,比只持其中一种更有用。

如果你更喜欢观看视频而不是阅读,这里有一段来自 The Primeagen 对上述 Shuttle 文章的视频阅读和点评:

(视频:Rust vs Go: Hands On Comparison)

初看最重要的命令

Go 开发者已经拥有了行业内最干净的工具链之一。在很久以前,它就开启了“自带电池(batteries included)”式工具链的潮流,为你提供了一个单一、一致的界面,用于构建、测试、格式化、lint 和管理依赖项。我很高兴 Rust 也效仿了这种做法,因为这是一个极好的模式。这是我最喜欢的这两个生态系统的部分之一。

cargo 甚至拥有更多内置功能:

最大的区别在于,在 Go 中你通常需要借助第三方工具(golangci-lint、mockgen、air、goreleaser)来填补空白。而在 Rust 中,原生(第一方)生态系统开箱即用的功能要丰富得多。有些需要外部 crate 的工具(例如 cargo watch、cargo nextest)只需一个命令即可完成安装并开始使用,例如运行 cargo install cargo-nextest 即可立即获得 cargo nextest。

两个社区在格式化工具上都达成了相同的共识:一个单一的、规范的风格,即使不是完美的,也远比在琐碎的争论(bikeshedding)上浪费时间要好。

“Gofmt 的风格不是任何人的最爱,但 gofmt 却是每个人的最爱。”

— Rob Pike, Go Proverbs

对于 rustfmt 也是如此;并非每个人都喜欢它的每个细节,但代码评审中不再存在关于代码风格的争端,远比偶尔遇到你不喜欢的格式化偏好要有价值得多。

Go 与 Rust 的关键差异

核心结论是,Go 和 Rust 都是编译型、静态类型、单二进制文件部署、具有强大并发能力的语言。不同之处在于编译器向你保证了什么,以及你对运行时行为拥有多少控制力

在深入探讨之前,有一个概念框架很有帮助:当你从 Go 迁移到 Rust 时,大部分变化都会被推入类型系统。 空值处理、错误传播、数据竞争、资源生命周期、取消机制、泛型,这些在 Go 中要么依赖运行时规范、工具链(go vet、errcheck、golangci-lint、-race),要么依赖运行时的自觉性。而 Rust 则将它们编码为类型,以便编译器在编译时强制执行。

常见的反对意见是这带来了“更多的认知负荷”。我不认同这种说法。我认为,这其实是将认知负荷从你由于必须记住规则而产生的焦虑中释放出来,转移到了编译器身上。一旦你内化了这种模式,并发现它在代码中无处不在(Option、Result、&mut T、Send/Sync、RAII 守卫),Rust 就会停止让你感到沉重,并开始感觉编译器正在为你做你以前必须在大脑中做的工作。

为什么 Go 开发者会考虑 Rust

Go 开发者通常不会因为 Go “太慢”而转向 Rust。对于大多数后端工作负载,Go 已经足够快了。人们普遍是对 Go 的一些由于设计不严密而产生的问题感到沮丧:nil 指针带来的隐患、段错误(segmentation faults)的风险、缺乏泛型(长期以来)或任何更复杂的类型系统特性(如枚举和强大的 trait),以及标准库中存在一些怪异的缺失,例如缺少一个内置的 Set 类型(惯用的替代方案是 map[T]struct{},它在实践中行得通,但感觉类型系统并没有真正起到作用)。

生产环境中的 nil panics

你部署了一个 Go 服务,它运行得很好,持续了几个月。然后,某条代码路径被执行,而其中有人忘记检查某个指针是否为 nil,导致 goroutine 崩溃。一个常见的例子是查找操作,它返回零值,或者反序列化后未填充结构体中的某个指针字段:

func (s *Service) Handle(req *Request) error {
    // Find 返回 (*User, error)。如果是 "not found",error 为 nil;
    // 调用者应该检查 user != nil,但这非常容易被遗漏。
    user, err := s.repo.Find(req.UserID)
    if err != nil {
        return err
    }

    return user.Account.Notify() // 如果 user 为 nil,或 Account 为 nil,则会发生崩溃
}

Linter 和 IDE 会捕获其中一些情况(通过 nilaway、staticcheck),但它们是选择性开启的、概率性的,而且不能可靠地跨越包边界。Rust 的编译器则根本不允许你忽略这种情况。Rust 的 Option 可以做到:

fn handle(&self, req: &Request) -> Result<(), ServiceError> {
    let user = self.repo.find(req.user_id)?; // 返回 Option<User>; ? 运算符进行短路处理
    user.notify()
}

如果没有显式处理 None 的情况,你甚至无法解引用一个 Option。一整类导致 pager-duty(线上紧急警报)事件的事故就这样消失了。

-race 未能捕获的数据竞争

go test -race 是一个优秀的工具,但它是一个运行时检测器,意味着它只能找到测试中实际执行到的竞争。在线上高负载下,多个 goroutine 在没有锁的情况下修改同一个 map 会轻松绕过该测试,并导致生产环境崩溃。

在 Rust 中,跨线程共享可变状态需要实现 Send 和 Sync。尝试共享一个普通的 HashMap 并且程序甚至无法编译。你被迫将其封装在 Arc<Mutex<…>> 或 Arc<RwLock<…>> 中,否则编译器会报错。这样,数据竞争在编译时就成了一个类型错误。

Paul Dix 对于什么促使了 InfluxDB 3.0 的重写非常坦诚,而数据竞争的故事就排在最前面:

“【最主要的好处是】无畏并发——消除了此前我们从未消除的数据竞争。在 Influx 1.x 版本中,确实存在一些非常棘手的 bug。”

— Paul Dix, InfluxData 创始人兼 CTO,摘自 Rust in Production

可组合的错误处理

在 Go 中,你会写:

if err != nil {
    return err
}

在一两年的开发后,你通常会注意到三件事:

  1. 样板代码冲淡了你函数的实际业务逻辑。
  2. 使用 fmt.Errorf(“doing X: %w”, err) 包装错误是一项纪律要求,而不是编译器强制的规则。这很容易丢失上下文。
  3. 通过 errors.Is/errors.As 使用哨兵错误可以工作,但当你忘记处理新变体时,编译器不会提醒你。

对反方观点保持诚实也很重要,因为在关于我的 Shuttle 文章的 Lobste.rs 讨论线程中,经验丰富的 Go 开发者指出,errcheck 和 golangci-lint 捕获了绝大多数“忘记处理错误”的情况,并且显式的 if err != nil 比深层嵌套的 ? 链更容易阅读。这两个观点都很中肯,显式风格是一个刻意的文化抉择,而不是一次疏忽:

“我认为错误处理应该是显式的,这应该是该语言的核心价值。”

— Peter Bourgon, GoTime #91,引用自 Dave Cheney 的 Zen of Go

我的看法是,lint 是一个你必须记住去配置的选择性安全网,而 Rust 的 Result<T, E> 是类型签名本身,无法被遗忘。样板代码与可读性之间的折中是非常真实且见仁见智的。

在 Rust 中:

#[derive(Debug, thiserror::Error)]
pub enum UserError {
    #[error("user {0} not found")]
    NotFound(UserId),
    #[error("user already exists")]
    AlreadyExists,
    #[error(transparent)]
    Repo(#[from] RepoError),
}

pub fn rename(id: UserId, name: &str) -> Result<User, UserError> {
    let mut user = repo::get(id)?; // ? 自动将 RepoError 转换为 UserError
    user.name = name.to_string();
    Ok(user)
}

? 运算符处理了错误传播,#[from] 处理了类型转换,而针对 UserError 的 match 是穷尽检查的。如果明天你添加一个新的错误变体,编译器会向你展示每一个需要更新的地方。

不装箱的泛型

Go 在 1.18 中引入了泛型,它们很有用,但实现上有一些限制(不支持类型参数上的方法GC shape stenciling,偶尔会有令人失望的性能表现)。Rust 泛型采用单态化(monomorphize),为每个实例生成具有零运行时开销的专门代码。结合 trait,这为你提供了真正的零成本抽象。

这在处理程序(handler)代码中不那么重要,而在共享基础设施(中间件、通用存储库、解码器、解析器)中更重要,在 Go 中,你常常被迫退回到 interface{} / any 外加类型断言。

可预测的延迟

Go 的 GC 非常优秀、并发、低停顿,针对典型的服务工作负载进行了很好的调优。但“低停顿”不等于“无停顿”。在重载情况下,P99 延迟尾部明显差于一个不在热路径上分配内存的 Rust 等效程序。

我不会过分夸大这一点,对于绝大多数服务来说,Go 的 GC 根本不是问题。但对于延迟敏感的系统(交易、实时竞价、网络代理、高吞吐量数据摄入),没有 GC 停顿是一个巨大的卖点。Stephen Blum 把它说得很直接:

“Go 在我们的规模下表现很好,但我们确实需要一些能给我们带来高性价比性能的东西,而 Rust 能够让我们达到那个目标。这就是为什么如今基本上所有的东西都在朝着 Rust 发展的原因。”

— Stephen Blum, PubNub CTO, 摘自 Rust in Production


总结

Go 像是遭受了千刀万剐(death by a thousand paper cuts)。它是一门非常实用的语言,如果你愿意忽略上述问题,你可以在其中获得极高的生产力。但在达到一定的代码规模后,问题就会开始累积。Go 失去吸引力并没有单一的瞬间,但团队会发现自己渴望更多(更多的安全性、更多的控制、更多的表现力),这就是他们开始寻找替代方案的时候。


Side By Side的对比两种语言

在 Rust 中感到舒适的最快方法是映射你已经知道的模式。如果要看在两种语言中构建相同后端服务的更长、包含大量代码的完整示例,请参阅 Shuttle 对比文章,本节重点介绍最常出现的模式。

错误处理:if err != nil 对比 Result<T, E>

Go:

func ReadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("reading config: %w", err)
    }

    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, fmt.Errorf("parsing config: %w", err)
    }

    return &cfg, nil
}

Rust:

fn read_config(path: &Path) -> Result<Config, ConfigError> {
    let data = fs::read_to_string(path)?;
    let cfg = serde_json::from_str(&data)?;
    Ok(cfg)
}

? 运算符替你完成了 if err != nil { return err } 的繁琐工作,如果为 E2 实现了 From,它还会进行类型转换(这在使用 thiserror 的 #[from] 时是惯用)。

空值:nil 对比 Option

Go:

func GetUser(id string) *User {
    for _, u := range users {
        if u.ID == id {
            return &u
        }
    }
    return nil
}

u := GetUser("123")
fmt.Println(u.Name) // 如果u 为 nil 则会发生panic

Rust:

let user = get_user("123");
println!("{}", user.name); // 编译错误:user 的类型是 Option<User>,而不是 User

// 你必须处理这两种情况:
match get_user("123") {
    Some(u) => println!("{}", u.name),
    None => println!("not found"),
}

在安全的 Rust 中没有 nil。引用不能是空的。指针可以是空的,但你几乎永远不会在应用程序代码中使用裸指针。

接口 对比 Traits

Go 的接口是结构化的,一个类型隐式地满足一个接口:

type Reader interface {
    Read(p []byte) (n int, err error)
}

Rust 的 trait 是标称的,你需要显式地实现它们:

pub trait Reader {
    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize>;
}

impl Reader for MyType {
    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { /* ... */ }
}

Go 的风格非常适合临时性的鸭子类型。Rust 的风格非常适合重构和可发现性,你可以用 grep 搜索某个 trait 的每个实现者。

Rust 中与 interface{} / any 最接近的等价物是 Box,但你几乎永远不会想要它。Go 社区习惯于伸手去拿 interface{},也是因为:

“interface{} 什么也没表达。”

— Rob Pike, Go Proverbs

带有 trait 约束的泛型函数(fn handle(r: R))涵盖了绝大多数情况,并通过单态化提供无运行时开销。在 Go 1.18 之前,这迫使你退回到 interface{} 加上类型断言,而 Rust 的 trait + 泛型让你能够非常具体。

当你确实需要运行时分发(例如,不同实现者的异构存储)时,你会选择 Box 或 Arc。这是 Go 中持有 interface 值最直接的 Rust 对应物。

Goroutines 对比 异步任务

Go 的并发模型以简单著称:

go doWork(ctx, input)

Goroutine 很廉价,运行时会在操作系统线程之间调度它们,而通道(chan T)是主要的协同原语。Go 谚语捕获了这一理念:

“不要通过共享内存来通信;而要通过通信来共享内存。”

— Rob Pike, Go Proverbs

这是 Go 真正大放异彩的地方,并且它对为什么非常明确:在 Go 中,顺序代码和并行代码之间没有语法上的区别。函数签名、它的调用者,或关于它如何编写的任何内容都毫无二致。没有 async fn,没有 .await,没有执行器可供选择,也没有 Send / Sync 约束。只要你不共享可变状态而不进行同步,顺序代码和并发代码看起来是一样的。

这种属性,即没有函数着色(function colouring),是 Go 相比 Rust 最大的日常生产力优势,而在迁移之后,这也是 Go 开发者最怀念的东西。Lobste.rs 讨论中的几位评论者准确地指出了这一点,他们说得很对。Rust 的 async 更加强大且经过更多检查,但它的显式度也更高,这带来了真正的开发体验成本。

Rust 在执行器(对于后端服务几乎总是 tokio)之上使用 async/await:

tokio::spawn(async move {
    do_work(input).await;
});

形式很相似。不同之处在于:

  • Rust 的异步函数返回 Future。除非被 .await 或 spawn,否则它们不会运行。
  • 编译器会跨 .await 点验证 Send/Sync 约束。如果你在跨 .await 期间持有一个非 Send 的值,你会得到一个非常精确的编译器错误,解释其原因。
  • 没有内置的 goroutine 风格的抢占。异步任务中长时间运行的 CPU 工作会使执行器饥饿;你需要将其卸载到 tokio::task::spawn_blocking 或 rayon。
  • 通道(tokio::sync::mpsc、broadcast、watch)是一流的,但存在于库中,而不是语言本身。

对于大多数后端代码,日常体验是类似的:启动一个任务,通过通道进行通信,并大方地使用超时。

context.Context 对比 CancellationToken

在 Go 中,你将 context.Context 传给每个阻塞调用:

func (s *Service) Fetch(ctx context.Context, id string) (*User, error) {
    return s.client.Get(ctx, "/users/"+id)
}

Rust 没有内置的 context.Context。最接近取消的等价物是 tokio_util::sync::CancellationToken:

pub async fn fetch(&self, token: CancellationToken, id: &str) -> Result<User, FetchError> {
    tokio::select! {
        _ = token.cancelled() => Err(FetchError::Cancelled),
        res = self.client.get(&format!("/users/{}", id)) => res,
    }
}

对于超时,tokio::time::timeout(dur, fut) 可以包装任何 future。对于截止时间/值,你通常将它们作为显式参数传递,或者使用 tracing span 而不是单一的上下文对象。

一些 Go 开发者怀念 ctx 的隐式感。但在实践中,显式的 Rust 风格更容易让人推断,因为你总是确切地知道什么是可以取消的,什么是不可以的。更深层次的观点是,没有任何一种语言可以免费给你取消机制,只是规约出现在不同的层面上:

“Go 并没有办法告诉一个 goroutine 退出。没有停止或杀死函数,这是出于充分的理由。如果我们不能命令一个 goroutine 退出,那么我们就必须礼貌地请求它。”

— Dave Cheney, The Zen of Go

在 Go 中,这种“礼貌地请求”是通过约定俗成地在每个调用点传递并检查 context.Context。在 Rust 中,则是 CancellationToken(或 watch 通道)传给每个调用点,但编译器实际上可以在你忘记时提醒你。

通道

两种语言都有通道。翻译很直接:

Go:

ch := make(chan int, 10)
go func() {
    ch <- 42
}()
v := <-ch

Rust:

let (tx, mut rx) = tokio::sync::mpsc::channel::<i32>(10);
tokio::spawn(async move {
    tx.send(42).await.unwrap();
});
let v = rx.recv().await.unwrap();

Rust 的通道将发送端(Sender)和接收端(Receiver)区分为不同的类型,这使得所有权和 Send 属性在类型层面是显式的。

结构体与方法

Go:

type Circle struct {
    Radius float64
}

func (c Circle) Area() float64 {
    return math.Pi * c.Radius * c.Radius
}

Rust:

pub struct Circle {
    pub radius: f64,
}

impl Circle {
    pub fn area(&self) -> f64 {
        std::f64::consts::PI * self.radius * self.radius
    }
}

Rust 的 &self 相当于 Go 的值接收者;&mut self 是一个带有修改权限的指针接收者。拥有的 self(消耗该值)在 Go 中没有对应物,但在(类型状态、构建器)模式中偶尔非常有用。

字符串:string 对比 String 与 &str

Go 的 string 是一个具有赋值时拷贝语义的 UTF-8 字节切片(头部被复制,底层数据是不可变且共享的)。Rust 将其分为两种类型:

  • String:拥有的、堆分配的、可增长的。相当于你打算修改的 []byte。
  • &str:借用的视图,指向别人的字符串数据。大部分时间相当于作为 Go 的 string 参数使用。

作为一条经验法则,参数中接收 &str,在生成新数据时返回 String。

fn greet(name: &str) -> String {
    format!("Hello, {name}")
}

一旦你内化了这一点,这基本上是无痛的。&str 与 String 的划分是 Rust 更广泛的“借用与拥有”模型的一个缩影。

Go 泛型:太少,太迟

Go 在 1.18(2022 年 3 月)引入了泛型,在语言出货十三年之后。它们很有用,但由于它们是后期补丁(tacked on),在实践中它们具有大多数你期望从 Rust、Haskell 甚至现代 C++ 获得的泛型系统的缺点,却没有任何优点

这是一个很强烈的说法,所以让我来支持它。

标准库几乎不使用它们

最明显的信号是,在泛型落地三年后,Go 自己的标准库仍然主要避免使用它们。sort.Slice 仍然接受一个 func(i, j int) bool 闭包,而不是 cmp.Ordered 约束。sync.Map 仍然被类型化为 any / any。除了 slices、maps 和少数组件外,几乎没有它们的身影。

公平地指出,向后兼容性是这里的主要原因:Go 1 的兼容性承诺意味着现有的非泛型 API 无法重构,因此任何泛型版本都必须与其并存(或在新的包中)。但这只是解释的一部分。已经有足够的时间来引入泛型替代方案,而几乎没有出现这一事实表明语言设计者并不倾向于将泛型作为他们使用的主要工具。

将其与 Rust 进行对比,在 Rust 中,泛型从第一天起就渗透到了标准库中:Option、Result<T, E>、Vec、HashMap<K, V>、Iterator、From、Into、AsRef、Borrow,每个集合、每个智能指针。在不使用泛型的情况下,你根本无法写出惯用的 Rust,因为标准库本身就是泛型的。

在 Go 中,泛型是库作者在确实需要时才选择使用的功能。在 Rust 中,它们是构建一切事物的底层基石。

没有 Trait 系统,只有结构化约束

Rust 的泛型与 trait 绑定,trait 兼作该语言进行多态、超类、关联类型、毯子实现(blanket impls)和一致性的机制。

Go 的约束只是带有一个额外 ~ 运算符的接口,用于类型集成员资格。这里没有:

  • 超类 / 约束继承体系: 在 Rust 中,你写 trait Ord: Eq + PartialOrd,任何满足 T: Ord 的类型自动满足 Eq 和 PartialOrd。Go 没有等价物;你可以嵌入接口,但约束求解器并不推断关于层次结构的任何信息。
  • 关联类型: Rust 的 Iterator 有 type Item;,因此 T::Item 是第一等公民,这体现在每个方法的签名中。Go 最接近的等价物是第二个类型参数,这会泄露到每个方法签名中。
  • 毯子实现(Blanket impls): 在 Rust 中,impl ToString for T 会自动为每一个实现了 Display 的类型实现 ToString 方法。在 Go 中,没有办法在定义包之外,为一个类型添加方法。
  • 拥有自己类型参数的方法: 这是一个显式且有文档记录的 Go 缺失功能 (译注:Go 1.27将补全泛型方法这一特性)。你不能写 func (s Set[T]) Map[U](f func(T) U) Set[U]。在 Rust 中,泛型方法是家常便饭。

实际的后果是,当你的抽象需要不仅仅是一个“适用于任何 T 的函数外加这几个操作”时,Go 就会迫使你退回到 any 以及类型断言、代码生成或运行时反射。

类型推导止于函数边界

Rust 使用 Hindley-Milner 风格的推导引擎,可以跨整个表达式传播类型信息,包括跨闭包、迭代器链和 ? 运算符。你经常写:

let evens: Vec<_> = (0..100).filter(|n| n % 2 == 0).collect();

而编译器会推断出 _ 是 i32,而 Vec<_> 目标是 Vec

Go 的推导要浅得多。它通常可以推断出函数参数的类型,但它不能从返回位置上下文中推断,不能通过泛型构建器跨链推断,并且经常在调用处强制使用显式的类型参数:

result := slices.Collect[int](iter) // 经常需要

在 Rust 中这是例外;在 Go 中这仍然很常见。

单态化 对比 GC Shape Stenciling

泛型没有免费的午餐:你必须要么在编译时买单,要么在运行时买单,要么通过代码膨胀(JIT)买单。C++ 和 Rust 在编译时通过单态化买单。Java 在运行时通过装箱买单。Go 选择了折中路线,采用了 GC 形状模板和字典,这有一篇众所周知的 PlanetScale 文章正好展示了这一点。

Rust 进行单态化:每个 Vec 和 Vec 都会产生专门的机器代码,具有零运行时开销。泛型代码是快速路径,而退回到 dyn Trait(相当于 Go 的接口分发)是一个深思熟虑的选择,在你需要运行时多态时做出。你要为单态化付出编译时间的代价,这和 C++ 几十年来付出的代价一样,但它们只是针对不同的事情进行了优化。

它们没有填补类型系统中的漏洞

这是最让我困扰的部分。

一个好的泛型系统可以消除退回到逃生舱口的理由。在 Rust 中,泛型 + trait 消除了你对 Box 或运行时反射的大部分需求。类型系统变得更强大了。

在 Go 中,泛型并没有消除 any,没有消除 reflect,没有消除代码生成作为诸如 ORM、解码器和 mock 等事物的首选模式。encoding/json 仍在使用反射。database/sql 仍在使用 any。mockgen 仍会生成代码。如果泛型系统能够大放异彩,最应该发挥作用的地方,正是 Go 在 1.18 之前就伸手去拿运行时机制的那些地方。

Go 中的泛型感觉是累加的,只是箱子里的一个新工具,在狭隘的案例中很有用。Rust 中的泛型感觉是基石般的;将它们移去,语言就会崩溃。

这就是区别所在,也是为什么在我的经验中,泛型 Go 代码读起来并不比它取代的基于 interface{} 的代码好;它只是读法不同,有更多标点符号罢了。

流行的 Go 包及其 Rust 对应物

如果你已经在 Go 中有了自己的偏好,Rust 生态系统已经趋于相似级别的“默认选择”。对于一个典型的后端服务:axum + sqlx + tokio + tracing + serde + clap 覆盖了你 90% 的需求。

过渡到 Rust 的关键挑战

我想坦率地说。从 Go 过来,你将会碰壁。这堵墙有一个名字。

借用检查器

Go 的运行时替你处理内存和别名。Rust 将这个决定推入类型系统。前几个星期你会写出“显然应该工作”的代码,然后编译器会拒绝它。

最常困扰 Go 开发者的模式有:

  1. 长生命周期引用: 在 Go 中,你可以很开心地在 map 中持有一个 *User,只要你愿意。在 Rust 中,该借用会在整个生命周期中锁住 map。解决方案通常是克隆(clone),或者缩小借用范围。
  2. 自引用结构体: 在 Go 中很常见(一个结构体同时持有数据和其上的迭代器)。在 Rust 中,这需要 Pin、ouroboros 或重新设计。几乎总是选择:重新设计。
  3. 跨 goroutine 共享可变状态: 在 Go 中你写成:mu sync.Mutex; data map[K]V,而在 Rust 中则变成 Arc<Mutex<HashMap<K, V>>>。稍微啰嗦一些,但经过了更多检查。
  4. 从函数返回引用: 生命周期标注(Lifetime annotations)就此出现。它们并不像其声誉那样糟糕,但对新手来说确实很陌生。

在所有的这些规则下,借用检查器确实听起来像一个“守门人”,不断阻碍,并且让人感到沮丧。但是,当你开始使用 Rust 时,不应该带着那样的心态。借用检查器真正揭示了你代码中现有的非常真实、非常微妙的 bug,如果你不解决它们,你的程序就会存在安全问题。因此,每当你从 rustc 得到编译器错误时,请退后一步,问自己以下几个问题:

  • 如果一个值被移动(moved)了,之后如果原位置试图再次使用它会发生什么?
  • 如果一个值被共享(shared)了,如果在另一个线程使用它的同时,有一个线程对其进行了修改会发生什么?
  • 如果一个指针被解引用(dereferenced),如果它是空值或悬空指针会发生什么?
  • 当一个值超出作用域(goes out of scope)时,如果其他地方仍然持有的引用正在被使用会发生什么?

这就是你需要理解借用检查器的心态。人类在推理内存方面真的很糟糕。我们很容易忘记指针可以为空,忘记旧的引用可以比它们指向的数据存活得更久,忘记多个线程可以同时修改同一块数据。我们倾向于对数据在程序中如何流动有一个“线性”的心理模型,但现实中它更接近于一个具有多条路径和交互的复杂图形。每一个 if 条件都会强制你考虑这两种分支中会发生什么。这正是借用检查器旨在为你做的事情!它强制考虑那些极其罕见但确实存在的、当你觉得可能不会发生但就是发生了的代码路径。

借用检查器其实是一个巨大的解脱。一旦它通过了,你就知道你的内存状态是 100% 连贯的,你可以专注于更高层次的问题。这也就是 Ed Page(clap 的维护者)说的:

“当你们刚开始接触它时:会感到沮丧。它让我想起了第一次学习编程的感觉,因为它太不一样了。由于借用检查器和生命周期,我不想去处理那些东西——但我被迫去了。”

— Stephen Blum, CTO, PubNub, 摘自 Rustacean Station

“……能够专注于更高层次的问题。在我进行自我分析并失败时,它帮助我发现了问题。”

— Ed Page, 摘自 Rustacean Station: clap with Ed Page

编译时间

对你的团队保持诚实,Rust 的编译时间相比 Go 的近乎瞬时的编译确实是一个退步。对于中等规模的服务,全新发布构建可能需要几分钟。增量构建和 cargo check 是合理的,并且编译时间在这些年里已经好了很多,但你仍然会感觉到差异。

为了缓解这种情况,在你的编辑循环中使用 cargo check,在项目见效后将其拆分进 workspace 中,并让你自己的 crate 中不要包含过程宏(proc-macro-heavy)重度依赖,这样它们就只在发生变化时才重新编译。请参阅《加速 Rust 编译时间的技巧》以进行更深入的探讨。

异步着色

正如《Goroutine 对比 异步任务》中所讨论的,Rust 的 async fn / fn 拆分是从 Go 迁移过来时最大的开发体验退步之一。异步 trait 自 Rust 1.75 以来已经稳定,但在将它们与动态分发结合时,仍然存在一些粗糙的边缘,你偶尔需要借助 async-trait crate 来解决。

某些细分领域中生态系统较小

Rust 的 crate 生态系统正在增长,并且库在整体上具有很高的质量,但 Go 在一些后端相邻领域具有领先优势:Kubernetes operator、云提供商 SDK、某些特定生态系统的数据库驱动。在做出承诺之前,请花一天时间检查你依赖的库是否具有你愿意使用的 Rust 对应物。我协助的团队经常不得不自己动手实现至少一两个核心库——例如,他们可能需要更新一个废弃的 XML 架构验证 crate,或为较少人知的协议编写自己的客户端。

集成策略

你不需要一次性重写所有内容。我听到的每一个成功的 Go 到 Rust 迁移案例都是战术性的,而不是大爆炸式的重写。Microsoft 的 Victor Ciura 总结得很到位:

“我们并不是疯狂地到处为了好玩而用 Rust 重写一切。我们在做出这些战术性选择,我们会说:好的,这个新组件,如果我们用 Rust 编写会更好。”

— Victor Ciura, 首席工程师, Microsoft, 摘自 Rust in Production

最有效的策略,按照我通常推荐的顺序如下:

1. 将“开辟热门路径”作为一种服务来提供

如果你的系统中某个特定服务一直存在各种问题(比如高 CPU 使用率、对延迟敏感,或者经常出现可靠性问题),那么你可以只用 Rust 重新编写这个服务,同时保持与原有 API 的兼容性。这是风险最低的迁移方式。其他用 Go 编写的服务仍然可以通过 HTTP/gRPC 与这个服务进行交互,而无需关心其底层编程语言是什么。Radar 公司的 Jeff Kao 指出,Discord 上的那些成功案例往往能激发团队尝试这种迁移方式的勇气。

如果你在 Hacker News 上搜索“迁移到 Rust”,第一个搜索结果一定是关于 Discord 从 Go 语言切换到 Rust 的报道。这一消息激励了我们,让我们也想看看自己是否也能做到同样的事情。
——Radar 公司的首席技术官 Jeff Kao 谈 Rust 在实际生产环境中的应用

2. 更换 Sidecar/Worker 进程

后台任务、队列消费者、数据摄取管道以及那些依赖 CPU 处理的批量作业,都是绝佳的优化目标。这些任务通常具有明确的输入/输出边界(比如队列或主题),且不会与系统的其他部分共享任何状态信息。

3. 使用 cgo 是可行的,但过程相当繁琐/麻烦

可以通过 cgo 在 Go 语言中调用 Rust 代码,关于如何操作的详细指南也很容易找到。(如果你需要我提供相关的指南,请随时联系我。)不过,实际上我并不推荐将 Rust 用于后端服务。与“直接创建一个 Rust 服务并将其置于网络调用之后”相比,其构建的复杂性以及 FFI 相关的开销通常会超过其带来的好处。不过,对于库和 CLI 工具来说,使用 Rust 则更为合适。

4. 网关背后的“绞杀者”模式

如果你使用了 API 网关或反向代理,就可以将特定的端点指向新的 Rust 服务,而其余部分则继续使用 Go 语言来实现。当某个特定的业务领域(如身份验证、搜索、计费)适合被迁移时,这种做法尤为有效。这种模式通常被称为“绞杀者模式”:新服务会逐渐取代旧服务,最终完全取代它。

实用的迁移技巧

  • 从一个边界清晰的服务开始。 不要选择你机群中最核心、部署最多的服务。挑一个与其他系统的契约定义清晰且影响范围较小的服务。
  • 保持相同的 API 契约。 如果你的 Go 服务暴露了 REST API,你的 Rust 服务也应该如此:相同的路径、相同的 JSON 格式、相同的错误响应。这样迁移对客户端是透明的,你可以通过网关安全地切换流量。
  • 不要逐字翻译习语。 克制住写“Go 风格 Rust”的冲动。将 if err != nil { return err } 转换为 ?。将 goroutine-per-request 转换为 tokio::spawn。只在真正需要时(axum 会并发地为你处理请求)才使用它们。带有单一方法的接口通常在 Rust 中表现为泛型约束,而不是 Box
  • 将编译器作为结对程序员。 Rust 的编译器错误通常非常有帮助。仔细阅读它们。它们几乎总会告诉你正确的答案。挣扎最久的团队成员通常是将编译器视为敌人而不是合作者的那些人。
  • 尽早投资于培训。 我经常看到团队试图通过“边做边学”来进行 Rust 迁移。这很少有好的结果。这有点像通过直接去跑马拉松并试图在跑的过程中摸索来为马拉松训练。你可以做到,但这将是极其痛苦的,而且你可能无法坚持到终点。为学习留出一些不被打扰的时间:一场研讨会,一个在线课程,以及在真实代码上进行结对。前期投入在团队流利掌握后会数倍地回报。(顺便说一下,如果你想讨论培训方案,我很乐意聊聊。)

保持 Go 语言的优势所在

并非所有东西都需要被迁移。Go 语言在以下方面表现优异:

  • Kubernetes 原生工具:Operator、controllers、CRD。该生态系统几乎完全由 Go 语言构建而成。
  • CLI 工具和开发工具:编译速度快、跨平台编译简单、部署便捷。
  • 胶水层服务:包括薄的 API 层、代理(proxy)服务器以及格式转换器。在 Rust 中,编写这些重复性的代码并不值得。
  • 在任何情况下,团队的工作效率都比追求绝对的准确性更为重要。

这并非什么小众职位。对于一家能够大规模提供这两种语言服务的公司来说,这一职位的设立显然意味著更重要的意义:

Go 语言是构建网络服务的绝佳选择。在 Canonical 公司,我们大量使用 Go 语言来开发软件——Juju 就是一个由 Go 语言编写的庞大软件项目。
——Canonical 公司工程部副总裁 Jon Seager 谈 Rust 在现实生产环境中的应用

混合策略其实很不错,也很常见。与我合作的许多团队都会采用这种策略:对于那些“没什么特别要求”的服务,使用 Go 语言来开发;而对于那些需要确保可靠性和性能的服务,则使用 Rust 语言来开发。

预期的改进/有望取得的提升

根据工作量的不同,具体数字会有很大差异,因此这些数据仅供参考而已。请不要把它们当作绝对的承诺!不过,以下是我在协助进行从 Go 语言到 Rust 语言的迁移过程中所得到的一些大致数据:

  • CPU 使用率:降低了 20%到 60%。这一效果不如将代码从 Python 转换为 Rust 时那么显著,因为 Go 本身的效率就已经很高了。其优势主要体现在无需进行垃圾回收,以及代码循环的效率更高。
  • 内存占用:减少了 30%到 50%,这主要得益于无需进行垃圾回收操作,以及运行时的开销更低。
  • P99 延迟方面:Rust 服务的稳定性明显更高。Go 服务则容易出现由垃圾回收引起的延迟波动。不过,自从 Go 语言引入了低延迟垃圾回收机制后,这种情况已经有所改善,但在高负载情况下,两者之间的差异依然存在。
  • 生产环境中的问题:这是各团队最乐于报告的问题类型。那些在测试阶段被发现,但最终还是进入了生产环境的错误类型(如数据竞争、空指针引用、错误处理路径被遗漏等),在 Rust 中根本无法编译通过。在从其他语言切换到 Rust 之后,处理这些问题的过程通常相当繁琐。Andrew Lamb 在 InfluxDB 的重写过程中也详细描述了这种现象。

“我不需要去追踪崩溃,或者某些奇怪的多线程竞争条件,或者其他那些实际上消耗了我之前大部分时间的事情。”

— Andrew Lamb, 软件工程师, InfluxData, 摘自 Rustacean Station: Rebuilding InfluxDB with Rust

说实话,与从 Python 转向 Rust 相比,从 Go 转向 Rust 后,很难实现 10 倍的性能提升。不过,你确实能减少“愚蠢的错误”,降低延迟,同时还能继续使用同一种语言来开发嵌入式系统或进行系统编程。这往往是代码迁移带来的最令人惊喜的副作用:那些原本需要使用不同编程语言的团队,现在可以共享代码了。Rust 几乎可以用于所有类型的开发场景。

结论

从 Go 迁移到 Rust 是与从 Python 或 TypeScript 迁移完全不同的一种类型。从 Go 过来,你深知静态类型、编译型语言的好处。所以你并不是在用动态类型或缓慢的运行时去交易。你是在交易 nil,换来一个漏洞更少、更健壮的代码库、更严格的编译器(可在编译时捕获更多错误)。不过,这里有一条更陡峭的学习曲线。

对于基础服务(你的组织所依赖的、需要极高可靠性、对你的业务至关重要的服务),这个迁移方式显然是值得的。对于其他服务,Go 仍然是正确的答案。迁移的目的是在最适合的语言中解决对应的问题。

准备好迈向 Rust 了吗?

我协助后端团队评估、规划并执行 Go 到 Rust 的迁移。无论你需要架构评审、培训,还是协助将关键服务进行移植,让我们聊聊你的需求吧。

原文正文到此为止!

社区深度观点

Matthias 的这篇文章在 Hacker News 上也引发了热烈的辩论。支持者、怀疑者、以及拥有多年双语言实战经验的系统架构师们纷纷下场,就 Go 与 Rust 的工业级博弈分享了大量第一手观点。我对其中的核心争议与洞察进行了系统性汇总:

1. 核心分水岭:你是否需要一个“托管运行时(Managed Runtime)”?

在 HN 的讨论中,社区普遍赞同的一个终极共识是:Go 与 Rust 的选择,90% 程度上取决于你是否想要一个托管运行时(垃圾回收,GC)。

  • Go 拥护者认为:世界上 95% 的应用都是普通的商业业务系统(LOB)。在这类场景下,Go 拥有世界上最优秀的并发 GC。它的高并发开销极小,虽然在 P99 停顿指标上存在微弱的抖动(Jitter),但对于绝大多数企业级 Web 后端而言,这完全可以忽略不计。
  • Rust 拥护者反驳:GC 不仅带来时延抖动,更重要的是它占用了额外的内存(通常需要 30%-50% 的额外物理内存作为缓冲来减少 GC 频率)。在超大规模云原生部署中,Rust 消除 GC 后带来的物理内存节省,可以直接转变为服务器账单上极具说服力的“降本增效”数字。

2. 编译速度与迭代效率的残酷现实

编译速度是 Go 阵营攻击 Rust 最锋利的武器之一。

  • Go 的快:Go 从设计之初就将编译速度作为核心优先级(由汇编器和简化的类型系统支撑)。在开发中,修改代码到重新运行几乎是“即时”发生的,这带来了极佳的开发体验和迭代速度。
  • Rust 的痛:由于采用了复杂的宏系统(Macros)和深度的单态化(Monomorphization)编译期展开,即使是增量编译,Rust 在大型项目中的等待时间依然可能长达数分钟。多位开发者抱怨:“在使用 AI 辅助编程或高频调试时,Rust 漫长的编译等待时间严重降低了开发者的心智流畅度。”

3. 错误处理理念的终极碰撞

在错误处理上,两个阵营各执一词,表现出截然不同的“开发文化”:

  • Go 的显式哲学:Go 拥护者(包括知名技术领袖 Peter Bourgon)强调,错误处理应当是显式的,这应该作为语言的核心价值观。 尽管 if err != nil 冗长,但它逼迫你在每一行可能出错的代码旁停下来,思考当前上下文的应对策略,而不是用一个抽象的 ? 闭着眼睛把错误向上抛出。
  • Rust 的类型保障:Rust 拥护者则认为,Go 的显式是一种“依靠肉体纪律维持的低效工程学”。一旦团队规模扩大,总有人会遗漏处理。而 Rust 将错误融入 Result<T, E> 类型签名,由编译器在底层进行穷尽性校验(Exhaustive checks),在代码简洁度(使用 ?)与安全性(不漏掉任何一种分支)之间找到了近乎完美的工程平衡。

4. 生态系统的对比:标准库(Batteries-Included)与模块化 Crates 依赖

开发者对两门语言的第三方生态设计表现出了明显的温度差:

  • Go 的稳定:Go 拥护者非常自豪于 Go 极其庞大且强大的核心标准库。你不需要引入任何第三方库,就能用纯标准库写出高可用的 HTTP 服务器、加解密引擎和网络代理。这避免了类似 Node.js 社区的“Dependency Hell(依赖地狱)”和安全供应链攻击风险。
  • Rust 的模块化:Rust 的标准库非常克制,甚至连异步运行时(tokio)、序列化(serde)和命令行解析(clap)都是第三方包。一些 Go 开发者迁往 Rust 后表达了这种不适:“在 Rust 里,写个简单的后台服务,一不小心就引入了上百个第三方 Crates,这让人有些缺乏安全感。”

5. AI 与 LLM 时代的编码体验

这是一个极具 2026 年时代特色的前沿议题。讨论区多位开发者分享了在使用大模型(如 Claude Code、Cursor)编写这两门语言时的反差体验:
* AI 写的 Rust 质量低下:由于 Rust 的生命周期(Lifetimes)和借用规则极度精密,AI 经常会生成那些无法通过编译的“幻觉代码”,试图滥用 Mutex、RefCell 等高级特权,或者在多线程中引入生命周期冲突。
* 但 Rust 拥有最强“安全网”:然而,反直觉的是,很多开发者表示他们更喜欢让 AI 写 Rust 而非 Go。因为如果 AI 写的 Go 逻辑错了(比如漏了 nil 检查或并发读写未加锁),代码依然能完美编译通过,并在生产环境中引发极其隐蔽的线上故障。而在 Rust 中,“只要 AI 写的代码能通过编译器的金睛火眼,我们几乎就可以闭着眼睛放心地把它部署上线。”

编辑结语:如何选择你的下一张船票?

Go 和 Rust 的博弈,本质上是“高带宽易上手的生产效率”“编译期极致安全的正确性承诺”之间的路线之争。

如果你正在构建一个高速迭代、团队规模庞大、需要快速抢占市场的业务系统,Go 依然是那张最稳健、最不容易出错且极其务实的船票。

但如果你的系统已经走过了野蛮生长阶段,开始面临极其严苛的 P99 停顿要求、高并发下的内存与 CPU 账单压力,或者是不容许有任何运行时恐慌(Panics)的国防级、金融级系统,那么正如 Matthias 团队所验证的那样,忍受 Rust 的学习曲线和编译成本,将为你换来长达数年、在睡梦中都无比踏实的“终极安全感”。

资料链接:

  • https://corrode.dev/learn/migration-guides/go-to-rust/
  • https://news.ycombinator.com/item?id=48259808

还在为写 Agent 框架频频死循环、上下文爆炸而束手无策?我的新专栏 从0 开始构建 Agent Harness 将带你:

  • 抛弃臃肿框架,回归“驾驭工程 (Harness Engineering)”的第一性原理
  • 用 Go 语言手写 ReAct 循环、并发拦截与上下文压缩引擎等,复刻极简OpenClaw
  • 构建坚不可摧的 Safety Middleware 与飞书人工审批防线
  • 在底层实现 Token 成本审计、链路追踪与自动化跑分评估
  • 从“调包侠”进化为掌控大模型边界的“AI 操作系统架构师”

扫描下方二维码,开启从 0 开始构建Agent Harness 的实战之旅。


原「Gopher部落」已重装升级为「Go & AI 精进营」知识星球,快来加入星球,开启你的技术跃迁之旅吧!

我们致力于打造一个高品质的 Go 语言深度学习AI 应用探索 平台。在这里,你将获得:

  • 体系化 Go 核心进阶内容: 深入「Go原理课」、「Go进阶课」、「Go避坑课」等独家深度专栏,夯实你的 Go 内功。
  • 前沿 Go+AI 实战赋能: 紧跟时代步伐,学习「Go+AI应用实战」、「Agent开发实战课」、「Agentic软件工程课」、「Claude Code开发工作流实战课」、「OpenClaw实战分享」等,掌握 AI 时代新技能。
  • 星主 Tony Bai 亲自答疑: 遇到难题?星主第一时间为你深度解析,扫清学习障碍。
  • 高活跃 Gopher 交流圈: 与众多优秀 Gopher 分享心得、讨论技术,碰撞思想火花。
  • 独家资源与内容首发: 技术文章、课程更新、精选资源,第一时间触达。

衷心希望「Go & AI 精进营」能成为你学习、进步、交流的港湾。让我们在此相聚,享受技术精进的快乐!欢迎你的加入!

img{512x368}


商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。

© 2026, bigwhite. 版权所有.

Related posts:

  1. “我们想用 Rust 重写的次数是:零”:云平台 Render 靠“无聊”的 Go 撑起了千亿流量
  2. 金融级基础设施重构:放弃 Rust 拥抱 Go,务实主义的最终胜利?
  3. 别搞“小而美”了!Rust 开发者请愿:求求标准库学学 Go 吧
  4. 为什么人人爱 Rust,但 RedMonk 榜单却给它泼了一盆冷水?
  5. Go, Rust 还是 Zig?一场关于“简单”与“控制”的灵魂拷问