RubyのORMのActiveRecordにfind_or_create_byというメソッドがある。このメソッドはデータがあったらselectした結果が返ってきて、ない場合はinsertをしてその結果を返してくれるという夢のような機能を実現してくれているらしい。
このデータがあったらselect、ない場合はinsert
のような処理は扱うときによく考えないとロック周りでDBが詰まってつらい思いすることがある。そこでこのメソッドはどんなクエリを投げるのか検証してみた。
検証方法
- ActiveRecord::Baseを系統したGamePlayCountモデルに対してrails cで操作を行う。
- GamePlayCountは対象のgame_idを遊ぶとcountを++していくテーブルだと仮定する。
テーブル構造はこんな感じ。
CREATE TABLE `game_play_counts` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `game_id` int(10) unsigned NOT NULL DEFAULT '0', `count` int(10) unsigned NOT NULL DEFAULT '0', `created_at` datetime NOT NULL, `updated_at` datetime NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `game_id` (`game_id`), )
今回はGamePlayCountに対して下記の操作を行う。
- find_or_create_by単体で使う
- find_or_create_byとlockを組み合わせて使う
- 検証する際は一つのケースに対してtransaction内、外両方のパターンを試してみる
検証結果はrails cでActiveRecord::Base.logger = Logger.new(STDOUT)
を実行してログに流れるSQLを順番に抽出したものを貼っています。早速検証スタートだドン!
find_or_create_by単体で使う
検証1
find_or_create_byをそのまま呼んでみます。
GamePlayCount.find_or_create_by(:game_id => 8)
SELECT `game_play_counts`.* FROM `game_play_counts` WHERE `game_play_counts`.`game_id` = 8 LIMIT 1 BEGIN INSERT INTO `game_play_counts` (`game_id`, ...) VALUES ('8' ...) COMMIT
一度トランザクションの外でSELECTして、トランザクション内でINSERTする結果となりました。こちらで明示的にトランザクションを指定したわけでないけど、ActiveRecordではINSERTしようとすると勝手にトランザクションがはられるようです。
検証2
明示的にトランザクションを指定して試します。
ActiveRecord::Base.transaction do GamePlayCount.find_or_create_by(:game_id => 9) end
BEGIN SELECT `game_play_counts`.* FROM `game_play_counts` WHERE `game_play_counts`.`game_id` = 9 LIMIT 1 INSERT INTO `game_play_counts` (`game_id`, ...) VALUES ('9', ...) COMMIT
こちらはトランザクション内でSELECT, INSERTを行うクエリが発行されました。この場合、同時にアクセスが来た場合に片方がduplicateになりそうですね。
lockの検証
find_or_create_byとlockの組み合わせを実行する前にlock単体でどういったクエリを投げるか確認します。
検証3
GamePlayCount.lock(:true)
SELECT `game_play_counts`.* FROM `game_play_counts` FOR UPDATE
テーブルに対してFOR UPDATEかけた...トランザクション外だから影響ないけどテーブルロック一歩手間ですね。
検証4
次にトランザクション内でlockを呼んでみます。もうこれテーブルロックでしょ
ActiveRecord::Base.transaction do GamePlayCount.lock(:true) end
BEGIN COMMIT SELECT `game_play_counts`.* FROM `game_play_counts` FOR UPDATE
トランザクション内でFOR UPDATEかけると思ったけれど、意外なことに外で実行されました。これはこれで安全設計かもだけどちょっと違和感が...。ちなみにlockの後にfind指定で実行したらきちんとトランザクション内で実行されました。
find_or_create_byとlockを組み合わせて使う
find_or_create_byとlockを呼ぶとどうなるのか。大変気になるので早速検証します。
検証5
まずは明示的にトランザクション囲まないケースから試します。
GamePlayCount.lock(true).find_or_create_by(:game_id => 10)
データがない状態
SELECT `game_play_counts`.* FROM `game_play_counts` WHERE `game_play_counts`.`game_id` = 10 LIMIT 1 FOR UPDATE BEGIN INSERT INTO `game_play_counts` (`game_id`, ...) VALUES ('10', ...) COMMIT
トランザクション外でFOR UPDATEが実行されて、後にトランザクション内でINSERTが走りました。気持ちだけFOR UPDATEつけといたよ感あるけどロックは取れません。
データが既にある状態
SELECT `game_play_counts`.* FROM `game_play_counts` WHERE `game_play_counts`.`game_id` = 10 LIMIT 1 FOR UPDATE
トランザクション外なので同様にロックは取れません。
検証6
明示的にトランザクション囲んだケースを試します。
ActiveRecord::Base.transcation do GamePlayCount.lock(true).find_or_create_by(:game_id => 11) end
データがない状態
BEGIN SELECT `game_play_counts`.* FROM `game_play_counts` WHERE `game_play_counts`.`game_id` = 11 LIMIT 1 FOR UPDATE INSERT INTO `game_play_counts` (`game_id`, ...) VALUES ('11', ...) COMMIT
トランザクション内でFOR UPDATEしてINSERTもする挙動となりました。最初にFOR UPDATEの空打ちをしているので、ギャップロックが気になります。高い並列度でよばれたら詰まることがありそうですね。
データが既にある状態
BEGIN SELECT `game_play_counts`.* FROM `game_play_counts` WHERE `game_play_counts`.`game_id` = 11 LIMIT 1 FOR UPDATE COMMIT
対象のデータがある場合はトランザクション内でFOR UPDATEをしていて、ロックは取れていました。
まとめ
今回は興味本位でfind_or_create_byを使うとどういったクエリになるか確認しました。find_or_create_byもduplicateのリスクがあるし、lockと組み合わせると初回アクセス時にギャップロックしてしまうしで、使い方によっては良くないことがあるなという感じです。エラー無く処理したいということならichirin2501先生のスライドにあるINSERT ... ON DUPLICATE KEY UPDATE
を使うのも良さそうです。INSERT ... ON DUPLICATE KEY UPDATE
は呼ばれる度にAUTO INCREMENT値がどんどん増えていってしまうので、BIGINTを使うなりしてOut of rangeになるリスクを避ける必要があるのでご注意ください。
またActiveRecordで気になるメソッドがあったら随時クエリをのぞいていけたならと思います。