从Go转向Rust迁移指南:从“靠自觉”到“靠编译器”


这篇文章就是帮你搞清楚,当你决定把后端服务从Go语言换成Rust语言的时候,到底会发生什么事。

简单来说,Go已经很不错了,速度快、工具全。但你换到Rust,不是为了更快,而是为了更稳。你不会再有“突然程序崩了,因为有个地方忘了判断是不是空指针”这种破事。

Rust的编译器像个特别较真又特别有用的同事,会在你写完代码、还没上线之前,就告诉你:“兄弟,你这里有个坑。”代价是,你得花几周时间适应它的脾气,编译也会慢一点。值不值?对于你们公司最核心、绝不能挂的那些服务,非常值。对于一般的普通服务,继续用Go也挺好。

核心思想:从“靠自觉”到“靠编译器”

Go和Rust都是好语言,但思路不一样

很多团队想从Go换成Rust,不是因为Go慢。说实话,对于大部分后端任务,Go的速度完全够用。那为什么还要换?主要是受不了那些时不时冒出来的小毛病。

比如,你在Go里写了个函数,返回一个指针。你心里想着“调用我的人应该会检查这个指针是不是空的吧”。结果某天某个角落真的忘了检查,程序就直接崩溃了。这种问题在Rust里根本不存在,因为Rust的Option类型逼着你在拿到值之前,必须先处理“有可能是空”的情况。

再比如,Go里两个 goroutine 同时写一个 map,不加锁,编译能过,但跑到线上压力一大就炸了。Go自带的检测工具 -race 能发现一部分,但它只在测试运行时有用,没跑到的那条路径就发现不了。Rust更狠,这种代码直接编译不过,会告诉你:“这两个线程要共享数据?你得用 Mutex 包一下。”

所以,从Go到Rust,最大的变化不是你写代码的方式,而是你心里那根弦。在Go里,很多安全保证是靠程序员自觉、靠团队规范、靠各种外部检查工具。在Rust里,这些全被焊死在类型系统里了,编译器替你盯着。

先看工具链:Cargo 和 Go 工具链,几乎一模一样

如果你是Go开发者,你会觉得Rust的工具链很亲切。两者都是“电池自带”的风格,一个命令搞定大部分事。

Rust的包管理器叫Cargo,Go里有 go build,Rust里就是 cargo build。
Go里有 go test,Rust里就是 cargo test。
Go里有 go fmt 自动格式化代码,Rust里有 cargo fmt。
Go里有 go mod tidy 整理依赖,Rust里有 cargo update。

代码检查方面,Go的 go vet 用于静态分析,而Rust的 cargo clippy 则是一个功能更强大、也更爱“管闲事”的检查工具。
代码格式化上,Go的 gofmt 或 goimports 对应 Rust 的 cargo fmt,两者都是零配置的自动格式化器。
如果你需要更严格的检查模式,Go里用 golangci-lint run,Rust里就用 cargo clippy -- -D warnings。

安装二进制文件时,Go的 go install ./cmd/foo 对应 Rust 的 cargo install --path .。
查看API文档,Go的 go doc 对应 Rust 的 cargo doc --open。
性能分析方面,Go的 pprof 在Rust里可以用 cargo flamegraph 或 samply 来替代。
最后,安全漏洞扫描,Go的 govulncheck 对应 Rust的 cargo audit,后者会查询一个安全漏洞数据库。

这里有个很大的不同点:
在Go的日常开发中,你通常需要借助很多第三方工具来补全功能,比如 golangci-lint、mockgen、air、goreleaser 这些。
而在Rust这边,官方工具链自己就覆盖了更多功能,开箱即用。当然,也有一些功能需要用到外部的第三方库,比如 cargo watch 和 cargo nextest。但这些工具安装起来非常方便,一条命令就搞定,用起来感觉就像原生工具一样。
举个例子,你执行 cargo install cargo-nextest,安装完成后,立刻就可以使用 cargo nextest 这个命令了。

大白话简短总结,两者区别在于,Rust自带的东西更多一点。比如,Go的代码检查工具 golangci-lint 是第三方的,你得自己装。Rust的检查工具 Clippy 是直接集成在Cargo里的,一条 cargo clippy 命令就搞定,而且它特别爱管闲事,能检查出很多你都没意识到的潜在问题。

