web-dev-qa-db-ja.com

関数内のSELECTまたはINSERTは、競合状態になりやすいですか?

簡単なブログエンジンの投稿を作成する関数を作成しました。

CREATE FUNCTION CreatePost(VARCHAR, TEXT, VARCHAR[])
RETURNS INTEGER AS $$
    DECLARE
        InsertedPostId INTEGER;
        TagName VARCHAR;
    BEGIN
        INSERT INTO Posts (Title, Body)
        VALUES ($1, $2)
        RETURNING Id INTO InsertedPostId;

        FOREACH TagName IN ARRAY $3 LOOP
            DECLARE
                InsertedTagId INTEGER;
            BEGIN
                -- I am concerned about this part.
                BEGIN
                    INSERT INTO Tags (Name)
                    VALUES (TagName)
                    RETURNING Id INTO InsertedTagId;
                EXCEPTION WHEN UNIQUE_VIOLATION THEN
                    SELECT INTO InsertedTagId Id
                    FROM Tags
                    WHERE Name = TagName
                    FETCH FIRST ROW ONLY;
                END;

                INSERT INTO Taggings (PostId, TagId)
                VALUES (InsertedPostId, InsertedTagId);
            END;
        END LOOP;

        RETURN InsertedPostId;
    END;
$$ LANGUAGE 'plpgsql';

複数のユーザーがタグを削除し、同時に投稿を作成すると、競合状態が発生しやすくなりますか?
具体的には、トランザクション(したがって関数)はそのような競合状態の発生を防ぎますか?
PostgreSQL9.2.3を使用しています。

27
user142019

これは、SELECTまたはINSERTの繰り返し発生する問題であり、同時書き込み負荷の可能性があり、UPSERTINSERTまたはUPDATE)。

Postgres9.5以降の場合

新しい PSERT実装_INSERT ... ON CONFLICT .. DO UPDATE_ を使用すると、大幅に簡略化できます。 PL/pgSQL関数からINSERTまたはSELECTasingle行(タグ):

_CREATE OR REPLACE FUNCTION f_tag_id(_tag text, OUT _tag_id int) AS
$func$
BEGIN
   SELECT tag_id  -- only if row existed before
   FROM   tag
   WHERE  tag = _tag
   INTO   _tag_id;

   IF NOT FOUND THEN
      INSERT INTO tag AS t (tag)
      VALUES (_tag)
      ON     CONFLICT (tag) DO NOTHING
      RETURNING t.tag_id
      INTO   _tag_id;
   END IF;
END
$func$ LANGUAGE plpgsql;
_

競合状態の小さなウィンドウがまだあります。 絶対に確実を作成するには、IDを取得します。

_CREATE OR REPLACE FUNCTION f_tag_id(_tag text, OUT _tag_id int) AS
$func$
BEGIN
LOOP
   SELECT tag_id
   FROM   tag
   WHERE  tag = _tag
   INTO   _tag_id;

   EXIT WHEN FOUND;

   INSERT INTO tag AS t (tag)
   VALUES (_tag)
   ON     CONFLICT (tag) DO NOTHING
   RETURNING t.tag_id
   INTO   _tag_id;

   EXIT WHEN FOUND;
END LOOP;
END
$func$ LANGUAGE plpgsql;
_

これは、INSERTまたはSELECTのいずれかが成功するまでループを続けます。コール:

_SELECT f_tag_id('possibly_new_tag');
_

後続のコマンド同じトランザクション内が行の存在に依存していて、他のトランザクションが同時にそれを更新または削除する可能性がある場合は、SELECTステートメントの既存の行を _FOR SHARE_
代わりに行が挿入された場合、とにかくトランザクションが終了するまで行はロックされます。

ほとんどの場合、新しい行が挿入される場合は、INSERTから始めて高速化します。

関連:

INSERTまたはSELECT複数行(セット)に一度に関連する(純粋なSQL)ソリューション:

this純粋なSQLソリューションの何が問題になっていますか?

以前、このSQL関数も提案しました。

_CREATE OR REPLACE FUNCTION f_tag_id(_tag text, OUT _tag_id int) AS
$func$
   WITH ins AS (
      INSERT INTO tag AS t (tag)
      VALUES (_tag)
      ON     CONFLICT (tag) DO NOTHING
      RETURNING t.tag_id
      )
   SELECT tag_id FROM ins
   UNION  ALL
   SELECT tag_id FROM tag WHERE tag = _tag
   LIMIT  1
