DEV Community

Kaziu
Kaziu

Posted on

もうActiveJob - Job,Que,Adaptorもレストランに例えますかーー

わかってるんですが、レストランに例えさせてください。


登場人物

まずは対応表です。この記事はずっとこの例えで進めます。

Rails の用語 レストランでいうと
Webリクエスト 来店したお客さんの注文
コントローラ レジ係
ジョブ 注文票
perform_later 注文票をボードに貼ること
キュー 注文票を貼っておくボード
ワーカー 料理人
アダプター ボードの種類

なぜ「あとでやる」必要があるのか

ユーザー登録したら歓迎メールを送る、という機能を考えます。

class UsersController < ApplicationController
  def create
    user = User.create!(user_params)
    WelcomeMailer.with(user: user).welcome.deliver_now # ← ここで数秒待たされる
    render json: user
  end
end
Enter fullscreen mode Exit fullscreen mode

これはレジ係が注文を受けたあと自分で厨房に入って料理を作り、出来上がるまでレジに戻らない状態です。後ろに並んだお客さんは、その間ずっと待たされます。

メール送信、CSV 出力、外部 API 連携のように時間がかかる処理は、「あとでやる」ことにしてすぐお客さんに返事をしたい。これがジョブの出番です。


ジョブ = 注文票

ジョブは「この仕事をやっておいて」という注文票です。

class WelcomeMailJob < ApplicationJob
  def perform(user_id)
    user = User.find(user_id)
    WelcomeMailer.with(user: user).welcome.deliver_now
  end
end
Enter fullscreen mode Exit fullscreen mode

レジ係(コントローラ)は注文票を書いてボードに貼るだけです。

def create
  user = User.create!(user_params)
  WelcomeMailJob.perform_later(user.id) # ← 注文票を貼るだけ。一瞬で終わる
  render json: user
end
Enter fullscreen mode Exit fullscreen mode

perform_later は「後で(later)実行して(perform)」という意味です。レジ係はすぐ次のお客さんの対応に戻れます。

注文票には「料理」ではなく「テーブル番号」を書く

引数には user そのものではなく user.id を渡しています。

注文票に料理そのものを貼り付ける人はいません。「3番テーブル、ハンバーグ」と書いておき、料理人が作るときに最新の情報を確認します。ジョブも同じで、ID だけ渡して、実行するときに DB から取り直すのが基本です。キューに積んでから実行されるまでの間に、データが変わっているかもしれないからです。


キュー = 注文票ボード

キューは注文票を到着順に並べておくボードです。

 レジ係                 注文票ボード(キュー)            料理人(ワーカー)
┌──────┐  貼る    ┌──────────────────────────┐  取る   ┌──────┐
│      │ ───────▶ │ [票3] [票2] [票1]         │ ──────▶ │ 調理 │
└──────┘          └──────────────────────────┘         └──────┘
Enter fullscreen mode Exit fullscreen mode
  • レジ係は貼るだけ、料理人は取って作るだけ。お互いの仕事を待ちません
  • 料理人は常に厨房で待機していて、票が貼られたらすぐ取りに来ます
  • 注文が殺到したら、票がボードに並ぶだけです。厨房はパンクしません

「注文のたびに料理人を雇えばいいのでは?」

ボードに並ばせず、注文ごとに新しい料理人を雇って即調理させればいい、と思うかもしれません。Rails でいえば「ジョブごとに新しいプロセスを起動する」方式です。

これは次の理由でうまくいきません。

  • 雇うのが高い: Rails のプロセスは起動に数秒、メモリに数百MB かかります。毎回の研修コストが重すぎます
  • 増えすぎる: 1秒に1000件の注文が来たら、1000人が同時に厨房に入ってきます。CPU より先に、メモリや DB 接続(厨房の広さやコンロの数)が限界を迎えます
  • 注文が消える: 料理人が途中で倒れたら、その注文は誰にも知られずに消えます
  • やり直しができない: 失敗しても、誰も作り直してくれません

ボードと常駐の料理人なら、人数を一定に保ったまま、注文をなくさず、確実にこなせます。キューは「待たせるための仕組み」ではなく、そのための仕組みです。


アダプター = ボードの種類

注文票ボードにはいろいろな種類があります。ホワイトボード、紙の伝票、本部と繋がった受注システム……。

ActiveJob は、どのボードを使っても注文票の書き方は同じになるように作られています。ボードごとの違いを吸収する「変換プラグ」がアダプターです。

