跳到正文
原文
Hacker News· drogus·· 5 小时前精选AI 评分65

AI 改写 Campfire 后的工程复盘,抱歉你还是得自己思考

I'm sorry, but you still have to think

AI 导读

Piotr Sarnacki 复盘 DHH 让 AI 智能体把 Campfire 分别重写为 Rust、Elixir 和 Go 后的代码质量与表现,指出不读代码、不设约束会带来一系列工程问题。

推荐理由

以 DHH 用 AI 重写 Campfire 后的真实表现为案例,拆解了不读代码、不设约束时容易踩到的工程陷阱。

正文 · AI 翻译

作者 Piotr Sarnacki
发布于 2026 年 10 月 11 日

DHH,Ruby on Rails 框架的创建者,决定将 Campfire Once 从 Ruby on Rails 用 Rust 重写。或者更准确地说,是请一个 clanker 帮他做这件事,因为他自己无法忍受阅读或编写 Rust 代码。在此之后,他又加入了 用其他语言进行的重写,比如 Elixir 和 Go。

这对我来说非常有趣,因为它很好地展现了某人在不阅读代码的情况下使用 AI 智能体的结果。在这种情况下,这个人还拥有大量的编程经验。而结果是……嗯,如果你希望很快就能停止阅读代码,或者干脆停止思考,那么这并不太令人鼓舞。这些重写存在多个问题,但让我们从非功能性差异开始,因为它们很好地展示了许多人似乎没有意识到的事情:如果你的提示不够具体,许多决定就如同抛硬币一样。这是因为大量的问题并没有唯一正确的答案。我们是否希望保持向后兼容性?我们更关心延迟还是吞吐量?在高负载下系统可以使用多少内存?崩溃后丢失新内容通知是否可以接受?你可能不关心这些问题,或者至少不关心其中的一些,但当 LLM 实现你以为你想要的东西时,它们会被隐式地回答。

如果你更仔细地查看这些重写,你会很快发现它们以不同的方式处理向后兼容性和其他约束。例如,Rust 版本并没有保持 100% 的向后兼容性,比如它丢弃了 CSRF token,以便于缓存。它还放弃了 Redis,转而使用进程内队列,例如用于通知。Elixir 重写则更接近 Rails 原版。这已经使得这个比较几乎毫无意义,因为这些差异是与语言无关的。并不是说 clanker 听到"用 Elixir 重写"就因为所选用的语言而选择保持 100% 的向后兼容性。但故事还没完!

如果你看过代码——是的,我知道,我们本不该再读代码了——你会很快发现很多东西并不理想。例如,我看到有人抱怨 Elixir 版本用一个进程顺序处理所有 SQL 查询,哪怕这些本可以并发执行的读操作也是如此。这个抱怨是合理的,但 DHH 似乎认为,让 AI 写不出高性能的代码,对 Elixir 来说并不是什么光彩的事。在看了 Rust 版本后,我不太同意这种看法。因为在 Rust 版本中,一些数据库操作并不是 async 的,在某些情况下可能更糟。你知道,在 Rust 中使用 async 运行时时,调度并非抢占式,而是协作式的。如果某个任务不让出(yield),同一个工作线程上就无法运行其他任务。这在实际中意味着,任务中花费的时间应该尽可能短。例如,当你执行一个耗时 100ms 的 SQL 查询时,你不会希望所有其他任务都在干等,因为发起查询的任务反正也在等待。因此,理想情况下你应该使用 async I/O 操作,在等待数据库响应时把控制权让给运行时。在 Rust 重写版本中,有些查询在工作线程上运行,但有些是作为阻塞操作运行在 async 任务中的。锁的情况也一样。使用 async 运行时时,最稳妥的做法是使用 async 锁,例如 tokio::sync::Mutex。如果你确定锁只持有很短的时间,使用非 async 版本也没问题;但如果你持有同步锁 10ms,同一线程上的所有任务都会等待它,而阻塞任务本身却什么都没做。所以,很遗憾地告诉你,编写代码这件事远远没有「解决」,你仍然需要知道自己在做什么。

进一步研究发现,垃圾基准测试其实也是,嗯,「垃圾」,因为它们只衡量吞吐量,而忽略了系统的其他属性。例如,Zach Daniels 测量了新帖通知在高负载下的送达率,发现在高负载下 Rust 版本的送达成功率只有 1%。这看起来并不好看。基准测试很难做。但即使是改进后的基准测试,也未必真正测试了你想要测试的东西——这取决于你的系统特性。要知道,在测试一个系统时,你可能希望在高负载下突出不同的特性。DHH 和 Zach 的基准测试都是闭环基准测试,因此它们测试的是「系统在一段特定时间内能处理多少请求?」。测试使用了 N 个客户端,每个客户端在收到上一次响应后就立即发送下一条消息。然而,在许多情况下,系统的负载增加可能来自大量用户同时执行某个操作,他们并不会等待其他用户完成。在这种情况下,你更倾向于使用 恒定到达率,也就是说你希望以恒定速率发送请求,而不是让速率取决于系统响应的速度。如果你想检查事件送达的可靠性,理想情况下应该比较相同数量的事件。这里,看一下结果就能发现:Rust 在 6-7k 条通知中送达了 1%,而 Elixir 在约 1.7k 条通知中送达了 100%。这说明 Elixir 版本在背压处理方面表现更好,但这一结论仅在请求数量不会让系统过载的前提下成立。

