DHH 用 AI 把 Campfire Once 从 Rails 重写成 Rust 后暴露了哪些问题
I'm sorry, but you still have to think
DHH 让 AI 智能体把 Campfire Once 从 Ruby on Rails 重写为 Rust、Elixir 和 Go,结果并不乐观。
作者: Piotr Sarnacki
于 2026年10月11日
Ruby on Rails 框架的创造者 DHH,决定把 Campfire Once 从 Ruby on Rails 重写为 Rust。更准确地说,是让一个 clanker 替他做这件事,因为他自己受不了阅读或编写 Rust 代码。之后他又添加了 其他语言的重写版本,比如 Elixir 和 Go。
这让我非常感兴趣,因为它很好地揭示了一个人不阅读代码就使用 AI agent 会得到什么结果。在这个例子中,这个人还拥有大量编程经验。而结果……如果你希望很快就能不再阅读代码,或者完全不再思考,那这些结果并不怎么令人鼓舞。这些重写版本存在多个问题,但让我们先从非功能性差异说起,因为它们很好地展示了很多似乎没有意识到的事情:如果你的提示词不够具体,很多决定就是抛硬币。这是因为大量问题并没有唯一正确答案。我们想保持向后兼容吗?我们更关心延迟还是吞吐量?系统在负载下可以使用多少内存?崩溃后丢失新内容通知可以接受吗?你可能不在乎这些问题,或者至少不在乎其中一些,但当 LLM 实现你认为你想要的东西时,它们会被隐式地回答。
如果你更仔细地查看这些重写版本,你很快就会发现它们对向后兼容和其他约束的处理方式不同。例如,Rust 版本没有保持 100% 向后兼容,它去掉了 CSRF token,以便更容易缓存。它还去掉了 Redis,改用进程内队列,例如用于通知。Elixir 重写版本则更接近 Rails 原版。这已经让比较几乎变得毫无意义,因为这些差异与语言无关。并不是说 clanker 听到“用 Elixir 重写”后,就因为所使用的语言而选择保持 100% 向后兼容。但还有更精彩的!
如果你看过代码——是的,我知道,我们不该再读代码了——你很快就会发现很多地方都不怎么样。比如,我看到有人抱怨 Elixir 版本用一个单进程顺序处理所有 SQL 查询,即使这些是读操作、本可以并发执行。这个抱怨很合理,但 DHH 似乎认为,这个 clanker 写不出高性能代码,对 Elixir 来说不是什么好形象。看看 Rust 版本,我不确定我是否同意。因为在 Rust 版本里,有些数据库操作不是异步的,这在某些情况下可能更糟。你看,在 Rust 里,使用异步运行时的时候,调度不是抢占式的,而是协作式的。如果一个任务不让出,同一个工作线程上就没有其他任务能运行。这在实践中意味着,任务里花费的时间应该尽可能短。比如,当你运行一个耗时 100ms 的 SQL 查询时,你不希望其他所有任务都等着,因为发起查询的那个任务反正也是在等待。因此,理想情况下你应该使用异步 I/O 操作,在等待数据库响应时让出给运行时。在 Rust 重写版里,有些查询跑在工作线程上,但有些则作为异步任务里的阻塞操作运行。锁也是一样。使用异步运行时的时候,最稳妥的选择是使用像 tokio::sync::Mutex 这样的异步锁。如果你确定锁只持有很短的时间,用非异步版本也没问题,但如果你持有一个同步锁 10ms,同一个线程上的所有任务都会等它,而那个阻塞任务本身什么也没干。所以,很遗憾地告诉你,不行,编程不太可能被“解决”,你仍然需要知道自己在做什么。
更进一步,结果发现那些 slop 基准测试也是,嗯,slop,因为它们只测量吞吐量,忽略了系统的其他属性。比如,Zach Daniels 测量了负载下新帖子通知的投递率,发现在重负载下 Rust 版本的成功投递率只有 1%。不太好看。基准测试很难。但即使是改进后的基准测试,设计得也不好。你看,当你测试一个系统时,你可能想在负载下强调不同的属性。DHH 和 Zach 的基准测试都是闭环基准测试,所以它们测试的是“系统在特定时间内能处理多少请求?”。测试使用 N 个客户端,每个客户端在收到上一个请求的响应后立刻发送一条新消息。如果你想比较原始请求处理性能,这也许没问题,但对于模拟真实世界负载就不太好了。通常你想要的是恒定到达率,也就是说,你想以恒定速率发送请求,而不是让速率取决于系统能多快响应。在这种情况下这一点尤其重要,因为每个添加新聊天消息的请求还会通过 WebSocket 向浏览器发送一个更新。如果你想检查事件投递的可靠性,理想情况下你会想比较相同数量的事件。这里,如果你看结果,它显示 Rust 在 6-7k 个通知中投递了 1%,而 Elixir 在约 1.7k 个通知中投递了 100%。基准测试很难。
不过,我们先暂时放下基准测试,来谈谈权衡取舍,因为编程的核心就是权衡取舍。当然,有些情况下某个工具或解决方案明显更好,没有任何缺点,但这种情况相当罕见,尤其是当你面对必须保证可靠性的复杂系统时。我见过不少来自 Elixir 社区的人,在看到 Rust 版本 1% 的投递率后就得出各种结论,却没有真正去理解为什么会这样。普遍的共识是什么?Elixir 就是更擅长并发!在 Rust 里调度器是协作式的,这日子怎么过?我喜欢 Elixir,也在生产环境中成功使用过它,但它并不是银弹。是的,Elixir(或其他基于 BEAM 的语言)非常擅长并发,而且不得不承认,用 Elixir 编写并发代码总体上比 Rust 更容易,但代价是失去了底层控制能力、更高的内存占用,以及常常还有速度上的劣势。这就是 rustler 存在的原因。仅凭单一指标、在不了解根本原因的情况下就宣称某种语言更优越,可能会产生误导。
还记得我提到过闭环压力测试并不完全公平吗?因为 Rust 处理了大约 4 倍的请求量,因此必须处理更多通过 WebSocket 发送给客户端的事件。我用 DHH 的 Rust 版本和 Zach 的 Elixir 版本(经过各种修复)在恒定投递率下重新运行了测试。在每秒 100 个 POST 的情况下,Rust 中客户端收到了约 14% 的事件。在 Elixir 中约为 60%。还是更好,对吧?别急!在这个流量水平下,Rust 没有任何 HTTP 错误。Elixir 在约 23% 的 HTTP POST 请求上超时了。那延迟呢?最差的事件投递延迟接近 180 秒。在 Rust 中,当投递滞后时,客户端会被断开连接。重新连接时,浏览器客户端会获取最新消息,这在很大程度上使得错过的事件变得无关紧要。你觉得哪种用户体验更好:客户端在后台静默重连并获取新更新,还是为了一条新消息的更新等待 3 分钟?这再次说明,单一指标并不能说明全部问题。顺便说一句,我测试的 Elixir 版本包含了在一位经验丰富的程序员阅读代码后所做的修复。有可能如果我把这些修复应用到 Rust 版本中所有明显的问题上——那些可能连中级程序员都能注意到的问题,比如在异步代码中使用阻塞的数据库调用,或在异步代码中使用阻塞的互斥锁——它会得到更好的结果,但我不会这么做,因为那不是重点。
回到权衡取舍。你知道 Rust 版本在重负载下为什么会丢弃这么多消息吗?它使用 tokio::sync::broadcast channel 向已连接的客户端广播事件。一条通知新聊天消息的事件可能需要发送到多个连接,所以这完全合理。广播通道的特性之一是它有固定的容量上限。如果接收方无法足够快地处理消息,接收方就会收到 RecvError::Lagged 错误(参阅文档了解更多关于 lagging 的信息)。Rust 重写版本中的广播容量被设置为 256。在 Elixir 中,GenServer 进程负责事件投递,而默认情况下 GenServer 邮箱没有限制。在 Rust 中只需改一行:
- stream_capacity: 256
+ stream_capacity: 16384将 100 req/s 时的投递率从约 14% 提升到约 90%。现在它比 Elixir 更好了,对吧?其实不然。我认为这个版本实际上更糟,因为在这种情况下,更好的做法是快速失败并强制客户端重新连接,而不是极其缓慢地处理事情。你知道提高容量后还发生了什么变化吗?pMAX 事件投递延迟从 11 秒上升到 >130 秒,与 Elixir 的表现类似,而我认为这严格来说比断开滞后的客户端更糟。如果你问我,即使 11 秒也太长了,如果接收方无法比这更快地传递事件,那么丢弃该事件会更好。这表明在系统中设置合理的约束有多么重要,也表明事实上 Elixir 并不会开箱即用地自动修复所有并发问题。此外,如果你不自己设置边界,你很可能会遇到外部限制。在 100 req/s 的压力测试中,Elixir 版本达到了 1.8GB 的内存使用量。还有一件事值得思考:是丢弃一些消息更好,还是被 OOM 杀死更好?一切都是权衡。Elixir 很棒,但它不会神奇地解决你所有的问题。
那么我们今天学到了什么?你仍然需要批判性地思考。了解自己在做什么是件好事。不要基于单一指标做出草率的假设。如果你想要一个可靠的系统,你应该知道何时失败。此外,基准测试很难。
如果你喜欢这篇文章,请考虑在 Twitter 上关注我。
来源:Hacker News · itsallaboutthebit.com