还有个好玩的事。两种语言的开发者社区,都达成了一个共识:自动格式化工具,哪怕它的风格你并不完全喜欢,也比每次代码审查时为了“这里该不该加空格”吵半天要强一万倍。Go有 gofmt,Rust有 rustfmt,它们的存在就是为了终结这种毫无意义的争论。

关键区别一览表

我们来快速过一下核心的不同点,心里有个谱。

Go 语言在2012年就发布了稳定版本,Rust是2015年。Go的类型系统是静态的、结构化的,虽然有泛型但用起来感觉是后来补上去的。Rust的类型系统是静态的、名义化的,加上泛型、trait和生命周期,整个体系从一开始就是设计好的。

内存管理这块,Go用垃圾回收(GC),Rust用所有权和借用机制,没有GC。空指针在Go里是老大难问题,到处都是nil。Rust里没有空指针,用Option类型来代替。

错误处理,Go是 if err != nil 满天飞。Rust用 Result 类型和问号操作符,代码简洁很多。并发方面,Go的 goroutine 加 channel 非常简单粗暴。Rust用 async/await 配合 tokio 运行时,功能强大但更复杂。

取消操作,Go靠 context.Context 约定俗成地传递。Rust用 CancellationToken 或者 watch channel,编译器能帮你检查有没有漏传。数据竞争,Go靠运行时检测工具 -race,但不是百分百能发现。Rust靠编译时的 Send 和 Sync 这两个trait,有问题直接编译不过。

编译时间,Go非常快,这是它的招牌。Rust比较慢,特别是全量编译的时候。运行时代码大小,两者都很小,一个几MB的二进制文件很正常。

学习难度,Go很平缓,号称几天就能上手。Rust的曲线很陡峭,需要一段时间适应。生态大小,Go有超过75万个模块,Rust有超过25万个crate。

为什么Go开发者会看Rust一眼?

其实大部分Go开发者并不是因为Go“太慢了”才来看Rust的。大家抱怨的通常是另外几件事。

第一个,错误处理太啰嗦。每个可能出错的调用后面都得跟一个 if err != nil。写多了感觉就像在写样板文件,真正的业务逻辑反而被淹没了。而且,如果你想给错误加点上下文信息,比如 fmt.Errorf("读取配置文件失败: %w", err),这是一种编程纪律,不是编译器强制要求的,很容易就忘了。Rust的问号操作符,一行代码就完成了“出错就提前返回”这件事,而且错误类型的转换也是自动的。

第二个,nil 指针带来的生产事故。这可能是最烦人的。你写了一个很稳的服务,跑了几个月都好好的。突然有一天,某个不那么常见的代码路径被触发了,而那个路径里有人忘了检查指针是不是 nil,整个 goroutine 就直接 panic 了。Rust 的 Option 类型让你完全没有忘记检查的可能性,因为你必须把 Option 里面的值取出来才能用,而取出来的过程本身就强迫你处理了“有可能是空”的情况。

第三个,数据竞争。Go 的 -race 工具很好,但它的局限性在于,它只能检测到测试过程中实际发生过的数据竞争。如果你的测试没有覆盖到那个并发的场景,或者竞争只在特定负载下才出现,那它就可能漏掉。Rust 的编译器直接禁止了无保护的可变共享数据。你想在两个线程里同时修改一个 HashMap?编译器会告诉你:“不行,你得用 Arc 和 Mutex 把它包起来。”这样一来,数据竞争就从运行时的偶发bug,变成了编译时的类型错误。

有个公司的CTO在播客里分享过,他们重写 InfluxDB 3.0 的时候,最大的动机就是被 Go 版本里那些极其难缠的数据竞争bug折磨得受不了了。Rust 的“无畏并发”对他们来说不是一句口号,而是实实在在解决了问题。

第四个,对更强大泛型的渴望。Go 在2022年才加入泛型,而且用起来感觉有点束手束脚。标准库本身都很少用泛型,比如 sort.Slice 依然用一个闭包而不是泛型约束。更重要的是,Go 的泛型没有 trait 系统那样的配套能力。你不能给一个泛型类型的方法再单独加泛型参数,也没有关联类型,更没有统一实现(blanket impls)。这些限制导致一旦你的抽象稍微复杂一点点,你就又得退回到 interface{} 加上类型断言的老路上去。Rust 的泛型是零成本的,编译时会把每种具体类型都生成一份专门代码,运行效率极高。

