这篇是 corrode.dev「Migration Guides」系列里 Go 到 Rust 的一篇,作者 Matthias Endler 做 Rust 培训和迁移咨询,原文 2026-05-21 发布、2026-07-20 修订,明确只谈后端服务。他的主张是:Go 开发者已经站在静态类型编译语言这一边,换 Rust 换来的主要不是速度,而是一批在 Go 里靠约定、linter 和运行时探测兜住的检查被挪进了类型系统;要付的是借用检查器、编译时间和 async 着色。

作者的立场:不喜欢 Go,开的是 Rust 咨询公司#

他不喜欢 Go,认为它把 easiness(写得容易)和 simplicity(本身简单)混为一谈,nil 到处都是、错误处理靠纪律、泛型缺席多年都是他不同意的方向。但他也承认 Go 的成功是事实:JetBrains 开发者生态调查里 Go 长期占 17–19%,Rust 2024 年是 11%。另一层利益相关他也写在前面:他开的是 Rust 咨询公司,用 Rust 的人越多生意越好;同时他在两种语言上都做过职业开发,把 Go 服务送上过生产。

指南的读者定位是想知道「换到 Rust 之后哪些地方变了」的 Go 开发者。他推荐同时读一篇立场相反的文章,Blain Smith 的 Just Fucking Use Go,两个观点同时在脑子里比只拿一个有用。

工具链:cargo 覆盖的范围比 go 命令更大#

Go 带头做了「batteries included」的工具链,编译、测试、格式化、依赖管理一套接口;Rust 跟上了这一步,而且 cargo 内置得更多。原文给了一张命令对照表,主干是这几行:

Go Rust
go build / go run . cargo build / cargo run
gofmt / goimports cargo fmt
go test ./... cargo test
go vet ./...golangci-lint run cargo clippycargo clippy -- -D warnings
go doc cargo doc
pprof cargo flamegraph / samply
govulncheck cargo audit

差别在于 Go 里常要找第三方工具(golangci-lintmockgenairgoreleaser)补缺口,Rust 的第一方生态开箱覆盖得更广。他自己注明 cargo audit 和两个 profiler 是社区 crate,不属于 cargo 本身,只是 cargo install 一条命令装完就像原生子命令。两个社区在格式化上得出同一个结论:一套唯一的规范风格,哪怕不完美,也比省下的争论值钱。

分水岭:检查从约定和工具挪进类型系统#

两门语言都是静态类型、编译执行、并发故事都强,分歧在编译器保证了什么。Go 押在 garbage collector、运行时 race detection 和 if err != nil 这套约定上;Rust 通过 ownership、Send / SyncResult<T, E> 把内存管理、data race 预防和错误处理推进类型系统。换成取舍来说:Go 给平缓的学习曲线、很快的编译和更大的生态;Rust 给没有 GC、更严的编译期检查和 zero-cost abstraction,代价是更陡的曲线和更慢的构建。

原文说从 Go 换到 Rust「变的大头」就是这一件事,我按他各节的说法把对应关系收成一张表:

问题 Go 里靠什么兜住 Rust 里变成什么
值不存在 约定,加 nilaway / staticcheck Option<T>,不处理 None 编译不过
错误传播 if err != nil,加 errcheck Result<T, E>?match 穷尽检查
data race go test -race 运行时探测 Send / Sync,跨线程共享裸 HashMap 编译不过
资源生命周期 defer 加自觉 ownership 加 Drop(RAII guard)
取消 context.Context 靠约定层层传 CancellationToken 同样层层传,漏传编译器能报
泛型 1.18 起有,靠 any 加类型断言补 trait 加 monomorphization

对「认知开销更大」这个常见反驳,他的回答是前期投入确实更多,但也更难写错。例子是 Mutex<T>:它不只是文档上写着「这份数据要加锁」,而是把锁变成通往数据的唯一路径,.lock() 拿到 guard 才能访问,guard 一 drop 锁自动释放,「忘了加锁」这条路径在类型里不存在。他还专门加了一段提醒:重点不在运行时。Go 优化的是迭代速度,Rust 优化的是正确性,对内存的控制权对多数生产负载只是附带好处。

