Rails 7中ActiveRecord load_async方法应用的深度指南

500 阅读13分钟

Rails 7引入了ActiveRecordload_async 方法,在后台线程中异步地运行SQL查询。这个看似简单的变化,只是添加了一个不需要参数的新方法,但对数据库层的交互却有着深远的影响。在本教程中,我们将深入研究这个新的加载异步API的复杂性。我们将讨论懒加载查询、Ruby线程模型、阻塞式IO、数据库池与最大连接数限制,以及并发数据库客户端的性能影响。我还会试着建议一些场景,在这些场景中,引入异步SQL查询可以带来最大的好处,而不会牺牲你的Rails应用的稳定性。

我们有很多内容要讲,所以让我们开始吧!

ActiveRecord load_async 101

Rails控制器动作触发不同模型的多个查询是很常见的:

class PagesController < ApplicationController
  def home
    @users = User.slow
    @comments = Comment.slow
    @posts = Post.slow
  end
end

并在后来像这样在视图中显示数据:

<ul>
  <%- @users.each do -%>
      <li><%= user.email %></li>
  <%- end -%>
</ul>

我们使用一个slow scope方法,实现如下:

scope :slow, -> {
  where("SELECT true FROM pg_sleep(1)").limit(10)
}

slow 范围在人为的一秒钟延迟后从表中返回十条记录。这种方法将使我们能够确定地测量添加load_async 的性能影响,而不需要用成千上万的记录来填充数据库。

执行这个控制器动作应该会打印出类似的服务器日志:

User Load (1006.0ms)  SELECT "users".* FROM "users" ...
Comment Load (1003.2ms)  SELECT "comments".* FROM "comments" ...
Post Load (1041.2ms)  SELECT "posts".* FROM "posts" ...
Completed 200 OK in 3204ms (Views: 51.2ms ...)

我们可以看到,查询是按顺序执行的,导致响应时间超过3秒。大部分时间是在ActiveRecord层中度过的。现在让我们看看如何使用load_async 来加快速度。

用异步SQL优化响应时间

我们可以重写我们的控制器来利用新的API:

class PagesController < ApplicationController
  def home
    @users = User.slow.load_async
    @comments = Comment.slow.load_async
    @posts = Post.slow.load_async
  end
end

请确保在你的环境配置文件中启用异步执行:

config/environments/development.rb

config.active_record.async_query_executor = :global_thread_pool

现在我们应该得到以下日志:

User Load (1008.7ms) ...
ASYNC Comment Load (62.1ms) (db time 1013.8ms) ...
ASYNC Post Load (0.0ms) (db time 1010.5ms) ...
Completed 200 OK in 1085ms (Views: 64.4ms ...)

看起来我们的响应时间降到了一秒,改善了约3倍。这是否意味着Rails确实可以扩展?只需更新到版本7并散布在代码库上即可!load_async

image.png

并非如此......过度使用新的load_async API来神奇地改善响应时间,很可能会使你的应用程序崩溃,并使数据库慢下来。

为了理解原因,我们需要退一步,首先回顾一下Ruby线程和ActiveRecord的基础知识。但你已经被警告过了,这是一个深入的研究...

Ruby线程简述

这篇博文绝不是要对Ruby线程模型进行全面介绍。我将专注于与理解load_async 的内部工作有关的基础知识。

Ruby支持所谓阻塞式I/O的并行执行。任何不直接使用其线程的CPU周期而将工作委托给外部进程的操作都是阻塞式I/O。在Ruby on Rails网络应用的背景下,典型的例子是SQL数据库查询、对文件的读/写或HTTP请求。相反,你无法加快,例如,使用Ruby多线程生成md5哈希值,因为它是一个受CPU约束的操作。我已经在我的另一篇博文中更深入地介绍了Ruby线程、GIL和阻塞式I/O。

下面是一个在独立线程中进行阻塞式IO的基本例子:

t1 = Thread.new { sleep 2 }
sleep 2
t1.join

在这种情况下,sleep 是我们的工作,我们的阻塞式IO。如果你把上面的片段复制粘贴到Ruby IRB中,它将冻结2秒。这意味着我们成功地在2秒内完成了4秒的睡眠,因为我们的工作是用一个单独的线程并行化的。虽然睡眠似乎不是最有用的事情,但你可以用SQL或HTTP调用来代替sleep ,并得到类似的结果。

让我们看看另一个例子:

t1 = Thread.new { sleep 3 }
sleep 2
t1.join

运行它将冻结IRB 3秒。我们试图在join 我们的t1 线程完成执行之前进入主线程。这就是为什么它多花了主线程一秒钟的执行时间。

所以,这里的关键启示是,可以用一个单独的Ruby线程来并行化阻塞的IO,如果它在运行中被加入到主线程中,它将阻塞,直到执行完成。