第五个,可预测的延迟。Go 的垃圾回收器已经非常优秀了,并发、低停顿。但“低停顿”不是“零停顿”。在内存分配非常频繁的场景下,99分位延迟还是会受到GC的影响。对于交易系统、实时竞价、网络代理、高吞吐量的数据采集这类对延迟极其敏感的系统,没有GC停顿确实是一个实实在在的优势。Rust 可以在热点路径上完全不分配内存,从而获得更平滑的延迟表现。

把Go里的常用招数,翻译成Rust的写法

当你刚开始写Rust的时候,最有效的方法就是把你在Go里已经熟悉的模式,对应到Rust的写法上。

错误处理。Go的 if err != nil 加上 fmt.Errorf 包装错误,对应到Rust就是问号操作符配合 thiserror 库。你定义一个错误枚举,每个成员标注好错误信息,然后用 ? 操作符自动传播错误。当你给错误枚举增加一个新类型时,编译器会帮你找出所有需要处理这个新错误的地方,这在Go里是很难做到的。

空值处理。Go的指针可能为 nil,对应到Rust就是 Option 枚举。在Go里你拿到一个指针,得靠自觉去判断 if user != nil。在Rust里,你拿到一个 Option,编译器强迫你处理两种情况:Some(值) 或者 None。如果你想不处理就直接用里面的值,根本编译不过。

接口与Trait。Go的接口是结构式的,只要一个类型实现了接口里定义的方法,它就自动满足了这个接口,不需要显式声明。Rust的trait是名义式的,你需要用 impl Trait for Type 的语法显式地为一个类型实现trait。Go的风格在快速实现鸭子类型时很方便,Rust的风格在大项目重构和查找接口所有实现者时更有优势。

Go里的 interface{}(也叫 any)代表完全未知的类型,使用它通常伴随着类型断言。Rust里很少需要这种东西。大部分情况下,你可以用泛型加trait约束来达到同样的目的,而且没有运行时开销。如果确实需要运行时多态,比如要在一个容器里存放多种实现了同一个trait的类型,那可以用 Box 或者 Arc,这正好对应了Go里把接口值赋给变量的感觉。

goroutine 与 async 任务。Go的并发模型简单到令人发指。你直接在前面加个 go 关键字,就把一个函数调用变成一个并发任务了。函数本身的写法和普通调用一模一样,不需要改签名,不需要操心运行时。这就是Go最厉害的地方,没有函数颜色的问题。

Rust的异步模型更显式。一个异步函数用 async fn 定义,它返回的是一个 Future。这个 Future 在你调用 .await 或者用 tokio::spawn 之前,什么都不会做。这个区别带来的后果就是,Rust的异步函数和普通函数签名不一样,调用方式也不一样,这就是所谓的“函数着色”问题。从Go转过来的人,一开始会非常不适应。

在Rust里,你通常会这样创建一个异步任务:

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

看起来和Go的 go doWork(input) 差不多,但背后区别很大。Rust的编译器会在 .await 的每一个点检查你持有的变量是否满足 Send 条件,也就是能否安全地在线程间传递。如果你在 .await 的时候还拿着一个不能跨线程的东西,编译器会报错,并告诉你为什么。

context.Context 与取消令牌。在Go里,你几乎在每个函数调用里都会传一个 context.Context。它负责超时、取消和传递一些请求范围的值。这是一种约定俗成的做法,编译器并不会强制你检查有没有漏传。

Rust标准库里没有内置类似的东西。最接近的是 tokio_util 包里的 CancellationToken。你可以创建一个令牌,克隆出多个副本,传给不同的任务。当你想取消时,调用令牌的 cancel 方法。接收端在代码里要显式地用 tokio::select 宏来监听取消事件和实际工作,哪一个先完成就处理哪一个。

这个区别体现了两门语言不同的哲学。Go靠的是约定,Rust靠的是显式的类型和宏。Rust的方式更啰嗦,但编译器能帮你检查是否遗漏了取消的处理。

