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