现在让我们来看看我们的load_async 谜题中的ActiveRecord这块。

ActiveRecord 延迟加载的查询

为了更好地解释load_async ,我们现在要讨论ActiveRecord的延迟加载行为。关键是要明白,运行:

@users = User.slow

本身并不执行一个SQL查询,而只是创建一个ActiveRecord_Relation 对象。实际的SQL只有在必要时才会被触发。这种行为是在Rails 3中引入的。

image.png

延迟加载允许许多很酷的功能,如合并作用域和传递ActiveRecord查询对象,在需要结果之前不运行SQL。

你可以在控制台中自己试试。首先,确保启用ActiveRecord STDOUT日志记录:

ActiveRecord::Base.logger = Logger.new(STDOUT)

并现在运行:

@users = User.slow

在延迟一秒钟后,你应该看到类似的输出:

User Load (1006.2ms)

所以看起来查询确实运行了。但我刚才不是告诉你它不会运行吗?在这种情况下,Ruby IRB是有责任的。默认情况下,它打印出最后一条命令的结果,有效地执行了查询。你可以通过运行以下命令来规避这种行为:

@users = User.slow; nil

而现在命令会立即执行,你应该不会再看到SQL日志了。通过附加nil 作为IRB显示的输出,我们防止了查询的执行。只有试图显示@users 的内容才会触发数据库的交互,并需要大约一秒钟的时间来运行。

另外,你可以通过附加to_a 来强制查询急于加载:

@users = User.slow.to_a; nil

你可以通过读取其loaded? 属性轻松检查关系对象是否已经被加载:

@lazy_users = User.slow; nil
@lazy_users.loaded? # => false

@loaded_users = User.slow.to_a
@loaded_users.loaded? # => true

显微镜下的异步ActiveRecord查询

我们现在已经涵盖了理解load_async 所需的基础知识。最后让我们继续解释这个方法本身。首先尝试在Rails控制台触发异步查询:

@users = User.slow.load_async

经过一秒钟的延迟,查询结果被打印出来,日志显示:

User Load (1001.2ms)

那么,为什么追加load_async ,却没有在后台触发我们的查询?在解释之前,让我们首先通过运行强制异步执行:

@users = User.slow.load_async; sleep 2; @users

现在我们终于得到了下面的输出:

ASYNC User Load (0.0ms) (db time 1003.1ms)

我们可以看到ASYNC 前缀和(0.0ms) ,表明查询没有占用任何主线程的处理时间。1006.5ms 是在后台进行的(描述为db time )。让我们看看当我们修改sleep 持续时间时,这些数字是如何变化的。

@users = User.slow.load_async; sleep 0.2; @users
ASYNC User Load (807.8ms) (db time 1003.8ms)

我们的查询是在200ms 之后加入到主线程中的,所以它需要额外的807.8ms 来完成执行。就像我们之前讨论的裸机线程一样,异步SQL可以在完成之前被加入到主线程中,并因此在剩余的执行时间内阻塞它。

这是一个关键的区别。load_async 查询从来不是懒惰加载的,而是类似于在一个单独的线程中对一个ActiveRecord_Relation 对象调用to_a 。调用load_async 总是将相应的关系对象的loaded 参数设置为true 。你可以查阅实现load_async 的PR以了解细节。这意味着,load_async 在被调用时总是立即被触发,并在需要其结果时加入到主线程中。

现在你应该明白为什么load_async 有时不能在后台安排查询。有三种情况是可能的:

  • 查询在后台完全执行,我们只是使用结果。
  • 查询开始在后台执行,我们等待它完成。
  • 查询还没有在后台开始,我们在前台执行它。

在第一次使用load_async API的时候,我不能总是强迫查询异步运行。提取这些简单的例子帮助我掌握了决定后台线程是否会被触发的因素。

现在我们可以更好地理解为什么我们看到了下面的日志输出:

User Load (1008.7ms) ...
ASYNC Comment Load (62.1ms) (db time 1013.8ms) ...
ASYNC Post Load (0.0ms) (db time 1010.5ms) ...

User 查询的结果被显示在HTML视图的顶部。因此,在相应的查询被安排在后台之前,它被移到了主线程。当它以阻塞的方式执行时,CommentPost 查询有时间在后台完成执行,没有给主线程增加任何阻塞。这就是~3倍速度的来源。

现在我们已经对load_async 的内部工作有了相当扎实的了解,让我们来讨论一下滥用它是如何破坏你的应用程序的。

何时不使用load_async

load_async 是我们SQL管道的一种横向扩展。我们不是通过单个连接顺序调度查询,而是使用线程在同一时间执行多个查询。你可以通过挖掘PostgreSQL metatable来说明这种区别。pg_stat_activity

select pid,
       application_name,
       backend_start,
       state
from pg_stat_activity
where state = 'active' and
application_name = 'bin/rails';

