首页 / 文章 / 后端服务选 Rust 还是 Go:只需支付所需的功能保障费用

后端服务选 Rust 还是 Go:只需支付所需的功能保障费用

为何 Rust 的安全性与速度往往无法解决 API 团队的真正瓶颈,Go 是如何为可维护性进行优化的,以及何时该将 Rust 作为默认的后端技术。

2501 词

Rust是过去十年中最令人瞩目的编程语言之一,正因如此,在它成为后端团队的默认选择之前,我们有必要仔细研究它。大多数后端服务并非受限于Rust所强调的那些优势,而是取决于不断变化的技术团队能够多快理解并安全地修改这些服务。本文从这一角度对比了Rust和Go,指出了每种语言在实际使用中会带来的成本,并为读者提供了明确的标准,帮助判断何时值得为Rust的强大功能付出代价。

一个值得关注的场景

想象一个团队用 Rust 实现一个普通的后端功能。由此产生的代码比用 Go 实现的同类代码更安全、更有条理,也拥有更强的编译时保障。不过它的开发时间明显更长,会引发关于类型和生命周期的漫长讨论,甚至让常规修改变成设计层面的探讨。而用 Go 实现同样的功能则能快速完成,团队中的任何人都能轻松理解。

这两种结果并不意味着某种语言更差,只是说明使用某种工具的成本需要与其要解决的问题相权衡。

才华并不等同于适用性

Rust 在不依赖垃圾回收机制的情况下,改变了业界对安全性、性能和正确性的认知。这一成就实属难得,也正因如此它才赢得了人们的尊重。

但大多数后端团队并不从事存储引擎、内核、浏览器、游戏引擎、嵌入式固件或对安全性要求极高的基础设施的开发工作,在这些领域中,每一次内存分配和边界处理都是至关重要的问题。他们大多在构建API。他们的日常工作就是在Postgres、Redis、Kafka、S3、支付网关、通知服务、内部系统以及那些总在关键时刻出故障的第三方API之间传输JSON数据。

这样的工作并不光鲜:要处理业务规则、重试机制、日志记录、仪表板展示、部署操作、数据迁移,还要努力保持生产环境的稳定。对于这样的环境而言,Rust或许能成为解决某个团队并未提出过的问题的绝佳方案。

提出正确的问题

通常的讨论方式都是以阵营划分的。一方认为Go过于简单化,另一方则认为Rust太过复杂。这两种观点都有其道理,但都未能抓住问题的核心。

“Rust比Go更好吗?”这个问题太过模糊,难以回答。电锯在砍树时确实比厨房刀更高效,但没人会拿电锯上餐桌。更有意义的比较应是两种语言各自优化的方向:

  • Rust具备强大的功能与精确性,它试图在程序运行之前就排除各类错误。
  • Go则强调简洁性与快速理解度,其设计理念是假设普通工程师在常规的时间压力下仍能维护代码。

第二个假设的实际重要性比表面上看要高得多。

当维护问题被纳入讨论时会发生什么变化

许多 Go 开发者并不反感 Rust。有些人赞赏它,有些人用它编写代码,还有些人想学习它,而且很多人都认同在处理严肃的低级任务时 Rust 是更优的选择。当话题从语言设计转向长期的后端维护时,态度便发生了变化。

到了这个阶段,已经没人会质疑 Rust 的强大之处。问题变成了:一个普通的后端团队是否应该承担使用 Rust 所带来的成本,去解决那些其服务实际上并不需要的问题。

正是在这方面,Go 成为了强有力的竞争者。并非因为它更先进——其实它并不更先进;也非因为它的类型系统更丰富——实际上并非如此;更不是因为它能让开发者感觉自己很聪明——它往往适得其反。Go 获胜的原因在于它揭示了软件团队一个令人不安的事实:大多数团队并不需要表达能力更强的代码,他们需要的是那种更多人可以修改而不必担心的代码。

瓶颈很少是 CPU

后端工程师们总以为自己的系统只需一次优化就能达到卓越水平。这是个听起来不错的说法,但通常也是错误的。

大多数性能低下的后端系统并非因为语言运行时导致的。其缓慢的原因在于查询效率低下、缓存提供的是过期数据、网络不稳定、队列存在积压、依赖项不可靠,或是业务流程将本应独立的五项操作捆绑在了一起。更快速的语言并不能解决这些问题。

后端工程中最困难的部分通常并非让机器执行指令,而是帮助团队深入理解系统,以便他们能够在不破坏系统的前提下对其进行修改。下图通过列出实际瓶颈所在之处来阐明这一点:

A Normal Backend Team's Real Bottleneck

          CPU
           |
           |   usually not here
           v

    -----------------
    |  application  |
    -----------------
       |     |     |
       v     v     v

  Postgres  Redis  Kafka
       |       |      |
       v       v      v

  unclear ownership
  changing product rules
  missing observability
  slow code reviews
  fear of refactoring
  tired on-call engineers

