LoginSignup
52
37

More than 5 years have passed since last update.

ActiveRecord の find_or_create_by を確実に実行するには

Last updated at Posted at 2016-12-21

Gaiax Advent Calendar 2016 の21日目の記事です。

ActiveRecord の find_or_create_by で苦労したので、それをまとめておきたいと思います。


まずは、以下のような状況を仮定します。

  • あるタスクが完了したら、その日分の TaskReport の count をインクリメントする
  • その日分のTaskReportレコードがあれば更新する、なければ作成する
CREATE TABLE `task_reports` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `date` date DEFAULT NULL,
  `count` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
)
task_report = TaskReport.find_or_create_by(date: Date.today)
task_report.increment!(:count)

このコードで実行されるSQL文は下記のとおりです。

SELECT `task_reports`.* FROM `task_reports` WHERE `task_reports`.`date` = '2016-12-22' LIMIT 1
BEGIN
INSERT INTO `task_reports` (`date`) VALUES ('2016-12-22')
COMMIT
UPDATE `task_reports` SET `count` = COALESCE(`count`, 0) + 1 WHERE `task_reports`.`id` = 1

TaskReportレコードが存在する場合は以下のようになります。

SELECT  `task_reports`.* FROM `task_reports` WHERE `task_reports`.`date` = '2016-12-22' LIMIT 1
UPDATE `task_reports` SET `count` = COALESCE(`count`, 0) + 1 WHERE `task_reports`.`id` = 1

ここで、複数プロセスA, Bが起動していて同時に処理が走った場合を考えます。この場合、以下のようなことが起こる可能性があります。

A> SELECT `task_reports`.* FROM `task_reports` WHERE `task_reports`.`date` = '2016-12-22' LIMIT 1
B> SELECT `task_reports`.* FROM `task_reports` WHERE `task_reports`.`date` = '2016-12-22' LIMIT 1 <- この時点ではまだTaskReportがない
A> BEGIN
B> BEGIN
A> INSERT INTO `task_reports` (`date`) VALUES ('2016-12-22')
B> INSERT INTO `task_reports` (`date`) VALUES ('2016-12-22') <- 二つ目のTaskReportが作成されてしまう
A> COMMIT
B> COMMIT
A> UPDATE `task_reports` SET `count` = COALESCE(`count`, 0) + 1 WHERE `task_reports`.`id` = 1
B> UPDATE `task_reports` SET `count` = COALESCE(`count`, 0) + 1 WHERE `task_reports`.`id` = 1

ユニーク制約をかける

これではTaskReportが複数作成されてしまいます。ので、ユニーク制約をかけることで防止します。

CREATE TABLE `task_reports` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `date` date DEFAULT NULL,
  `count` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `index_task_reports_on_date` (`date`)
)
A> SELECT `task_reports`.* FROM `task_reports` WHERE `task_reports`.`date` = '2016-12-22' LIMIT 1
B> SELECT `task_reports`.* FROM `task_reports` WHERE `task_reports`.`date` = '2016-12-22' LIMIT 1
A> BEGIN
B> BEGIN
A> INSERT INTO `task_reports` (`date`) VALUES ('2016-12-22')
B> INSERT INTO `task_reports` (`date`) VALUES ('2016-12-22') <- Duplicate entry '2016-12-22' for key 'index_task_reports_on_date' でコケる

ひとまずTaskReportが複数作成されてしまうことは防げましたが、プロセスBにはプロセスAの作成を待ってから取得を行ってほしいですね。

悲観ロックをかける

そこでデータの取得時に悲観ロックをかけてみます。
モデルの lock メソッドを使って、find_or_create_by にロックをかけました。

参考: Railsのデータロック

task_report = TaskReport.lock.find_or_create_by(date: Date.today)
task_report.increment!(:count)

発行されるSQL文はSELECT ~ FOR UPDATEに変わります。

SELECT  `task_reports`.* FROM `task_reports` WHERE `task_reports`.`date` = '2016-12-22' LIMIT 1 FOR UPDATE
BEGIN
INSERT INTO `task_reports` (`date`) VALUES ('2016-12-22')
COMMIT
UPDATE `task_reports` SET `count` = COALESCE(`count`, 0) + 1 WHERE `task_reports`.`id` = 5

これを同時に実行されると、下記のようになります。

A> SELECT `task_reports`.* FROM `task_reports` WHERE `task_reports`.`date` = '2016-12-22' LIMIT 1 FOR UPDATE <- 次のINSERTまで他のINSERTをブロック
B> SELECT `task_reports`.* FROM `task_reports` WHERE `task_reports`.`date` = '2016-12-22' LIMIT 1 FOR UPDATE <- 次のINSERTまで他のINSERTをブロック
A> BEGIN
B> BEGIN
A> INSERT INTO `task_reports` (`date`) VALUES ('2016-12-22') <- 待ち状態になる
B> INSERT INTO `task_reports` (`date`) VALUES ('2016-12-22') <- Deadlock found when trying to get lock; try restarting transaction でコケて、AINSERTが成功する

プロセスAは次のデータ更新があるまでその対象の行をロックし、プロセスBはロック解除まで待つようになります。
しかしプロセスBも同時に行ロックを行うため、互いにロックがかかった状態になりデッドロックに...。

参考: デッドロックを回避するために

また、このSQLにはもう一つ問題があります。
SELECT ~ FOR UPDATE は既に存在するデータに対してであれば、その対象の行だけをロックしますが、この場合は SELECT ~ FOR UPDATE の先が存在しないため、ギャップロックが発生します。
例えば、TaskReport に id = 10 までデータがあるとして、存在しない id = 100 にロックをかけると、id > 10 の行すべてにロックがかかってしまいます。

参考: 良く分かるMySQL Innodbのギャップロック

悲観ロックせずにロールバック

最終的には以下のようになりました。(Retryable gemを使用しています)

require 'retryable'

Retryable.retryable(:on => [ActiveRecord::RecordNotUnique], :tries => 5) do
  ActiveRecord::Base.transaction do
    task_report = TaskReport.find_or_create_by(date: Date.today)
    task_report.increment!(:count)
  end
end

悲観ロックはせず、Duplicate entry ... (ActiveRecord だと ActiveRecord::RecordNotUnique) が発生した場合にロールバックしてリトライするようにしました。
リトライ後は既にレコードが追加されている状態なので、後発の処理での INSERT は実行されません。

また、もう一つ INSERT ~ ON DUPLICATE KEY UPDATE を使用する方法もありますが、その場合はSQLが発行される度に AUTO_INCREMENT の値が増えていってしまう問題があります。

今回でいくつかの方法を調べましたが、最終的にはこのような方法に落ち着くようです。
下記の記事を参考にさせていただいたので、確認してみてください。

参考:
- ActiveRecordのfind_or_create_byが投げるクエリを検証した
- なかったらINSERTしたいし、あるならロック取りたいやん?
- Rails におけるレースコンディションの例とその回避方法
- レコードがなかったらINSERTして返すみたいなのを確実にやる

52
37
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
52
37