[博客翻译]神话般的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密集型的,你可能能够使用更多的并发(进程、线程或纤程)来获得额外的吞吐量。
  • 如果所有这些时间都花在等待调度程序上,那么你可能希望做完全相反的事情,减少并发以降低延迟。

这个问题常常导致人们认为他们的应用程序比实际更IO密集型,而且这是一个自我实现的预言,因为如果你假设你的应用程序是IO密集型的,鉴于这种说法很常见,你会在生产环境中使用线程化或异步服务器运行它,并使用适量的并发,生产日志将通过显示大量时间等待IO来证实你的假设。

这个问题在共享托管平台上更为常见,因为它们并不总是保证你可以100%使用“虚拟CPU”。在这些平台上,你的应用程序必须与其他应用程序共享物理CPU核心,根据这些其他应用程序在做什么以及它们有多忙,你可能能够使用比你支付的“虚拟CPU”多得多或少得多的资源。

这个问题并非Ruby独有,每当你有一个混合IO和CPU工作的工作负载时,你必须在允许更多并发和降低延迟之间进行权衡,或者减少并发以确保低延迟但降低利用率。你的工作负载越异构,如在单体应用中,找到最佳折衷方案就越困难。这是微服务的好处之一,获得更同质的工作负载,更容易在不影响延迟太多的情况下实现更高的服务器利用率。

然而,鉴于Ruby的默认实现有GVL,这个问题更加突出。服务器上的所有线程必须共享所有CPU核心,你最终会有多个小线程池,每个线程池必须共享一个CPU,因此你可能会遇到即使有一些空闲核心或服务器,线程也无法恢复的情况。

需要记住的是,作为一个普遍规则,不仅适用于Ruby,CPU密集型和IO密集型工作负载不能很好地混合,理想情况下应该由不同的系统处理。对于小项目,你可能可以容忍将所有工作负载放在一个通用系统中对延迟的影响,但随着你扩展,你将越来越需要将IO密集型工作负载与CPU密集型工作负载分开。

任务队列不同

需要注意的是,我上面所说的仅针对Rails应用程序的Web服务器部分。大多数应用程序还使用后台任务运行器,Sidekiq是最受欢迎的,后台任务通常负责许多慢速IO操作,如发送电子邮件、执行API调用等。

因此,任务运行器通常更加IO密集型,延迟通常对它们来说不那么重要,因此它们通常可以比Web服务器使用更高的并发。

但即便如此,用户通常会将任务运行器的并发设置得过高,导致所有IO看起来慢得多。一个很好的例子是Sidekiq的维护者要求我实现一种在C中测量往返延迟的方法,以免受到GVL争用的影响

为什么这很重要?

此时,你可能会想知道Rails应用程序花费多少时间等待IO为什么重要。

对于普通Rails用户来说,了解这一点很重要,因为它定义了哪种执行模型最适合部署他们的应用程序:

  • 如果一个应用程序确实是IO密集型的,即花费超过95%的时间等待IO,那么使用异步执行模型可能会带来最佳结果。
  • 如果一个应用程序不是完全IO密集型的,但仍然相当IO密集型,那么使用线程化服务器,每个进程使用合理数量的线程,可能会在延迟和吞吐量之间取得最佳平衡。
  • 如果一个应用程序没有显著超过一半的时间花在IO上,那么使用纯进程的解决方案可能更可取。

普通Rails应用程序中IO与CPU的比例可能是什么样子,也是Rails将默认生成的Puma配置从5个线程改为仅3个的原因。

但对于整个Ruby社区来说,我认为重要的是不要以“反正都是关于数据库”为由忽视Ruby的性能。这并不是说Ruby慢,毫无疑问,它足够快,可以编写提供良好用户体验的Web应用程序。

但也可以以性能不佳的方式编写Ruby代码,无论是为了可用性还是仅仅因为使用元编程很有趣,或者没有人花时间使用分析器来查看是否有可能以更高效的方式做同样的事情。

作为一个花费大量时间查看Rails应用程序生产配置文件的人,我可以自信地说,Rails和其他常用gem中有许多东西可以显著更快,但由于它们的公共API阻止了任何进一步的优化。

作为Ruby开发者,我们自然倾向于将开发者的幸福感放在首位,这很好,但我们也应该确保不要忽视性能。可用性和性能不必相互排斥。API可以既非常方便又高效,但两者必须从一开始就考虑。一旦定义了公共API并被广泛使用,除非你愿意弃用它以支持更高效的API,否则你能做的只有这么多,但社区对弃用和破坏性改变的胃口已不如从前。

阅读全文(20积分)