はじめに
ゲームサービス事業本部の三軒家です。
社内のとある Ruby on Rails プロダクトにて、ジョブキューシステムのバックエンドを Solid Queue に移行するプロジェクトを推進しています。
この記事では、Solid Queue への移行に際して実施した負荷試験の様子と、その結果として得られた Solid Queue のパフォーマンス特性に関する知見についてご紹介しようと思います。
TL; DR
- Solid Queue は、シングルスレッドな設定において、 1000 job/s 程度のスループットを安定して処理することができる
- 1000 job/s = 86400000 job/day なので、8000万 job/day ということになる
- 37signals1 の DHH氏は Tens of millions of daily HEY jobs now run off SQ exclusively! (数千万/day) と発言しているが、それを超える負荷でもさばき切れるということが示せた
- さらに、マルチスレッドモードを利用したり、後述の extra claim パッチを利用すれば、不安定ながらも 2000 job/s 程度の負荷であれば捌き切ることができる
- 高負荷環境においては、Solid Queue のワーカープロセス数の調整が必要となる
Solid Queue 移行の背景
今回 Solid Queue への移行を行ったプロダクトは、全世界へ配信を行う複数のモバイルゲームタイトルで利用されている、基盤サーバーシステムです。このプロダクトは Ruby on Rails で長年開発されており、AWS / Google Cloud の両方で運用されています。全世界展開を行うという背景から、高品質でありながらも高いパフォーマンスを発揮しなければならないというミッションが課されています。
このプロダクトでは、発足当初からメッセージキューとして MySQL の Storage Engine である Q4M と、それを ActiveJob から利用するための QueueAdapter である Shinq が利用されていました。これは、DeNA においてハイパフォーマンスなメッセージキューとして Q4M が長年運用されていたことによる技術選定であり、実際に本プロダクトにおいてもその高い性能・スケーラビリティが遺憾なく発揮されています。
しかし、以下のような理由から、2026年においても Q4M / Shinq を利用し続けるのは難しいとも感じるようになっていました。
- マネージドな MySQL を利用していく潮流の中で、Q4M だけがいまだにその波に乗れていない
- Q4M は MySQL の Pluggable Storage Engine であるため、自前で MySQL のサーバーをビルドする必要があり、当然マネージドな MySQL 上では動作しません
- DeNA として Q4M のメンテナンスにコミットできていない
- たとえば、MySQL 8.0 の新し目のパッチバージョンでは正常に動作しないという問題がありますが、修正に貢献できていない状況です
- DeNA として Shinq のメンテナンスにコミットできていない
- たとえば、現代の ActiveJob に標準搭載されているような各種機能(retry_on によるリトライ機構、perform_all_later 等)が実装されておらず、代わりにアプリケーション側の実装が複雑化している面があります
長らく Q4M からの脱却の道を模索していましたが、Solid Queue が移行先として最適なジョブキューシステムであるという結論に至りました。
Solid Queue は 37signals が開発した Ruby on Rails の QueueAdapter であり、Ruby on Rails 8.0 からはデフォルトの Queue Adapter として採用されています。
バックエンドとして MySQL などの RDBMS を利用しており、SELECT ... FOR UPDATE SKIP LOCKED というクエリを活用することで高いパフォーマンスを実現しています。
詳しい説明については、
手厚い README
や、先行する各社様のブログなど、参考になる資料が多く存在するので割愛しますが、我々としては以下の点が特に良いと考えました。
- MySQL をバックエンドとして採用している
- MySQL 自体の高い性能を活かすことができる
- 問題が発生したとしても、一般的な MySQL の知見とツールがあればトラブルシュートできる
- マルチクラウド環境にフィットしている
- マネージドな MySQL を利用することができる
- クラウド事業者固有のキューサービスに依存しないため、AWS / Google Cloud の両方で運用できる
- マルチプロセスモードが標準でサポートされており、CPU バウンドなワークロードにおいても性能を発揮しやすい
- Rails のデフォルトのジョブキューであり、標準的な機能が備わっていて、かつ今後もメンテナンスされる公算が高い
このような背景から、Q4M / Shinq を脱却し、Solid Queue に移行するという意思決定を行いました。
Solid Queue の負荷試験
Solid Queue への移行に際し、既存のワークロードを問題なくさばき切れることを、簡単な負荷試験で確認することにしました。
目標性能
現状の負荷状況を調査したところ、以下のような負荷をクリアできれば、余裕を持って既存のワークロードをさばき切れるという結論に至りました。
- 1ジョブあたり 300ms ほどの実行時間を要する
- 450 job/s のペースでジョブを処理することができる
これを担保するために、何もせずに 300ms だけ時間を潰すジョブを定義し、それを 450 job/s のペースでエンキューし、詰まることなくさばき切れることを検証することにしました。
なお、本プロダクトはスレッドセーフではないコードを含むため、queue.yml の threads を 1 に設定することで、アプリケーションコードは必ず 1 スレッドで実行されるようにしています2。
また、負荷試験においては、AWS の Aurora MySQL を利用しました。
負荷試験で見つかったボトルネック
まず手始めに、数十job/s から負荷をかけ始め、徐々にペースを上げてみたところ、おおむね 200 job/s 程度で頭打ちになるということがわかりました。ワーカープロセスの数を増やしてもこのボトルネックは解消しませんでした。また、DB の負荷状況としてもそれほど切羽詰まっているようには見えませんでした。
Solid Queue のデバッグログを詳しく調査したところ、solid_queue_ready_executions というテーブルに対する DELETE クエリが長い時間を消費していることがわかりました。このテーブルはジョブの実行状態を管理するためもので、Solid Queue の「ウリ」であるところの FOR UPDATE SKIP LOCKED クエリが打たれるテーブルでもあります。
この DELETE クエリについて Aurora の Database Insights を確認したところ wait/synch/cond/innodb/row_lock_wait で長い時間を消費していることがわかりました。これは、この DELETE クエリが、他のクエリが獲得しているロックに待たされているということを意味します。
MySQL の innodb_lock_waits を利用してロックの状況についてさらに調査を進めたところ、上記 DELETE クエリは、以下のクエリに待たされているということが判明しました。
SELECT 1 AS one FROM `solid_queue_ready_executions` LIMIT 1 FOR UPDATE SKIP LOCKED
このクエリは Solid Queue の 内部 で発行されていますが、どうも意図したロックではないように見えました。そのため、Solid Queue に以下のようなパッチを当て、このクエリが発行されないようにしました。
module Extension
# 元の実装は https://github.com/rails/solid_queue/blob/2c4bbd30aa5c21773b4e74d160f91a80d833dd51/app/models/solid_queue/ready_execution.rb#L32-L34
# 最後に `.to_a` を実行することにより、返り値が ActiveRecord::Relation ではなく Array になるようにし、`.none?` が呼ばれたときに不要なクエリが飛ばないようにする。
def select_candidates(queue_relation, limit)
queue_relation.ordered.limit(limit).non_blocking_lock.select(:id, :job_id).to_a
end
end
SolidQueue::ReadyExecution.singleton_class.prepend Extension
実質的には .to_a というたった五文字の変更ですが、この修正により性能が大きく改善し、細かい調整を経て 450 job/s の性能を達成できました 🎉
なお、上記の修正は、すでに Solid Queue に 取り込まれ て リリース もされています。
さらなる高負荷状況における性能特性
元々の目標性能が達成できることは確認できましたが、Solid Queue の特性をさらに深く理解するため、さらに高い負荷をかけたときの挙動についても調査しました。
前述の設定でスループットを高めていくと 1000 job/s あたりに次の限界があることがわかりました。このときのボトルネックは、ワーカーがジョブを確保する際に実行される FOR UPDATE SKIP LOCKED クエリのようでした。
SELECT
`solid_queue_ready_executions` . `id` ,
`solid_queue_ready_executions` . `job_id`
FROM
`solid_queue_ready_executions`
ORDER BY
`solid_queue_ready_executions` . `priority` ASC ,
`solid_queue_ready_executions` . `job_id` ASC
LIMIT ?
FOR UPDATE SKIP LOCKED
このクエリは、理論的には無限の並列度であっても高速に実行されるべきものですが、Database Insights を確認すると、wait/synch/mutex/innodb/lock_sys_page_mutex に長い時間を消費していることがわかりました。この instrument は以下のドキュメントに記載されています。
MySQL :: MySQL 8.0 Reference Manual :: 1.3 What Is New in MySQL 8.0
私の理解では、MySQL 8.0 において行をロックする際には、512個に sharding された mutex を内部的にロックする必要があり、その待ち時間が長いと wait/synch/mutex/innodb/lock_sys_page_mutex が増加するということのようです。つまり、FOR UPDATE SKIP LOCKED クエリの並列度は、実際にはこの mutex によってキャップされているということだと考えられます。
この限界を突破するためには、FOR UPDATE SKIP LOCKED クエリの発行数を抑えつつ、ジョブのスループットを向上させる必要があります。その手段として、マルチスレッド化と ”extra claim” について検討しました。
マルチスレッド化
冒頭で述べた通り、このプロダクトのアプリケーションはスレッドセーフではないため、queue.yml の threads を 1 に設定しています。しかし、仮にアプリケーションがスレッドセーフである場合は、threads を 2 以上に設定できます。Solid Queue は、一度のポーリングで最大で threads の数だけジョブを取り出す
仕組み
なので、threads の数を大きくすることで FOR UPDATE SKIP LOCKED クエリの発行頻度を抑えることができ、パフォーマンスが向上することが予想されます。
実際、試しに threads の数を 3 にしてみたことがあったのですが、このときは threads = 1 のときよりも高いスループットが実現されていました。ただし、一般論として、スレッド数を増やすと CPU バウンドな処理の性能はむしろ劣化する可能性があるため、適切なスレッドの数はそのプロダクトのワークロードによりそうです。
See also: 100万種類以上のランキング集計を支える Sidekiq チューニング
extra claim
前述のマルチスレッド化という手法は、スレッドセーフではない我々のアプリケーションには適用できないため、シングルスレッドでもより高いパフォーマンスを達成する方法がないかを検討しました。改めて考えてみると、単に FOR UPDATE SKIP LOCKED のクエリ数を減らせば良いというだけの話なので、シングルスレッドであっても複数のジョブをまとめて取り出すようになっていれば、DBの負荷を減らすことができそうです。
この仕組みを実現したのが以下のパッチになります。
Extra claim by genya0407 · Pull Request #2 · genya0407/solid_queue
この extra claim 機能を利用すると、シングルスレッドの設定であっても、Solid Queue は複数のジョブを同時に取り出すようになります。これにより、 FOR UPDATE SKIP LOCKED クエリの発行数が減り、負荷試験でも、やや不安定ながら 2000 job/s ほどのスループットを達成できました 🎉
我々のプロダクトでは 1000 job/s ほどのパフォーマンスが出ていればひとまず十分ということと、シングルスレッドで 2000 job/s ものパフォーマンスが必要とされる環境が世の中にどのぐらいあるのか怪しいということがあり、このパッチを Solid Queue に出すかどうかはわかりませんが、読者の方の中でそのような需要があれば参考にしてください。
なお、extra claim によって余分に取り出されたジョブは、各ワーカープロセスにおいて直列化され実行を待たされます。そのため、即応性が求められるようなケースや、1ジョブの実行時間が長いケースではこのパッチを利用するのは避けたほうがよさそうです。
ワーカープロセス数の最適化について
負荷試験を行うなかで、Solid Queue のワーカープロセス数の調整方法について検討しましたので、こちらについてもご紹介します。
直感的には、ワーカープロセスを増やせば増やすほど性能が高まると考えられるため、コストが許す限りたくさんプロセスを立てておくという戦略でも良さそうに思えます。 しかし、実際に高い負荷をかけてみたところ、ワーカープロセスの数をむやみに増やすと、ジョブを取り出すためのポーリングの頻度が増えてDBの負荷が高まってしまい、逆にスループットが劣化するということがわかりました。 そのため、どんなに高い負荷が来ても大丈夫なようにとにかくワーカープロセスをたくさん用意しておく、というのではダメで、ちょうどいい数だけワーカープロセスを準備しておく必要があります。
とりあえず適当な数のワーカーを立てておいて詰まり具合を見て増減させる、という方法を取ることもできますが、我々は「リトルの法則」を利用してワーカー数のキャパシティプランニングを行うことにしました。
待ち行列理論において、リトルの法則は以下の数式で表現されます。
$$ L = \lambda W $$
ここで $L$ はシステム内の平均要求数、$\lambda$ は平均到着率、$W$ は平均要求時間となります。3
たとえばジョブの要求が毎秒平均 15 のペースで届き、1ジョブを処理するのに平均 200ms かかるようなシステムがあり、この負荷を安定して処理できているケースを考えます。 このとき、システム内の要求数、すなわち同時に処理されているジョブの数は、
$$ L = 15 \times 0.2 = 3 $$
となるため、ワーカープロセスの数は最低 3 必要ということになります。実際には、この値に少し余裕をもたせた数のワーカープロセスを用意しておくのが良いと思います。また、もちろんマルチスレッドの設定にした場合は、プロセスの数を減らすことができます。
目標性能のところで述べた通り、我々の目標は 300ms かかるジョブを 450 job/s のペースで捌き切ることなので、最低で 135 ほどのプロセスが必要ということになります。これに多少の余裕を持たせて 180 のワーカープロセスを用意し、実際に負荷がさばき切れることを確認しました。
まとめ
全世界へ配信を行う複数のモバイルゲームタイトルで利用されている基盤サーバーシステムにおいて、ジョブキューのバックエンドを Q4M / Shinq から Solid Queue へ移行しています。
負荷試験の中で、Solid Queue 内部で意図せず行ロックを取得してしまっている箇所を発見し、これを修正することで大きく性能が改善しました。その結果として、シングルスレッド環境においても 1000 job/s ほどの性能を達成し、さらに “extra claim” パッチを利用することで 2000 job/s ほどの性能が実現できるということを示しました。
また、高負荷な領域においてはワーカープロセスの数を増やしすぎると逆に性能が劣化するということを示し、適切なワーカープロセス数のキャパシティプランニング方法について提案を行いました。
この記事を書いたタイミングでは、まだ移行が完了していないジョブも多くあり、本番環境への反映も完了していません。それらを進めていく中でまた何か学びがあればご共有したいと思います。
この記事が、高負荷な環境において Solid Queue の採用をご検討されている方の参考になれば幸いです。
-
このような設定にしても、Solid Queue 内部でポーリングやハートビートを実行するためのスレッドは起動されてしまいますが、アプリケーションコードはシングルスレッドで動作するので許容としました ↩︎
-
詳解 システム・パフォーマンス 第2版 の 2.6.5 待ち行列理論 より ↩︎
最後まで読んでいただき、ありがとうございます!
この記事をシェアしていただける方はこちらからお願いします。