团队为什么动心:nil panic、-race 漏网、错误分支、GC 长尾#

他听到的动机很少是「Go 太慢」,而是一堆小痛点攒出来的挫败感。原文列了五条,前四条都是「运行时才暴露、编译期本可拦住」的同一类问题,第五条是 GC。

生产环境里的 nil panic#

一个跑了几个月的 Go 服务,某条代码路径终于被走到,那里有人忘了检查指针是不是 nil,goroutine 直接 panic。典型情形是查找返回了零值,或者反序列化之后指针字段没被填上。linter 和 IDE 能抓一部分,但它们是自愿开启、尽力而为的,而且跨不了 package 边界。Rust 的 Option<T> 逼你处理值不存在的情况,想绕过 None 分支直接解引用做不到。他在脚注里补了一句:.unwrap() / .expect() 是主动放弃、遇到 None 会 panic,但这些调用显式、可 grep、在 review 里显眼;Cloudflare 2025 年 11 月 18 日的故障就被追到一次 Rust .unwrap(),说明 panic 在 Rust 里不是不可能,只是「无视值不存在」必须是一个看得见的决定。

-race 只抓测试里真的跑到的 race#

go test -race 是运行时探测器,两个 goroutine 不加锁改同一个 map 在 Go 里编译照过,只会在生产压力下炸。Rust 里跨线程共享可变状态要求类型实现 SendSync,裸 HashMap 直接编译不过,只能包成 Arc<Mutex<...>> / Arc<RwLock<...>> 或改用 channel。他同时划清边界:安全的 Rust 从构造上消除的是 data race,不是更广义的 race condition(死锁、livelock、同步逻辑里的业务 bug)。InfluxDB 从 Go 重写成 Rust,Paul Dix 在他播客里说最大的好处就是 fearless concurrency,Influx 1.x 出过这类非常难查的 bug。

错误处理的可组合性#

if err != nil { return err } 几年之后会暴露三件事:样板代码冲淡函数逻辑;fmt.Errorf("...: %w", err) 包装靠自觉,忘了包上下文就丢了;errors.Is / errors.As 判断 sentinel error 可行,但新增一个分支编译器不会提醒。他老实交代了反方:有经验的 Go 开发者认为 errcheckgolangci-lint 实践中能抓到大部分漏处理,显式的 if err != nil 比密集的 ? 链更好读,而且这是 Go 有意识的文化取向。他的回应是 ? 同样显式,写过一段时间 Rust 的人都把它当成「这个函数会失败」的信号,样板代码和可读性之间的取舍确实主观。

Rust 侧的做法是把所有错误分支放进一个 enum,让类型系统做转换:

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

pub fn rename(id: UserId, name: &str) -> Result<User, UserError> {
    let mut user = repo::get(id)?; // ? 借 #[from] 把 RepoError 转成 UserError
    user.name = name.to_string();
    Ok(user)
}

传播交给 ?,包装交给 #[from],对 UserErrormatch 做穷尽检查,明天加一个分支,编译器把所有要改的地方列出来。

泛型是漏水的抽象#

Go 1.18 的泛型有用,但带着约束:方法不能有自己的类型参数、GC shape stenciling、偶尔让人意外的性能。Rust 的泛型做 monomorphization,每个实例化产出特化代码,配上 trait 才是真正的 zero-cost abstraction。他说这件事在 handler 代码里不重要,在 middleware、通用 repository、decoder、parser 这类共享基础设施里才重要,那里 Go 往往把人推回 any 加类型断言。后面有单独一节展开。

GC 的长尾延迟#

Go 的 GC 并发、低停顿、为服务负载调优得很好,但低停顿不等于没停顿。高内存压力下 P99 会冒尖,而 Rust 版本可以在热路径上压根不分配。他不把这件事说过头:绝大多数服务 Go 的 GC 不是问题;对交易、实时竞价、网络代理、高吞吐摄入这类延迟敏感系统,没有 GC 停顿是实打实的卖点。PubNub 的 CTO 在播客里说的是 Go 在他们的规模下好用,但他们需要把每一分钱的性能容量都拿出来,所以基本所有东西都在往 Rust 走。

