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

  • 10
    いいね
  • 1
    コメント

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して返すみたいなのを確実にやる