キャリアの中で、シュレーディンガーの猫の問題、うまくいくこともあればうまくいかないこともある状況に遭遇するでしょう。競合状態はこれらの課題の 1 つです (そうです、1 つだけです!)。
このブログ投稿全体を通じて、実際の例を示し、問題を再現する方法を示し、PostgreSQL でシリアル化可能なトランザクション分離と勧告ロックを使用して競合状態を処理する戦略について説明します。
「データ集約型アプリケーションの設計」、第 7 章 - トランザクション「弱い分離レベル」からインスピレーションを得た
実践例を含む Github リポジトリ
このアプリケーションは、病院の医師のオンコールシフトを管理します。競合状態の問題に焦点を当てるために、シナリオを単純化してみましょう。私たちのアプリは、この単一のテーブルを中心に解決します:
CREATE TABLE shifts ( id SERIAL PRIMARY KEY, doctor_name TEXT NOT NULL, shift_id INTEGER NOT NULL, on_call BOOLEAN NOT NULL DEFAULT FALSE );
重要なビジネス ルールがあります:
ご想像のとおり、単純な API を実装すると、競合状態のシナリオが発生する可能性があります。次の仮定の状況を考えてみましょう:
ジャックとジョンは二人とも同じシフトで病院で当直中です。ほぼ同時に休暇申請を決定する。 1 つは成功しましたが、もう 1 つはシフトに勤務している医師の数に関する古い情報に依存しています。その結果、どちらも最終的にシフトを離れることになり、ビジネス ルールに違反し、待機医師がいない状態で特定のシフトを離れることになります。
John --BEGIN------doctors on call: 2-------leave on call-----COMMIT--------> (t) \ \ \ \ \ \ \ \ Database ------------------------------------------------------------------> (t) / / / / / / / / Jack ------BEGIN------doctors on call: 2-----leave on call----COMMIT-------> (t)
このアプリケーションは、Golang で実装されたシンプルな API です。この競合状態のシナリオを再現するスクリプトの実行方法については、GitHub リポジトリを確認してください。要約すると、次のことを行う必要があります:
テストは 2 人の医師を同時に呼び止めようとします。異なるアプローチでエンドポイントにアクセスします。shiftId=1 は 勧告ロック を使用し、shiftId=2 は シリアル化可能なトランザクション分離、およびshiftId=3は、同時実行制御のない単純な実装です。
k6 の結果は、どのシフト ID がビジネス ルールに違反したかを示すカスタム メトリクスを出力します。
✓ at least one doctor on call for shiftId=1 ✓ at least one doctor on call for shiftId=2 ✗ at least one doctor on call for shiftId=3 ↳ 36% — ✓ 123 / ✗ 217
Yarn、Go、K6、Docker などのツールが必要になります。または、DevBox を使用してリポジトリの依存関係を簡単にセットアップできます。競合状態への対処
シリアル化可能なトランザクションの分離
トランザクション分離の背後にある理論については深く掘り下げませんが、これは多くの一般的なデータベース管理システムで共通のトピックです。トランザクション分離に関する PostgreSQL 公式ドキュメントのこの記事のように、スナップショット分離を検索すると、適切な資料が見つかります。さらに、この解決策を数年前に提案した論文を次に示します。話は簡単なので、コードを見てみましょう:
まず、トランザクションを開始し、分離レベルを Serializable に設定します。
// Init transaction with serializable isolation level tx, err := db.BeginTxx(c.Request().Context(), &sql.TxOptions{ Isolation: sql.LevelSerializable, })その後、操作の実行に進みます。私たちの場合は、次の関数を実行します:
CREATE OR REPLACE FUNCTION update_on_call_status_with_serializable_isolation(shift_id_to_update INT, doctor_name_to_update TEXT, on_call_to_update BOOLEAN) RETURNS VOID AS $$ DECLARE on_call_count INT; BEGIN -- Check the current number of doctors on call for this shift SELECT COUNT(*) INTO on_call_count FROM shifts s WHERE s.shift_id = shift_id_to_update AND s.on_call = TRUE; IF on_call_to_update = FALSE AND on_call_count = 1 THEN RAISE EXCEPTION '[SerializableIsolation] Cannot set on_call to FALSE. At least one doctor must be on call for this shiftId: %', shift_id_to_update; ELSE UPDATE shifts s SET on_call = on_call_to_update WHERE s.shift_id = shift_id_to_update AND s.doctor_name = doctor_name_to_update; END IF; END; $$ LANGUAGE plpgsql;同時実行により矛盾したシナリオが発生した場合、シリアル化可能な分離レベルにより 1 つのトランザクションが成功し、他のトランザクションはこのメッセージとともに自動的にロールバックされるため、安全に再試行できます。
ERROR: could not serialize access due to read/write dependencies among transactions
トランザクション レベルでアドバイザリー ロックを使用して実現できます。このタイプのロックはアプリケーションによって完全に制御されます。詳細については、こちらをご覧ください。
ロックはセッション レベルとトランザクション レベルの両方で適用できることに注意することが重要です。ここで利用可能なさまざまな機能を探索できます。この例では、 pg_try_advisory_xact_lock(key bigint) → boolean を使用します。これにより、コミットまたはロールバック後にロックが自動的に解放されます。
BEGIN; -- Attempt to acquire advisory lock and handle failure with EXCEPTION IF NOT pg_try_advisory_xact_lock(shift_id_to_update) THEN RAISE EXCEPTION '[AdvisoryLock] Could not acquire advisory lock for shift_id: %', shift_id_to_update; END IF; -- Perform necessary operations -- Commit will automatically release the lock COMMIT;アプリケーションで使用される完全な関数は次のとおりです:
-- Function to Manage On Call Status with Advisory Locks, automatic release when the trx commits CREATE OR REPLACE FUNCTION update_on_call_status_with_advisory_lock(shift_id_to_update INT, doctor_name_to_update TEXT, on_call_to_update BOOLEAN) RETURNS VOID AS $$ DECLARE on_call_count INT; BEGIN -- Attempt to acquire advisory lock and handle failure with NOTICE IF NOT pg_try_advisory_xact_lock(shift_id_to_update) THEN RAISE EXCEPTION '[AdvisoryLock] Could not acquire advisory lock for shift_id: %', shift_id_to_update; END IF; -- Check the current number of doctors on call for this shift SELECT COUNT(*) INTO on_call_count FROM shifts s WHERE s.shift_id = shift_id_to_update AND s.on_call = TRUE; IF on_call_to_update = FALSE AND on_call_count = 1 THEN RAISE EXCEPTION '[AdvisoryLock] Cannot set on_call to FALSE. At least one doctor must be on call for this shiftId: %', shift_id_to_update; ELSE UPDATE shifts s SET on_call = on_call_to_update WHERE s.shift_id = shift_id_to_update AND s.doctor_name = doctor_name_to_update; END IF; END; $$ LANGUAGE plpgsql;
Dealing with race conditions, like the write skew scenario we talked about, can be pretty tricky. There's a ton of research and different ways to solve these problems, so definitely check out some papers and articles if you're curious.
These issues can pop up in real-life situations, like when multiple people try to book the same seat at an event or buy the same spot in a theater. They tend to appear randomly and can be hard to figure out, especially if it's your first time dealing with them.
When you run into race conditions, it's important to look into what solution works best for your specific situation. I might do a benchmark in the future to compare different approaches and give you more insights.
I hope this post has been helpful. Remember, there are tools out there to help with these problems, and you're not alone in facing them!
以上が競合状態への対処: 実践的な例の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。