発端
@soudai1025 https://t.co/ZrXgtDi2La
— Ryo Tomidokoro (@hanhan1978) 2022年7月2日
ファントムリードとファジーリード、それぞれがRCでも発生するかどうかって話ですか?
— そーだい@初代ALF (@soudai1025) 2022年7月2日
端的に言うと,
— RDB 初心者 (@mpyw) 2022年7月2日
A: UPDATE tbl SET amount = amount - 1 WHERE amount > 0;
B: UPDATE tbl SET amount = amount -1 WHERE amount > 0;
で A と B がほぼ同時に流れたとき,amount がマイナスにならない保証があるかどうかですね。SET する部分がアトミックでも WHERE は…?というところです
反復不能読み取り=ファジーリードがどのように作用するかって質問
追記
質問主が完璧な検証してた。 そっちの方が良いのでそっちを見ましょう。
結論
PostgreSQLは該当のUPDATEは共有テーブルロックをとり、実行時に取得対象になった対象に対して順次SQLが実行される*1*2ので値がマイナスになることはない。
PostgreSQLのロックについては澤田くんが詳しいのでこの記事を読みましょう。 PostgreSQLアンカンファレンスなどでも発表されたりしています。
とはいえ、後述のそーだい本にも書いてありますが、ツイート元のSQLのような値が不定のSQLはロックを取って実行しましょう。
ファジーリードとトランザクション分離レベル
失敗から学ぶRDBの正しい歩き方って本に書いてある。
ロックの功罪
失敗から学ぶRDBの正しい歩き方から引用。
ロストアップデートはトランザクション分離レベルによって振る舞いが変わります。repeatable readを設定しているときは図14.5のように後勝ちで、完全に上書きになります。read committedの場合は、
UPDATE 商品 SET 価格 = 価格+5 WHERE id =1;
のようなSQLであればほかのトランザクションのCOMMIT後の値が参照できるため、足し算した結果が保存されます。 このように、ロストアップデートの振る舞いはトランザクション分離レベルによって変わるため、アンチパターンの例のように運用途中で変更した場合は在庫の値がずれるなど、SQLの結果が意図せず変更されることがあります。 とくに、 repeatable readがデフォルトのMySQLとread committedがデフォルトのPostgreSQLを併用して運用する場合、よく起こるミスです。
質問への検証
-- トランザクション分離レベルの確認 SHOW TRANSACTION ISOLATION LEVEL; read committed -- テストテーブル create table test ( id bigserial constraint test_pk primary key, amount numeric default (round(((random() * ((0 - 10))::double precision))::numeric, 0) + (10)::numeric) not null ); alter table test owner to femcle_user; -- まず100件くらいデータを作る INSERT INTO test DEFAULT VALUES; -- 実行するたびレコードがポポポポ~ンと増える INSERT INTO test SELECT FROM test; -- 登録件数 SELECT count(*) FROM test; 207093760 -- 初期状態 SELECT count(*) FROM test WHERE amount > 0; 196740402 SELECT count(*) FROM test WHERE amount = 1; 20712095 SELECT count(*) FROM test WHERE amount = 2; 20709853 SELECT count(*) FROM test WHERE amount = 3; 20706995 SELECT count(*) FROM test WHERE amount = 4; 20710020 -- トランザクション A UPDATE test SET amount = amount -1 WHERE amount > 0; 196,740,402 rows affected in 14 m 55 s 360 ms -- トランザクション B UPDATE test SET amount = amount -1 WHERE amount > 0; 176,028,307 rows affected in 1 h 2 m 25 s 172 ms -- UPDATE 実行中 SELECT count(*) FROM test WHERE amount = 1; 20712095 SELECT count(*) FROM test WHERE amount = 2; 20709853 -- トランザクション A終了後 SELECT count(*) FROM test WHERE amount = 1; 20709853 SELECT count(*) FROM test WHERE amount = 2; 20706995 -- トランザクションB終了後 SELECT count(*) FROM test WHERE amount = 1; 20706995 SELECT count(*) FROM test WHERE amount = 2; 20710020
まとめ
みんな失敗から学ぶRDBの正しい歩き方を読んでくれよな!
*1:PostgreSQLのロックマネージャはほぼFIFO
*2:しかし複数のトランザクションがロック待ちで且つ、お互いが競合しない場合はロックの取得順ではなく、開放時に先に処理を始めた方なので厳密ではない