gotoshin

主に学んだ事の自分メモ用です。記事に書くまでも無いような事はhttps://scrapbox.io/study-diary/に書いてます。

【PostgreSQL】 並列実行時のトランザクション管理の注意が必要なパターンと解決策

この記事について

前提

  • ある口座から別の口座へ金額を移すAPIを2つのゴールチンでテストして、トランザクションの正当性を確認している

同じ時点のデータを2ルーチンが同時に更新してしまうパターン

事象

  • 更新前
    • 送金元:380
    • 送金先:390
      • 送金元から送金先へ10ドル送付するという処理を2ルーチンで動かす
  • 更新後
    • ルーチン1
      • 送金元:370(380-10)
      • 送金先:400(390+10)
        • 想定通り
    • ルーチン2
      • 送金元370 ※想定360
      • 送金先410(400+10)
        • 送金元が想定と異なる!

原因

  • select時に2ルーチンで同じ時点(値が370時点)の送信元データを取得してしまっているため

解決策

  • SELECT文にFOR UPDATEを追加することで、あるルーチンでデータ取得している際は、そのルーチンが COMMITROLLBACK するまでブロックされ処理が止まる
SELECT * FROM accounts 
WHERE id = $1 LIMIT 1
FOR UPDATE;

外部キー制約が原因でクエリの実行がブロックされるパターン

事象

  • 更新用にaccounts取得のクエリを実行したところ、transfers tableのレコード 挿入が原因でクエリの実行がブロックされる
// ルーチン2で実行し、ブロックされたクエリ
SELECT * FROM accounts 
WHERE id = $1 LIMIT 1
FOR UPDATE;

// ルーチン1で実行し、ブロック原因になっているクエリ
INSERT INTO transfers (
    from_account_id,
    to_account_id,
    amount
) VALUES (
    $1,
    $2,
    $3
) RETURNING *;

原因

  • 前提として、transfers tableのfrom_account_idto_account_idは、accountstableの外部キーとなっている。
  • transfers tableの更新が行われるトランザクション実行中に、万が一accounts tableのidが書き換えられてしまうと、不整合なtrasferレコードを作成してしまうことになる。
  • それを防ぐためtransfers tableの実行中はaccountstableの更新ができない
  • 動画ではルーチン1が更に SELECT * FROM accounts・・・を発行し、ルーチン2でそれ以前に実行されたINSERT INTO transfersによりブロック、一方でルーチン2はルーチン1によってブロックされているという状態つまりdeadlock状態となっていた

解決策

  • SELECT文にFOR NO KEY UPDATE を追加することで、キーの更新が行われないことをPostgreSQLへ伝える事ができ、ブロックされなくなる
SELECT * FROM accounts 
WHERE id = $1 LIMIT 1
FOR NO KEY UPDATE;

後日追記