下面的SQL显示当前由Rails控制台进程建立的与你的数据库的活动客户连接。你可以比较这个查询对以下Ruby片段的结果(将slow 范围延迟增加到几秒钟,会使同时运行这两个命令更容易):

@users = User.slow
@comments = Comment.slow
@posts = Post.slow

puts @users
puts @comments
puts @posts

显示:

 pid | application_name |         backend_start         | state
-----+------------------+-------------------------------+--------
 226 | bin/rails        | 2022-02-23 00:10:34.900532+00 | active

和运行:

@users = User.slow.load_async
@comments = Comment.slow.load_async
@posts = Post.slow.load_async

puts @users
puts @comments
puts @posts

会显示类似的内容:

 pid | application_name |         backend_start         | state
-----+------------------+-------------------------------+--------
 226 | bin/rails        | 2022-02-23 00:10:34.900532+00 | active
 230 | bin/rails        | 2022-02-23 00:12:30.44271+00  | active
 231 | bin/rails        | 2022-02-23 00:12:30.443108+00 | active

这就是我们如何根据经验检查异步查询产生更多的独立数据库连接。

平衡数据库池、线程和异步查询的最大连接

当我们想推理在Puma服务器或Sidekiq worker等多线程进程中运行异步SQL的影响时,事情变得有点复杂。Rails提供了一个全局pool 配置,可以在config/database.yml 文件中定义。这个值决定了每个Ruby进程可以生成多少个数据库连接。每个Puma或Sidekiq工作者都是一个单独的进程,可以初始化预定数量的线程。一个常见的建议是,将pool 设置为你的应用程序进程所支持的最大并发值。例如,如果你的Sidekiq工作者最多使用25个线程,那么pool 的值至少应该是25 。否则,你的进程可能无法连接到数据库,因为所有的连接都很忙,而pool 限制不允许产生更多的连接。结果,你会看到下面的错误。

ActiveRecord::ConnectionTimeoutError
  (could not obtain a connection from the pool within 5.000 seconds (waited 5.003 seconds);
    all pooled connections were in use):

你可能会想,为什么不能把pool 设置为某个任意高的值,这样就不会出现可用连接不足的情况?

答案是,数据库本身也有一个全局限制,即它能处理多少个并发客户端。例如,Heroku的PostgreSQL插件在其数据库计划中规定了一个硬编码的限制:MAX_CONNECTIONSAWS RDS在配置PostgreSQL的内部结构方面提供了更多的灵活性,这只是我通常建议将Heroku数据库迁移到AWS RDS的原因之一。

我更详细地描述这个设置,因为使用load_async ,意味着我们在线程中使用线程......

每个异步SQL查询是一个单独的线程,需要一个新的连接。子线程是从一个现有的池中重复使用的。有一个新的配置选项可用。

config.active_record.global_executor_concurrency

让你定义每个进程可以执行多少个并发的SQL查询。现在,数学变得有点混乱了,所以让我们用一个简单的例子来试试。

想象一下,你正在使用一个运行Puma服务器的Performance-L Heroku网络动态器,并使用推荐的设置,即8个工作者(进程),每个最大5个线程。此外,你在Performance-M dyno上使用一个Sidekiq工作者,最大并发数配置为10。

在没有load_async ,你应该将pool 值设置为10,以便你的Sidekiq工作器始终有一个可用的活动连接。在这种配置下,你的数据库可能收到的最大并发连接数是快速计算出来的。

5 x 8 + 1 x 10 = 50

如果你将load_async 与默认的global_executor_concurrency 的4引入,你会得到。

8 x 5 + 8 x 4 + 1 x 10 + 1 x 4 = 86

pool 应该配置为14 ,以考虑到Sidekiq进程可能产生的额外连接。

所以你可以看到,仅仅通过启用新的API,我们几乎将最大并发数据库客户端的潜在数量增加了一倍。我希望你目前的Heroku计划已经准备好了......

但是,这也意味着,每个进程可以同时安排最多4个异步查询。因此,如果你试图在你的瓶颈网络终端内并行化三个SQL查询,你已经用尽了4个连接中的3个,可用于load_async 。而这只是5个Puma线程中的一个(我们使用的是每个工作者4个异步连接的限制)。所以我不确定这是否是一个完美的配置。同时,增加global_executor_concurrency ,应该始终谨慎执行,因为每一个额外的连接都会使用更多的数据库内存,并降低数据库的整体性能。

老实说,我不知道什么是这里的完美权衡。但是,我希望这个关于这些变量如何相互作用的扩展描述能够给你一个坚实的背景,让你有信心在你的生产应用中调整它们。使用元数据SQL查询检查当前活动连接的数量,并与Siege负载测试一起玩不同的线程和池设置,可以更好地了解这些值如何相互影响。