The machine was rarely the hard part.
The humans were.

这就解释了为何 Rust 虽然在技术层面具有优势,却仍不适合许多后端团队作为入门选择。Rust 注重代码之间的正确性,而 Go 则往往着眼于团队层面的生存需求进行优化。这种差异便概括了整个争论的要点。

Go 的简洁性是一种刻意的设计限制

一个典型的 Go HTTP 处理器毫无优雅可言。它首先解码请求体,解码失败则返回 400 错误码,接着调用相关服务,若服务调用也失败则返回 500 错误码,否则便输出 JSON 格式的结果:

func CreateOrder(w http.ResponseWriter, r *http.Request) {
    var req CreateOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeError(w, "invalid request", http.StatusBadRequest)
        return
    }
    order, err := service.CreateOrder(r.Context(), req)
    if err != nil {
        writeError(w, err.Error(), http.StatusInternalServerError)
        return
    }
    writeJSON(w, order)
}

没人会称其为编程的未来,但几乎每位后端开发者都能立刻看懂它。初级工程师可以逐行分析,资深审查员几分钟内就能批准,新员工无需指导也能理解其控制流程。处理故障的人能够清楚地看到请求从何处进入、可能在哪出错以及最终从何处离开。

这种可读性并非微不足道的优势,在许多组织中它才是最重要的因素。这段代码还展示了 Go 语言存在的明显问题:直接将 err.Error() 返回给客户端会泄露数据库等内部细节,而在实际服务中应当记录错误并发送通用消息。正因为没有任何隐藏内容,这个缺陷才如此容易被发现。

限制巧妙决策的价值

在长期运行的系统中工作的经历往往会削弱人们对巧妙设计的钦佩之情。真正赢得尊重的是那些存在明显缺陷、却无需原作者解释的乏味代码。

Go语言在某种意义上并不具备吸引力:它限制了团队能够做出的复杂决策数量。这听起来像是批评,但直到你维护过一个充满复杂决策的代码库才会明白其含义。当初引入的每一个抽象概念都看似合理,每个通用辅助函数都有存在的充分理由,每一种框架选择也都各有其说服力。然而后来人们转向了其他方向,需求也发生了变化,那个代码库便逐渐变成了一堆没人能完全理解的过去自信的产物。

Go语言刻意追求的简洁性试图抵制这种趋势,但并非总能成功。糟糕的Go代码十分常见:重复的错误处理、过于简单的领域模型、全局状态、数据竞争问题、在不需要接口的地方使用接口,以及到处滥用context.Context却未能真正理解其取消机制。两者的区别在于,Go语言中的混乱通常显而易见,而Rust语言的混乱可能更为复杂。这并非Rust的缺陷,而是一种语言为能力强的工程师提供更多空间来展现其能力的自然结果。

当简单任务变得复杂时

这正是Rust可能给后端团队带来困扰的地方。任务本身或许很简单,但与之相关的类型却未必如此。下面的函数会按顺序处理一批数据,为每项数据等待异步处理程序,并在遇到第一个错误时就停止:

use std::future::Future;
trait Processable {
    type Output: Send + 'static;
}
async fn process_batch<F, Fut, T, E>(
    items: Vec<T>,
    handler: F,
) -> Result<Vec<T::Output>, E>
where
    F: Fn(T) -> Fut + Send + Sync + Clone + 'static,
    Fut: Future<Output = Result<T::Output, E>> + Send + 'static,
    T: Processable + Send + 'static,
    E: Send + 'static,
{
    let mut output = Vec::new();
    for item in items {
        output.push(handler(item).await?);
    }
    Ok(output)
}

其逻辑只是一个简单的循环。不过,接口定义必须明确说明该处理函数是可调用的、可克隆的,并且可以在不同线程间安全地传递和共享;它返回的 Future 类型为 Send 且具有 'static 特性;同时,项目类型与错误类型需满足相同的约束条件。这并非糟糕或人为设计的 Rust 语法。当异步代码、泛型处理函数、共享边界、派生任务、错误类型以及生命周期共同出现时,Rust 要求你必须明确说明那些其他后端语言会隐含处理的内容。

这种明确性具有实际价值,有时正是系统所需要的。不过它并非免费之物。其成本体现在入门培训、代码审查中,也体现在那些从产品角度看很简单、但从类型系统角度看却很复杂的特性上。当工程师花费更多精力去说服编译器接受某个解决方案,而非思考该方案是否有存在的必要时,成本便显现出来了。

更好了,但好在何处?

