31

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

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';

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

4

3 に答える 3

54

これは、 (またはとは異なる)に関連する(ただし異なる)同時書き込み負荷の可能性がある、SELECTまたはそのINSERT下で繰り返し発生する問題です。UPSERTINSERTUPDATE

この PL/pgSQL 関数はUPSERT ( INSERT ... ON CONFLICT .. DO UPDATE)INSERT単一SELECTに使用します:

CREATE OR REPLACE FUNCTION f_tag_id(_tag text, OUT _tag_id int)
  LANGUAGE plpgsql 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$;

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

CREATE OR REPLACE FUNCTION f_tag_id(_tag text, OUT _tag_id int)
  LANGUAGE plpgsql 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$;

デシベル<>ここでフィドル

INSERTこれは、またはSELECTが成功するまでループし続けます。電話:

SELECT f_tag_id('possibly_new_tag');

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

一般的なケース ( INSERTvs SELECT) から始めて、高速化します。

関連している:

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

この純粋な SQL ソリューションの何が問題なのですか?

CREATE OR REPLACE FUNCTION f_tag_id(_tag text, OUT _tag_id int)
  LANGUAGE sql 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$;

完全に間違っているわけではありませんが、@FunctorSalad がうまくいったように、抜け穴を塞ぐことはできません。並行トランザクションが同時に同じことを行おうとすると、関数は空の結果を出す可能性があります。マニュアル:

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

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

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

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

何も得られません。意図したとおりではありません。これは素朴なロジックとは直感に反するものですが (そして私はそこに引っ掛かりました)、Postgres の MVCC モデルはこのように機能し、機能する必要があります。

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

Postgres 9.4 以前

この(少し簡略化された)表を考えると:

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

新しいタグを挿入/既存のタグを選択するためのほぼ 100% 安全な機能は、次のようになります。

CREATE OR REPLACE FUNCTION f_tag_id(_tag text, OUT tag_id int)
  LANGUAGE plpgsql 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$;

ここでdb<>fiddle
古いsqlfiddle

なぜ100%ではないのですか? UPSERT関連する例については、マニュアルのメモを検討してください。

説明

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

  • CTEを使用して、競合状態の (すでに小さい) タイムスロットを最小限に抑えます。

  • 1 つのクエリ内SELECTのと の間の時間枠は非常に小さいです。同時負荷が大きくない場合、または年に 1 回の例外に耐えることができる場合は、大文字と小文字を区別せずに SQL ステートメントを使用できます。これはより高速です。INSERT

  • FETCH FIRST ROW ONLY(= )は必要ありませんLIMIT 1。タグ名はもちろんUNIQUE.

  • 通常、同時またはテーブル上にFOR SHAREない場合は、私の例では削除してください。わずかなパフォーマンスのコストがかかります。DELETEUPDATEtag

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

  • idやのような説明的でない列名は使用しないでくださいname。いくつかのテーブルを結合すると (これはリレーショナル DB で行うことです)、複数の同一の名前になり、エイリアスを使用する必要があります。

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

この関数を使用すると、次のように大幅に簡素化できますFOREACH LOOP

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

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

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

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

代替ソリューション

このバリアントはUNION ALLwithLIMIT句の動作に基づいています: 十分な行が見つかるとすぐに、残りは決して実行されません:

INSERTこれに基づいて、別の機能にアウトソーシングできます。そこでだけ例外処理が必要です。最初のソリューションと同じくらい安全です。

CREATE OR REPLACE FUNCTION f_insert_tag(_tag text, OUT tag_id int)
  RETURNS int
  LANGUAGE plpgsql 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$;

メイン関数で使用されるもの:

CREATE OR REPLACE FUNCTION f_tag_id(_tag text, OUT _tag_id int)
   LANGUAGE plpgsql 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$;
  • ほとんどの呼び出しで のみが必要な場合、これは少し安くなります。これは、句を含むSELECTより高価なブロックがめったに入力されないためです。クエリも簡単です。INSERTEXCEPTION

  • FOR SHAREここではできません (UNIONクエリでは許可されていません)。

  • LIMIT 1必要ありません(9.4ページでテスト済み)。Postgres は派生LIMIT 1INTO _tag_id、最初の行が見つかるまで実行されます。

于 2013-04-11T13:39:43.227 に答える
-1

タグが既に存在していた場合、トランザクションがタグを見つけた後、別のトランザクションによってタグが削除される可能性がわずかにあると思います。SELECT FOR UPDATE を使用すると、それが解決するはずです。

于 2013-04-11T04:53:34.983 に答える