ActiveJobのバックエンドと、永続化先としてRedisが好まれる理由
Rails 4.2で、ActiveJobというクラスが導入されました。
これ以前より、Railsで非同期処理を行う際にはResqueやSidekiq、Delayed Jobなどが広く使われていましたが、 ActiveJobはジョブを記述するためのインタフェースを抽象化して、 ジョブの実装を変えること無くジョブランナーを切り替えることを可能にするものです。 なおActiveJobにおいては、バックエンドを指定しなければジョブは非同期実行しようとしても即座に実行されます。
Rails 4.2.0時点では、ActiveJobのバックエンドとして以下のページにある9つのいずれかを使用できます。
- Backburner
- Delayed Job
- Qu
- Que
- queue_classic
- Resque 1.x
- Sidekiq
- Sneakers
- Sucker Punch
ちなみにRuby ToolboxのBackground Jobsというカテゴリの上位3つは、Resque、Sidekiq、Delayed Jobでした。この3つがよく使われている印象です。
特にResque、Sidekiqは、ジョブの情報を永続化する先としてRedisを必須としています。
情報の保存先ならActiveRecord(RDBMS)でも良さそうなものですが、あえてRedisを必須としている理由は何だろうか考えてみました。
ぱっと思いつくのは、「キューにジョブが積まれた」というイベントをワーカーへ伝えるための手段として、 RedisのPub/SubやBLPOP/BRPOPのようなメッセージング機構を使うのが簡単で都合が良かったのではないか?ということです。
バックエンドにMySQLのようなRDBMSを使う場合、普通に考えるとワーカー側はジョブを保存したテーブルをロックしつつポーリングしないといけないのでスケールしなさそうだし、 RabbitMQのようなメッセージングサービスを用いるのと比べて、RedisであればRailsエンジニアは元々better memcachedとして使用しているケースが多いので導入のハードルが低いです。
というわけで、このエントリではResque、Sidekiqの2つについてはRedisのメッセージング機構を使っているかどうかを確認しつつ、 ActiveJobのページに挙げられている9つのプロダクトについてジョブ情報の保存先を調べてまとめてみようと思います。
9つの中で特にSucker Punchはジョブの保存先もワーカープロセスの起動も必要とせずに非同期処理を行えるので、簡単に作りたいならおすすめです。詳細は後述。
Resque
Rails 4.2.0時点においてActiveJobが対応しているのはRedisのバージョン1系列です。
ワーカーを起動する場合はrakeタスクを実行するので、そのあたりからコードを読んでいきます。
lib/tasks/resque.task これはresque/tasksを読み込んでいるだけ。
resque/tasks.rb
Resque::Worker#work
がメインループのようです。
resque/worker.rb
ジョブが無い場合はインターバル分だけスリープする実装になっていました。
ジョブの取得はさらに読んでいくとRedisのLPOP
を使っていて、キューが空でもブロックせずにnilが帰ってきます。
Resque::Worker#work
Resque#reserve
Resque::Job.reserve
Resque.pop
Redis#lpop(redis-3.2.1)
結論
Resqueのバージョン1系はRedisのメッセージング機構は使わずにポーリングしてました。
ただ、2015-02-26時点のmasterブランチのHEADでは、インターバルに設定した値をタイムアウト値としてBLPOP
を使う実装になっていました。
(インターバルが1より小さい場合はnon-blockで実質ポーリング)
Sidekiq
現時点で最新の3.3.2を読んでいきます。
Sidekiqのエントリポイントはbin/sidekiqです。 中身はSidekiq::CLI#runなのでそこを見てみます。
Sidekiq::Launcher#runがメインループのようです。
Celluloidを使ったアクターモデルでFetcherとManagerとPollerという3つのインスタンスが強調しつつ並行処理を行っているように見えます。 Pollerがスケジュールされたジョブをワークキューに載せ替える、ということをやっていました。 Managerの方では同時実行数の数だけProcessorのインスタンスを作って、 ProcessorごとにFetcher#fetchしています。 Fetcherの中身をざっくり追いかけると、 sidekiq/fetch.rb
Fetcher#fetch
Fetcher.strategy
Fetcher::BasicFetch#retrive_work
Redis#brpop
という流れで最終的にRedisのBRPOP
でジョブ情報を読みだしていました。
結論
Sidekiqはポーリングせずに、RedisのBRPOP
を使ってジョブが積まれたイベントをワーカーに通知していました。
その他のバックエンド
Backburner
beanstalkd というジョブキューサーバをバックエンドに使うようです。
Delayed Job
バックエンドとして複数のプロダクトを指定できます。(DJはDelayed Jobのこと)
- ActiveRecord (DJ 3.0+)
- DataMapper
- IronMQ
- Mongoid
- MongoMapper
- MongoMapper (DJ 3.0+, MongoMapper 0.11.0+)
- Redis (DJ 3.0+, experimental) Backends · collectiveidea/delayed_job Wiki
Qu
RedisかMongo。
Que
PostgreSQLのAdvisory Lockを使います。
Advisory Lockを用いてMessage Queueを実現する方法については下記のサイトが詳しいです。
PostgreSQL で簡易に MQ - Mi manca qualche giovedi`?
ちなみにMySQLでMessage Queueやる場合はQ4MというストレージエンジンがありますがActiveJobで使う方法は見つかりませんでした。
queue_classic
PostgreSQL。listen / notify & row lockingで実装。PostgreSQLはPub/Sub機能があるんですね。
Sneakers
RabbitMQ。サンプルコードでRedisを使っていますが、デモのためのカウントアップをRedisでやっているだけです。
Sucker Punch
ジョブ情報の保存先やワーカープロセスの立ち上げが必要ありません。 Celluloidで即座に非同期処理を開始するモデルのようですね。 RailsやSinatraのようなアプリケーションプロセス一つでジョブの実行が(非同期に)完結するというのがウリみたいです。
そのため、RedisやRabbitMQを用意する必要はないし、ワーカープロセスを起動する必要すらありません。
Herokuで安価にサービス運用したいのでRedisやRabbitMQは使いたくないけどビューのレスポンスは高速化したいような場合に使うと良さそうです。 個人的にはこれがActiveJobのデフォルトのバックエンドでも良いくらいだと思いました。
development環境のWebrickで試してみましたが、コントローラのアクションメソッドでTestJob.perform_later
しても、アクションそのものはすぐにレスポンスが返ってきました。
また、ジョブが完了していなくても次のリクエストを処理することができていました。
Celluloid使っているらしいのでジョブはThreadで実行されていると思われます。
以下動作を確認したサンプルコードです。
# config/routes.rb
Rails.application.routes.draw do
get 'top/welcome'
get 'top/job'
root 'top#welcome'
end
# config/initializers/sucker_punch.rb
Rails.application.configure do
config.active_job.queue_adapter = :sucker_punch
end
# app/jobs/test_job.rb
class TestJob < ActiveJob::Base
queue_as :default
def perform(*args)
logger.info "job started"
sleep 5
logger.info "job finished"
end
end
# app/controllers/top_controller.rb
class TopController < ApplicationController
def welcome
end
def job
TestJob.perform_later
flash[:notice] = "done!"
redirect_to top_welcome_path
end
end
# app/views/welcome.view.erb
<% if flash[:notice] %>
<%= flash[:notice] %>
<% end %>
<%= link_to "Do job", top_job_path %>
感想
- resque 1系がRedisを使いつつ実はポーリングしていたのが意外でした。
- PostgreSQLはほとんど使ったこと無いんだけど、RDBMSでPub/Subできたら楽できそう。
- Sucker Punchはワーカープロセスの起動無しで非同期処理できる点が便利。これでOKという場面は結構ありそうなので良い発見でした。