如果你想将load_async ,并将其引入到具有非微不足道的流量的生产应用中,全面的监控是至关重要的。我强烈建议为一些活跃的数据库客户端和内存使用添加警报。这是AWS RDS优于Heroku PostgreSQL插件的另一个领域,这要感谢CloudWatch的无缝集成。

image.png AWS Cloudwatch 仪表板。网络服务器和PG的统计数据一目了然。

你可以在我的另一篇博文中阅读更多关于将你的Heroku db转移到AWS RDS的信息。

load_async 在生产中的推荐用例

现在我们明白了为什么异步查询应该总是小心翼翼地引入。数据库连接是一种宝贵的资源,应该只分配给精心挑选的地方,在那里它可能会产生最好的整体影响。ScoutAPM是我在进行Rails性能审计时经常使用的一个工具。我最喜欢的功能是对优化性能最有意义的地方的即时概述。你可以根据所使用的资源百分比轻松地对你的端点进行排序。

image.png

ScoutAPM报告的Slack最慢的端点的Abot

之后你可以深入了解每个端点的性能特征,并检查其处理时间的哪一部分是花在ActiveRecord 层。

image.png

image.png

ScoutAPM提供的端点处理时间层的可视化概述。

但是花在ActiveRecord 层的时间并不一定意味着你在处理缓慢的查询。通常情况下,一个N+1的错误可能会导致在一个请求中产生数百个查询。你不能用load_async 来修复N+1(查看这篇博文,了解如何正确操作)。为了验证你是否在处理缓慢的查询,你可以使用Slow Query Insights ScoutAPM功能,跟踪缓慢的数据库查询,并将它们与潜在的瓶颈端点相匹配。

或者,你可以使用Rails PG Extrascallsoutliers 方法来检测那些使用了你的数据库处理时间的重要比例的慢查询。然后在marginalia gem 的帮助下,你可以追踪产生这些查询的端点。你可以在这篇博文中找到更多关于使用Rails PG Extras来提高数据库性能的信息。

一旦你有了这些数据,你就可以决定是否有一些地方占用了你的应用程序的大部分处理时间,而这些时间是在数据库层度过的。在这种情况下,缓慢的查询有可能被并行化,并导致整体的显著改善。

另一个适合异步SQL的用例是端点,这些端点除了慢速查询外,还执行第三方HTTP请求。在HTTP调用之前,在SQL查询语句中添加load_async ,可以有效地将其并行化,减少响应时间。

在为非琐碎的Rails应用程序应用性能修复时,持续观察和迭代方法至关重要。在向任何一个查询添加load_async 之前,你应该仔细检查加快它的速度和付出额外的数据库连接的代价是否有潜在的价值。

杂项

关系的异步急于加载

N+1查询是Rails应用程序的首要性能杀手。大多数时候,通过使用所谓的急迫加载,它们可以被轻松避免。我在另一篇博文中详细介绍了这一点。

目前load_async ,似乎不支持急于加载的查询运行中:

@users = User.slow.includes(:comment).load_async; sleep 2; @users

产生了:

ASYNC User Load (0.0ms) (db time 1003.1ms)
Comment Load (20.0ms)

所以,关系似乎是在一个主线程内同步加载的。但是,无论如何,将急于加载的查询放在后台可能没有什么好处,因为它们通常是通过良好索引的外键来获取数据。现在,一个相关的PR似乎被搁置了。

事务中的异步SQL

如果你试图在一个事务中运行一个异步查询,它就会退回到同步执行:

ActiveRecord::Base.transaction do
  @users = User.slow.load_async; sleep 2; @users
end

产生的:

TRANSACTION (2.1ms) BEGIN
User Load (2005.3ms) ...
TRANSACTION (2.7ms) COMMIT

你必须记住,臭名昭著的ActiveRecord回调方法(不包括after_commit )总是隐含地被包裹在数据库事务中。这意味着异步查询永远不会在那里工作。

另一个不明显的情况是,所有的调用都被包裹在一个事务中,这就是Rails控制台sandbox 模式。运行:

rails console --sandbox

生成一个新的控制台进程,退出时所有的数据库变化都会回滚。所以,你必须记住,不可能在沙盒模式内测试load_async

总结

在Rails 7之前,有可能将SQL调用包装成异步线程,并为数据库交互添加并行性。但是,DIY解决方案极有可能是偷偷摸摸的bug的来源。新的内置方式用于调度异步查询,带有额外的线程安全和数据一致性保证。我相信,如果谨慎使用,load_async ,有可能大大改善许多典型的性能瓶颈情况。

我确实尝试对load_async API的工作方式做了足够深入的研究,并包括了所有的相关信息。但是,考虑到它的新颖性和复杂性,我相信一旦它在生产环境中被更广泛地采用,就会有新的有趣的事实出现。我计划保持本指南的更新,所以如果你发现某些信息缺失或不准确,请让我知道。