paint-brush
レースへ出発: 競合状態を緩和するための 3 つの効果的な戦略@ahasoftware
402 測定値
402 測定値

レースへ出発: 競合状態を緩和するための 3 つの効果的な戦略

Aha!7m2023/09/22
Read on Terminal Reader

長すぎる; 読むには

競合状態を修正する万能薬はありませんが、多くは適切な戦略で修正できます。 2 つの競合状態カテゴリとそれらを解決する 3 つの方法の例を示します。
featured image - レースへ出発: 競合状態を緩和するための 3 つの効果的な戦略
Aha! HackerNoon profile picture



競合状態とは何ですか?

競合状態の適切な定義を探しましたが、これが私が見つけた最良の定義です。


競合状態は、複数のプロセスが予想とは異なる順序で共有リソースと対話することによって引き起こされる予期しない動作です。


これはかなり長文であり、 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 つの主なカテゴリがあります。

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: "[email protected]").upsert({}, unique_by: :email)


これにより、レコードがデータベースに挿入されます。電子メールに競合がある場合 (電子メールに一意のインデックスが必要となる場合)、挿入は単純に無視されます。

2. 検出と回復

クリティカルセクションを削除できない場合があります。アトミックなアクションがある可能性もありますが、コードが要求するようには完全に機能しません。このような状況では、検出と回復のアプローチを試すことができます。このアプローチでは、競合状態が発生した場合に通知する保護手段が設定されます。操作を正常に中止するか、再試行することができます。


読み取り-変更-書き込みの例では、これはオプティミスティック ロックを使用して実行できます。楽観的ロックは 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の呼び出しなど、多くの操作は冪等ではない可能性があります。

3. コードを保護する

検出して回復できない場合は、コードの保護を試みることができます。ここでの目標は、一度に 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 での私の講演をご覧ください。



著者について

Kyle d’Oliveira

カイル・ドリベイラ


カイルは、抽象的なアイデアを実際に動作するソフトウェアに変えることに情熱を持っています。彼はAha!の主任ソフトウェア エンジニアです。 — 世界ナンバー 1 の製品開発ソフトウェア。カイルは、成長していないときは、カナダのバンクーバーの自宅の近くで素晴らしい食事とクラフトビール醸造所を楽しんでいます。