[博客翻译]神话般的IO绑定Rails应用程序


原文地址:https://byroot.github.io/ruby/performance/2025/01/23/the-mythical-io-bound-rails-app.html


传说中的IO密集型Rails应用

我打算写一篇关于Pitchfork的文章,解释它的起源、现状以及我对它未来的看法。但在深入探讨之前,我觉得有必要先澄清一些概念。

每当谈及Rails性能时,常听到的说法是数据库是瓶颈,因此Rails应用本质上是IO密集型的,Ruby的性能并不那么关键,只需适当增加并发就能让服务扩展自如。

但这种说法真的普遍适用吗?

混淆规模与性能

首先,确实,在扩展Rails应用时,首先遇到的主要瓶颈通常是数据库。

Rails,如同绝大多数现代Web框架,是无状态的,因此水平扩展以应对任意负载相对简单。只要持续增加服务器容量,Rails就能持续扩展。虽然这可能不是最经济的扩展方式,但它与其他无状态Web框架的扩展能力相当。要处理10倍的流量,大约需要10倍的服务器容量,这很简单,不是那些需要扩展Rails应用的团队会头疼的问题。

然而,关系型数据库的扩展要困难得多。默认情况下,除非你以某种方式实现数据分片,否则无法水平扩展,而根据你的数据模型,这有时会非常具有挑战性。因此,关系型数据库通常最初是垂直扩展的,即迁移到更强大的服务器。垂直扩展可以让你走得很远,远超过大多数Rails用户的需求,但成本增长不会是线性的,如果你足够成功,垂直扩展将不再可行,你将不得不分片或使用其他类型的数据存储。

这就是数据库成为瓶颈的含义。并不是说查询数据库慢,也不是说它是整体服务延迟中最显著的因素,而是说当你的服务使用量不断增加时,数据库是你最需要关注的基础设施部分。

许多公开讨论混淆了规模和性能,更准确地说,是混淆了吞吐量和延迟。

能够扩展意味着在服务更多用户的同时,以接近线性的成本维持某种服务水平。这并不意味着特别快、便宜或高效,它仅仅意味着能够增长而不遇到瓶颈,且不会让你花费指数级更多的钱。

因此,数据库是瓶颈这一说法是正确的,但这并不意味着应用程序大部分时间都在等待IO。

大多数Rails性能问题都是数据库问题

另一个常被用来解释Rails应用为何是IO密集型的事实是,Rails应用最常见的性能问题是缺少数据库索引、N+1查询和其他数据访问问题。

根据我的个人观察,这确实没错,但这些都是bug,而不是系统的固有属性。它们应该被识别和修复,因此,设计基础设施来适应这一特性并不合理。

当数据库被正确索引且未过载时,绝大多数查询,尤其是通过主键进行的常规查找,耗时不到几毫秒,通常只有几分之一毫秒。如果应用程序在将数据渲染为HTML或JSON时进行了大量转换,那么毫无疑问,执行Ruby代码所花费的时间将与等待IO的时间相当甚至更多。

证据:YJIT的有效性

当然,每个应用程序都是不同的,我只能对我工作过的那些应用程序有把握地发表意见。

然而,在过去几年中,许多人报告说YJIT将他们的应用程序延迟减少了15%到30%。例如,Discourse在使用JIT 3.2时看到了15.8-19.6%的速度提升,Lobsters看到了26%的速度提升,Basecamp和Hey看到了26%的速度提升,Shopify的Storefront Renderer应用看到了17%的速度提升

如果这些应用程序真的花费了绝大部分时间等待IO,YJIT不可能整体上表现得这么好。

即使在完全没有IO的非常JIT友好的基准测试中,YJIT也只能将Ruby速度提高2到3倍。在更现实的基准测试中,如lobsters,速度提升大约在1.7倍左右。基于此,我们可以相当自信地假设,所有这些应用程序肯定没有花费80%的时间等待IO。

对我来说,这足以认为大多数Rails应用程序并不是IO密集型的。确实存在一些IO密集型的应用程序,我也与维护这些应用程序的几个人交谈过。但就像飞鱼一样,它们存在,但并不构成大多数。

CPU饥饿在大多数人眼中看起来像IO

导致人们高估应用程序IO量的一个原因是,在大多数情况下,CPU饥饿会看起来像是Ruby在等待IO。

如果你看看绝大多数IO持续时间的测量方式,包括在Rails日志和所有最流行的应用程序性能管理器中,通常是以一种非常简单、明显的方式完成的:

start = Time.now
database_connection.execute("SELECT ...")
query_duration = (Time.now - start) * 1000.0
puts "Query took: #{query_duration.round(2)}ms"

逻辑上,如果这段代码记录:Query took: 20.0ms,你可能会合理地认为执行SQL查询花费了20毫秒,但这并不一定正确。

实际上,这意味着执行查询让线程再次被调度花费了20毫秒,你无法单独知道每个部分花费了多少时间(编辑:应John Duff的要求,我写了一篇非常简短的指南,告诉你如何判断你的应用程序是否正在经历某种形式的CPU饥饿)。

因此,你所知道的是,查询可能在不到一毫秒的时间内完成,其余时间都花在等待获取GVL、运行GC或等待操作系统调度程序恢复你的进程上。

知道哪部分是关键:

  • 如果所有这些时间都花在执行查询上,这表明你的应用程序是IO密集型的,你可能能够使用更多的并发(进程、线程或纤程)来获得额外的吞吐量。
  • 如果所有这些时间都花在等待调度程序上,那么你可能希望做完全相反的事情,减少并发以降低延迟。

这个问题常常导致人们认为他们的应用