[译]我们为了继续使用 Golang 而对自己说的谎言
本文是对 Lies we tell ourselves to keep using Golang的整理与翻译。观点仅代表原作者内容结构概览开篇Go 的吸引力与长期成本第一层自我安慰质疑批评者而不是讨论问题第二层自我安慰大厂在用所以我们也能用Go 确实有优点运行时、goroutine、GC、工具链真正的代价语言设计不足会转移到业务代码里Go 是一座孤岛工具链、FFI、cgo、跨语言集成问题零值哲学的两面性slice、map、channel、结构体默认值“小心点就行”为什么不够人工审查和 linter 无法替代类型系统这不是 Rust 神教文章Rust 也有问题但它提供了另一种增量采用路径Go 适合原型吗没有真正的“临时代码”重写往往不会发生结尾我们应该如何更诚实地评估技术选型我们为了继续使用 Go对自己说过哪些谎很多程序员第一次接触 Go都会有一种轻松感。语法简单编译快部署方便标准库够用go test、go fmt、go build开箱即用。写一个 HTTP 服务几行代码就能跑起来起 goroutine 也不需要先学习一整套复杂的异步模型交叉编译更是让不少被 C/C 构建系统折磨过的人眼前一亮。所以 Go 很容易让人喜欢。但问题是一门语言容易上手不等于它适合长期维护复杂系统。一个工具在早期给你的速度可能会在几年后变成组织里的隐性债务。很多 Go 项目不是一开始就痛苦而是随着系统变大、团队变大、边界变多、线上事故变多才慢慢发现那些“简单设计”背后其实藏着大量人工约定。原文《Lies we tell ourselves to keep using Golang》讨论的正是这个问题。它不是简单地说“Go 不好”也不是把 Rust 拿出来当万能解药。更准确地说它在拆解一种技术选型中的自我辩护机制当我们已经投入大量 Go 代码、团队习惯、招聘体系和基础设施之后我们会很自然地为继续使用它寻找理由。这些理由里有些是合理的有些则只是让我们暂时不去面对问题。这篇文章就按原文思路把这些“我们对自己说过的话”重新梳理一遍。一、当我们不想听批评时最容易先质疑批评者很多技术争论不是从问题本身开始的而是从质疑发言者开始的。有人指出 Go 的问题常见回应往往不是“这个具体问题是否存在”而是“你是不是不懂语言设计”“你是不是只会 Rust”“你是不是带着偏见”“你是不是拿 Go 去做了不适合它的事情”这种回应很省力。只要把批评者归类为“外行”“喷子”“语言洁癖”“Rust 传教士”就不需要继续面对他提出的问题了。但这其实是一种逃避。一个问题是否真实存在不取决于指出它的人是谁。初级工程师也可能看出一个系统设计里的明显缺陷资深工程师反而可能因为长期适应了某种痛苦把痛苦当成常态。在很多团队里越是老员工越习惯对各种不合理设计说“这个一直都是这样”。新人反而会问“为什么这里要手动判空”“为什么这个字段没有初始化也能编译”“为什么这个函数会不会修改参数从调用处看不出来”“为什么这个接口接收interface{}但实际只允许三种类型”这些问题听起来幼稚但它们非常重要。因为它们说明系统的约束没有被语言和工具表达出来而是被默默转移到了人的脑子里。成熟的工程文化不应该因为提问者不够资深就忽略问题本身。二、“大公司也在用 Go”并不能证明它适合你技术选型时很多人喜欢看大公司案例。某某公司使用 Go 支撑了海量流量。某某基础设施项目是 Go 写的。某某云原生组件全家桶都是 Go。这些信息当然有价值。它们可以帮助我们发现值得评估的技术。但它们不能直接替代评估。公司技术博客通常不会完整展示真实成本。它会展示成功经验、架构图、性能优化、业务收益却很少说“我们当初选错了。”“这个语言给我们制造了很多额外复杂度。”“我们花了很多人力绕过工具链和类型系统限制。”“我们现在已经被历史包袱绑住了。”因为公司博客还有招聘、品牌、技术影响力等目标它天然更愿意讲一个成功故事。这就导致一个误区看到优秀团队使用 Go我们以为 Go 本身解决了问题但也可能是优秀团队用大量经验、人力和内部工具弥补了 Go 的不足。原文提到 Tailscale 的例子很典型。Tailscale 确实大量使用 Go而且团队里有非常强的 Go 专家。他们能深入 Go 运行时、链接器、网络栈和底层细节遇到坑时有能力自己修、自己绕、甚至推动上游改进。但问题是你的团队也是这样吗你是否也有足够多理解 Go 底层实现的人你是否能承担为了一个语言限制而长期维护复杂 workaround 的成本你是否像 Google 一样有能力在 Go 之上构建一整套额外工程体系大公司能用不等于你能用。专家团队能承受不等于普通团队也能承受。更关键的是有些复杂度并不是业务本身带来的而是语言选择带来的。比如 Go 缺少 sum type导致表达“IPv4 或 IPv6”这类概念并不自然缺少不可变数据支持导致防止修改只能靠复制和约定缺少 newtype 一类机制导致类型隔离很别扭不支持运算符重载导致一些本该自然表达的值对象操作变得啰嗦。这些并不是“高深学术问题”它们会实实在在地影响业务建模。三、承认 Go 的优点它确实很会让人上手批评 Go并不意味着否认 Go 的优点。Go 的优点非常明确而且很多都是工程上真正有价值的优点。第一Go 的并发模型很容易使用。goroutine和channel让很多网络服务开发者第一次感受到“原来并发程序也可以写得这么直接。”不需要显式管理线程池不需要一开始就被复杂的 async/await 生态吓住。第二Go 的运行时做了大量事情。调度、网络 I/O、GC、goroutine 栈、阻塞检测、profiling这些都和语言体验绑定得很紧。你可以很方便地 dump goroutine 栈可以用 pprof 分析程序可以在很多情况下不用过早关心“函数颜色”问题。第三Go 的工具链非常统一。gofmt消灭了大量代码风格争论。go test降低了测试门槛。go build和交叉编译让部署体验比很多传统语言舒服得多。go mod虽然也有争议但相比许多历史包袱沉重的构建系统它确实简单直接。第四Go 的标准库覆盖了很多后端开发常见需求。HTTP、TLS、JSON、profiling、testing、context、template、net、sync 等基本能让你不依赖太多第三方库就写出一个可运行服务。这些优点解释了为什么很多人一开始会被 Go 吸引。问题在于一个非常方便的运行时并不自动等于一门适合复杂系统建模的语言。四、一个好用的运行时不能抵消语言设计的所有问题Go 最大的吸引力之一是它的运行时。但当你选择 Go 时你不只是选择了 goroutine 和 GC。你还选择了Go 自己的工具链Go 自己的构建系统Go 自己的链接模型Go 自己的调用约定Go 自己的 GC 行为Go 标准库的设计取舍Go 语言本身的类型系统和表达能力这是一整套平台不是一个可以随便拆出来的“并发库”。原文里有一个很尖锐的判断Go 团队真正喜欢的是异步运行时和网络服务开发体验语言本身像是围绕这个目标“长出来”的而不是从严肃的类型建模、语义表达和长期维护角度精心设计出来的。这句话可能有争议但它指出了一个真实感受Go 在很多地方更像是“够用就行”。比如错误处理。Go 的错误处理很显式这是优点。但它没有提供足够强的类型机制来表达错误的结构、上下文和可恢复性最后大量业务代码都变成result,err:doSomething()iferr!nil{returnerr}显式是显式了但并不代表信息更丰富也不代表调用方更安全。再比如可变性。在 Go 里你很难从调用处直接判断一个方法是否会修改接收者typeUserstruct{Namestring}func(u User)Rename(namestring){u.Namename}func(u*User)RenameInPlace(namestring){u.Namename}调用时看起来都像u.Rename(alice)u.RenameInPlace(bob)真正决定是否修改原对象的是方法签名里的值接收者还是指针接收者。调用方如果不跳到定义处很难仅凭调用点判断副作用。在小程序里这不算问题。在大型代码库里这会逐渐变成认知成本。函数是否会修改输入是否会持有某个指针是否会启动 goroutine是否会把对象保存到全局结构里是否允许传入 nil这些信息如果不能被类型签名清楚表达就只能依赖文档、注释、代码评审和团队习惯。而文档会过期注释会过期评审会疲劳团队成员会流动。五、Go 是一座孤岛舒适也封闭原文有一节叫 “Go is an island”。这个比喻很准确。Go 的封闭性带来了一些好处。比如 Go 可以深度控制自己的运行时、调度器、网络栈和 profiling 体验。你可以用同一套工具观察业务代码和标准库内部行为这在很多语言里并不容易。但代价也很明显Go 和外部世界的互操作并不舒服。如果你不用 cgo你基本生活在 Go 自己的世界里。Go 有自己的汇编风格、自己的链接器、自己的调用约定、自己的运行时。很多其他语言生态里成熟的调试器、内存检查工具、FFI 经验并不能直接平滑迁移到 Go。如果你要从 Go 调 C通常会碰到 cgo。如果你要从其他语言调用 Go也会把 Go runtime 一起带进去。这不是单纯的技术细节而是架构层面的影响。很多团队最后会发现和 Go 集成最舒服的边界是网络边界。也就是说不要直接把 Go 嵌进别的语言进程里而是让 Go 服务通过 HTTP、gRPC、JSON-RPC 或其他 RPC 协议对外提供能力。这当然可行也是微服务架构中很常见的做法。但网络边界不是免费的。你要维护协议。你要维护序列化和反序列化。你要处理版本兼容。你要处理超时、重试、幂等、鉴权、观测性。你还要确保边界两侧的数据约束是一致的。如果语言本身不能很好表达不变量这些不变量就会被迫散落在校验逻辑、文档、单元测试、接口约定和线上监控里。这就是 Go 的一个核心问题它把很多复杂度从语言层面移走了但复杂度并没有消失只是换了地方。六、零值Go 最迷人的设计之一也是最危险的设计之一Go 很推崇零值。零值的好处是显而易见的变量声明后马上可用很多类型不需要显式构造。比如varcountintvarnamestringvaritems[]intcount是 0name是空字符串items是 nil slice。很多情况下这很方便。但方便的背后是一个问题零值到底是不是一个合法业务状态有时候是。比如 nil slicevaritems[]intfmt.Println(len(items))// 0这很自然。nil slice 可以被当成空集合看待len返回 0很多操作也能正常工作。但 nil map 就不一样varmmap[string]intm[age]18// panic你必须先初始化m:make(map[string]int)m[age]18再看结构体字段默认值typeParamsstruct{AintBint}funcWork(p Params){fmt.Println(p.A,p.B)}funcmain(){Work(Params{A:1})}这段代码可以编译B会默认为 0。问题是这个 0 是调用者故意传的还是忘了传编译器不知道。当你给Params新增字段时很多调用点不会报错。它们会静默使用零值。这个行为在小代码里很方便在大代码库里却可能制造非常隐蔽的 bug。如果B表示重试次数0 也许合理。如果B表示超时时间0 可能意味着立刻超时也可能意味着永不超时。如果B表示价格、余额、权限级别、版本号、租户 ID它到底是不是合法值就必须靠业务逻辑判断。Go 的零值哲学把这个判断交给了程序员。七、channel 的零值和关闭语义也不是免费午餐Go 的 channel 是并发模型的核心之一但 channel 的一些语义非常容易踩坑。常见规则包括向 nil channel 发送会永久阻塞 从 nil channel 接收会永久阻塞 向已经关闭的 channel 发送会 panic 从已经关闭的 channel 接收会立即返回零值这些规则都能解释也都有设计上的理由。但从使用者角度看它们意味着你必须非常清楚一个 channel 当前处于什么状态。它是不是 nil它是不是已经关闭谁负责关闭会不会重复关闭有没有 goroutine 永远阻塞在发送或接收上关闭后读到的零值是真的业务值还是通道关闭导致的默认值Go 提供了一些机制帮助你发现问题比如 pprof、goroutine dump、deadlock 检测等。但这些更多是问题发生后帮助你定位而不是在编译期阻止你写出有问题的程序。这就是原文反复强调的一点Go 的回答经常是“你小心点”。但“你小心点”不是工程体系。八、“每个问题单独看都不大”不代表合起来也不大Go 争论中很常见的一种回应是“这个确实要注意但也没那么严重。”“nil 要检查这不是常识吗”“map 要初始化这不是 Go 基础吗”“channel 要规范使用这不是代码评审该管的吗”“结构体字段新增后要检查调用点这不是测试该覆盖的吗”单独看每句话好像都对。但工程问题的难点不在于单个坑而在于所有坑会叠加。一个语言如果在很多地方都要求人“记得小心”那么最后系统安全性就依赖于每个开发者都理解这些细节每次代码评审都能发现这些问题每个测试都覆盖到边界情况每份文档都及时更新每次重构都不会漏掉隐含约束每次线上变更都没有侥幸路径这不现实。人类不擅长长期维护大量隐式不变量。我们会累会忘会换项目会交接不完整会在凌晨三点处理事故时做出错误判断。优秀的语言和工具不可能消灭所有 bug但可以消灭一部分本不该存在的 bug。“不能解决所有问题所以不值得解决任何问题”是一种错误逻辑。安全带不能阻止所有车祸但这不是不系安全带的理由。类型系统不能证明所有业务逻辑正确但这不是放弃类型表达的理由。编译器不能替你设计系统但它可以帮你拒绝明显错误的状态。九、这不是“Rust 完美Go 垃圾”的故事原文很明确地反对一种稻草人式解读“你批评 Go是不是因为你觉得 Rust 完美”不是。Rust 有自己的问题。学习曲线陡编译时间长生命周期和借用检查会让新人痛苦异步生态也有复杂性unsafe Rust 写错了照样危险。Rust 的类型系统强大但也意味着你需要在前期投入更多建模成本。所以问题不是“Go 和 Rust 谁是神”。问题是当我们面对复杂系统时是否愿意承认一些约束应该由工具帮我们维护而不是全部压给人Rust 的成功很大程度上来自它可以增量采用。很多真实项目并不是一夜之间用 Rust 重写全部系统而是在 C/C、Python、Firefox、Android、Linux kernel 等生态里把某些安全性要求更高、边界更清晰的组件逐步迁移到 Rust。这和 Go 的成功路径不同。Go 的成功很大程度来自“开箱即用”和“默认选择足够好”。它让你快速写出服务。Rust 的成功更多来自“可以和现有系统逐步结合”并在内存安全、类型表达、可变性控制等方面提供更强工具。这两者都是成功故事但不是同一种成功。十、不要制造虚假的二选一很多争论会被简化成二选一要么追求开发速度要么追求正确性。要么快速上线要么过度设计。要么用 Go 写业务要么用 Rust 把所有细节建模到极致。这是假二分。现实里你可以做中间选择。你可以在 Rust 里选择不建模所有细节把不关心的错误归到Other或Unknown。你也可以在 Go 里非常谨慎地做类型封装、构造函数、校验器、lint 规则、代码生成、review checklist。区别在于成本。在 Go 里你可以写出相对安全的代码但语言本身给你的帮助有限。你要靠纪律、封装、测试、规范、review 和经验。在 Rust 里你也可以写得很粗糙但语言会强迫你面对一些 Go 可以暂时忽略的问题比如空值、所有权、可变借用、错误类型、生命周期等。这不是说 Rust 总是更好而是说不同语言把复杂度放在了不同位置。Go 倾向于让代码早期看起来简单把很多约束推迟到运行期、测试期、评审期和线上期。Rust 倾向于让你在编码阶段就处理更多约束因此早期更慢但后期能减少一部分不确定性。技术选型要诚实地评估这种成本转移。十一、Go 适合做原型吗很多人会退一步说“好吧也许 Go 不适合特别复杂的生产系统。但它很适合做原型。先用 Go 快速写出来以后真复杂了再重写。”这句话听起来很合理但原文指出了一个残酷事实几乎不存在真正的临时代码。大多数团队都非常抗拒重写而且有充分理由。重写很贵。重写期间不能快速交付新功能。迁移过程容易丢细节。新旧系统要长时间并存。线上流量切换有风险。团队要重新学习新技术。业务方也不愿意为“技术债偿还”长期等待。所以很多所谓原型最后都会变成生产系统。一旦第一批服务用 Go 写了后续服务继续用 Go 的理由会越来越多“团队已经会 Go 了。”“已有代码都是 Go。”“已有脚手架都是 Go。”“已有库都是 Go。”“已有部署模板都是 Go。”“别的语言和 Go 集成太麻烦。”这时你继续写 Go可能不是因为它仍然是最佳选择而是因为退出成本越来越高。这就是技术路径依赖。十二、Go 的“简单”可能只是把复杂度藏起来了Go 的拥护者常说Go 很简单。这句话要分两层理解。Go 的语法确实简单。但语法简单不代表系统简单。当语言缺少表达能力时复杂度不会消失而是进入业务代码。比如没有 sum type你就需要用字符串常量、枚举模拟、接口模拟、结构体加 tag 模拟。比如没有不可变数据你就需要靠约定和复制避免意外修改。比如零值可能合法也可能非法你就需要靠构造函数和校验函数维持不变量。比如函数签名不能表达副作用你就需要靠命名、注释和文档提示调用方。比如interface{}太宽你就需要在运行时做类型断言和错误处理。于是代码库里会出现大量样板逻辑ifvalue{returnerrors.New(missing value)}ifp.Timeout0{p.TimeoutdefaultTimeout}ifreq.UserID0{returnerrors.New(invalid user id)}switchv:x.(type){casestring:// ...caseint:// ...default:returnerrors.New(unsupported type)}这些代码不是业务核心却充满代码库。它们本质上是在手动维护类型系统没帮你表达的东西。长期看所谓“简单语言”可能会制造“复杂项目”。十三、为什么 Go 不一定适合初学者Go 经常被推荐给初学者因为语法少、工具简单、上手快。但原文提出了一个反直觉观点Go 不一定适合初学者因为它允许太多明显有问题的代码通过编译。对初学者来说最难的不是少写几个关键字而是建立正确的程序思维。什么状态是合法的什么状态是不可能出现的哪些值可以为空哪些对象可以被修改谁拥有资源谁负责关闭错误是否被处理并发是否可能泄漏这些问题如果语言不提醒初学者就只能靠踩坑学习。Go 的编译器经常保持沉默。它相信程序员会小心处理。但初学者恰恰最不知道哪里需要小心。这会导致一种错觉代码能编译服务能跑就以为系统没问题。直到线上出现 nil、零值、竞态、goroutine 泄漏、map panic、channel 阻塞、错误吞掉才发现“简单”并不等于“安全”。十四、linter 和代码评审能解决吗能解决一部分但不能解决根本问题。linter 很有用。代码评审也很有用。测试更有用。但它们不能完全替代语言本身的表达能力。如果某个约束可以被类型系统表达就应该尽量让编译器检查。因为编译器不会疲劳不会漏看不会因为赶需求而降低标准也不会因为团队新人不了解历史背景而放过错误。而 linter 和 review 往往是补救机制。它们是在语言允许你写出危险代码之后再试图把其中一部分拦下来。这就像门本来没有锁然后你安排几个人每天站在门口提醒大家别乱进。提醒当然有用但不如门锁本身可靠。Go 的问题不是“完全不能写好代码”。Go 当然能写出好代码很多优秀项目就是 Go 写的。问题是写出长期可靠的大型 Go 系统需要大量额外纪律和经验而这些成本经常在技术选型时被低估。十五、我们为了继续使用 Go常说哪些话原文最后总结了一组“我们为了继续使用 Go对自己说的谎”。换成中文语境大概可以整理成下面这些第一别人都在用所以它也适合我们。但别人有别人的团队能力、历史包袱、业务场景和专家储备。大厂案例只能作为评估输入不能作为决策结论。第二批评 Go 的人都是精英主义者或语言洁癖。但问题是否存在和提出问题的人是否讨人喜欢无关。把批评者标签化只会阻止我们讨论事实。第三Go 的 runtime、GC 和工具链足够好所以语言层面的缺陷可以忽略。Go 的运行时确实优秀工具链也确实舒服。但你采用的是整个平台不只是 goroutine。第四每个语言设计缺陷单独看都不严重所以合起来也不严重。这不成立。工程事故往往不是一个大问题造成的而是很多“小心点就行”的地方叠加造成的。第五只要团队小心一点加 linter多 review就能克服这些问题。这能缓解但不能根治。长期把系统正确性寄托在人的持续注意力上本身就是风险。第六Go 写起来快所以开发生产系统也快。写代码只是软件工程的一小部分。真正耗时的是理解、维护、排障、重构、扩展和协作。第七语言简单所以系统也简单。语言少了表达能力复杂度就会转移到业务代码和团队流程里。第八可以先少量使用以后再迁移。技术一旦进入核心系统就会形成路径依赖。越往后迁移越难。第九以后可以重写。绝大多数“以后重写”最后都不会发生。即使发生也会非常昂贵。十六、那我们到底该怎么评价 Go更现实的态度不是“用”或“不用”而是问清楚几个问题。第一你的项目复杂度在哪里如果是简单 CLI、内部工具、小型服务、脚本替代品、基础设施 glue codeGo 可能非常合适。如果是复杂业务领域建模、高安全性系统、跨语言嵌入、强约束协议、长期演进的大型核心系统就要更谨慎。第二你的团队是否真的理解 Go 的成本不是会写http.HandleFunc就叫理解 Go。真正的成本包括 nil、zero value、interface、反射、逃逸分析、GC 行为、goroutine 生命周期、channel 关闭、context 传播、错误处理、cgo、模块管理、代码生成、lint 体系、测试策略等。第三你是否有能力建立工程约束比如强制构造函数避免裸interface{}明确 nil 语义对结构体新增字段建立检查机制对 goroutine 生命周期做统一管理对 channel 所有权和关闭责任做规范用静态分析补充编译器不足对错误处理建立统一模式用契约测试保护服务边界如果没有这些Go 的“简单”会很快变成线上复杂度。第四你是否能接受未来退出成本一旦项目大量使用 Go并且内部库、脚手架、部署、监控、招聘都围绕 Go 建立未来切换语言会越来越难。这不是 Go 独有的问题所有技术栈都有路径依赖。但 Go 的“孤岛”特征会让跨语言集成和渐进迁移更痛。十七、结语不要因为一开始舒服就忽略长期代价Go 最大的魅力是它让人一开始很舒服。这也是它最危险的地方。很多语言会在一开始就暴露复杂度让你觉得难。Go 则倾向于把复杂度推迟让你先跑起来再在系统变大后慢慢付账。这不是说 Go 不能用。Go 可以用而且在很多场景里非常好用。但我们不应该用一些自我安慰的话掩盖它的成本“大家都在用。”“Go 很简单。”“我们小心点就行。”“以后可以重写。”“加点 linter 就好了。”“这个坑大家都知道。”真正成熟的工程判断不是选择某个流行技术而是诚实面对它会把复杂度放在哪里。有些复杂度放在编译器里。有些复杂度放在类型系统里。有些复杂度放在运行时里。有些复杂度放在代码评审里。有些复杂度放在凌晨三点的值班电话里。选择 Go并不是选择没有复杂度。只是选择让很多复杂度以后再出现。而工程里最贵的往往正是那些一开始被推迟的问题。