![](https://images.thoughtbot.com/9x2ifndcpktpwnxebpbey2505h7a_sync-requests.webp)
异步编程可以让你的应用程序运行得更快。我将分享如何在 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 秒钟即可完成!我们正在尽快发出另一个请求,这基本上意味着我们正在等待它们并行完成。
以下是正在发生的情况的直观表示:
本文以 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_tests
和 flatware
。
异步可以让你的应用运行得更快,但同时也会使代码变得更加复杂!你可能会觉得自己对执行过程的控制力减弱,错误也变得更难调试。
也就是说,在异步之前你应该做好功课。不要把这些技术当成 真实的 性能问题。我指的是一些基本问题,比如为数据库列添加索引、修复 N+1 查询, 使用 低级缓存 和 视图缓存 在哪里有意义,通常遵循 良好的 Ruby 和 Rails 实践。
明智地运用这些原则。与任何简化一样,它们在某些情况下可能是错误的。 这些不是规则!在很多情况下,“主动”比“懒惰”要好(例如预先计算值)。但我希望这可以作为您开始更多地思考异步和 Rails 的起点。