异步 Ruby on Rails

异步编程可以让你的应用程序运行得更快。我将分享如何在 Ruby on Rails 中使用异步来加速你的应用程序。虽然 Ruby 中有示例,但这些原则适用于任何语言。

我将把这些例子归纳为两个基本原则。这是第一个:

尽可能推迟做事。懒惰不一定是坏事。实际上,这意味着几件事:

使用以以下内容结尾的方法时请注意 _now。它们是异步执行任务的有力候选者。一个常见的例子是发送电子邮件。想象一下,Rails 控制器在用户注册后发送电子邮件:

class RegistrationsController
 def create
    @registration = Registration.new(params)
    if @registration.save
      RegistrationMailer
        .welcome_email(@registration)
        .deliver_now
      redirect_to @registration
    else
    # ...
    end
  end
end

请求不需要等待电子邮件发送完成。使用
deliver_later 此处可以加快请求速度。这同样适用于任何其他类型的工作!如果您正在保存统计数据、处理数据或其他不需要立即完成的事情, perform_later

您还可以删除与以下对象异步的 Active Storage 文件 purge_later

class User < ApplicationRecord
  has_one_attached :avatar
end

User.first.avatar.purge_later # enqueue a job to delete the file

从 Rails 6.1 开始,你可以删除 async with 的依赖关联 dependent: :destroy_async

class Team < ApplicationRecord
  has_many :players, dependent: :destroy_async
end

class Player < ApplicationRecord
  belongs_to :team
end

Team.destroy_by(name: "Flamengo")
# Enqueued ActiveRecord::DestroyAssociationAsyncJob

可以配置 后台作业中销毁的最大记录数 dependent: :destroy_async 关联选项。

太棒了,这就是第一个原则。下面是第二个原则:

懒惰是好事,但你不能等着什么都不做!考虑以下例子:

puts(
  Benchmark.realtime do
    5.times do
      Net::HTTP.get(URI.parse("https://httpbin.org/delay/2"))
    end
  end
)

由于请求是同步的,总时间大约为 10 秒(+ 一些网络开销)。糟糕的是,代码并没有做太多事情:它主要是等待这些请求完成。从视觉上看,它的执行方式如下:

我们可以采取主动,在等待先前的请求完成的同时开始发出更多请求。以下是使用 async
宝石

puts(
  Benchmark.realtime do
    Sync do
      5.times.map do
        Async do
          Net::HTTP.get(URI.parse("https://httpbin.org/delay/2"))
        end
      end.map(&:wait)
    end
  end
)

变化不大,但现在只需要大约 2 秒钟即可完成!我们正在尽快发出另一个请求,这基本上意味着我们正在等待它们并行完成。

以下是正在发生的情况的直观表示:

请求是并发的,这使得等待 I/O 的时间并行

本文以 HTTP 请求为例,但请尝试将此原则应用于任何其他类型的 I/O 绑定操作。文件操作、系统调用和数据库查询都是此类优化的良好候选对象。说到数据库查询……

异步数据库查询

从 Rails 7 开始,你可以使用
ActiveRecord::Relation#load_async

在后台线程中运行数据库查询。当您想在后台加载关系但不需要立即获得结果时,这很有用。

因此,假设我们有一个控制器,它执行几个查询来呈现页面:

class ReportsController
  def create
    @new_authors = Author.recent
    @new_books = Book.recent
    @new_reviews = BookReview.recent
  end
end

如果每个查询需要 1 秒才能运行,则这里的总时间将是 3 秒。但是,如果我们使用 load_async 并行运行它们:

class ReportsController
  def create
    @new_authors = Author.recent.load_async
    @new_books = Book.recent.load_async
    @new_reviews = BookReview.recent.load_async
  end
end

那么总时间大约是 1 秒!同样,我们不需要等待一个查询完成即可开始下一个查询。Rails 日志将显示以下内容:

ASYNC Author Load  (1010.2ms) (db time 1011.4ms)
ASYNC Book Load       (2.2ms) (db time 1013.8ms)
ASYNC BookReview Load (0.2ms) (db time 1014.7ms)

第一个数字列显示了查询在前台线程中运行所花费的时间,而第二列显示了查询在数据库上所花费的总时间。

正如任何性能改进的承诺一样,这里也存在权衡。当使用 load_async,我们在单个请求中使用了更多资源(数据库连接线程)。如果您在应用程序负载很重的部分使用此功能,则可能会出现问题,因为一个或几个用户可能会耗尽连接,而其他用户将不得不等待(并且可能超时)。因此, 小心 load_async