$func$ LANGUAGE sql;
_

これは完全に間違っているわけではありませんが、 @ FunctorSaladは彼の追加された回答で解決しました のように抜け穴を塞ぐことができません。並行トランザクションが同じことを同時に行おうとすると、関数は空の結果を出す可能性があります。 CTEを使用したクエリ内のすべてのステートメントは、仮想的に同時に実行されます。 マニュアル:

すべてのステートメントは同じスナップショットで実行されます

並行トランザクションが同じ新しいタグを少し前に挿入したが、まだコミットしていない場合:

  • 並行トランザクションが終了するのを待った後、UPSERT部分は空になります。 (同時トランザクションがロールバックする必要がある場合でも、新しいタグが挿入され、新しいIDが返されます。)

  • SELECT部分​​も空になります。これは、同じスナップショットに基づいており、(まだコミットされていない)同時トランザクションからの新しいタグが表示されないためです。

nothingを取得します。意図したとおりではありません。これは単純なロジックとは直観に反します(そして私はそこで捕まりました)が、PostgresのMVCCモデルが機能する方法です-機能する必要があります。

したがって、複数のトランザクションが同じタグを同時に挿入しようとする可能性がある場合は、これを使用しないでください。 または実際に行を取得するまでループします。一般的な作業負荷では、ループがトリガーされることはほとんどありません。

元の回答(Postgres 9.4以前)

この(少し簡略化された)テーブルを考えると:

_CREATE table tag (
  tag_id serial PRIMARY KEY
, tag    text   UNIQUE
);
_

...新しいタグを挿入/既存のタグを選択するための実質的に100%安全な関数は次のようになります。
100%ではないのはなぜですか? 関連するUPSERTの例に関するマニュアルの注記 を検討してください。

_CREATE OR REPLACE FUNCTION f_tag_id(_tag text, OUT tag_id int) AS
$func$
BEGIN

LOOP
   BEGIN

   WITH sel AS (SELECT t.tag_id FROM tag t WHERE t.tag = _tag FOR SHARE)
      , ins AS (INSERT INTO tag(tag)
                SELECT _tag
                WHERE  NOT EXISTS (SELECT 1 FROM sel)  -- only if not found
                RETURNING tag.tag_id)  -- qualified so no conflict with param
   SELECT sel.tag_id FROM sel
   UNION  ALL
   SELECT ins.tag_id FROM ins
   INTO   tag_id;

   EXCEPTION WHEN UNIQUE_VIOLATION THEN     -- insert in concurrent session?
      RAISE NOTICE 'It actually happened!'; -- hardly ever happens
   END;

   EXIT WHEN tag_id IS NOT NULL;            -- else keep looping
END LOOP;

END
$func$ LANGUAGE plpgsql;
_

SQLフィドル。

