Ruby on Rails 中的 Solid Queue 简介
Hans-Jörg Schnedlitz 发表于 2025年5月7日
本页内容
分享这篇文章
您的应用程序是否出现故障?AppSignal 会通知您。 AppSignal 监控
Rails 8 最令人兴奋的新增功能之一无疑是 Solid Queue,这是一个用于处理后台任务的新库。
您可能认为这没什么大不了的。毕竟,市面上有很多其他的队列系统。如果您使用 Rails,您可能了解 Sidekiq 和 Resque,它们都具有出色的性能和可靠性。还有 GoodJob 和历史悠久的 DelayedJob。在所有这些可用选项中,我们真的需要另一个队列系统吗?
让我们一起来看看。在这个由两部分组成的系列中,我们将深入研究 Solid Queue 的内部结构,发现它的独特之处,并更多地了解它最初创建的原因。
为什么选择 Ruby on Rails 的 Solid Queue?
自 Rails 7 以来,37Signals 团队一直在努力减少启动新 Rails 应用程序所需的运营开销。作为其中的一部分,他们将 SQLite 作为 Rails 应用程序的新默认数据库,即使在生产环境中也是如此。此外,他们开始努力消除额外的基础设施依赖项,以充分利用这个新的默认设置。
37Signals 之前一直使用 Resque,而 Resque 需要 Redis 才能运行。Sidekiq 也是如此。为了摆脱 Redis,他们必须创建一个仅依赖于数据库的队列系统,而这个队列系统最终变成了 Solid Queue。
这就是它的主要卖点:没有额外的依赖项;只需使用您的数据库。非常好!但是,与任何队列系统一样,尤其是作为新的 Rails 默认队列系统,Solid Queue 需要满足一些严格的要求。
它必须提供 Rails 开发人员从其他后台任务系统中习惯的所有功能。作为 Rails 的默认队列,它必须支持 Rails 可以使用的所有数据库。显然,它需要满足标准的安全性要求,即绝不能丢失任务。最后但同样重要的是,它必须足够快,才能成为大型生产系统的可行选择。
这是一个相当高的要求!那么,Solid Queue 如何满足所有这些要求呢?
Solid Queue 概览
有很多细节需要考虑,但让我们从一个高层次的架构概述开始。您需要了解两个重要的组成部分:Jobs 和 Workers。
Job
是一个 ActiveRecord
模型,也是用户与之交互的对象。请注意,对于其他 ActiveJob
后端而言,情况不一定如此,这只是 SolidQueue 实现后台任务的方式。如果您需要创建一个新的后台任务,那么这就是您需要继承的类。Job
还定义了使您能够将任务入队的方法,例如 Job.perform_later
。
Ruby
# app/jobs/my_job.rb
class MyJob < ApplicationJob
queue_as :default
def perform
# Do something later
end
end
Workers,顾名思义,是执行实际工作的元素。这些通常不是由程序员直接创建的,而是根据您配置应用程序的方式自动创建的。例如,要让您的应用程序分别生成两个监听所有队列和两个特定队列的 workers,您可以使用以下配置文件:
YAML
# config/queue.yml
production:
workers:
- queues: "*"
- queues: [default, critical]
Workers 作为进程生成,在后台运行,等待任务分配给它们。您可能已经猜到,您的数据库是任务和 workers 之间的缺失环节。每当 Solid Queue 执行任何操作时,都会涉及一个或多个数据库表。SolidQueue 会做很多事情,因此需要 很多表。
Ruby
# lib/generators/solid_queue/install/templates/db/queue_schema.rb
ActiveRecord::Schema[7.1].define(version: 1) do
create_table "solid_queue_jobs", force: :cascade do |t|
# ...
end
create_table "solid_queue_ready_executions", force: :cascade do |t|
# ...
end
create_table "solid_queue_scheduled_executions", force: :cascade do |t|
# ...
end
create_table "solid_queue_claimed_executions", force: :cascade do |t|
# ...
end
create_table "solid_queue_blocked_executions", force: :cascade do |t|
# ...
end
create_table "solid_queue_failed_executions", force: :cascade do |t|
#...
end
# Lots more tables below...
end
↓ 文章继续
您的应用程序是否出现故障或速度缓慢?AppSignal 会通知您。
SOLID Job 的生与死
为了了解所有这些表的作用以及它们如何与 Solid Queue 的各种功能相关联,让我们看一下 job 的生命周期。当用户将 job 排队以供稍后执行时(例如 MyJob),会在 solid_queue_jobs
表中创建一个记录。该记录包含执行 job 所需的所有数据,包括参数、名称、放入的队列等等。如果该 job 被排队以便尽快运行(而不是计划在稍后的某个时间点运行),则会在 solid_queue_ready_executions
中写入另一个记录。
例如,运行 MyJob.perform_later
会导致以下 SQL:
SQL
INSERT INTO "solid_queue_jobs" ("queue_name", "class_name", "arguments", "priority", "active_job_id", "scheduled_at", "finished_at", "concurrency_key", "created_at", "updated_at")
VALUES ('default', 'MyJob', '{"job_class": "MyJob","...",}', 0, '...', '2024-12-01 14:00:00', NULL, NULL, '2024-12-01 14:00:00', '2024-12-01 14:00:00')
RETURNING "id"
INSERT INTO "solid_queue_ready_executions" ("job_id", "queue_name", "priority", "created_at")
VALUES (1, 'default', 0, '2024-12-01 14:00:00')
RETURNING "id"
您的 workers 轮询此表以获取新记录。找到新记录的 worker 进程将首先通过在 solid_queue_claimed_executions
表中写入另一个记录来声明它,我们稍后将了解为什么这是必要的。只有这样,worker 才会实际执行 job。以下是一些经过大量编辑的代码,用于说明正在发生的事情(实际代码中发生的事情要多得多)。如果您对具体细节感到好奇,我强烈建议您 查看原始源代码。
Ruby
class Worker
def run
loop do
break if shutting_down?
unless poll > 0
# Polling interval is configurable and defaults to 1ms
sleep(polling_interval)
end
end
end
def poll
# Claim jobs and then execute claimed jobs.
claim_executions.then do |executions|
executions.each do |execution|
# Actually execute the job
end
end
end
def claim_executions
# Query the ready executions table and claim a job for execution.
with_polling_volume do
SolidQueue::ReadyExecution.claim
end
end
end
一旦 worker 完成一个 job,它会从 solid_queue_jobs
、solid_queue_ready_executions
和 solid_queue_claimed_executions
表中删除相应的记录。这就是全部内容,只是轮询一些表,创建和删除记录。不是那么棘手,对吧?如果没有需要考虑的关键非功能性需求,那将会是这样。
关于性能
为了实现生产就绪的性能,Solid Queue 使用了巧妙的数据库设计。您可能想知道为什么 workers 轮询 solid_queue_ready_executions
而不是 solid_queue_jobs
。乍一看,额外的表似乎是多余的。
请考虑,solid_queue_jobs
可能包含数千甚至数百万条记录,而查询这些数据需要时间。相比之下,solid_queue_ready_executions
非常小,因为它只包含必须立即执行的 job 的记录!这会带来一些严重的加速。
引入额外的表也简化了查询。Workers 仅使用两个不同的查询进行轮询。它们要么轮询所有队列,要么轮询特定队列。反过来,这允许一些不错的 覆盖索引。
SQL
SELECT job_id
FROM solid_queue_ready_executions
WHERE queue_name = "default"
ORDER BY priority ASC, job_id ASC
LIMIT 4
FOR UPDATE SKIP LOCKED
Ruby
# Indices for polling solid queue ready executions
create_table "solid_queue_ready_executions", force: :cascade do |t|
t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all"
t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue"
end
所有这些仍然不足以实现真正出色的性能。传统上,依赖于轮询表的队列系统存在一个重要问题。一个 worker 会在查询和更新轮询表时阻止所有其他 worker。
让我们看看为什么。考虑以下查询:
SQL
SELECT id
FROM jobs
WHERE queue = "default"
AND claimed = 0
ORDER_BY priority, id
LIMIT 2
FOR UPDATE;
FOR UPDATE
语句锁定查询选择的行。这是必要的,以避免出现糟糕的竞争条件,例如多个 workers 抢夺同一 job。但这也意味着运行此查询的任何 worker 都会阻止对表的读取访问。因此,其他 workers 将不得不等待该查询完成。轮询表成为阻碍快速 job 执行的瓶颈。
幸运的是,现代数据库(PostgreSQL >= 9.5, MySQL >= 8.0)解决了这个问题。SKIP LOCKED
语句允许数据库仅锁定正在更新的记录。表的其余部分保持解锁状态,可以自由地并发轮询。
SQLite 不支持 SKIP LOCKED
,因此 worker 进程必须排队。在大多数情况下,这不应该是一个问题。由于数据库存在于磁盘上,因此 SQLite 写入速度很快。即便如此,这是一个您应该注意的限制。
无论您使用的是 SQLite 还是其他数据库,AppSignal 都提供了开箱即用的 Solid Queue 性能监控!在本系列的第二部分中,我们将详细讨论这一点。
安全第一
我们花了一些时间讨论 solid_queue_ready_executions
,但另一个表对于确保 Solid Queue 可靠运行至关重要。任何队列系统的一个关键要求是,任何正在排队的 job 至少执行一次。换句话说,job 绝不能丢失,我们已经在介绍中暗示了这一点。
如果没有额外的安全措施,这可能会很快发生。想象一下,一个 worker 开始处理一个 job,并在这样做时更新相应的 job 记录以声明它。当然,这是必要的,以避免多个 workers 同时运行一个 job。
想象一下,突然之间,这个 worker 进程在没有完成执行的情况下死亡。您的机器可能会崩溃,并且操作系统可能会杀死 worker 以使其消耗过多的内存,您知道,事故会发生。它声明的 job 将永远卡住,因为没有其他 workers 可以获取它。因此,它将永远不会被执行,并且您的用户会感到悲伤和愤怒。结束。
也就是说,除非我们添加额外的安全措施。Solid Queue 通过引入更多的表来解决这个问题,即 solid_queue_claimed_executions
和 solid_queue_processes
。
Ruby
ActiveRecord::Schema[7.1].define(version: 1) do
create_table "solid_queue_claimed_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.bigint "process_id"
# ...
end
create_table "solid_queue_processes", force: :cascade do |t|
t.datetime "last_heartbeat_at", null: false
t.integer "pid", null: false
# ...
end
# ...
end
我们已经提到了 solid_queue_claimed_executions
。让我们看看当 worker 声明一个 job 时会发生什么。首先,它在 solid_queue_jobs
表中设置 claimed
标志。此外,还在 solid_queue_claimed_executions
中创建一个记录。此记录包含被声明的 job 的 job_id
和发出声明的 worker 进程的 ID。
那么,solid_queue_processes
表有什么用呢?任何 worker 进程都会通过设置 last_heartbeat_at
在此表中创建并定期更新一条记录。当然,仅靠这一点并不能解决我们的问题。
我们需要另一个进程来跟踪正在运行的进程:所谓的 supervisor。此进程在后台运行并定期检查 solid_queue_processes
。last_heartbeat_at
早于阈值(默认为 5 分钟)的记录表示相应的 worker 已经遭遇了不幸的命运。
如果找到这样的记录,supervisor 就会立即采取行动。首先,它从 solid_queue_processes
中删除该记录。然后,它将先前由现在已故的 worker 声明的任何 job 标记为可供争抢。因此,其他 workers 可以声明它们,从而避免出现 stuck-job 的情况。
Solid Queue 的更多发现
在这篇文章中,我们介绍了 Solid Queue 的很多内部结构。我们了解了它的高层架构,以及它的最基本功能(将 job 排队和执行 job)如何在底层工作。我们还了解了 FOR UPDATE SKIP LOCKED
在性能方面的关键作用。最后,我们了解了 supervisor 进程如何帮助避免 stuck jobs。
但还有更多值得发现。Solid Queue 提供了许多我们尚未触及的功能,例如调度重复性和顺序性 job。请继续关注,我们将在本系列的第二部分中继续深入研究。
想知道接下来可以做什么?
读完这篇文章了吗?您可以执行以下操作:
-
订阅我们的 Ruby Magic newsletter,并且不会再错过任何文章。
-
开始使用 AppSignal 监控您的 Ruby 应用程序。
-
在社交媒体上分享这篇文章
最受欢迎的 Ruby 文章
Ruby on Rails 8 的新特性 让我们来探索 Rails 8 提供的一切。了解更多
使用 AppSignal 衡量 Ruby on Rails 中 Feature Flags 的影响 我们将使用 Flipper 和 AppSignal 的自定义指标在 Solidus storefront 中设置 feature flags。了解更多
在 Ruby 中要避免的五件事 我们将深入研究五个常见的 Ruby 错误,并看看如何解决它们。了解更多
Hans-Jörg Schnedlitz
我们的客座作者 Hans 是来自奥地利维也纳的 Rails 工程师。他大部分时间都在编写代码或阅读有关编码的内容,有时甚至在他的博客上写有关编码的内容!当他不坐在屏幕前时,您可能会在外面找到他,攀登一些山峰。 Hans-Jörg Schnedlitz 的所有文章 成为我们的下一位作者! 了解更多
AppSignal 监控您的应用程序
AppSignal 为 Ruby, Rails, Elixir, Phoenix, Node.js, Express 以及许多其他框架和库提供见解。我们位于美丽的阿姆斯特丹。我们喜欢 stroopwafels。如果您也这样做,请告诉我们。我们可能会给您寄一些!
发现 AppSignal
功能
- Error Tracking
- Performance Monitoring
- Host Monitoring
- Anomaly Detection
- Uptime Monitoring
- Metric Dashboards
- Workflow
- Log Management
- Automated Dashboards
- Check-ins
- Time Detective
资源
比较
支持
您是否需要帮助,有功能要求或只是需要有人一起编程?请与我们的工程师联系。
关于我们
AppSignal 位于美丽的荷兰。我们喜欢 stroopwafels。如果您也这样做,请告诉我们。我们可能会给您寄一些!
语言
-
Ruby Active Record, Capistrano, Delayed::Job, Garbage Collection, Global VM Lock, Grape, GraphQL, Hanami, MongoDB, Padrino, Puma, Que, Ruby on Rails, Rake, Resque, Shoryuken, Sidekiq, Sinatra, Solid Queue, ViewComponent, Webmachine
-
Elixir Absinthe, Ecto, Erlang, Finch, Oban, Phoenix, Plug, Tesla
-
Node.js AMQPlib, Apollo Gateway, BullMQ, Express, Fastify, GraphQL, Knex.js, Koa, MongoDB, Mongoose, MySQL, NestJS, [Next.js](https://blog.appsignal.com/2025/05/07/<https:/www.appsignal.com/nodejs/