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 でコケて、AのINSERTが成功する
プロセスAは次のデータ更新があるまでその対象の行をロックし、プロセスBはロック解除まで待つようになります。
しかしプロセスBも同時に行ロックを行うため、互いにロックがかかった状態になりデッドロックに...。
参考: デッドロックを回避するために
また、このSQLにはもう一つ問題があります。
SELECT ~ FOR UPDATE
は既に存在するデータに対してであれば、その対象の行だけをロックしますが、この場合は SELECT ~ FOR UPDATE
の先が存在しないため、ギャップロックが発生します。
例えば、TaskReport に id = 10 までデータがあるとして、存在しない id = 100 にロックをかけると、id > 10 の行すべてにロックがかかってしまいます。
悲観ロックせずにロールバック
最終的には以下のようになりました。(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
の値が増えていってしまう問題があります。
今回でいくつかの方法を調べましたが、最終的にはこのような方法に落ち着くようです。
下記の記事を参考にさせていただいたので、確認してみてください。
参考: