这篇文章就是帮你搞清楚,当你决定把后端服务从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。
区别在于,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依然是正确的答案。迁移的意义在于,把每个问题放到最合适的语言里去解决。
原文:https://www.jdon.com/92253-go-to-rust-migration-guide.html