不过,一个很好的用例是当您有一个 HTTP 请求和一个可以并行完成的数据库查询时:

class BooksController
  def show
    @new_books = Book.recent.load_async
    @external_books = HTTP.get("https://external.com/books")
  end
end

异步视图

我认为 Rails 不会并行渲染部分内容,但您可以使用 Turbo Frames 并行加载页面的部分内容。只需给它一个 URL,它就会从该路由加载其内容。 延迟加载的框架 对于那些对用户体验不太重要或重量级的页面部分特别有用。

向您的视图添加涡轮框架:


  id="best_sellers"
  src="books/best_sellers"
  loading="lazy"
>

编写呈现框架内容的控制器动作:

class BooksController
  def best_sellers
    @books = Book.best_sellers
  end
end

以及呈现内容的视图:

 id="best_sellers">
  Best Sellers
  <%= render @best_sellers %>

就是这样!如果页面上有多个框架,它们将并行加载。当然,这意味着需要向服务器发送更多请求,所以请记住这一点。另外,不要过度加载。用户看到页面加载然后每次都“再次加载内容”会感到沮丧(看看你,SPA)。

如果您需要渲染某些成本高昂或并非每个用户都需要的内容,则可以将其推到初始视口之外,然后使用涡轮帧延迟加载。

异步资产

进一步扩展这个概念,我们可以对资产做同样的事情。例如,你可以设置 async 属性 在您的脚本标签上并行加载它们:

"render" async src="async-script.js">

拆分关键和非关键 CSS 也有帮助。你可以 延迟加载字体
font-display: swap,它将在自定义字体加载时使用后备字体呈现文本。

对于图像,你可以使用以下方式进行延迟加载: loading="lazy" 属性。其工作方式与 Turbo Frames 相同,仅在图像即将进入视口时才加载图像。Rails 甚至有一个配置选项来 默认延迟加载图像

所有这些都将帮助您的页面更快地显示,而不是长时间看到空白屏。

同时添加索引

最后,我想提一下您可以在开发中或更“幕后”场景中使用的一些异步工具。第一个是如果您正在使用 PostgreSQL。

在向 Postgres 表添加索引时,它会阻止表写入。如果您有一个大表,这可能会导致生产停机。幸运的是,我们可以使用 concurrently 在不阻塞表的情况下创建索引的选项:

class AddIndexToUserRoles < ActiveRecord::Migration
  disable_ddl_transaction!

  def change
    add_index :users, :role, algorithm: :concurrently
  end
end

需要注意的是——引用文档—— 这种方法需要的总工作量比标准索引构建要多,而且需要更长的时间才能完成。但是,由于它允许在构建索引时继续进行正常操作,因此这种方法对于在生产环境中添加新索引非常有用。当然,索引创建带来的额外 CPU 和 I/O 负载可能会减慢其他操作的速度。

并行运行测试

Rails 6 引入了并行测试。您需要做的就是指定需要多少个 worker:

class ActiveSupport::TestCase
  parallelize(workers: 2)
  # or let it figure out looking at the number of CPUs
  parallelize(workers: :number_of_processors)
end

数学很简单(因为我正在简化事情):你拥有的工人越多,你的测试运行得越快。

工人 测试套件时间
1 40 分钟
2 20 分钟
4 10 分钟

很遗憾, RSpec 不支持 Rails 的开箱即用并行测试。不过,有几款 Gem 为 RSpec 实现了这种行为。以下是一些示例
parallel_testsflatware

异步可以让你的应用运行得更快,但同时也会使代码变得更加复杂!你可能会觉得自己对执行过程的控制力减弱,错误也变得更难调试。

也就是说,在异步之前你应该做好功课。不要把这些技术当成 真实的 性能问题。我指的是一些基本问题,比如为数据库列添加索引、修复 N+1 查询, 使用 低级缓存视图缓存 在哪里有意义,通常遵循 良好的 Ruby 和 Rails 实践

明智地运用这些原则。与任何简化一样,它们在某些情况下可能是错误的。 这些不是规则!在很多情况下,“主动”比“懒惰”要好(例如预先计算值)。但我希望这可以作为您开始更多地思考异步和 Rails 的起点。

Leave a Reply

Your email address will not be published. Required fields are marked *

近期新闻​

编辑精选​