Rust的支持者认为这种阻力能造就更好的系统,他们有时确实是对的。后端团队应该提出更精确的问题:好在哪个方面?

  • 内存安全:有可能,不过Go在避免数据竞争以及不滥用unsafe的前提下也具备内存安全特性。
  • 性能:通常如此。
  • 防止某些并发错误:在很多情况下确实如此。
  • 在拥有混合经验团队的情况下,持续三年处理常规业务:并非必然。
  • 最后一个评估维度是大多数后端团队被考核的重点,而在这方面Rust的优势并非那么明显。

    真正的成本在于维护而非编写代码

    一位出色的Rust工程师能够构建出优秀的Rust系统,这一点毋庸置疑。问题在于,组织无法在拥有该工程师的那一刻就让团队停滞不前——人员会加入或离开,截止日期会变动,产品方向会调整,而最初设计架构的人要么得到晋升,要么精疲力尽,要么转到其他团队。代码却依然存在。

    在这种情况下,语言选择的意义更多在于其社会可持续性而非优雅性。以下几个问题可以概括这一点:

    • 下一位工程师能否理解这段代码?
    • 团队能否逐步对其进行重构?
    • 疲惫的值班工程师能否在不记住整个类型图的情况下安全地进行修改?
    • 新员工需要多快才能开始高效工作?
    • 该系统在平均天数内能否稳定运行,而不仅仅是理想情况下?

    Go在回答这些问题时往往表现更好。这并非因为Go开发者技术更娴熟,也不是因为Go代码天生就更简洁,而是因为该语言的表面积较小,复杂性难以藏匿。这也正是部分工程师觉得它令人沮丧的原因——Go不会讨好使用者。Rust会让你感觉自己在构建重要的东西,而Go则可能让你觉得自己只是在做些基础的工作。

    后端工程大多就是这类基础工作。水需要流动,管道需要易于定位,后续接手的人应该能够在不先了解整个系统架构的情况下更换阀门。

    Rust显然是合适的选择

    但这并不意味着Rust在所有场景下都适用。当性能、内存安全性和底层控制是项目开发的核心需求时,Rust值得认真考虑。如果程序崩溃是不可接受的,如果内存安全漏洞会带来安全风险,或者如果延迟是产品的重要指标而非仅仅一个数据值,那么Rust很可能是最明智的选择。

    代理、数据库、语言运行时、安全工具、嵌入式系统、高性能网络、开发者工具以及某些基础设施服务都属于这类应用场景。在这些领域,选择Rust是一项成熟的工程决策。

    然而,标准的后端 API 并不会因为用 Rust 编写而变得更成熟,有时反而会成本更高。团队选择 Rust 是有充分理由的,但同时也因为觉得 Go 太简单、Java 太商业化、Python 太松散,而 Rust 才像是严肃的工程师应该选择的工具。这并非工程上的判断,而是伪装成系统编程决策的美学不安。

    快速决策方法

    当出现以下多数情况时,Rust 是更合适的选择:

    • 该服务属于基础设施类,性能或内存控制是产品的重要功能;
    • 团队已有数名经验丰富的 Rust 工程师,并且能够进一步招聘;
    • 崩溃或内存安全漏洞会带来严重的安全或业务损失。

    当出现以下多数情况时,Go 或其他注重可读性的语言则是更安全的选择:

    • 该服务主要用于在数据库、队列和 API 之间传输数据,
    • 团队成员经验参差不齐且人员流动频繁,
    • 主要风险在于职责不明确、需求不断变化以及可观测性较差,而非处理能力不足。

    总结

    Rust 的卓越性能毋庸置疑。关键在于你的后端团队是否缺乏这种卓越性能。

    如果团队由经验丰富的 Rust 工程师组成,且所处理的基础设施中 Rust 的特性能够直接对应各种风险,那么无需犹豫就应选择 Rust;当工作需求如此时,使用更强大的工具才是正确的选择。但如果团队只是构建在存储系统、队列、API、仪表板及内部工具之间传输数据的传统服务,选择 Rust 反而可能反映出过高的成本偏好,而非技术成熟度。

    Go 并非因为更强大才更受青睐。对许多后端团队而言,它更易使用才是其优势:代码更易阅读,招聘人员更容易找到,审查和部署也更简便,而且一旦最初的热情消退,它仍能保持平平无奇的状态。平平无奇并非失败,正是在炒作过后,生产系统依然需要的状态。

    真正让后端团队陷入困境的故障,很少源于缺少借用检查机制。它们往往来自不清晰的服务边界、非幂等性的重试逻辑、悄悄成为实际 API 的数据库、吸收了所有设计捷径的队列、只能反映部分信息的日志,以及为根本不存在的理想化团队设计的架构。Rust 虽然能避免许多类型的错误,但却无法防止在团队需要清晰度时却选择强大功能的错误。正因如此,对于大多数后端工作而言,Rust 并非理想的默认选择:并非因为它本身薄弱,而是因为当核心问题主要关乎人为因素时,它的优势反而会带来高昂代价。

    相关阅读