select_for_update() を利用して行ロックを掛ける

先週、今更理解するトランザクションという記事を書きました。

トランザクションは「全て反映 or 反映しない」する仕組みですが、そのトランザクション内で利用できる仕組みとしてロックがあります。ロックを掛けることで、そのテーブルに対しての操作を制限でき、意図しないデータの操作等を防ぐことができます。

実際に体験した事象と、その対策例を交えて説明します。

実際に体験した事象

あるとき、次のような挙動を確認しました(実際とは異なります)。

  • データを一括で登録するプログラムが存在する
    • データの該当グループ単位で登録する。
    • e.g. 果物グループ「りんご、いちご、みかん、バナナ、もも」野菜グループ「きゅうり、冬瓜、かぼちゃ、里芋、さつまいも」
  • 一括で登録するプログラムが二重送信される
  • 果物グループが2重で登録されてしまう

実際に調べてみた時、他のトランザクション中の内容が見えてしまっていることが原因だと分かりました。 一括で登録する際、レコードが既に存在していたら削除するようになっていたのですが、以下のような挙動となっていました。

  • トランザクション1が果物グループを削除する
  • トランザクション2がレコードの件数チェックをする
  • トランザクション1が果物グループを登録する
  • (トランザクション2ではトランザクション1によって果物グループが削除された直後のため、件数は0件と判定されて削除処理は発生しない)
  • トランザクション2が果物グループを登録する

このように、トランザクション2がトランザクション1で削除した内容が見えてしまっていたので、トランザクション2側で削除もなく、2重登録されていました。

では、どのように対応すべきでしょうか。

トランザクション分離レベル

1つはトランザクション分離レベルを厳格にすることが挙げられます。

SQLの標準規格でトランザクション分離レベルというものが定義されています。

  • READ UNCOMMITTED
  • READ COMMITTED
  • REPEATABLE READ
  • SERIALIZABLE

詳しい説明は割愛しますが、SERIALIZABLEにすることで直列での動作をさせることが可能です。

※ https://www.postgresql.jp/document/16/html/transaction-iso.html ではカタカナ表記ですが、英語大文字表記で使われることが多そうなのでこの表記としています。 また、PostgreSQLではREAD UNCOMMITTEDはREAD COMMITTEDのように動作するため実質3つです。RDBの内部実装により、この辺りは挙動が変わるようでした。

しかし、そのためだけにSERIALIZABLEにすることもデメリットもありそうでした。並列ではなく直列に処理させるためです。

そこでもう1つの方法が、ロックを掛ける方法です。

ロックを掛ける

テーブル全体にロックを掛ける表ロックと、特定行にロックを掛ける行ロックが存在します。

表ロックは今回割愛しますが、行ロックはSELECT ~ FOR UPDATE と、SELECT文の末尾にロック処理句を記述します(ただ、テーブル全体をロックするように書くと、表ロックと同じ挙動になる認識です)。

行レベルロックモードの説明には、以下のようにあります。

FOR UPDATEによりSELECT文により取り出された行が更新用であるかのようにロックされます。 これにより、それらは現在のトランザクションが終わるまで、他のトランザクションがロック、変更、削除できなくなります。

https://www.postgresql.jp/document/16/html/explicit-locking.html#LOCKING-ROWS

このようにして、対策することができます。

また、FOR UPDATE NOWAIT と記述することで後から同じようにロックを掛けようとした際、エラーを出力してくれます。

他のトランザクションのコミットを待機することなく操作を進めるには、NOWAITあるいはSKIP LOCKEDオプションを使用してください。 NOWAITでは、選択行のロックを即座に獲得できない時、文は待機せずに、エラーを報告します。

https://www.postgresql.jp/document/16/html/sql-select.html#SQL-FOR-UPDATE-SHARE より

上記を踏まえると、以下のようになります。

  • 先勝ち(先にロックを掛けて、後からロックを掛けようとしたら失敗する): FOR UPDATE NOWAIT
  • 後勝ち(先にロックを掛けたら、後のロックは待つ。先のロック解放後に後のロックが実行され、後続処理が実行される): FOR UPDATE

Djangoの場合はどうするの?

select_for_update() というQuerySet APIを利用します。上述したNOWAITも、nowait=Trueと引数を記述すればOKです。

https://docs.djangoproject.com/ja/5.1/ref/models/querysets/#select-for-update

この対応を取る時、私は以下の点を抑えておく必要があると思いました。

  • トランザクション内で実行する必要がある(FOR UPDATE(NOWAIT)はBEGIN~COMMITのトランザクション内である必要がある)
  • select_for_update()は呼び出しただけでは評価されないので、for文やexists()などを利用して評価させる必要がある

特に後者は、例えば一括でロックを掛ける場合の注意点だと感じています。

気がつけば導入文が長くなりましたが、select_for_update()を利用した行ロックの方法でした。 DB周りの知らなかったことを知ることが出来て楽しかったです。