PostgreSQLでNoSQLのような楽観的並行性制御を行う

# はじめに
PostgreSQLで、既存の行がなければINSERTし、既存行より新しいデータであれば更新、同じか古いデータであれば更新しない、という制御を行いたい場合についてです。

※ 楽観的並行性制御について：
https://dev.classmethod.jp/articles/elasticsearch-getting-started-03/
https://www.elastic.co/jp/blog/elasticsearch-versioning-support


基本的にはUPSERTの一種ですが、PostgreSQLではUPSERTは大まかに以下2種類の書き方ができます。INSERT ON CONFLICTでは、UPDATE内のWHERE句に、MERGEではWHEN MATCHEDのCONDITIONに、INSERTするか否か（ここでは、より新しいデータか否か）の判定条件を記述できます。



```
create table t (id int primary key, value text, date timestamp);
```

```
insert into t
(id,value,date) values (2,'A','2024-09-24')
on conflict (id)
do update set value=excluded.value, date=excluded.date
  where t.date < excluded.date;
```
このdate列は、外部で発生したデータの発生時刻が格納されている列と考えて下さい。トリガーによるupdated_atカラムなどを用意して、DB内部の更新時刻で楽観的並行性制御を行う場合はwhere句が少し変わります。

```
merge into t
using (values (2,'A','2024-09-24'::timestamp)) as i (id, value, date)
on t.id = i.id
when matched and t.date < i.date then
  update set value=i.value, date=i.date
when not matched then
  insert (id,value,date) values (i.id,i.value,i.date);
```

これらは一見同じ結果が得られるように見えますが、[ドキュメント](https://www.postgresql.jp/document/15/html/sql-merge.html)には「2つの文タイプの間には様々な違いや制限があり、相互に交換することはできません。」と書かれています。
具体的な違いとして、[PostgreSQL 「MERGE」と「INSERT ON CONFLICT」でのUPSERTの違い](https://qiita.com/SP-ya/items/552ebba06f60aa729218)でも述べられているように、同時実行性に違いがあります。

単一SQL文として実行する場合は違いが見えにくいですが、**何か他のテーブルへの操作を行うなどtransactionを用いる場合** は差が見えてきます。

# 検証

## INSERT ON CONFLICT
```
--transaction T1
begin; --ISOLATION LEVELはREAD COMMITTEDとしています。
insert into t
(id,value,date) values (2,'A','2024-09-24')
on conflict (id)
do update set value=excluded.value, date=excluded.date
  where t.date < excluded.date; --INSERT 0 1
  
                --transaction T2
                begin;
                insert into t
                (id,value,date) values (2,'A','2024-09-25')
                on conflict (id)
                do update set value=excluded.value, date=excluded.date
                  where t.date < excluded.date;
                --T1のcommitまでblockされて待機
                
commit; --success
                --INSERT 0 1
                commit;--success
```
より新しいデータをinsertしようとすると、T1 commitまでupdateがブロックされ、T1 commit後にINSERT 0 1と1行insert on conflictの結果が返ってきます。INSERTとありますが、updateで影響された行数を示しています。

```
--transaction T1
begin;
insert into t
(id,value,date) values (3,'A','2024-09-24')
on conflict (id)
do update set value=excluded.value, date=excluded.date
  where t.date < excluded.date; --INSERT 0 1
  
                --transaction T2
                begin;
                insert into t
                (id,value,date) values (3,'A','2024-09-23')
                on conflict (id)
                do update set value=excluded.value, date=excluded.date
                  where t.date < excluded.date;
                --T1のcommitまでblockされて待機
                
commit; --success
                --INSERT 0 0
                commit;--success
```
より古いデータをinsertしようとした場合も、T1 commitまでinsertがブロックされ、T1 commit後にINSERT 0 0と、「INSERT/UPDATEされなかった」という結果が返ってきます。いずれにしても **T2 transactionは成功します。**

## MERGE
次にMERGE文を使った場合を見ていきます。

```
--transaction T1
begin;
merge into t
using (values (5,'A','2024-09-24'::timestamp)) as i (id, value, date)
on t.id = i.id
when matched and t.date < i.date then
  update set value=i.value, date=i.date
when not matched then
  insert (id,value,date) values (i.id,i.value,i.date); --MERGE 1
  
            --transaction T2
            begin;
            merge into t
            using (values (5,'A','2024-09-25'::timestamp)) as i (id, value, date)
            on t.id = i.id
            when matched and t.date < i.date then
              update set value=i.value, date=i.date
            when not matched then
              insert (id,value,date) values (i.id,i.value,i.date);
            --T1のcommitまでblockされて待機

commit; --sucess
            --ERROR:  duplicate key value violates unique constraint "t3_pkey"
            --DETAIL:  Key (id)=(5) already exists.
            commit;
            --ERROR:  current transaction is aborted, 
            --commands ignored until end of transaction block.
```
より新しいデータであっても、`MERGE`で検出されずにWHEN NOT MATCHED句の`INSERT`が実行され、blockされた上に最後は **一意性違反でabort** してしまいます。なお、古いデータの場合も検出できずに`INSERT`されて一意性違反になります。

T1, T2より前に既存データが存在する場合の挙動については、気になる方は手元で確かめてみて下さい。

# まとめ
INSERT ON CONFLICT文では、INSERTがブロックされて、T1 commit後にINSERTが成功したかどうか（duplicate keyが発生したか）が判定される一方、MERGE文ではあくまで見えているCOMMITに対してMERGEが実行され、MATCHED/NOT MATCHEDが判定された後のINSERTがブロックされている、と考えると、この挙動になることが理解できます。

INSERTの結果で分岐するのか、分岐してからINSERTするのかの違いに起因すると考えられます。

insertのボリュームやconflictの発生する頻度にも依りかもしれませんが、今回のようなケースでは基本的には`MERGE`文よりも`INSERT ON CONFLICT`文を使った方が良さそうです。

# 参考
https://qiita.com/SP-ya/items/552ebba06f60aa729218