本指南假设你正在运行 MRI,即 Ruby 的规范实现,也称为 CRuby。如果你正在使用其他 Ruby 实现,例如 JRuby 或 TruffleRuby,本指南的大部分内容都不适用。如果需要,请查阅特定于你的 Ruby 实现的资料。
1. 选择应用程序服务器
Puma 是 Rails 的默认应用程序服务器,也是社区中最常用的服务器。它在大多数情况下都表现良好。在某些情况下,你可能希望切换到其他服务器。
应用程序服务器使用特定的并发方法。例如,Unicorn 使用进程,Puma 和 Passenger 是混合进程和线程的并发模式,而 Falcon 使用纤程。
全面讨论 Ruby 的并发方法超出了本文的范围,但将介绍进程和线程之间的关键权衡。如果你想使用除进程和线程之外的方法,则需要使用不同的应用程序服务器。
本指南将重点介绍如何调整 Puma。
2. 优化目标是什么?
实质上,调整 Ruby Web 服务器是在内存使用、吞吐量和延迟等多个属性之间进行权衡。
吞吐量衡量服务器每秒可以处理的请求数量,而延迟衡量单个请求所需的时间(也称为响应时间)。
有些用户可能希望最大化吞吐量以降低托管成本,另一些用户可能希望最小化延迟以提供最佳用户体验,许多用户会在两者之间寻求某种折衷。
重要的是要理解,优化一个属性通常会至少损害另一个属性。
2.1. 理解 Ruby 的并发和并行性
CRuby 有一个 全局解释器锁,通常称为 GVL 或 GIL。GVL 阻止多个线程在单个进程中同时运行 Ruby 代码。多个线程可以等待网络数据、数据库操作或其他通常称为 I/O 操作的非 Ruby 工作,但一次只能有一个线程主动运行 Ruby 代码。
这意味着基于线程的并发允许通过在进行 I/O 操作时同时处理 Web 请求来提高吞吐量,但当 I/O 操作完成时,可能会降低延迟。执行它的线程可能必须等待才能恢复执行 Ruby 代码。同样,Ruby 的垃圾收集器是“停止世界”的,因此当它触发时,所有线程都必须停止。
这也意味着,无论一个 Ruby 进程包含多少个线程,它都不会使用超过一个 CPU 核心。
正因为如此,如果你的应用程序只有 50% 的时间用于 I/O 操作,那么每个进程使用超过 2 或 3 个线程可能会严重损害延迟,并且吞吐量的收益会很快达到边际递减。
一般来说,一个精心制作的 Rails 应用程序,如果没有慢 SQL 查询或 N+1 问题,不会花费超过 50% 的时间进行 I/O 操作,因此不太可能从超过 3 个线程中受益。然而,一些内联调用第三方 API 的应用程序可能会花费大部分时间进行 I/O 操作,并可能从更多线程中受益。
使用 Ruby 实现真正并行性的方法是使用多个进程。只要有空闲的 CPU 核心,Ruby 进程在 I/O 操作完成后无需相互等待即可恢复执行。然而,进程仅通过 写时复制 共享一小部分内存,因此一个额外的进程比一个额外的线程使用更多的内存。
请注意,虽然线程比进程便宜,但它们并非免费,并且增加每个进程的线程数也会增加内存使用量。
2.2. 实际影响
关注吞吐量和服务器利用率的用户将希望每个 CPU 核心运行一个进程,并增加每个进程的线程数,直到对延迟的影响被认为过大。
关注延迟优化的用户会希望保持每个进程的线程数较低。为了进一步优化延迟,用户甚至可以将每个进程的线程数设置为 1,并每个 CPU 核心运行 1.5 或 1.3 个进程,以应对进程空闲等待 I/O 操作的情况。
值得注意的是,某些托管解决方案可能每个 CPU 核心只提供相对较小的内存 (RAM) 量,从而阻止你运行所需数量的进程来使用所有 CPU 核心。然而,大多数托管解决方案都有不同的计划,提供不同比例的内存和 CPU。
另一个需要考虑的是,由于 写时复制,Ruby 内存使用受益于规模经济。因此,2 台服务器,每台有 32 个 Ruby 进程,将比 16 台服务器,每台有 4 个 Ruby 进程,每个 CPU 核心使用更少的内存。
3. 配置
3.1. Puma
Puma 配置位于 config/puma.rb 文件中。Puma 最重要的两个配置是每个进程的线程数和进程数,Puma 将其称为 workers。
每个进程的线程数通过 threads 指令配置。在默认生成的配置中,它设置为 3。你可以通过设置 RAILS_MAX_THREADS 环境变量或直接编辑配置文件来修改它。
进程数由 workers 指令配置。如果你每个进程使用多个线程,那么它应该设置为服务器上可用的 CPU 核心数,或者如果服务器运行多个应用程序,设置为你希望应用程序使用的核心数。如果你每个工作进程只使用一个线程,那么你可以将其增加到每个进程一个以上,以应对工作进程空闲等待 I/O 操作的情况。
你可以通过设置 WEB_CONCURRENCY 环境变量来配置 Puma 工作进程的数量。将 WEB_CONCURRENCY=auto 设置为自动调整 Puma 工作进程数量以匹配可用 CPU 数量。但是,此设置在具有共享 CPU 的云主机或报告 CPU 数量不正确的平台上可能不准确。
3.2. YJIT
最新的 Ruby 版本带有一个名为 即时编译器 的 YJIT。
无需过多细节,JIT 编译器可以加快代码执行速度,但代价是使用更多内存。除非你实在无法节省这额外的内存使用量,否则强烈建议启用 YJIT。
对于 Rails 7.2,如果你的应用程序运行在 Ruby 3.3 或更高版本上,YJIT 将默认由 Rails 自动启用。较旧的 Rails 或 Ruby 版本必须手动启用它,请参阅 YJIT 文档 了解如何操作。
如果额外的内存使用是一个问题,在完全禁用 YJIT 之前,你可以尝试通过 --yjit-exec-mem-size 配置 来调整它以使用更少的内存。
3.3. 内存分配器和配置
由于大多数 Linux 发行版中默认内存分配器的工作方式,Puma 在多线程下运行可能导致由于 内存碎片化 而导致的内存使用量意外增加。反过来,这种增加的内存使用量可能会阻止你的应用程序充分利用服务器 CPU 核心。
为了缓解这个问题,强烈建议配置 Ruby 使用替代内存分配器:jemalloc。
Rails 生成的默认 Dockerfile 已经预先配置为安装和使用 jemalloc。但如果你的托管解决方案不是基于 Docker 的,你应该研究如何在其中安装和启用 jemalloc。
如果由于某种原因无法做到这一点,一个效率较低的替代方法是通过在环境中设置 MALLOC_ARENA_MAX=2 来配置默认分配器,以减少内存碎片化。但请注意,这可能会使 Ruby 变慢,因此 jemalloc 是首选解决方案。
4. 性能测试
因为每个 Rails 应用程序都不同,而且每个 Rails 用户可能希望针对不同的属性进行优化,所以无法提供适用于所有人的默认配置或指南。
因此,选择应用程序设置的最佳方法是测量应用程序的性能,并调整配置直到它满足你的目标。
这可以通过模拟生产工作负载来完成,或者直接在生产环境中使用实时应用程序流量进行。
性能测试是一个深入的课题。本指南仅提供简单的指导。
4.1. 衡量什么
吞吐量是你的应用程序成功处理的每秒请求数。任何好的负载测试程序都会测量它。吞吐量通常是一个以“每秒请求数”表示的单一数字。
延迟是从发送请求到成功接收响应之间的时间延迟,通常以毫秒表示。每个单独的请求都会有自己的延迟。
百分位数 延迟表示一定百分比的请求具有比该值更好的延迟。例如,P90 是第 90 百分位延迟。P90 是单个负载测试的延迟,其中只有 10% 的请求处理时间更长。P50 是延迟,其中一半请求速度较慢,也称为中位数延迟。
“尾部延迟”指的是高百分位延迟。例如,P99 是这样的延迟,只有 1% 的请求更差。P99 是尾部延迟。P50 不是尾部延迟。
一般来说,平均延迟不是一个好的优化指标。最好关注中位数 (P50) 和尾部 (P95 或 P99) 延迟。
4.2. 生产环境测量
如果你的生产环境包含多个服务器,那么在那里进行 A/B 测试 是一个好主意。例如,你可以让一半服务器每个进程运行 3 个线程,另一半运行 4 个线程,然后使用应用程序性能监控服务比较两组的吞吐量和延迟。
应用程序性能监控服务有很多,有些是自托管的,有些是云解决方案,许多都提供免费套餐。推荐特定的服务超出了本指南的范围。
4.3. 负载测试工具
你需要一个负载测试程序来向你的应用程序发送请求。这可以是一种专用负载测试程序,或者你可以编写一个小型应用程序来发送 HTTP 请求并跟踪它们所需的时间。你不应该通常检查 Rails 日志文件中的时间。该时间仅是 Rails 处理请求所需的时间。它不包括应用程序服务器所花费的时间。
发送许多并发请求并计时它们可能很困难。很容易引入微妙的测量误差。通常你应该使用负载测试程序,而不是自己编写。许多负载测试工具使用简单,并且许多优秀的负载测试工具都是免费的。
4.4. 你可以更改什么
你可以更改测试中的线程数,以找到应用程序在吞吐量和延迟之间的最佳权衡。
内存和 CPU 核心更多的大型主机需要更多进程才能实现最佳使用。你可以改变托管提供商提供的不同大小和类型的主机。
增加迭代次数通常会给出更精确的答案,但需要更长的测试时间。
你应该在与生产环境相同的类型的主机上进行测试。在你的开发机器上进行测试只会告诉你哪些设置最适合该开发机器。
4.5. 预热
你的应用程序在启动后应处理一些不包含在最终测量中的请求。这些请求被称为“预热”请求,通常比后来的“稳态”请求慢得多。
你的负载测试程序通常会支持预热请求。你也可以多次运行它并丢弃第一组时间。
当你增加预热请求的数量不会显著改变你的结果时,就说明你已经进行了足够的预热。 这背后的理论可能很复杂,但大多数常见情况都很简单:多次测试不同的预热量。看看需要多少次预热迭代才能使结果大致保持不变。
非常长的预热对于测试内存碎片和其他只在许多请求后才发生的问题很有用。
4.6. 哪些请求
你的应用程序可能接受许多不同的 HTTP 请求。你应该从只用其中几个请求进行负载测试开始。你可以随着时间的推移添加更多种类的请求。如果你的生产应用程序中某种请求太慢,你可以将其添加到你的负载测试代码中。
合成工作负载无法完美匹配你的应用程序的生产流量。它仍然有助于测试配置。
4.7. 要关注什么
你的负载测试程序应该允许你检查延迟,包括百分位和尾部延迟。
对于不同数量的进程和线程,或总体上不同的配置,请检查吞吐量和一项或多项延迟,例如 P50、P90 和 P99。增加线程数会在一定程度上提高吞吐量,但会恶化延迟。
根据你的应用程序需求,在延迟和吞吐量之间做出权衡。