管道(Channel)。两门语言都有管道的概念,而且用法几乎一样。Go里用 make(chan int, 10) 创建一个带缓冲的管道,然后通过箭头操作符发送和接收。Rust里用 tokio::sync::mpsc::channel(10) 创建,发送和接收是分开的类型,调用 send 和 recv 方法。

Rust的管道把发送端和接收端分离成了不同的类型,这点很有用。当你把发送端传给一个任务,把接收端传给另一个任务时,编译器就能清楚地知道所有权是怎么转移的。

字符串:string vs String 和 &str。Go的字符串是一个只读的UTF-8字节切片,你可以随意复制它的句柄,底层数据是共享且不可变的。Rust把字符串分成了两种:String 是拥有所有权的、可增长的、在堆上分配的字符串,相当于Go里你想修改的 byte。而 &str 是一个对别人字符串数据的借用视图,相当于Go里大多数时候当参数传递的 string。

有个实用的经验法则:函数参数用 &str,当你需要产生新字符串数据时,返回 String。这个区分一开始会让人困惑,但它是理解Rust整个“借用vs拥有”模型的一个缩影。一旦你搞懂了字符串,你就搞懂了一半的所有权系统。

Go的泛型:来得太晚,做得太少

我得直接一点:Go的泛型虽然有了,但给人的感觉是硬贴上去的补丁。标准库在那之后三年了,依然很少用它。sort.Slice 还是那个样子,sync.Map 还是 any 对 any。你可能会说,这是因为Go 1的兼容性承诺导致不能改现有的API。但问题是有足够的时间引入新的、泛型化的替代品,但没有怎么出现。

对比Rust,它的标准库从第一天起就充满了泛型。Option、Result、Vec、HashMap,所有的集合、所有的智能指针,都是泛型的。你没法写不使用泛型的惯用Rust,因为标准库本身就是泛型构建的。

更重要的是,Go的泛型没有配套的trait系统。你不能定义trait的继承关系,没有关联类型,不能给已有的类型统一实现方法,泛型类型的方法也不能再有自己的额外泛型参数。这些缺失导致当你的抽象需求稍微复杂一点时,你就会被推回到 any 加类型断言、代码生成或者反射的老路上。

类型推断也是一个差异点。Rust有很强的类型推断引擎,它能从整个表达式的上下文推断出类型来。你经常会写出类似 let evens: Vec<_> = (0..100).filter(|n| n % 2 == 0).collect(); 这样的代码,编译器能从范围推断出是 i32,从收集目标推断出是 Vec。Go的类型推断浅得多,通常只能从函数参数推断,没法从返回位置推断,所以经常需要你在调用泛型函数时显式写出类型参数。

最后是性能。Go的泛型实现用了叫做GCShape模板化和字典的机制,试图在编译速度和运行时性能之间取个折中。代价是,泛型代码的每个方法调用可能都有一层间接寻址的开销,手写的非泛型版本反而可能更快。Rust的泛型是单态化,每种具体类型都会生成一份专门代码,没有运行时开销。泛型代码就是快路径。当然,代价是编译时间变长了。

Rust学习的几个坎儿

我得坦白告诉你,从Go转Rust,你一定会撞上一堵墙,这堵墙的名字就叫“借用检查器”。

借用检查器就是Rust用来保证内存安全和避免数据竞争的核心机制。它会检查你的代码中每一个引用的生命周期,确保没有任何引用指向已经被释放的内存,也没有多个可变引用同时指向同一块数据。

对于从Go这种有垃圾回收语言过来的开发者,一开始会觉得借用检查器简直是个不讲道理的暴君。你会写出你觉得“明显应该能工作”的代码,然后编译器劈头盖脸地拒绝你。

最常见的几个坑是这样的:你在Go里会很乐意从一个map里拿出一个指针,然后想用多久就用多久。在Rust里,这个借用的行为会阻塞你对map的其他修改,直到借用结束。解决办法要么是克隆一份数据,要么是缩小借用的作用范围。

你可能会想在结构体里同时保存数据和一个指向这些数据的迭代器。这在Go里很常见,但在Rust里,这需要复杂的技术,或者更实际的做法是重新设计你的数据结构。

