PostgreSQL
トランザクション

ファントムリードは起こらないのにSEIRIALIZABLEでない、それがポスグレのREPEATABLE READ

トランザクション分離レベル、苦手意識を拭えない方はわりといらっしゃるんじゃないかと思います。なにしろ私がそうですからね! だから本題の前にまずはおさらいから入りましょう。

トランザクション

SQLのすべて成功する最初からなかったことになるかしかないように管理された一連の操作のことをトランザクションと呼びます。

Javaのsynchronizedブロックのようなものをイメージしてしまうこともあるかもしれませんが、それは全然違いまして、トランザクションの進行中にもほかのセッションによってDB内のデータは刻々と書き換えられていきます。最初からなかったことにできる、それだけがトランザクションの特徴ですから。

トランザクション分離

しかし進行中にどんどんデータが変化していってしまうのではプログラミングになりません。ある程度、ほかのセッションからの干渉を防いでもらわないと困ります。それがトランザクションの分離です。
トランザクションの分離は、具体的には進行中にどんなデータ変化を見てしまわないことを保証してもらうかで表されます。

そしてこの「どんなデータ変化が」には、SQL標準で2種類しか定義されていません。

  • レコード単位のデータ再現保証
  • テーブル単位のデータ再現保証

この二つです。
テーブル単位の方はレコード単位の方を含んでいますから、

  • データ再現保証なし
  • レコード単位のデータ再現保証のみ
  • レコード単位+テーブル単位のデータ再現保証を両方

の3段階の分離レベルがありえるとなります。そこで出てきます、あのイヤな表。

レコード単位のデータ再現保証 テーブル単位のデータ再現保証
READ COMMITTED ×非保証
一度読んだレコードを再度読んだら変化しているかもしれない
非再現リード
(ファジーリード)
×非保証
一度問い合わせて見つからなかったレコードが再度問い合わせて見つかるかもしれない
ファントムリード
REPEATABLE READ ○保証あり ×非保証
SERIALIZABLE ○保証あり ○保証あり

さてこの表の最下段に注目します。SERIALIZABLEとあります。

えっ、「READ UNCOMMITTED」は? 「ダーティリード」はって? そんな野蛮なものはありません。ないんですそんなものは。

SERIALIZABLE

SERIALIZABLE - “直列化可能”とは、各トランザクションをあたかもシングルスレッドで順番に実行したかのように、実行順序関係が決められることを意味します。

もちろん本当にシングルスレッドでやる必要はありません。並列でやってくれていいんです。っていうか並列でやってくれないと困ります。ただ、並列に処理した結果前後関係が決められなくなるような矛盾が起こったら完了時にそれを検出して確実にエラーにしてくれればいいんです。

さて、テーブル単位でのデータ再現保証があることって、トランザクションの実行順序関係を決定できることとイコールなんでしょうか? それで合っているような何か間違っているような⋯ このモヤモヤはちょっと保留しておきます。しかし少なくともSQL標準はイコールだと定義づけています。

そろそろこの記事のタイトルがSQLの標準と食い違ったことを言っていること、見えてきたでしょうか。

PostgreSQLのREPEATABLE READ

PostgreSQLの9.1以降では、トランザクション分離レベルにREPEATABLE READがあります。そして、それはマニュアルによるとファントムリードも起こさないことになっています。

おや、ファントムリードを起こさないならすでにそれはSERIALIZABLEレベルでは? しかし、SERIALIZABLEは別にちゃんとあります。何が違う? そう、REPEATABLE READはファントムリードを起こさないけれど、実行順序関係を決定できなくなるケースがあるというレベルなのです。

それも、そんな特別に複雑なトランザクションでなくてもそれは発生します。例えばこんなトランザクション、書いたことある人は多いんじゃないでしょうか。

BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- ユーザーネーム alice を使用しているユーザーがいないことを確認
SELECT COUNT(*)
FROM users
WHERE username = 'alice';

-- 前の問い合わせ結果が0だったら、ユーザーネーム alice のユーザー新規登録実行
INSERT INTO users (username)
VALUES ('alice');

COMMIT;

UNIQUEインデックス張らずにアプリコードで一意性を保とうとしているコードですね。非常によろしくないんですが諸般の事情で書かざるを得ないこと、まれによくあります。
(諸般の事情というのが論理削除とうまく共存できなくて、という話だったら、以前書いた記事ですがお役に立てば: https://qiita.com/yuba/items/70165875cfe02b03513d)

このトランザクション、REPEATABLE READレベル同士の2セッションが並行実行して両方成功してしまいます。'alice'さん、いとも簡単に多重登録されてしまいます。全然ダメじゃん。

REPEATABLE READレベルはファントムリードを防ぐのに何を防げないか。
計算の根拠となったデータがINSERTによって変わったことを検知できません。なるほどそれじゃ多重登録が起こってしまうわけです。そして、「やっぱりファントムリードを防いだだけじゃSERIALIZABLEとは言えないじゃん」が先ほどの疑問の答えとなってきます。

PostgreSQL 9.1で何が起こったか

先ほど「9.1以降では」とバージョン番号をちらっと書きました。ポスグレはこの9.1でトランザクション分離レベルを劇的に進化させています。

いまのREPEATABLE READレベル、9.0以前では実はSERIALIZABLEレベルと呼んでいました。上記のとおり直列化可能とは言えない仕様なのですが、マニュアルにも「だって仕方ない、まともに防ごうとしたら大変すぎるし⋯」的に注意書きしてある状態でした。

それが9.1で一転して、真のSERIALIZABLEを実現してしまったのです。それは述語ロックという機構が導入されてのことでした。「述語」とは、先ほどのトランザクション例で言うと最初のSELECTのWHERE username = 'alice'のことです。テーブルに対して
username = 'alice'って条件で読み取っていった奴がいるから、当てはまるレコードをINSERTするときは注意しろよ」
という印を付けられるようになったということです。9.0のマニュアルでは「とてもそこまでできない」と書いてあったのに。すごい。勢いしか感じない。

9.1から登場したREPEATABLE READは、9.0以前の不完全なSERIALIZABLEを互換のために改称して残したものとなります。

まとめ

  • ファントムリードを防いだだけではSERIALIZABLEの十分条件ではない。
  • SERIALIZABLEレベルは思っている以上に必要。「存在しなければ挿入」の処理がすでにSERIALIZABLEを必要としている。
  • SERIALIZABLEレベルは、コミット時に実行順序関係の矛盾を検知してエラーになる可能性がある。アプリコードにはリトライ処理が必須。
  • PostgreSQLのSERIALIZABLEは9.0以前と9.1以降で意味が違うので、移行の際には注意が必要。