五条合起来,他的判断是:Go 只是优化了另一组价值,把交付速度和运维简单性放在编译期保证前面。代码库大到某个程度,问题开始复利式累积;发布 bug 的成本高过一个更严编译器的成本时,Rust 就值。

逐项对照:interface、goroutine、self 和字符串#

原文按错误处理、空值、interface、goroutine、channel、struct、字符串七项并排给了代码。前两项上面已经说过,剩下几项里他花笔墨最多的是这四组。

interface 与 trait#

Go 的 interface 是结构化的,有对的方法就满足,好处是临时 duck typing 方便,毛病是你可能不知不觉满足了某个 interface,往 interface 里加方法把所有实现打破时编译器也不吭声。Rust 的 trait 必须显式 impl,所以能 grep 出全部实现者。interface{} / any 最接近的对应是 Box<dyn Any>,但几乎永远不该想要它。

goroutine 与 async 任务#

他先说明自己真心喜欢 Go 的并发模型:函数前加一个 go 就跑成 green thread,配 channel 是超能力,而且串行代码和并发代码在语法上没有区别,任何函数都能塞进 go 语句而不改签名。这个「没有函数着色」的性质,他认为是 Go 相比 Rust 在日常生产力上最大的赢面,也是换过去的人最想念的东西。Rust 在 executor(后端几乎总是 tokio)之上用 async / await:future 不被 await 或 spawn 就不会跑;编译器跨 .await 点追踪 Send / Sync;没有 goroutine 式抢占,长 CPU 活要丢给 spawn_blockingrayon;channel 以 crate 形式演进。

但他也列了 Go 这套的四条原罪:WaitGroupsync.Once 容易用错导致泄漏或死锁;Go 1.14 之前调度器是协作式的,紧密 CPU 循环能饿死系统,1.14 起有了基于信号的异步抢占;context.Context 是不错的取消约定,但忘了在某个调用点传下去编译器不管;sync.Mutex 少加一次锁只在运行时失败。取消这件事两边形态其实一样,都是一个穿过每个调用点的东西,区别是 Rust 的 CancellationToken 漏传时编译器能告诉你。他的收尾是实践中这些没那么要紧,日常手感接近:起任务、用 channel 通信、大方地用超时。他也提到 Go 1.25 新加的 WaitGroup.Go,把 Add(1) / go / defer Done() 压成一次调用。

struct、方法与消耗 self#

Rust 的 &self 相当于 Go 的值接收者,&mut self 是带修改的指针接收者,获得所有权的 self(把值消耗掉)在 Go 里没有对应物,typestate 和 builder 会用到。channel 两边都直白,Rust 把发送端和接收端做成两个类型,所有权和 Send 在类型层面摆明。

字符串:一种 stringString&str#

Go 只有一种字符串类型,按惯例装 UTF-8 但不保证,"héllo"[1] 返回的是 é 的第一个字节 0xC3;Rust 里同样的 s[1] 是编译错误。Rust 的两个主力是有所有权、可增长的 String 和借来的视图 &str,经验法则是参数收 &str、产出新数据返回 String。脚注里给了对应:只读视图上 Go 的 string&str,可变缓冲区上 []byteVec<u8>String 多一条「内容一定是合法 UTF-8」的保证。他的评价是这笔账被推到了开发阶段,开头难受,但把「字符串应该怎么表现」和「实际怎么表现」之间的错觉提前清掉。

Go 泛型「太少也太晚」的五条依据#

Go 在 1.18(2022 年 3 月)拿到泛型,距语言发布十三年。他的判断是它们占了泛型类型系统的大部分坏处,却没给出 Rust、Haskell 甚至现代 C++ 那种好处,然后给了五条依据。他也先声明这一节对大多数一线工程师不要紧,只是解释两门语言设计上的分水岭。

