web-dev-qa-db-ja.com

同時トランザクションの結果、挿入に一意の制約がある競合状態が発生する

ユーザーがリソースを安静に作成できるようにする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つは失敗しました。

私はこれをどのように防御するかを検討し、いくつかの異なるオプションを見つけましたが、いくつかの理由でそれらが私たちのニーズに合っているようには見えず、代替策を見つけるのに苦労しています。

  1. _insert ... on conflict do nothing/update..._最初に見栄えのよい_on conflict_オプションを調べましたが、唯一のオプションは_do nothing_を使用することです。これにより、衝突の原因となったレコードのIDが返されず、_do update_は、実際にはデータが変更されていないときにトリガーが起動されるため、機能しません。これが問題にならない場合もありますが、多くの場合、これはセッションユーザーセッションを無効にする可能性がありますが、これは私たちにできることではありません。
  2. _set transaction isolation level serializable;_これは最も魅力的な回答のようですが、テストスイートでも、上記のように、何かが存在しない場合は挿入し、存在する場合はそれを返す必要がある読み取り/書き込み依存関係を引き起こす可能性があります。さらなる操作。上記のコードを実行する保留中のトランザクションがいくつかある場合、 Postgresドキュメントのtransaction-isoに概要が示されているように、依存関係の読み取り/書き込みエラー が発生します。

この種の同時読み取り/書き込みトランザクションはどのように処理する必要がありますか?

私も私のチームも、Postgresの専門家は言うまでもなく、データベースの専門家であるとは主張していませんが、これは解決された問題であるに違いないと感じています。私たちはどんな提案にもオープンです。上記の情報では不十分な場合はコメントしてください。必要に応じてさらに情報を追加します。

7

最初にinsertを試してください。on conflict ... do nothingおよびreturning idを使用してください。値がすでに存在する場合は、このステートメントから結果が得られないため、selectを実行してIDを取得する必要があります。

2つのトランザクションがこれを同時に実行しようとすると、そのうちの1つがinsertでブロックされ(データベースは他のトランザクションがコミットまたはロールバックするかどうかをまだ認識していないため)、他のトランザクションの後にのみ続行します終わりました。

5
CL.

問題の根本は、デフォルトの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;

詳細な説明:

3

最善の解決策は、挿入を行い、エラーをキャッチして適切に処理することです。エラーを処理する準備ができている場合、シリアル化可能な分離レベルは(明らかに)ケースでは不要です。エラーを処理する準備ができていない場合、シリアライズ可能な分離レベルは役に立ちません。処理する準備ができていないエラーをさらに作成するだけです。

別のオプションは、ON CONFLICT DO NOTHINGを実行し、何も起こらない場合は、現在実行している必要がある値を取得するために、すでに実行しているクエリを実行することです。つまり、select id into articleId from articles where title = 'my new blog';プリエンプティブステップから、ON CONFLICT DO NOTHINGが実際に何もしない場合にのみ実行されるステップへ。レコードを挿入してから再度削除できる場合は、再試行ループでこれを行う必要があります。

3
jjanes