PostgreSQLでUPSERT(マージ、挿入…重複更新時)する方法


268

ここで非常によく寄せられる質問は、MySQLが呼び出しINSERT ... ON DUPLICATE UPDATE、標準がMERGE操作の一部としてサポートする、アップサートの実行方法です。

PostgreSQLがそれを直接サポートしていないことを考えると(pg 9.5より前)、これをどのように行いますか?以下を検討してください。

CREATE TABLE testtable (
    id integer PRIMARY KEY,
    somedata text NOT NULL
);

INSERT INTO testtable (id, somedata) VALUES
(1, 'fred'),
(2, 'bob');

今、あなたは「アップサート」にタプルをしたいことを想像し(2, 'Joe')(3, 'Alan')新しいテーブルの内容は次のようになりので、:

(1, 'fred'),
(2, 'Joe'),    -- Changed value of existing tuple
(3, 'Alan')    -- Added new tuple

それは人々が議論するときに話していることupsertです。重要なのは、明示的なロックを使用するか、結果として生じる競合状態から防御することによって、同じテーブルで作業する複数のトランザクションが存在する場合でも、どのようアプローチも安全でなければならないことです。

このトピックについては、挿入、PostgreSQLでの重複更新について広く議論されていますか?ですが、それはMySQL構文の代替手段であり、時間の経過とともに無関係な詳細がかなり増えてきました。決定的な答えに取り組んでいます。

これらの手法は、「存在しない場合は挿入し、それ以外の場合は何もしない」、つまり「挿入時に重複するキーを無視する」の場合にも役立ちます。



8
@MichaelHamptonここでの目標は、複数の古い回答と混同されず、ロックされた最終バージョンを作成することでした。私はclosevoteに同意しません。
クレイグリンガー、

なぜ、これはすぐに古くなり、ロックされるので、誰もそれについて何もできなくなりました。
マイケルハンプトン

2
@MichaelHampton気になる場合は、リンクしたものにフラグを付けて、ロックを解除してクリーンアップできるように要求することができます。これをマージできます。 ass-dupは、混乱を招き、混乱を招きます。
クレイグリンガー

1
そのQ&Aはロックされていません!
マイケルハンプトン

回答:


396

9.5以降:

PostgreSQL 9.5以降のサポートINSERT ... ON CONFLICT UPDATE(およびON CONFLICT DO NOTHING)、つまりupsert。

との比較ON DUPLICATE KEY UPDATE

簡単な説明

使用方法について参照マニュアル、具体的- CONFLICT_ACTIONの構文図の句、および説明文を

以下に示す9.4以前のソリューションとは異なり、この機能は複数の競合する行で機能し、排他ロックや再試行ループを必要としません。

機能を追加するコミットはここにあり、その開発に関する議論はここにあります


9.5を使用していて、下位互換性を維持する必要がない場合は、今すぐ読むのをやめることができます


9.4以前:

PostgreSQLには組み込みUPSERT(またはMERGE)の機能がなく、同時に使用する場合に効率的に行うことは非常に困難です。

この記事では、問題について有用な詳細を説明します。

通常、次の2つのオプションから選択する必要があります。

  • 再試行ループでの個々の挿入/更新操作。または
  • テーブルのロックとバッチマージの実行

個別の行再試行ループ

挿入を実行しようとする多数の接続が同時に必要な場合は、再試行ループで個々の行のアップサートを使用するのが妥当なオプションです。

PostgreSQLのドキュメントには、データベース内のループでこれを実行できる便利な手順が含まれています。ほとんどの素朴なソリューションとは異なり、更新の喪失や競合の挿入を防ぎます。ただし、これはREAD COMMITTEDモードでのみ機能し、トランザクションで唯一実行する場合にのみ安全です。トリガーまたは2次固有キーが固有違反を引き起こした場合、関数は正しく機能しません。

この戦略は非常に非効率的です。実用的な場合は常に、作業をキューに入れて、以下で説明するように一括アップサートを実行する必要があります。