标准库几乎不用#

泛型落地三年后,sort.Slice 仍收 func(i, j int) bool 闭包而不是 cmp.Ordered 约束,sync.Map 仍是 any / any,真正的泛型工具集中在 slicesmapscmpsync 里少数几个条目。Go 1 兼容性承诺能解释一部分,非泛型 API 没法回炉,但三年足够引入泛型替代品。对比 Rust,OptionResultVecHashMapIteratorFrom / Into 从第一天起就是泛型的,不写泛型写不出地道的 Rust。

没有 trait 体系,只有结构性约束#

Go 的约束是 interface 加一个 ~ 运算符,缺四样:supertrait 层级(trait Ord: Eq + PartialOrd)、associated type(Iteratortype Item)、blanket impl(impl<T: Display> ToString for T)、以及带自己类型参数的方法。结果是抽象一旦超出「对任何带这几个操作的 T 都能用的函数」,Go 就把人推回 any 加类型断言、代码生成或反射。脚注里他注明最后一条正在退场:generic methods 提案在 2026 年初被接受,目标 Go 1.27。

类型推断止步于函数边界#

Rust 用 Hindley-Milner 风格的推断,类型信息穿过闭包、iterator 链和 ? 传播,let evens: Vec<_> = (0..100).filter(|n| n % 2 == 0).collect();_Vec<_> 都推得出来。Go 通常能从实参推类型参数,但不能从返回值位置推,也不能穿过泛型 builder 链,slices.Collect[int](iter) 这种显式类型实参在调用点很常见。

monomorphization 与 GC shape stenciling#

泛型的账要么编译期付、要么运行时付、要么放弃特化。Go 选了 GC shape stenciling 加 dictionary,同一 GC shape 的类型共用一份编译产物、运行时分发,保住了编译速度,代价是泛型代码可能比手写非泛型版本明显更慢,PlanetScale 那篇文章演示过。Rust 做 monomorphization,泛型是快的那条路,dyn Trait 是想要运行时多态时的有意识选择,账单是编译时间。他认为两种取舍谈不上谁对,优化的东西不同。

泛型没补上类型系统的洞#

这条最让他难受。好的泛型系统会减少回退到 escape hatch 的理由,Rust 里泛型加 trait 干掉了大部分要拿 Box<dyn Any> 或反射干的活。Go 里泛型没有移除 anyreflect 和代码生成:encoding/json 仍用反射,database/sql 仍用 any,mock 仍靠生成代码。他的结论是 Go 的泛型是加法,Rust 的泛型是地基;同时承认这对市面上 95% 的代码都不重要。

包对应表:六个 crate 覆盖九成后端需求#

Go 开箱自带 net/httpencoding/jsondatabase/sqllog/slogtestinghttptest,Rust 的 stdlib 刻意做小,随机数都住在 rand crate 里。Go 的 stdlib 也背历史包袱,math/rand 在 Go 1.22 以 math/rand/v2 重发过一次。原文那张对应表的主干:

