簡単なブログエンジンの投稿を作成する関数を作成しました。
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を使用しています。
これは、SELECT
またはINSERT
の繰り返し発生する問題であり、同時書き込み負荷の可能性があり、UPSERT
( INSERT
またはUPDATE
)。
新しい PSERT実装_INSERT ... ON CONFLICT .. DO UPDATE
_ を使用すると、大幅に簡略化できます。 PL/pgSQL関数からINSERT
またはSELECT
asingle行(タグ):
_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)ソリューション:
以前、この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モデルが機能する方法です-機能する必要があります。
したがって、複数のトランザクションが同じタグを同時に挿入しようとする可能性がある場合は、これを使用しないでください。 または実際に行を取得するまでループします。一般的な作業負荷では、ループがトリガーされることはほとんどありません。
この(少し簡略化された)テーブルを考えると:
_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;
_
SELECT
firstを試してください。このようにして、かなり高価例外処理を99.99%の確率で回避します。
[〜#〜] cte [〜#〜] を使用して、競合状態の(すでに小さい)タイムスロットを最小化します。
SELECT
とINSERT
の間の時間枠1つのクエリ内は非常に小さいです。同時負荷が大きくない場合、または年に1回例外が発生する可能性がある場合は、ケースを無視してSQLステートメントを使用できます。これはより高速です。
_FETCH FIRST ROW ONLY
_(= _LIMIT 1
_)は必要ありません。タグ名は明らかにUNIQUE
です。
テーブルDELETE
に通常UPDATE
またはtag
が同時に存在しない場合は、私の例では _FOR SHARE
_ を削除します。わずかなパフォーマンスのコストがかかります。
言語名は絶対に引用しないでください。 '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;
...
_
ただし、 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
_を導出し、最初の行が見つかるまでのみ実行されます。
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つの場所に追加されない場合、これは簡単ではありません。