この問題に対して試行された解決策の多くはロールバックを考慮していないため、更新が不完全になります。2つのトランザクションは互いに競合します。それらの1つが正常に実行されINSERTます。もう1つは重複キーエラーを受け取り、UPDATE代わりに行います。UPDATE以下のためのブロックが待っているINSERTロールバックまたはコミットします。ロールバックすると、UPDATE条件の再チェックはゼロ行と一致するため、UPDATEコミットは実際に期待どおりのアップサートを実行していません。結果の行数を確認し、必要に応じて再試行する必要があります。

いくつかの試みられた解決策はまた、SELECTレースを考慮できません。あなたが明白でシンプルなものを試した場合:

-- THIS IS WRONG. DO NOT COPY IT. It's an EXAMPLE.

BEGIN;

UPDATE testtable
SET somedata = 'blah'
WHERE id = 2;

-- Remember, this is WRONG. Do NOT COPY IT.

INSERT INTO testtable (id, somedata)
SELECT 2, 'blah'
WHERE NOT EXISTS (SELECT 1 FROM testtable WHERE testtable.id = 2);

COMMIT;

次に、2つを同時に実行すると、いくつかの障害モードがあります。1つは、更新の再チェックで既に説明した問題です。もう1つは、両方UPDATEが同時に、ゼロ行に一致して続行する場合です。次に、両方がEXISTSテストを実行します。これは、のに行われINSERTます。どちらもゼロ行を取得するため、どちらもを実行しINSERTます。1つは重複キーエラーで失敗します。

これが、再試行ループが必要な理由です。巧妙なSQLを使用すると、重複キーエラーや更新の損失を防ぐことができると思うかもしれませんが、それはできません。行数を確認するか、重複したキーエラーを処理して(選択したアプローチに応じて)、再試行する必要があります。

このために独自のソリューションをロールバックしないでください。メッセージのキューイングと同様に、おそらく間違っています。

ロック付きバルクアップサート

古いデータセットにマージする新しいデータセットがある場合に、一括アップサートを実行したい場合があります。これは、個々の行のアップサートよりもはるかに効率的であり、実用的であればいつでも推奨されます。

この場合、通常は次のプロセスに従います。

  • CREATETEMPORARYテーブル

  • COPY または新しいデータを一時テーブルに一括挿入する

  • LOCKターゲット表IN EXCLUSIVE MODE。これにより、他のトランザクションSELECTはにアクセスできますが、テーブルに変更を加えることはできません。

  • やるUPDATE ... FROM一時テーブル内の値を使用して既存のレコードのを。

  • やるINSERTすでにターゲットテーブルに存在しない行のを。

  • COMMIT、ロックを解除します。

たとえば、質問の例では、多値INSERTを使用して一時テーブルにデータを入力します。

BEGIN;

CREATE TEMPORARY TABLE newvals(id integer, somedata text);

INSERT INTO newvals(id, somedata) VALUES (2, 'Joe'), (3, 'Alan');

LOCK TABLE testtable IN EXCLUSIVE MODE;

UPDATE testtable
SET somedata = newvals.somedata
FROM newvals
WHERE newvals.id = testtable.id;

INSERT INTO testtable
SELECT newvals.id, newvals.somedata
FROM newvals
LEFT OUTER JOIN testtable ON (testtable.id = newvals.id)
WHERE testtable.id IS NULL;

COMMIT;

関連読書

どうMERGEですか?

SQL標準は、MERGE実際には不十分に定義された並行性セマンティクスを持ち、最初にテーブルをロックせずに更新することには適していません。

これは、データのマージに非常に役立つOLAPステートメントですが、同時実行セーフアップサートに実際に役立つソリューションではありません。MERGEアップサートに使用するために他のDBMS を使用している人々へのアドバイスはたくさんありますが、それは実際には間違っています。

その他のDB:


