ユーザーがリソースを安静に作成できるようにするWebサービス(http api)があります。認証と検証の後で、データをPostgres関数に渡し、認証をチェックしてデータベースにレコードを作成できるようにします。
今日、同じ秒内に2つのhttpリクエストが行われ、この関数が同じデータで2回呼び出されると、バグが見つかりました。関数内に、値が存在するかどうかを確認するためにテーブルを選択する句があります。存在する場合はIDを取得し、次の操作でそれを使用します。存在しない場合は、データを挿入します。 IDを戻し、次の操作でそれを使用します。以下は簡単な例です。
_select id into articleId from articles where title = 'my new blog';
if articleId is null then
insert into articles (title, content) values (_title, _content)
returning id into articleId;
end if;
-- Continue, using articleId to represent the article for next operations...
_
おそらくご想像のとおり、両方のトランザクションが_if articleId is null then
_ブロックに入り、テーブルに挿入しようとしたデータについて、幻像が読み取られました。フィールドに対する一意の制約のため、1つは成功し、もう1つは失敗しました。
私はこれをどのように防御するかを検討し、いくつかの異なるオプションを見つけましたが、いくつかの理由でそれらが私たちのニーズに合っているようには見えず、代替策を見つけるのに苦労しています。
insert ... on conflict do nothing/update...
_最初に見栄えのよい_on conflict
_オプションを調べましたが、唯一のオプションは_do nothing
_を使用することです。これにより、衝突の原因となったレコードのIDが返されず、_do update
_は、実際にはデータが変更されていないときにトリガーが起動されるため、機能しません。これが問題にならない場合もありますが、多くの場合、これはセッションユーザーセッションを無効にする可能性がありますが、これは私たちにできることではありません。set transaction isolation level serializable;
_これは最も魅力的な回答のようですが、テストスイートでも、上記のように、何かが存在しない場合は挿入し、存在する場合はそれを返す必要がある読み取り/書き込み依存関係を引き起こす可能性があります。さらなる操作。上記のコードを実行する保留中のトランザクションがいくつかある場合、 Postgresドキュメントのtransaction-isoに概要が示されているように、依存関係の読み取り/書き込みエラー が発生します。この種の同時読み取り/書き込みトランザクションはどのように処理する必要がありますか?
私も私のチームも、Postgresの専門家は言うまでもなく、データベースの専門家であるとは主張していませんが、これは解決された問題であるに違いないと感じています。私たちはどんな提案にもオープンです。上記の情報では不十分な場合はコメントしてください。必要に応じてさらに情報を追加します。
最初にinsert
を試してください。on conflict ... do nothing
およびreturning id
を使用してください。値がすでに存在する場合は、このステートメントから結果が得られないため、select
を実行してIDを取得する必要があります。
2つのトランザクションがこれを同時に実行しようとすると、そのうちの1つがinsert
でブロックされ(データベースは他のトランザクションがコミットまたはロールバックするかどうかをまだ認識していないため)、他のトランザクションの後にのみ続行します終わりました。
問題の根本は、デフォルトのREAD COMMITTED
分離レベルでは、各同時UPSERT(またはさらに言えばクエリ)は、クエリの開始時に表示されていた行のみを表示できることです。 マニュアル:
トランザクションがこの分離レベルを使用する場合、
SELECT
クエリ(FOR UPDATE
/SHARE
句なし)は、クエリの開始前にコミットされたデータのみを認識します。コミットされていないデータや、同時トランザクションによるクエリ実行中にコミットされた変更は表示されません。
しかし、UNIQUE
インデックスはabsoluteであり、同時に入力された行を考慮しなければなりません。したがって、一意の違反の例外を取得できますが、それでもsee競合する行同じクエリ内はできません。 マニュアル:
ON CONFLICT DO NOTHING
句を指定したINSERT
は、その影響がINSERT
スナップショットに表示されない別のトランザクションの結果が原因で、行の挿入を続行できない場合があります。繰り返しますが、これはコミット読み取りモードの場合にのみ当てはまります。
この問題に対するブルートフォースの「解決策」は、競合する行をON CONFLICT ... DO UPDATE
で上書きすることです。その後、新しい行バージョンが同じクエリ内で表示されます。しかし、いくつかの副作用があり、私はそれに対して助言します。それらの1つは、UPDATE
トリガーが起動されることです-明示的に避けたいものです。 SOの密接に関連する回答:
残りのオプションは、新しいコマンドを(同じトランザクションで)開始することです。これにより、前のクエリからこれらの競合する行をseeできます。既存の回答はどちらも同じくらい示唆しています。 再びマニュアル:
ただし、
SELECT
は、まだコミットされていなくても、自身のトランザクション内で実行された以前の更新の影響を確認します。また、最初のSELECT
が開始してから2番目のSELECT
が開始するまでの間に他のトランザクションが変更をコミットすると、2つの連続するSELECT
コマンドが単一のトランザクション内であっても、異なるデータを表示できることに注意してください。
しかし、もっと欲しい:
-続いて、articleIdを使用して次の操作の記事を表します...
並行書き込み操作で行を変更または削除できる場合は、確実に、lock選択行。 (挿入行はとにかくロックされています。)
そして、あなたは非常に競争の激しい取引をしているように見えるので、確実に成功するために、loop成功するまで。 plpgsql関数にラップされます。
CREATE OR REPLACE FUNCTION f_articleid(_title text, _content text, OUT _articleid int) AS
$func$
BEGIN
LOOP
SELECT articleid
FROM articles
WHERE title = _title
FOR UPDATE -- or maybe a weaker lock
INTO _articleid;
EXIT WHEN FOUND;
INSERT INTO articles AS a (title, content)
VALUES (_title, _content)
ON CONFLICT (title) DO NOTHING -- (new?) _content is discarded
RETURNING a.articleid
INTO _articleid;
EXIT WHEN FOUND;
END LOOP;
END
$func$ LANGUAGE plpgsql;
詳細な説明:
最善の解決策は、挿入を行い、エラーをキャッチして適切に処理することです。エラーを処理する準備ができている場合、シリアル化可能な分離レベルは(明らかに)ケースでは不要です。エラーを処理する準備ができていない場合、シリアライズ可能な分離レベルは役に立ちません。処理する準備ができていないエラーをさらに作成するだけです。
別のオプションは、ON CONFLICT DO NOTHINGを実行し、何も起こらない場合は、現在実行している必要がある値を取得するために、すでに実行しているクエリを実行することです。つまり、select id into articleId from articles where title = 'my new blog';
プリエンプティブステップから、ON CONFLICT DO NOTHINGが実際に何もしない場合にのみ実行されるステップへ。レコードを挿入してから再度削除できる場合は、再試行ループでこれを行う必要があります。