説明

  • SELECTfirstを試してください。このようにして、かなり高価例外処理を99.99%の確率で回避します。

  • [〜#〜] cte [〜#〜] を使用して、競合状態の(すでに小さい)タイムスロットを最小化します。

  • SELECTINSERTの間の時間枠1つのクエリ内は非常に小さいです。同時負荷が大きくない場合、または年に1回例外が発生する可能性がある場合は、ケースを無視してSQLステートメントを使用できます。これはより高速です。

  • _FETCH FIRST ROW ONLY_(= _LIMIT 1_)は必要ありません。タグ名は明らかにUNIQUEです。

  • テーブルDELETEに通常UPDATEまたはtagが同時に存在しない場合は、私の例では _FOR SHARE_ を削除します。わずかなパフォーマンスのコストがかかります。

  • 言語名は絶対に引用しないでください。 'plpgsql'plpgsql識別子です。 引用は問題を引き起こす可能性があります そして下位互換性のためにのみ許容されます。

  • idnameなどのわかりにくい列名は使用しないでください。いくつかのテーブルを結合すると(リレーショナルDBでは)、複数の同一の名前になり、エイリアスを使用する必要があります。

関数に組み込まれています

この関数を使用すると、_FOREACH LOOP_を次のように大幅に簡略化できます。

_...
FOREACH TagName IN ARRAY $3
LOOP
   INSERT INTO taggings (PostId, TagId)
   VALUES   (InsertedPostId, f_tag_id(TagName));
END LOOP;
...
_

ただし、 unnest() を使用した単一のSQLステートメントとしてより高速です。

_INSERT INTO taggings (PostId, TagId)
SELECT InsertedPostId, f_tag_id(tag)
FROM   unnest($3) tag;
_

ループ全体を置き換えます。

代替ソリューション

このバリアントは、_UNION ALL_の動作にLIMIT句を付けて構築されています。十分な行が見つかるとすぐに、残りは実行されません。

これに基づいて、INSERTを別の関数にアウトソーシングできます。そこでのみ、例外処理が必要です。最初の解決策と同じくらい安全です。

_CREATE OR REPLACE FUNCTION f_insert_tag(_tag text, OUT tag_id int)
  RETURNS int AS
$func$
BEGIN
INSERT INTO tag(tag) VALUES (_tag) RETURNING tag.tag_id INTO tag_id;

EXCEPTION WHEN UNIQUE_VIOLATION THEN  -- catch exception, NULL is returned
END
$func$ LANGUAGE plpgsql;
_

これは主な機能で使用されます:

_CREATE OR REPLACE FUNCTION f_tag_id(_tag text, OUT _tag_id int) AS
$func$
BEGIN
   LOOP
      SELECT tag_id FROM tag WHERE tag = _tag
      UNION  ALL
      SELECT f_insert_tag(_tag)  -- only executed if tag not found
      LIMIT  1  -- not strictly necessary, just to be clear
      INTO   _tag_id;

      EXIT WHEN _tag_id IS NOT NULL;  -- else keep looping
   END LOOP;
END
$func$ LANGUAGE plpgsql;
_
  • SELECT句を含むINSERTを含むより高価なブロックがめったに入力されないため、ほとんどの呼び出しでEXCEPTIONのみが必要な場合、これは少し安価です。クエリも簡単です。

  • _FOR SHARE_はここでは使用できません(UNIONクエリでは使用できません)。

  • _LIMIT 1_は必要ありません(9.4ページでテスト済み)。 Postgresは_LIMIT 1_から_INTO _tag_id_を導出し、最初の行が見つかるまでのみ実行されます。

39

Postgres9.5で導入されたON CONFLICT句を使用する場合でも、注意が必要なことがあります。次の場合、@ ErwinBrandstetterの回答と同じ関数とサンプルテーブルを使用します。

Session 1: begin;

Session 2: begin;

Session 1: select f_tag_id('a');
 f_tag_id 
----------
       11
(1 row)

Session 2: select f_tag_id('a');
[Session 2 blocks]

Session 1: commit;

[Session 2 returns:]
 f_tag_id 
----------
        NULL
(1 row)

したがって、f_tag_idはセッション2でNULLを返しました。これは、シングルスレッドの世界では不可能です。

トランザクション分離レベルをrepeatable read(またはより強力なserializable)に上げると、セッション2は代わりにERROR: could not serialize access due to concurrent updateをスローします。したがって、少なくとも「不可能な」結果は発生しませんが、残念ながら、トランザクションを再試行する準備をする必要があります。

編集:repeatable readまたはserializableを使用して、セッション1がタグaを挿入し、セッション2がbを挿入し、セッション1がbを挿入しようとし、セッション2がaを挿入しようとすると、1つのセッションがデッドロックを検出します。

ERROR:  deadlock detected
DETAIL:  Process 14377 waits for ShareLock on transaction 1795501; blocked by process 14363.
Process 14363 waits for ShareLock on transaction 1795503; blocked by process 14377.
HINT:  See server log for query details.
CONTEXT:  while inserting index Tuple (0,3) in relation "tag"
SQL function "f_tag_id" statement 1

デッドロックエラーを受け取ったセッションがロールバックした後、他のセッションは続行されます。したがって、このような状況では、デッドロックをserialization_failureのように扱い、再試行する必要があると思いますか?

または、タグを一貫した順序で挿入しますが、すべてが1つの場所に追加されない場合、これは簡単ではありません。

2
FunctorSalad