一括アップサートでは、INSERTをフィルタリングするのではなく、newvalsから削除することに価値がありますか?例:WITH upd AS(UPDATE ... RETURNING newvals.id)DELETE FROM newvals USING upd WHERE newvals.id = upd.id、続いて裸のINSERT INTO testtable SELECT * FROM newvals?これに関する私の考え:INSERTで2回フィルタリングする(JOIN / WHEREおよび一意の制約に対して)代わりに、すでにRAMにあり、はるかに小さい可能性があるUPDATEからの存在チェック結果を再利用します。一致する行が少ない場合やnewvalsがtesttableよりもはるかに小さい場合、これは成功する可能性があります。
Gunnlaugur Briem 2014

1
まだ未解決の問題があり、他のベンダーについては、何が機能し、何が機能しないかは明らかではありません。1.上記のPostgresループソリューションは、一意のキーが複数ある場合は機能しません。2. MySQLの重複キーも、複数の一意のキーに対して機能しません。3.上記のMySQL、SQL Server、Oracleの他のソリューションは機能しますか?それらの場合に例外は可能ですか?ループする必要がありますか?
dan b

@danbこれは本当にPostgreSQLに関するものだけです。クロスベンダーのソリューションはありません。PostgreSQLのソリューションは複数の行に対しては機能しません。残念ながら、行ごとに1つのトランザクションを実行する必要があります。上記のようにMERGE、SQL ServerとOracle で使用する「ソリューション」は正しくなく、競合状態になりがちです。具体的には、各DBMSを調べてそれらの処理方法を見つける必要があります。私は、PostgreSQLに関するアドバイスしか提供できません。PostgreSQLで安全な複数行のアップサートを行う唯一の方法は、コアサーバーにネイティブアップサートのサポートを追加することです。
クレイグリンガー

PostGresQLの場合でも、テーブルに複数の一意のキーがある場合(1行のみを更新)、ソリューションは機能しません。その場合、更新するキーを指定する必要があります。たとえば、jdbcを使用するクロスベンダーソリューションがある場合があります。
dan b

2
Postgresは今UPSERTをサポート- git.postgresql.org/gitweb/...
クリス・

32

PostgreSQLの9.5より前のバージョンでの単一挿入の問題に対する別の解決策を提供しようとしています。アイデアは、単に最初に挿入を実行しようとすることであり、レコードがすでに存在する場合は、それを更新することです。

do $$
begin 
  insert into testtable(id, somedata) values(2,'Joe');
exception when unique_violation then
  update testtable set somedata = 'Joe' where id = 2;
end $$;

このソリューションは、テーブルの行が削除されていない場合にのみ適用できることに注意してください

私はこのソリューションの効率については知りませんが、私には十分合理的に思えます。


3
ありがとう、それがまさに私が探していたものです。なぜ見つけるのが難しかったのか理解できません。
isapir

4
うん。この単純化は、削除がない場合にのみ機能します。
クレイグリンガー

@CraigRinger削除があった場合、どうなるか正確に説明できますか?
ターバノフ

@turbanoffレコードが既に存在するために挿入が失敗する可能性があり、その後、レコードは同時に削除され、行が削除されたため、更新はゼロ行に影響します。
クレイグリンガー、

@CraigRingerだから。削除は同時に行われます。これがあれば可能outways何である作品の罰金?削除が同時に機能している場合-ブロックの直後に実行できます。私が言おうとしていること-同時削除がある場合-このコードは適切と同じように動作しますinsert on update
turbanoff

29