但让我们暂时把基准测试放一放,谈谈权衡,因为编程归根结底就是关于权衡。当然,确实存在一些情况,某种工具或解决方案明显更好,没有任何缺点,但这相当罕见,尤其是在涉及需要可靠性的复杂系统时。我见过很多来自 Elixir 社区的人,在看到 Rust 版本的 1% 投递率后就得出各种结论,而没有真正去试着理解为什么会这样。普遍的共识是什么?Elixir 就是更擅长并发!在 Rust 中,调度器是协作式的,你们怎么能忍受得了?我喜欢 Elixir,也曾在生产环境中成功使用过它,但它并非银弹。是的,Elixir(或其他基于 BEAM 的语言)确实非常擅长并发,而且坦白说,在 Elixir 中编写并发代码通常比在 Rust 中更容易,但代价是失去对底层的控制能力、更高的内存占用,而且常常伴随着速度的牺牲。rustler 之所以存在是有原因的。仅凭单一指标就宣称某种语言更优越,而不去了解根本原因,可能会产生误导。

还记得我提过闭环压力测试只展示了系统的一个特性吗?因为 Rust 处理了约 4 倍的请求,因此不得不通过 WebSocket 向客户端发送更多事件。我使用 DHH 的 Rust 版本和 Zach 的 Elixir 版本(带各种修复)以恒定投递速率重新运行了该测试。在 100 POSTs/s 的速率下,Rust 端的客户端收到了约 14% 的事件。在 Elixir 端这一数字是约 60%。仍然是 Elixir 更好,对吧?且慢!在这个流量级别下,Rust 没有出现任何 HTTP 错误。而 Elixir 大约 23% 的 HTTP POST 请求超时了。那延迟又如何呢?最差的事件投递延迟接近 180 秒。在 Rust 中,当投递出现延迟时,客户端会断开连接。重新连接时,浏览器客户端会获取最新的消息,这在很大程度上抵消了对错过事件的需求。你认为哪种用户体验更好:客户端在后台静默重连并获取新更新,还是等待一条关于新消息的更新长达 3 分钟?这恰恰说明,单一指标并不能说明全部问题。

回到权衡。你知道 为什么 Rust 版本在重负载下会丢弃这么多消息吗?它使用 tokio::sync::broadcast channel 向已连接的客户端广播事件。通知新聊天消息的事件可能需要发送到多个连接,所以这完全合理。广播 channel 的一个特性是它有一个设定的容量上限。如果接收者不能足够快地处理消息,接收者将收到一个 RecvError::Lagged 错误(参考文档了解更多关于 lagging 的信息)。Rust 重写中的广播容量被设置为 256。在 Elixir 中,GenServer 进程负责处理事件投递,默认情况下 GenServer 邮箱没有容量限制。在 Rust 中只需要一行修改:

- stream_capacity: 256
+ stream_capacity: 16384

将 100 req/s 下的投递速率从 ~14% 提升到 ~90%。现在比 Elixir 更好了,不是吗?并非如此。我认为这个版本实际上更糟,因为在这种情况下,快速失败并强制客户端重新连接,比极度缓慢地处理事务要好。提升容量后还有什么发生了变化?pMAX 事件投递延迟从 11s 上升到了 >130s,这与 Elixir 的表现类似,我认为这严格来说比断开滞后客户端更糟。如果你问我的话,11s 都太长了,如果接收方无法更快地传递事件,最好还是丢弃事件。这表明在系统中设置合理的约束有多重要,而且事实上,Elixir 并不会自动开箱即用地解决所有的并发问题。此外,如果你不自行设置边界,你很可能会遇到外部限制。在 100 req/s 压力测试中,Elixir 版本达到了 1.8GB 内存使用量。另一个值得思考的问题是:丢弃一些消息更好,还是被 OOM 杀掉更好?处处都是权衡。Elixir 很棒,但它不会神奇地解决你所有的问题。无论你使用什么语言,你都必须考虑失败模式和权衡。

那么今天我们学到了什么?你仍然必须批判性地思考。了解自己在做什么很重要。不要基于单一指标做出草率的假设。如果你想要一个可靠的系统,你应该知道何时该失败。此外,基准测试很难做。


如果你喜欢这篇文章,请考虑在 Twitter 上关注我。

来源:Hacker News · itsallaboutthebit.com