競合状態の適切な定義を探しましたが、これが私が見つけた最良の定義です。
競合状態は、複数のプロセスが予想とは異なる順序で共有リソースと対話することによって引き起こされる予期しない動作です。
これはかなり長文であり、 Railsで競合状態がどのように発生するかはまだあまり明確ではありません。
Rails を使用すると、常に複数のプロセスを操作できます。各リクエストまたはバックグラウンド ジョブは、他のプロセスからほぼ独立して動作できる個別のプロセスです。
また、私たちは常に共有リソースにも取り組んでいます。アプリケーションはリレーショナル データベースを使用していますか?それは共有リソースです。アプリケーションは何らかのキャッシュ サーバーを使用していますか?はい、それは共有リソースです。何らかの外部 API を使用していますか?ご想像のとおり、これは共有リソースです。
競合状態には 2 つのカテゴリの例があり、それらについて説明し、次にそれらに対処する方法について触れたいと思います。
読み取り-変更-書き込みカテゴリは、あるプロセスが共有リソースから値を読み取り、メモリ内の値を変更し、それを共有リソースに書き戻そうとする競合状態の一種です。これを単一プロセスのレンズを通して見ると、非常に簡単に見えます。ただし、2 番目のプロセスが起動すると、予期しない動作が発生する可能性があります。
次のようなコードを考えてみましょう。
class IdeasController < ActionController::Base def vote @idea = Idea.find(params[:id]) @idea.votes += 1 @idea.save! end end
ここでは、読み取り ( Idea.find(params[:id])
)、変更 ( @idea.votes += 1
)、書き込み ( @idea.save!
) を行っています。
これにより、アイデアの投票数が 1 つ増えることがわかります。ゼロ票のアイデアがあった場合、それは 1 票の投票で終了します。ただし、2 番目のリクエストが到着し、まだ投票が 0 である間にデータベースからアイデアを読み取り、メモリ内のその値をインクリメントすると、2 つの投票が同時に到着する状況が発生する可能性がありますが、最終的には投票数が減少します。データベースには 1 つだけあります。
これは、更新喪失競合状態とも呼ばれます。
check-then-act カテゴリは、データが共有リソースからロードされる競合状態の一種で、存在する値に応じて、アクションを実行する必要があるかどうかを判断します。
これがどのように現れるかの典型的な例の 1 つは、次のような Rails のvalidates_uniqueness_of
検証にあります。
class User < ActiveRecord::Base validates_uniqueness_of :email end
次のようなコードを考えてみましょう。
User.create(email: "[email protected]")
検証が行われると、Rails はそのメールを使用する既存のユーザーが存在するかどうかを確認します。他に存在しない場合は、ユーザーをデータベースに永続化することによって機能します。しかし、2 番目のリクエストが同時に同じコードを実行していた場合はどうなるでしょうか?最終的には、両方のリクエストが重複データがあるかどうか (そして重複データは存在しない) を確認するという状況に陥る可能性があります。その場合、両方のリクエストがデータを保存することによって動作し、データベース内に重複したユーザーが作成されます。
競合状態を修正する特効薬はありませんが、特定の問題に対して活用できる戦略はいくつかあります。競合状態を削除するには、次の 3 つの主なカテゴリがあります。
これは問題のあるコードを削除することとみなされる可能性がありますが、競合状態に対して脆弱にならないようにコードをリファクタリングできる場合もあります。また、アトミックな操作を調べることもできます。
アトミック操作とは、他のプロセスが操作を中断できないため、常に単一のユニットとして実行される操作です。
読み取り-変更-書き込みの例では、メモリ内でアイデアの投票を増やす代わりに、データベース内でアイデアの投票を増やすことができます。
@ideas.increment!(:votes)
これにより、次のような SQL が実行されます。
UPDATE "ideas" SET "votes" = COALESCE("votes", 0) + 1 WHERE "ideas"."id" = 123
これを利用すると、同じ競合状態の影響を受けることはありません。
check-then-act の例では、Rails にモデルを検証させる代わりに、upsert を使用してレコードをデータベースに直接挿入できます。
User.where(email: "[email protected]").upsert({}, unique_by: :email)
これにより、レコードがデータベースに挿入されます。電子メールに競合がある場合 (電子メールに一意のインデックスが必要となる場合)、挿入は単純に無視されます。
クリティカルセクションを削除できない場合があります。アトミックなアクションがある可能性もありますが、コードが要求するようには完全に機能しません。このような状況では、検出と回復のアプローチを試すことができます。このアプローチでは、競合状態が発生した場合に通知する保護手段が設定されます。操作を正常に中止するか、再試行することができます。
読み取り-変更-書き込みの例では、これはオプティミスティック ロックを使用して実行できます。楽観的ロックは Rails に組み込まれており、複数のプロセスが同じレコードに対して同時に動作していることを検出できます。オプティミスティック ロックを有効にするには、テーブルにlock_version
列を追加するだけで、Rails が自動的にロックを有効にします。
change_table :ideas do |t| t.integer :lock_version, default: 0 end
その後、レコードを更新しようとすると、Rails はlock_version
メモリ内にあったバージョンと同じである場合にのみレコードを更新します。そうでない場合は、 ActiveRecord::StaleObjectError
例外が発生しますが、これを処理するためにレスキューできます。これを処理するには、 retry
ことも、単にエラー メッセージをユーザーに報告することもできます。
def vote @idea = Idea.find(params[:id]) @idea.votes += 1 @idea.save! rescue ActiveRecord::StaleObjectError retry end
check-then-act の例では、列の一意のインデックスを使用してこれを実行し、データを永続化するときに例外をレスキューできます。
add_index :users, [:email], unique: true
一意のインデックスが設定されている場合、そのemail
データがデータベースにすでに存在する場合、Rails はActiveRecord::RecordNotUnique
エラーを生成しますが、これはレスキューして適切に処理できます。
begin user = User.create(email: "[email protected]") rescue ActiveRecord::RecordNotUnique user = User.find_by(email: "[email protected]") end
アクションを再試行するには、操作全体がべき等であることが重要です。これは、操作が複数回実行された場合、結果は 1 回だけ適用された場合と同じであることを意味します。
たとえば、ジョブが電子メールを送信し、アイデアの投票が変更されるたびに電子メールが実行される場合を想像してください。再試行のたびにメールが送信されるのは非常に問題です。操作をべき等にするために、投票操作全体が完了するまで電子メールの送信を保留することができます。あるいは、前回の送信時から投票が変更された場合にのみ電子メールを送信するように、電子メールを送信するプロセスの実装を更新することもできます。競合状態が発生して再試行する必要がある場合、最初の電子メール送信試行は何も行われない可能性があるため、再度トリガーしても安全です。
バックグラウンド ジョブのキューへの登録、電子メールの送信、サードパーティAPIの呼び出しなど、多くの操作は冪等ではない可能性があります。
検出して回復できない場合は、コードの保護を試みることができます。ここでの目標は、一度に 1 つのプロセスのみが共有リソースにアクセスできるコントラクトを作成することです。事実上、同時実行性が削除されます。共有リソースにアクセスできるのは 1 つのプロセスだけであるため、ほとんどの競合状態を回避できます。ただし、同時実行性が削除されるほど、他のプロセスがアクセスが許可されるまで待機するため、アプリケーションの速度が遅くなる可能性があります。
これは、Rails に組み込まれている悲観的ロックを使用して処理できます。悲観的ロックを使用するには、構築中のクエリにlock
を追加すると、Rails はデータベースにそれらのレコードの行ロックを保持するように指示します。データベースは、ロックが完了するまで他のプロセスがロックを取得できないようにします。データベースがロックを解放するタイミングを認識できるように、必ずコードをtransaction
内にラップしてください。
Idea.transaction do @idea = Idea.lock.find(params[:id]) @idea.votes += 1 @idea.save! end
行レベルのロックが不可能な場合は、Redlock や with_advisory_lock などの他のツールを使用できます。これらにより、コードの任意のブロックをロックできるようになります。これを使用すると、次のように簡単になります。
email = "[email protected]" User.with_advisory_lock("user_uniqueness_#{email}"} do User.find_or_create_by(email: email) end
これらの戦略により、プロセスはロックが取得されるまで待機します。そのため、プロセスが永久に待機することを防ぐために、何らかの形式のタイムアウトも必要になります。また、タイムアウトが発生した場合の対処方法も必要になります。
競合状態を修正する万能薬はありませんが、多くの競合状態はこれらの戦略によって修正できます。ただし、それぞれの問題は少し異なるため、解決策の詳細は異なる場合があります。競合状態について詳しく説明したRailsConf 2023 での私の講演をご覧ください。
カイル・ドリベイラ
カイルは、抽象的なアイデアを実際に動作するソフトウェアに変えることに情熱を持っています。彼はAha!の主任ソフトウェア エンジニアです。 — 世界ナンバー 1 の製品開発ソフトウェア。カイルは、成長していないときは、カナダのバンクーバーの自宅の近くで素晴らしい食事とクラフトビール醸造所を楽しんでいます。