场景 Go Rust
HTTP server net/httpchigin axum(基于 hyper
HTTP client net/httpresty reqwest
gRPC google.golang.org/grpc tonicprost
SQL database/sqlsqlcgorm sqlxsea-ormdiesel
JSON encoding/json serdeserde_json
日志 log/slogzerologzap tracingtracing-subscriber
CLI flagcobra clap(derive)
错误 errorsfmt.Errorf("%w") thiserror(库)、anyhow(二进制)
Mock uber-go/mockmoq 手写 fake(惯用法)、mockall
后台任务 goroutine 加 errgroup tokio::spawnJoinSet

他的估算是 axumsqlxtokiotracingserdeclap 这六个覆盖典型 Rust 后端九成的需要。依赖数比 Go 等价组合多是真实成本:Cargo.lock 变动更频繁,供应链暴露面更大。标准库已经够用的服务留在 Go,在他看来完全站得住。

会撞的墙:借用检查器、编译时间、async 着色、细分生态#

他直说从 Go 过来会撞墙,墙有四堵。

借用检查器#

Go 的运行时替你处理内存和别名,Rust 把这个决定推进类型系统。头几周最常挡路的四种情形:活得很长的引用(Go 里从 map 拿一个 *User 想持有多久都行,Rust 里这次借用会在整个生命周期内阻止修改 map,解法是 clone 或收窄作用域);自引用 struct(要么 Pin / ouroboros,绝大多数时候是重新设计);跨任务的共享可变状态(mu sync.Mutex; data map[K]V 变成 Arc<Mutex<HashMap<K, V>>>);从函数返回引用(生命周期标注登场)。

他反对把借用检查器当守门人的心态:它挖出来的是代码里本来就存在的 bug,那些问题早就在,只是它第一个指出来。收到报错先问四个问题:值被移动后原地还能安全用吗;值被跨线程共享时一个线程能在另一个读的同时改吗;指针被解引用时可能是空或悬垂吗;值离开作用域时还有别的东西指着它吗。第一个月最难,之后它像一个专注内存安全的结对伙伴。他引了两头的说法:PubNub 的 CTO 回忆刚上手时的挫败,像第一次学编程;clap 的维护者 Ed Page 则说借用检查器让他不用再想这些问题,把注意力放在更高层。

编译时间#

相比 Go 是实实在在的降级:中等服务的 clean release build 要几分钟,Go 几乎秒出。缓解手段是编辑循环里用 cargo check、划算时拆 workspace、把 proc-macro 用得重的 crate 单独放。他自己的体验是现代笔记本上这已经很少成为问题。

async 着色#

async fn / fn 的分裂是从 Go 过来写法上最大的退步之一。async trait 从 Rust 1.75 起稳定,但和动态分发混用仍有毛刺,有时要靠 async-trait crate。

某些细分领域生态更小#

Kubernetes operator、云厂商 SDK、冷门存储的驱动这几块 Go 有先发优势。他的建议是拍板前花一天确认依赖都有愿意用的 Rust 对应物;他帮的团队常常得自己写一到两个核心库,比如接手一个没人维护的 XML schema 校验 crate,或者给冷门协议写 client。

集成策略:四条增量路径与五条实操建议#

他听说的每一个成功案例都很有条理,没有一次性全部重写的。微软的 Victor Ciura 在他播客里的说法是他们不是为了好玩满世界重写,而是在新组件上做战术性选择。四条路径:

  • 把一条热点路径拆成独立服务:只重写那个 CPU 高、延迟敏感或可靠性差的服务,对外保持同一份 API 契约,其他 Go 服务通过 HTTP / gRPC 跟它说话。Radar 的 CTO 说他们是受 Discord 那篇 Go 换 Rust 的文章鼓舞才动手的。
  • 替换一个 sidecar / worker 进程:后台 worker、队列消费者、摄入管道、批处理任务有清晰的输入输出边界,跟系统其余部分没有进程内共享状态。
  • cgo 可行但难受:构建复杂度和 FFI 开销通常盖过收益,对后端服务他很少推荐,直接起一个 Rust 服务放到一次网络调用后面更划算;对库和 CLI 这条路更可行。
  • 网关后面的 strangler 模式:把特定端点路由到新的 Rust 服务,剩下留在 Go;认证、搜索、计费这类 bounded context 是合适的迁移单元。

实操建议五条:从边界清晰、blast radius 小的服务开始,别挑最核心部署最多的;保持同一份 API 契约(同样的路径、JSON 结构、error envelope),用网关增量切流量;别把惯用法逐字翻译,if err != nil?,每请求一个 goroutine 只在真需要时才变 tokio::spawn,单方法 interface 通常变成泛型上的 trait bound 而不是 Box<dyn Trait>;把编译器当结对程序员,慢慢读报错;早点在学习上下注,试图边干边学的团队结局通常不好。

该留在 Go 的场景和迁移后能期待的改善#

不是所有东西都该迁。Kubernetes 原生工具(operator、controller)的生态压倒性是 Go 的;CLI 和开发工具适合 Go,编译快、交叉编译容易、部署简单;薄 API 层和格式转换器这类胶水服务里 Rust 的样板代码占比不值得。更宽泛地说,团队交付速度比绝对正确性保证更重要的地方,Go 都出彩。Canonical 的工程副总裁在播客里说 Go 对网络服务是很好的选择,Juju 就是一个巨大的 Go 代码库。他合作的团队很多最后是多语言后端:无聊的服务用 Go,可靠性和性能能把额外投入赚回来的用 Rust。

改善的量级他给了几个区间,并且自己标注这些数因负载差异极大,只能当粗略参考,不是承诺,来源是他参与过的迁移:

项目 原文给的区间
生产事故 过了 -race 还能上线的那几类 bug 在 Rust 里编译不过,值班变得无聊
CPU 改善 20–40%,没有 Python 到 Rust 那么戏剧性
内存 下降 30–50%,主要来自没有 GC 开销和更小的运行时
P99 延迟 压力下更平,Go 那边的 GC jitter 在重压下仍会出现

他明说别指望 10 倍吞吐,拿到的是更少的低级错误和更平的延迟长尾,外加能用同一门语言扩到嵌入式和系统编程。结论落在:对组织依赖的、有高可用要求的、业务关键的基础服务,这笔交易明显值;其他服务 Go 挺好,迁移的意义是把每个问题放进最能解决它的语言里。


几点笔记(个人观点)#

  • 「检查被挪进类型系统」这句是全文最值钱的地方,比「Rust 更快」更能解释为什么后端团队真的在动手。我打算把它当一把尺用:一个坑如果靠 linter 加 code review 加 CI 这三层能兜住,那还是 Go 的活;只有它反复穿过这三层,才轮到 Rust 出场。
  • CPU 20–40%、内存 30–50% 这两组区间,来源是他参与过的迁移,没有测量条件,也没有负载描述,他自己在那一节开头标的就是「粗略参考,不是承诺」。所以这两个数在我这里只能当量级的感觉,不会进任何评估材料。四条改善里只有 GC 长尾那一条有机制可推:Go 的 GC 停顿在内存压力大时抬高 P99,这是有解释的;CPU、内存和事故率三条,要在自己的服务上跑出来才有数。
  • goroutine 那一节没有函数着色,是全文对 Rust 最狠的一刀,而且是从 Go 阵营内部递过来的。他列的 Go 侧那四条原罪(context 不强制传、WaitGroup / sync.Once 误用、漏加锁只在运行时炸、scheduler 抢占的历史)正好是 Go 服务最常见的事故面,比 Rust 侧那套借用检查器更容易在 code review 里被要求逐条确认。
  • async 着色这条对我们这种 Java 加 Go 的混合栈更有参考价值:Java 上了虚拟线程(JEP 444)之后也不引入 async 关键字,函数不着色,跟 goroutine 是同一条路;只有 Rust 把这笔代价显式留在代码里。所以同一份服务,用 Go 或 Java 写不产生这个问题,只有换 Rust 才会新增这笔税——迁移评估时该把它单独列一行,而不是混在「性能收益」里抵掉。
  • 「先花一天点名自己的依赖」「团队常常得自己写一到两个核心库」这两句是全文最有操作性的一条。真要评估,先按他给的清单(K8s operator SDK、云厂商 SDK、XML schema 校验、冷门协议 client)把缺口找出来,把「自建某某」写进工期,再算迁移划不划算。
  • Go 泛型那一整节的论据有一部分会在短期内过期。他自己引的 go#77273 我 2026-09-09 查了一下,确实是 Proposal-Accepted、2026-05-26 关闭,目标 Go 1.27——也就是说「不能给泛型类型写带自己类型参数的方法」这条马上就不是限制了。读这一节时要把那一条按「将废」处理,其余几条(标准库不用泛型、没有 trait 层级、推断止步于函数边界)短期还站得住。

参考资料#