🧵

ActiveRecord::Relation#find_or_create_by を使うときは適切に lock を併用しよう

2024/07/26に公開

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 メソッドを使用する際の競合リスクとその解決策について説明しました。アプリケーションのスケールアップに伴って複数サーバー構成を考える場合には、データベースの整合性を保ちつつパフォーマンスを高く維持するために、適切なロック機構の利用を考える必要があります。今回はロックについての説明はあまりしませんでしたが、それだけで本が書ける内容です。サーバサイドアプリケーション開発に携わるのであればぜひ調べてみてください。

SHE Tech Blog

Discussion