maco's life

主にエンジニアリングと読書について書いていきます。

ActiveRecordのfind_or_create_byが投げるクエリを検証した

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で気になるメソッドがあったら随時クエリをのぞいていけたならと思います。