在Go里,你想共享可变状态,会写 var mu sync.Mutex; data := make(map[K]V)。在Rust里,你需要写 Arc>。多了点代码,但也多了很多安全保障。

还有函数返回引用。在Go里你随手就返回一个指向局部变量的指针,没问题,Go会通过逃逸分析把它分配到堆上。在Rust里,你得显式标注生命周期参数,告诉编译器这个引用到底能活多久。

这些规则听起来很烦。但你要换个心态去看借用检查器。它不是在跟你作对,而是在帮你发现你脑子里没想到的那些bug。当编译器拒绝你的代码时,问自己几个问题:如果这个值被移动走了,原来的地方再用它会发生什么?如果一个值在线程间共享,一个线程修改它时另一个线程在读它,会发生什么?如果这个指针是空的或者悬空的,会发生什么?如果这个值出了作用域,其他地方还在用它,会发生什么?

人类天生不擅长推理内存。我们会忘记指针可以是空的,会忘记旧的引用可能比它指向的数据活得还久,会忘记多个线程可能同时碰同一份数据。借用检查器就是替你做这些枯燥又容易错的事。好消息是,一旦你内化了这些规则,借用检查器就不再跟你打架了。大多数有经验的Rust开发者会说,大概在第4周到第12周之间,借用检查器就变成了你的盟友。第一个月是最难的。

编译时间是另一个明显的降级。Go的编译快得让人感动,一个中型服务一两秒就搞定。Rust的全量release构建,同样的规模可能需要几分钟。虽然增量编译和 cargo check 会让开发过程中的体验好很多,但你确实会感受到差距。

为了减轻这个问题,你可以在编辑循环里多用 cargo check,不用每次都完整编译。当项目变大时,把它拆分成工作区(workspace)。把那些用了很多过程宏的重crate单独放,这样它们只在改动时才重新编译。

还有一个挑战,就是异步函数的颜色问题。Go里没有普通函数和异步函数的区别,同一个函数既可以是同步的也可以是并发的。Rust里区分 async fn 和普通 fn,这使得调用方式、组合方式都不一样。从Go转过来的人会觉得这是个巨大的倒退。虽然异步trait在最近的Rust版本中已经稳定了,但和动态派发一起用的时候还有一些粗糙的边缘。

最后,有些领域的生态系统Rust还不如Go成熟。特别是Kubernetes相关的工具,比如operator、controller,Go的生态是压倒性的。一些云厂商的SDK,还有某些小众数据库的驱动,可能Rust的版本还不太成熟。在你决定迁移之前,花一天时间把你依赖的核心库在Rust生态里找一遍,确保有你能接受的替代品。

实战策略:怎么把Go服务换成Rust

你不需要一次性把整个系统都重写。我听说过的所有成功的迁移故事,都是战术性的,不是大爆炸式的。微软的一位工程师说得好:“我们不是疯狂地到处为了好玩把东西用Rust重写,而是做战术选择,这个新组件,用Rust更好。”

最推荐的策略,按顺序来说,第一是把最麻烦的那个服务单独换成Rust。如果你的系统里有一个服务总是出问题,CPU高、延迟敏感、或者可靠性老出状况,那就只重写这一个。把它背后的语言换成Rust,但对外暴露的API接口保持不变。其他Go服务继续通过HTTP或者gRPC和它通信,根本不知道背后换语言了。这是风险最低的迁移方式。

第二个策略是替换一个边车或者后台worker进程。后台worker、队列消费者、数据采集管道、CPU密集的批处理任务,这些都是很好的迁移目标。它们通常有清晰的输入输出边界,比如一个消息队列,而且和系统其他部分没有共享的进程内状态。

第三种,通过网关的绞杀者模式。如果你有一个API网关或者反向代理,你可以把特定端点的请求路由到新的Rust服务,其余的流量继续走到老的Go服务。这特别适合按业务边界来迁移,比如先把认证服务换了,或者先把搜索服务换了。新服务在老服务旁边慢慢长大,直到最终完全取代它。

虽然你可以通过cgo从Go调用Rust代码,但我很少给后端服务推荐这种做法。构建的复杂性和FFI的开销通常比直接起一个新的Rust服务然后用网络调用来得大。对于库和CLI工具来说,cgo方案更可行。