insert ... on conflict ...pg 9.5+)の例をいくつか示します。

  • 挿入時に、競合する場合- 何もしません
    insert into dummy(id, name, size) values(1, 'new_name', 3)
    on conflict do nothing;`  
  • 挿入、競合- 更新時にを介して競合ターゲットを指定します
    insert into dummy(id, name, size) values(1, 'new_name', 3)
    on conflict(id)
    do update set name = 'new_name', size = 3;  
  • 挿入、競合- 更新時に制約名を介して競合ターゲットを指定します。
    insert into dummy(id, name, size) values(1, 'new_name', 3)
    on conflict on constraint dummy_pkey
    do update set name = 'new_name', size = 4;

すばらしい答え-質問:なぜ、またはどのような状況で、列または制約名を介してターゲット仕様を使用する必要がありますか?さまざまなユースケースに利点/欠点はありますか?
Nathan Benton

1
@NathanBenton少なくとも2つの違いがあると思います。(1)列名はプログラマーによって指定されますが、制約名はプログラマーによって指定されるか、テーブル/列名に従ってデータベースによって生成されます。(2)各列には複数の制約がある場合があります。とはいえ、どちらを使用するかは、ケースによって異なります。
Eric Wang

8

Postgres> = 9.5のSQLAlchemyアップサート

上記の大きな投稿はPostgresバージョンの多くの異なるSQLアプローチ(質問の非9.5だけでなく)をカバーしているので、Postgres 9.5を使用している場合、SQLAlchemyでそれを行う方法を追加したいと思います。独自のアップサートを実装する代わりに、SQLAlchemyの関数(SQLAlchemy 1.1で追加された)を使用することもできます。個人的には、できればこれらの使用をお勧めします。利便性のためだけでなく、発生する可能性のあるすべての競合状態をPostgreSQLで処理できるためです。

私が昨日与えた別の回答からのクロスポスト(https://stackoverflow.com/a/44395983/2156909

SQLAlchemyはON CONFLICT2つのメソッドon_conflict_do_update()とでサポートするようになりましたon_conflict_do_nothing()

ドキュメントからのコピー:

from sqlalchemy.dialects.postgresql import insert

stmt = insert(my_table).values(user_email='a@b.com', data='inserted data')
stmt = stmt.on_conflict_do_update(
    index_elements=[my_table.c.user_email],
    index_where=my_table.c.user_email.like('%@gmail.com'),
    set_=dict(data=stmt.excluded.data)
    )
conn.execute(stmt)

http://docs.sqlalchemy.org/en/latest/dialects/postgresql.html?highlight=conflict#insert-on-conflict-upsert


4
PythonとSQLAlchemyは質問には含まれていません。
Alexander Emelianov 2017年

私が書いたソリューションではPythonをよく使用しています。しかし、私はSQLAlchemyを調べていません(またはそれを認識していませんでした)。これはエレガントなオプションのようです。ありがとうございました。チェックアウトした場合、私はこれを私の組織に提示します。
Robert

3
WITH UPD AS (UPDATE TEST_TABLE SET SOME_DATA = 'Joe' WHERE ID = 2 
RETURNING ID),
INS AS (SELECT '2', 'Joe' WHERE NOT EXISTS (SELECT * FROM UPD))
INSERT INTO TEST_TABLE(ID, SOME_DATA) SELECT * FROM INS

Postgresql 9.3でテスト済み


@CraigRinger:これについて詳しく教えてもらえますか?cteはアトミックではありませんか?
パリ

2
@parisniいいえ。書き込みを実行する場合、各CTE用語は独自のスナップショットを取得します。また、見つからなかった行に対してはなんらかの述語ロックが実行されないため、別のセッションで同時に作成できます。SERIALIZABLE分離を使用した場合は、シリアル化の失敗で異常終了します。それ以外の場合は、おそらく固有の違反が発生します。アップサートを再発明しないでください。再発明は間違っています。を使用しINSERT ... ON CONFLICT ...ます。PostgreSQLが古すぎる場合は、更新してください。
クレイグリンガー、

@CraigRinger INSERT ... ON CLONFLICT ...は、一括読み込みを目的としたものではありません。あなたの投稿からLOCK TABLE testtable IN EXCLUSIVE MODE;、CTE内はアトミックなものを取得するための回避策です。番号 ?
パリ

@parisni一括読み込みを目的としたものではありませんか?誰が言ったのですか?postgresql.org/docs/current/sql-insert.html#SQL-ON-CONFLICT。確かに、upsertのような動作がないバルクロードよりもはるかに低速ですが、それは明白であり、何をしても同じです。確かに、サブトランザクションを使用するよりもはるかに高速です。最速のアプローチは、ターゲットテーブルロックしてinsert ... where not exists ...から、当然のことながら同様のことを行うことです。
クレイグリンガー

1

この質問は締め切られたので SQLAlchemyを使用してそれを行う方法についてここに投稿します。再帰により、競合状態と戦うために一括挿入または更新を再試行しますや検証エラーます。

まず輸入

import itertools as it

from functools import partial
from operator import itemgetter

from sqlalchemy.exc import IntegrityError
from app import session
from models import Posts

いくつかのヘルパー関数

def chunk(content, chunksize=None):
    """Groups data into chunks each with (at most) `chunksize` items.
    https://stackoverflow.com/a/22919323/408556
    """
    if chunksize:
        i = iter(content)
        generator = (list(it.islice(i, chunksize)) for _ in it.count())
    else:
        generator = iter([content])

    return it.takewhile(bool, generator)


def gen_resources(records):
    """Yields a dictionary if the record's id already exists, a row object 
    otherwise.
    """
    ids = {item[0] for item in session.query(Posts.id)}

    for record in records:
        is_row = hasattr(record, 'to_dict')

        if is_row and record.id in ids:
            # It's a row but the id already exists, so we need to convert it 
            # to a dict that updates the existing record. Since it is duplicate,
            # also yield True
            yield record.to_dict(), True
        elif is_row:
            # It's a row and the id doesn't exist, so no conversion needed. 
            # Since it's not a duplicate, also yield False
            yield record, False
        elif record['id'] in ids:
            # It's a dict and the id already exists, so no conversion needed. 
            # Since it is duplicate, also yield True
            yield record, True
        else:
            # It's a dict and the id doesn't exist, so we need to convert it. 
            # Since it's not a duplicate, also yield False
            yield Posts(**record), False

そして最後にアップサート機能

def upsert(data, chunksize=None):
    for records in chunk(data, chunksize):
        resources = gen_resources(records)
        sorted_resources = sorted(resources, key=itemgetter(1))

        for dupe, group in it.groupby(sorted_resources, itemgetter(1)):
            items = [g[0] for g in group]

            if dupe:
                _upsert = partial(session.bulk_update_mappings, Posts)
            else:
                _upsert = session.add_all

            try:
                _upsert(items)
                session.commit()
            except IntegrityError:
                # A record was added or deleted after we checked, so retry
                # 
                # modify accordingly by adding additional exceptions, e.g.,
                # except (IntegrityError, ValidationError, ValueError)
                db.session.rollback()
                upsert(items)
            except Exception as e:
                # Some other error occurred so reduce chunksize to isolate the 
                # offending row(s)
                db.session.rollback()
                num_items = len(items)

                if num_items > 1:
                    upsert(items, num_items // 2)
                else:
                    print('Error adding record {}'.format(items[0]))

使い方は次のとおりです

>>> data = [
...     {'id': 1, 'text': 'updated post1'}, 
...     {'id': 5, 'text': 'updated post5'}, 
...     {'id': 1000, 'text': 'new post1000'}]
... 
>>> upsert(data)

これの利点bulk_save_objectsは、挿入時に関係やエラーチェックなどを処理できることです(一括操作とは異なります)。


それも私には間違って見えます。IDのリストを収集した後、同時セッションが行を挿入するとどうなりますか?または削除しますか?
クレイグリンガー

良い点@CraigRinger私はこれと同じようなことをしますが、ジョブを実行しているセッションは1つだけです。複数のセッションを処理するための最良の方法は何ですか?おそらく取引?
reubano 2017

トランザクションは、すべての同時実行性の問題に対する魔法の解決策ではありません。SERIALIZABLE トランザクションを使用してシリアル化の失敗を処理することはできますが、速度は遅くなります。エラー処理と再試行ループが必要です。私の回答とその中の「関連する読み物」セクションを参照してください。
クレイグリンガー

@CraigRinger落とし穴。他の検証エラーのため、実際に自分の場合に再試行ループを実装しました。この回答は適宜更新します。
reubano 2017
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.