# 注文票の書き方は、どのボードでも同じ
WelcomeMailJob.perform_later(user.id)

# どのボードを使うかは設定 1 行で切り替える
config.active_job.queue_adapter = :async
Enter fullscreen mode Exit fullscreen mode

代表的なボードを見ていきます。

:async ─ 店員が頭の中で覚えておく

┌───────────── お店(Rails プロセス 1 つ)─────────────┐
│  レジ係 ──▶ 店員の頭の中のメモ ──▶ 店内の料理人       │
└──────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Rails のデフォルトです。キューも料理人も、Rails プロセスの中に全部入っています。

  • 追加で何もインストールしなくても動くので、学習や開発に便利です
  • ただし注文は店員の頭の中にあるだけです。店を閉める(プロセスを再起動する、デプロイする)と、まだ作っていない注文は全部忘れられます

:inline ─ レジ係がその場で作る

ボードを使わず、レジ係がその場で自分で料理します。perform_later と書いても非同期になりません。

開発中に「ジョブがちゃんと動くか」をすぐ確かめたいときに便利です。

:test ─ 研修用のボード

実際には料理を作らず、「注文票が正しく貼られたか」だけを記録します。RSpec でテストするときに使います。

expect {
  post '/users', params: { ... }
}.to have_enqueued_job(WelcomeMailJob)
Enter fullscreen mode Exit fullscreen mode

:solid_queue / :sidekiq / :sqs など ─ 店の外にあるボード

本番用です。注文票は店の外にある受注システム(DB、Redis、Amazon SQS など)に記録されます。

┌── お店(Rails プロセス)──┐      ┌── 店の外の受注システム ──┐
│  レジ係                    │ ───▶ │  注文票ボード             │
└────────────────────────────┘      └───────────────────────────┘
   ↑ 何度閉店・開店しても               ↑ 注文はこっちに残る
Enter fullscreen mode Exit fullscreen mode
  • 店を閉めても(Rails を再起動・デプロイしても)、注文は残ります
  • 料理人を別の厨房(別のサーバー)に置いて、必要なだけ増やせます
  • 失敗した注文を「5分後にもう一度」と貼り直せます

ただし、ボード自体が壊れたときにどこまで残るかは、ボード次第です。

  • Redis もメモリに置いています。永続化(スナップショットや追記ログ)を設定していないと、Redis の再起動で消えます
  • DB や SQS はディスクや複数拠点に保存されるので、より消えにくいです

:async との本当の違いは「メモリかディスクか」ではなく、ボードがお店(Rails)と一緒に閉まるかどうかです。

アダプター ボードの置き場所 Rails 再起動で消える? ボード自体の再起動で消える? 主な用途
:async 店員の頭の中(Rails のメモリ) 消える ─ 学習・開発
:inline なし(その場で作る) ─ ─ 開発・デバッグ
:test 研修用の記録 ─ ─ テスト
:sidekiq Redis(別サーバーのメモリ) 消えない 永続化の設定次第 本番
:solid_queue DB(ディスク) 消えない ほぼ消えない 本番
:sqs 系 Amazon SQS 消えない 複数拠点に保存され消えにくい 本番

本番で気をつけたいこと

店の外にあるボードを使うと、:async や :inline では見えなかった問題が出てきます。

1. 同じ注文票が 2 枚届くことがある

SQS のような分散キューでは、まれに同じジョブが 2 回実行されることがあります。ハンバーグが 2 皿届いたら困りますよね。

ジョブは「何回実行しても結果が同じになる」ように作ります(冪等性)。例えば「送信済みフラグが立っていたら何もしない」といった工夫です。

2. 会計が確定する前に注文票を貼らない

ActiveRecord::Base.transaction do
  user = User.create!(user_params)
  WelcomeMailJob.perform_later(user.id) # ← 危険
  # ...この後で例外が起きてロールバックすると?
end
Enter fullscreen mode Exit fullscreen mode

トランザクションの中で perform_later すると、次のことが起こりえます。

  • 料理人が先に注文票を見て厨房に行ったのに、まだ注文が確定していない(コミット前なので User.find で見つからない)
  • 注文がキャンセルされた(ロールバック)のに、料理人は作り始めてしまう

注文票は会計が確定してから(トランザクションのコミット後に)貼りましょう。after_commit を使うのが定番です。

なお Rails 7.2 以降には、トランザクション中に積んだジョブをコミット後まで遅らせる仕組み(enqueue_after_transaction_commit)もあります。

Top comments (0)