ActiveRecord::Relation#find_or_create_by を使うときは適切に lock を併用しよう
Ruby on Rails の ActiveRecord::Relation
クラス には find_or_create_by
find_or_create_by!
というインスタンスメソッドがあります。このメソッドを複数サーバーで稼働している Rails アプリ上で使うには注意点があることについて、先日私の所属する開発チームで話しました。そのことを紹介します。
ActiveRecord::Relation#find_or_create_by
について
ActiveRecord::Relation
には、指定した条件に合致するレコードをデータベースから検索し、見つからなければ新しいレコードを作成してデータベースに保存する(その後作成したレコードを1件取得する)便利なメソッド find_or_create_by
および失敗時に例外を発生させる find_or_create_by!
があります。これにより、特定の条件を満たすレコードを確実に1行以上作成することができます。
公式ドキュメント ActiveRecord::Relation#find_or_create_by
レコードを検索し、存在しない場合に新規追加するということで、実際に実行される SQL は2つになります。例えば以下のような User
Todo
クラスを定義しているとします。
# Table name: users
# id :bigint not null, primary key
class User < ApplicationRecord
has_many :todos
end
# Table name: todos
# id :bigint not null, primary key
# user_id :bigint not null
# content :string(255) not null
#
# Indexes
# index_todos_on_user_id_and_content (user_id,content) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (user_id => users.id)
#
class Todo < ApplicationRecord
belongs_to :user
end
以下のようなコードを実行するとします。
user = User.find(1)
todo = Todo.find_or_create_by!(user: user, content: 'Sample Todo')
# TODO: todo を使った何らかの処理をここで実行する
このとき以下のような SQL が実行されます。
SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1;
SELECT "todos".* FROM "todos" WHERE "todos"."user_id" = 1 AND "todos"."content" = 'Sample Todo' LIMIT 1;
INSERT INTO "todos" ("user_id", "content", "created_at", "updated_at") VALUES (1, 'Sample Todo', '2024-07-16 10:00:00', '2024-07-16 10:00:00');
SELECT "todos".* FROM "todos" WHERE "todos"."user_id" = 1 AND "todos"."content" = 'Sample Todo' LIMIT 1;
ただし、複数サーバー/マルチプロセスで動作する環境では競合のリスクがあるため注意が必要です。これらのクエリが実行される間に他のプロセスが同じ処理を実行する可能性があるため、 todos
テーブルに定義されているユニークインデックスのエラーが発生することがあります。例えば Sidekiq で非同期処理をしている場合で、処理能力向上のために複数台 Sidekiq を実行しているとすると、 Sidekiq は1つの queue を取り合ってこういった状況になりやすいです。
具体的には、プロセスAとプロセスBという2つの Rails アプリケーションプロセスがそれぞれ同時に処理を実行した場合、処理順が以下のようになる可能性があります。
// ① プロセスA
SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1;
// ② プロセスB
SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1;
// ③ プロセスA
SELECT "todos".* FROM "todos" WHERE "todos"."user_id" = 1 AND "todos"."content" = 'Sample Todo' LIMIT 1;
// ④ プロセスB
SELECT "todos".* FROM "todos" WHERE "todos"."user_id" = 1 AND "todos"."content" = 'Sample Todo' LIMIT 1;
// ⑤ プロセスA
INSERT INTO "todos" ("user_id", "content", "created_at", "updated_at") VALUES (1, 'Sample Todo', '2024-07-16 10:00:00', '2024-07-16 10:00:00');
// ⑥ プロセスB
INSERT INTO "todos" ("user_id", "content", "created_at", "updated_at") VALUES (1, 'Sample Todo', '2024-07-16 10:00:00', '2024-07-16 10:00:00');
// ⑦ プロセスA
SELECT "todos".* FROM "todos" WHERE "todos"."user_id" = 1 AND "todos"."content" = 'Sample Todo' LIMIT 1;
// ⑧ プロセスB
SELECT "todos".* FROM "todos" WHERE "todos"."user_id" = 1 AND "todos"."content" = 'Sample Todo' LIMIT 1;
このとき、プロセスBは④で todos
テーブルを検索して条件に合致するレコードがなかったため⑥で INSERT しようとしていますが、その直前に⑤でプロセスAによってレコードが作成されてしまっているため、 ActiveRecord::RecordInvalid
エラーが発生します。
余談: ActiveRecord::RecordNotUnique
似たエラーに ActiveRecord::RecordNotUnique
がありますが、こちらは SQL 実行時のエラーではなく、 ActiveRecord のバリデーションエラーになります。
以下のようなクラスにしたとします。
class Todo < ApplicationRecord
belongs_to :user
validates :content, uniqueness: { scope: :user_id }
end
このとき、先ほどの SQL でいうと、プロセスBが④の SELECT 文を実行したときにすでに作成済みの同じレコードが見つかると ActiveRecord::RecordNotUnique
エラーになります。
エラーハンドリング
この問題を解決する最も簡単な対応は、エラーハンドリングをすることです。以下のようにすることでエラーが発生しないようにできます。
begin
todo = Todo.find_or_create_by!(user: user, content: 'Sample Todo')
# TODO: todo を使った何らかの処理をここで実行する
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
retry
end
エラーが発生したときに retry
をすることで、2回目の実行ではレコード検索がヒットするので、 INSERT 処理をスキップして処理を続行できます。 Rails のエラーログにも残らず、エラー記録ツールを使っていれば通知なども飛ばないでしょう。しかし、データベース側では依然としてエラーが発生しており、これがデータベースのエラーログに記録されることになります。アプリケーション側でエラーが発生していないように見えても、データベースのパフォーマンスや整合性に影響を与える可能性があります。頻繁に同じ条件でレコードを作成する操作が行われることのある環境では、エラーログ増加や retry
によるデータベース負荷が問題になることがあります。
ActiveRecord::Locking::Pessimistic
を使う
ActiveRecord::Locking::Pessimistic
という悲観ロックを扱うモジュールがあります。 ActiveRecord::Locking::Pessimistic#lock!
ActiveRecord::Locking::Pessimistic#with_lock(&block)
は、 ActiveRecord の行ロック機能を利用するためのメソッドで、データベースのロック機能を使用してレコードの競合を防ぎます。 lock!
with_lock
ともに行ロックを取得しますが、個人的にはブロックを渡す with_lock
を使うほうがスコープが明確になりやすい点でチーム開発作業がしやすいと考えており、ここでは with_lock
を使うことにします。 with_lock
は対象行のトランザクションを開始し、ブロック内の処理が完了するまで他のトランザクションがそのレコードにアクセスできないようにします。これにより、複数のプロセスが同時に同じレコードを操作しようとした場合でも、データの整合性を保つことができます。
公式ドキュメント ActiveRecord::Locking::Pessimistic
以下のように書き換えるとします。
user = User.find(1)
user.with_lock do
Todo.find_or_create_by!(user: user, content: 'Sample Todo')
end
このとき実行される SQL は以下のようになります。
BEGIN;
SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1 FOR UPDATE;
SELECT "todos".* FROM "todos" WHERE "todos"."user_id" = 1 AND "todos"."content" = 'Sample Todo' LIMIT 1;
INSERT INTO "todos" ("user_id", "content", "created_at", "updated_at") VALUES (1, 'Sample Todo', '2023-07-17 12:34:56', '2023-07-17 12:34:56');
COMMIT;
最初の SELECT
クエリに含まれる FOR UPDATE
に注目してください。ここで行ロックを獲得します。複数サーバーが同時に処理を実行しようとしたとき、ここで先に他のプロセスによってロックをとられている場合は重ねてロックをとることができないため、一連の処理が終了するまで待つ必要があります。こうすることで、データベース側でもエラーが発生することはなくなり、 retry
によって SELECT
クエリを無駄打ちする心配もなくなります。
ロックをとる際の注意点
悲観ロックだとロックが解放されるまで他の処理をブロックします。頻繁に呼び出されるレコードについては使うことで逆にパフォーマンスを悪化させる場合があります。そういった場合については、 upsert
などのメソッドを使うことでエラーを無視できます。ただしこれはこれで activerecord の validation など callback をスキップするので、使う際は明示的にバリデーションロジックを実装するなど別の観点で注意が必要になります。
ActiveRecord::Persistence::ClassMethods#upsert
create_or_find_by について
似たメソッドに create_or_find_by
があります。こちらは名前の通り、先に insert 処理を走らせてみて、エラーが起きたらハンドリングする形式になります。実は find_or_create_by
の内部でも使われているものです。レコードが存在しない可能性が高い場合には最初の SELECT 文を発行したくない場合が多いと思いますので、そのときに使うと便利です。
こちらは ActiveRecord::RecordNotFound
エラーが発生した場合にエラーハンドリングを行い、 find_by
を実行するものです。 ActiveRecord::RecordInvalid
は rescue されません。また DB サーバー側でのエラーはそのまま発生します。
ActiveRecord::Relation#create_or_find_by
おわり
以上のように、 find_or_create_by
メソッドを使用する際の競合リスクとその解決策について説明しました。アプリケーションのスケールアップに伴って複数サーバー構成を考える場合には、データベースの整合性を保ちつつパフォーマンスを高く維持するために、適切なロック機構の利用を考える必要があります。今回はロックについての説明はあまりしませんでしたが、それだけで本が書ける内容です。サーバサイドアプリケーション開発に携わるのであればぜひ調べてみてください。
「SHElikes(シーライクス)」を運営するSHEの開発チームがお送りするテックブログです。私たちは社会的不均衡の解決を目指すインパクトスタートアップです。【エンジニア積極採用中】カジュアル面談、副業からのトライアル etc 承っております💪 採用情報 -> bit.ly/3XxywnD
Discussion