Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: Solid Queueで重要なUPDATE SKIP LOCKEDを理解する(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

参考: 週刊Railsウォッチ20240117: Solid Queue: 37signalsによるActive Job向けDBベースのジョブバックエンド

Rails: Solid Queueで重要なUPDATE SKIP LOCKEDを理解する(翻訳)

🔗 Solid Queueについて

最近になって、37signalsがSolid Queueをオープンソースとして公開しました(関連記事)。

basecamp/solid_queue - GitHub

Solid QueueはActive Jobで利用できるクエリバックエンドであり、データベース上に構築されます(これと対照的に、SidekiqやResqueはRedis上に構築されるクエリバックエンドです)。

同記事でRosa Gutiérrezが言及していた以下の点が私たちの注目を集めました。

私たちにとって、PostgreSQLでかなり前から利用可能だったある機能が、ついにMySQL 8にも導入されたことが、この実装のために重要でした。

それがSELECT ... FOR UPDATE SKIP LOCKEDなのです。

これによって、Solid Queueのワーカーが他のワーカーをロックせずにジョブをフェッチおよびロック可能になります。

Rosaによると、この機能はPostgreSQLには前から存在していて、MySQLにもこの機能が導入されたことでSolid Queueを構築可能になったそうです。

私たちはPostgreSQLとMySQLのUPDATE SKIP LOCKEDについてまったく初耳で、Solid Queueの実現を可能にしたこの機能かどういうものかがわからなかったので、詳しく調べることにしました。

🔗 キューから受け取ったジョブの処理

多数のジョブをバックグラウンドで処理しなければならないシステムを構築する必要がある場合を考えてみましょう。

このシステムにはジョブを待ち構えるワーカーがたくさん存在し、ジョブが利用可能になったらただちに処理を開始します。ここで課題となるのは、複数のワーカーが同時に同じジョブの処理を引き受けようとしたときに、そのジョブの処理を引き受けるワーカーが常に1個になるよう保証する方法です。いかなる時点においても、ワーカーは「引き受け前」のジョブだけを要求すべきであり、「引き受け前」のジョブを引き受けるワーカーは常に1個にとどめるべきです。

以下のような実装があるとします。

START TRANSACTION
SELECT * FROM JOBS WHERE processed='no' LIMIT 1;
-- ジョブを処理する
COMMIT;

しかし上のコードでは、2つのワーカーが同じジョブの処理を引き受ける可能性があります。

この問題を解決する方法の1つは、特定の行をFOR UPDATEでロックすることです。

START TRANSACTION;
SELECT * FROM JOBS WHERE processed='no' FOR UPDATE LIMIT 1;
-- ジョブを処理する
COMMIT

SELECT ... FOR UPDATEは特定の行だけをロックするので、そのレコードは他のどこからもロックできなくなります。

新しいジョブが1件やってくると、複数のワーカーが上のクエリを実行して、そのレコードのロックを取得しようとします。データベースは、1つのワーカーだけがロックを取得するようにします。

最初のワーカーがFOR UPDATEでレコードをロックしたとします。他のワーカーは、そのレコードにアクセスしてFOR UPDATEでロックされていることを知ると、ロックが解除されるまで待機します。はい、他のワーカーはロックが解除されるまで待つしかありません。

このロックはトランザクションがコミットされるまで解除されません。トランザクションをコミットしてロックが解除されると、他のワーカーはレコードを取得し、ジョブが処理済みであることを知ります。もうおわかりのように、この処理はきわめて非効率です。

ここでFOR UPDATE SKIP LOCKEDの出番です。

🔗 ロックされた行をSKIP LOCKEDでスキップする

START TRANSACTION;
SELECT * FROM jobs_table FOR UPDATE SKIP LOCKED LIMIT 1;
-- ジョブを処理する
COMMIT;

ここでも同じシナリオを考えてみましょう。ジョブがやってくると、多くのワーカーがジョブを引き受けようと奪い合いになります。データベースは1個のワーカーだけがロックを取得できるようにしますが、他のワーカーはロックの解除を待たずに次のレコードに進みます。これがSKIP LOCKEDの役割です。

SKIP LOCKEDのしくみに興味のある方は、MySQLのドキュメントにある詳しい説明をどうぞ。

Solid Queueでは、このFOR UPDATE SKIP LOCKED機能を用いて、ジョブを引き受けるワーカーが常に1個になるようにしています。

🔗 GoodJobがSKIP LOCKEDを使わずにジョブを管理するしくみ

bensheldon/good_job - GitHub

GoodJobが登場したのは2020年7月頃でした。GoodJobsがサポートするデータベースはPostgreSQLだけですが、その理由は同一ジョブを2つのワーカーが引き受けないことを保証するために勧告的ロック(advisory lock: アドバイザリロック)を利用しているためです。

PostgreSQL方面では、PostgreSQLが提供するこのロックメカニズムが、アプリケーションで発生する可能性のあるケースを満たせない場合があることが理解されています。勧告的ロックは、アプリケーションでさまざまなセッション間やトランザクション間の操作を調整するための通信チャネルを確立できるようにするメカニズムです。データベースが強制する通常の行レベルロックと異なり、勧告的ロックはアプリケーションの要求に応じてロックの獲得と解除を行うのに利用できる低レベル関数のセットとして実装されます。詳しくは以下をどうぞ。

参考: 13.3.5. 勧告的ロック -- PostgreSQL 15ドキュメント

pg_advisory_lock関数は指定のリソースをロックしますが、別のセッションが同じリソースを既にロック済みの場合はロック解除を待ちます。これは、上述のFOR UPDATEのケースと似ています。

ただし、pg_try_advisory_lock関数はロックをただちに取得できた場合はtrueを返し、ロックをただちに取得できなかった場合はfalseを返します。この関数は名前の通りロックの取得を試行しますが、ロックを取得できなかった場合は待機しません。この機能をキューイングシステムの構築に利用できます。

勧告的ロックを使う場合、操作の調整をアプリケーションが行わなければならなくなります。アプリケーションの能力が向上する代わりに、アプリケーションの作業量も増えます。これはPostgreSQLでネイティブサポートされているFOR UPDATE SKIP RECORDと対照的です。

参考: Investigating performance issues with good_job · Issue #896 · bensheldon/good_job
参考: Proposal: convert from session-based Advisory Locks to locked_at/locked_by columns · bensheldon/good_job · Discussion #831

GoodJobでは、上の議論に基づいてパフォーマンス向上のために勧告的ロックからFOR UPDATE SKIP LOCKEDに移行する可能性を評価中のようです。これらのissueを通じてさまざまな点が明らかになり、これまで知らなかった多くのことを学べました。

🔗 DelayedJobの実装

collectiveidea/delayed_job - GitHub

DelayedJobはSidekiqよりずっと歴史が長く、2009年から存在しています。DelayedJobではSKIP LOCKを使わず、代わりにジョブ処理中を示すジョブレコード内フィールドを更新する形で行レベルロックシステムを利用しています(719b628)。要するに、DelayedJobはデータベース機能の助けを一切借りずに、2個のワーカーがアプリケーションレベルで同じジョブを引き受けることのないよう保証しています。

🔗 SQLiteではどうか

ここまではPostregSQLとMySQLについての説明でしたが、SQLiteではどうでしょうか?

SQLiteはSKIP LOCKをサポートしていませんが、大丈夫です。SQLiteドキュメントによると、同時に1個だけ存在できるライター(writer)をサポートしています。

高コンカレンシー
SQLiteで同時に利用できるリーダー(reader)数には上限がありませんが、ライターは同時に1個のみが存在を許されます。ライターはキューイングされるので、これは多くの状況で問題ありません。個別のアプリケーションはデータベース処理を迅速に実行して次に進み、ロックが十数ミリ秒以上持続することはありません。ただし高いコンカレンシーを要求するアプリケーションでは、別のソリューションを探す必要があるかもしれません。

🔗 NOWAIT

完全を期すため、NOWAIT機能についても説明します。FOR UPDATEで行ロックを取得すると、他のワーカーがロック解除まで待機することについては既に見てきました。

START TRANSACTION;
SELECT * FROM JOBS WHERE processed='no' FOR UPDATE NOWAIT LIMIT 1;
-- ジョブを処理する
COMMIT

NOWAIT機能を使うと、他のトランザクションがロック解除を待たずに済むようになります。NOWAITでトランザクションが指定行のロックを取得できなかった場合はエラーが発生するため、アプリケーションはこのエラーを処理する必要があります。

SKIP LOCKEDはそれと対照的に、既に行ロックが取得済みの場合はトランザクションが次の行に進みます。

🔗 Redisバックエンドとデータベースバックエンドの比較

FOR UPDATE SKIP LOCKがデータベース上にキューイングシステムを構築するうえでどのように役立つかについて見てきたので、今度はキューイングシステムの種類ごとにメリットとデメリットを見ていくことにしましょう。

🔗 シンプルで親しみやすい

特にアプリケーションで既にリレーショナルデータベースが使われているのであれば、データベースバックエンドのキューイングシステムの方が多くの場合セットアップも管理もシンプルです。Redisなどの依存関係を追加する必要もありません。

🔗 追加インフラが不要

データベースバックエンドのキューイングシステムは、ジョブ情報をアプリケーションデータと同じデータベースに保存するので、Redisサーバーなどのインフラを別途セットアップしてメンテする必要がありません。

🔗 トランザクションが使える

データベースバックエンドのキューイングシステムでは、データベーストランザクションを活用することで、ジョブの作成などのデータベース操作をコミット/ロールバックできるようになります。データ一貫性が重要なシナリオでは、この特性が大事になってくる場合があります。

🔗 ジョブの書き換えはどちらも要注意

データベースに保存したジョブは、Redisに保存したジョブよりも変更が容易ですが、そもそもジョブの変更は注意が必要であり、一般には推奨されません。Redisで保存されるデータはシリアライズされていることが多いので、直接変更することは簡単ではなく、一般的でもありません。Redisはデータ操作用コマンドも提供していますが、ジョブデータの直接変更は標準から外れており、データが破損する可能性もあります。

関連記事

Rails: SidekiqはActive Jobを経由せずに直接使おう(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。