本指南假设您正在运行 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 代码。多个线程可以等待网络数据、数据库操作或其他非 Ruby 工作(通常称为 I/O 操作),但一次只能有一个线程可以主动运行 Ruby 代码。
这意味着基于线程的并发允许通过在执行 I/O 操作时并发处理 Web 请求来提高吞吐量,但当 I/O 操作完成后,可能会降低延迟。执行 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 操作的时间。
重要的是要注意,一些托管解决方案可能只提供相对少量的内存 (RAM) 每个 CPU 核心,这会阻止您运行足够多的进程来使用所有 CPU 核心。但是,大多数托管解决方案都有不同的计划,这些计划有不同的内存和 CPU 比例。
另一个需要考虑的是,由于 写时复制,Ruby 内存使用量得益于规模经济。因此,具有 32
个 Ruby 进程的 2
台服务器比具有 4
个 Ruby 进程的 16
台服务器每个 CPU 核心使用更少的内存。
3 配置
3.1 Puma
Puma 配置位于 config/puma.rb
文件中。两个最重要的 Puma 配置是每个进程的线程数和进程数(Puma 称为 workers
)。
每个进程的线程数通过 thread
指令配置。在默认生成的配置中,它设置为 3
。您可以通过设置 RAILS_MAX_THREADS
环境变量或简单地编辑配置文件来修改它。
进程数通过 workers
指令配置。如果您使用多个线程每个进程,则应将其设置为服务器上可用的 CPU 核心数,或者如果服务器正在运行多个应用程序,则设置为您希望应用程序使用的核心数。如果您每个工作进程只使用一个线程,那么您可以将其增加到每个进程一个以上,以考虑工作进程空闲等待 I/O 操作的时间。
您可以通过设置 WEB_CONCURRENCY
环境变量来配置 Puma 工作进程的数量。
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
。增加线程数量会提高吞吐量,直到某个点,但会降低延迟。
根据您的应用程序的需要,选择延迟和吞吐量之间的权衡。