实用的迁移小贴士

从哪个服务开始?挑一个有清晰边界的服务。不要选最核心、部署最频繁的那个。选一个和系统其他部分接口定义清楚、爆炸半径小的服务。

保持API契约不变。如果你的Go服务暴露REST API,你的Rust服务也要暴露一样的API,一样的路径,一样的JSON格式,一样的错误封装。这样迁移对客户端完全透明,你可以通过网关慢慢切流量。

不要把Go的习惯生搬硬套到Rust里。克制住写“Go风味的Rust”的冲动。if err != nil 变成问号操作符。每个请求一个goroutine的模式变成用tokio::spawn,而且实际上像axum这样的框架本身就已经并发处理请求了,你都不用自己spawn。只有一个方法的接口,通常应该写成泛型trait约束,而不是 Box。

把编译器当结对编程的伙伴。Rust的编译错误信息通常写得很好。慢点读,它们几乎总是告诉你正确答案。那些学得最痛苦的团队成员,往往是那些和编译器对着干,而不是把它当协作伙伴的人。

早点做培训投入。我见过一些团队试图“顺便学一下Rust”,一边写生产代码一边学。结果很少有好下场。这有点像报名马拉松,然后毫无训练就直接去跑。你能跑下来,但过程会很痛苦,而且不一定能完赛。专门划出整块时间学习,工作坊也好,线上课程也好,结对编程也好,前期的投入会在团队熟练之后成倍回报。

什么时候继续用Go,别换成Rust

并不是所有东西都应该迁移。Go在几个领域依然非常强。

Kubernetes原生的工具,比如operator、controller、CRD,这些生态基本在Go手里。CLI工具和开发者工具,Go编译快、交叉编译简单、部署方便,很好用。胶水服务,比如薄薄的API层、代理、格式转换器,这些地方Rust那点啰嗦不值得。任何你的团队迭代速度比绝对正确性更重要的场合,Go依然是很好的选择。

混合策略挺好,也很常见。我合作的很多团队最后都是多语言后端。那些“无聊”的服务继续用Go,而那些把可靠性和性能放在第一位的服务,就用Rust。

换成Rust以后,能得到什么好处

数字会因工作负载而异,所以只能给个大概范围。根据我参与过的迁移项目,CPU使用率通常能降低20%到60%。没有Python到Rust那么夸张,因为Go本身已经很高效了。收益主要来自没有GC和更紧凑的循环。

内存使用通常能减少30%到50%,主要是因为没有了GC的开销和更小的运行时。P99延迟会变得更一致。Rust服务倾向于一条平直的线,而Go服务在GC发生时会有可见的抖动,虽然Go的低延迟GC已经改善了很多,但在高负载下差别依然存在。

生产事故的数量,这个是团队们最热情报告的。那些能逃过 go test -race 跑到生产环境才出问题的bug类别,比如数据竞争、空指针解引用、漏掉的错误路径,在Rust里根本编译不过。有一个工程师在重写InfluxDB后分享说:“我不用再去追查一个崩溃,或者某个奇怪的多线程竞争条件了,这些以前消耗了我大量时间的事情,现在基本没了。”

老实说,从Go换成Rust,你不太可能像从Python换成Rust那样获得10倍的吞吐量提升。你得到的是更少的低级错误,更平滑的延迟尾巴,以及可以用同一门语言扩展到其他领域,比如嵌入式开发或者系统编程的能力。这往往是迁移最令人惊喜的副作用:不同团队之间可以共享更多代码,以前他们被迫使用不同的技术栈。你现在可以用Rust做所有事情了。

最后的话

从Go到Rust的迁移,和从Python或TypeScript过来感觉很不一样。从Go过来,你已经知道静态类型、编译型语言的好处。所以你不是在用Rust换掉动态类型或者慢速运行时。你是在用Rust换掉nil,换来一个更健壮的代码库,更少的陷阱,还有一个更严格的编译器,能在编译时抓住更多的错误。当然,学习曲线也更陡。

对于那些基础服务,就是你们公司严重依赖的、对正常运作时间要求极高、对业务至关重要的服务,这笔交易显然是值得的。对于其他服务,Go依然是正确的答案。迁移的意义在于,把每个问题放到最合适的语言里去解决。