LIKE
文の使用LIKE
インデックス- データベースのカラムを確実に参照
- IDの抜き取り
- ApplicationRecordからの継承
- UNIONの使用
- 作成日順
- の代わりに
WHERE EXISTS
を使用します。WHERE IN
-
.find_or_create_by
はアトミックではありません -
代替案1:
UPSERT
- 本番環境でのSQLクエリの監視
- 一般的な表式を使用する場合
SQLクエリのガイドライン
このドキュメントでは、ActiveRecord/Arelまたは生のSQLクエリを使用してSQLクエリを記述する際の様々なガイドラインについて説明します。
LIKE
文の使用
データを検索する最も一般的な方法は、LIKE
文を使用することです。例えば、タイトルが “Draft: “で始まるすべてのイシューを取得するには、次のようなクエリを記述します:
SELECT *
FROM issues
WHERE title LIKE 'Draft:%';
PostgreSQL ではこのLIKE
文は大文字小文字を区別 LIKE
します。大文字LIKE
小文字を区別しない LIKE
検索を行うには、ILIKE
を代わりに使用する必要があります。
Arelは自動的にPostgreSQL上でILIKE
。 これを自動的に処理するためには、生のSQLフラグメントの代わりにArelを使用したLIKE
クエリを使用してください。
Issue.where('title LIKE ?', 'Draft:%')
代わりにこのように書きます:
Issue.where(Issue.arel_table[:title].matches('Draft:%'))
ここで、matches
は、使用するデータベースに応じて正しいLIKE
/ILIKE
ステートメントを生成します。
複数のOR
条件を連鎖させる必要がある場合は、Arelを使用してこれを行うこともできます:
table = Issue.arel_table
Issue.where(table[:title].matches('Draft:%').or(table[:foo].matches('Draft:%')))
PostgreSQLでは、これは以下のようになります:
SELECT *
FROM issues
WHERE (title ILIKE 'Draft:%' OR foo ILIKE 'Draft:%')
LIKE
インデックス
PostgreSQLは、LIKE
/ILIKE
の先頭にワイルドカードを使用した場合、インデックスを使用しません。例えば、これはインデックスを使用しません:
SELECT *
FROM issues
WHERE title ILIKE '%Draft:%';
ILIKE
の値がワイルドカードで始まるため、データベースはインデックスを使用することができません。
幸運なことに、_PostgreSQLは_trigram Generalized Inverted Index(GIN) インデックスという解決策を提供しています。これらのインデックスは以下のように作成することができます:
CREATE INDEX [CONCURRENTLY] index_name_here
ON table_name
USING GIN(column_name gin_trgm_ops);
ここで重要なのはGIN(column_name gin_trgm_ops)
の部分です。これは演算子クラスをgin_trgm_ops
に設定したGINインデックスを作成します。 これらのインデックスはILIKE
/LIKE
_。_これらのインデックスの欠点の1つは、(インデックスされるデータ量に依存しますが)非常に大きくなりやすいことです。
これらのインデックスの命名に一貫性を持たせるために、以下の命名パターンを使用してください:
index_TABLE_on_COLUMN_trigram
例えば、issues.title
の GIN/trigram インデックスはindex_issues_on_title_trigram
となります。
これらのインデックスの構築にはかなりの時間がかかるため、同時に構築する必要があります。これは、CREATE INDEX
の代わりにCREATE INDEX CONCURRENTLY
を使用することで可能です。同時実行インデックスはトランザクション内では作成_できません_。マイグレーション用のトランザクションは、以下のパターンで無効にすることができます:
class MigrationName < Gitlab::Database::Migration[2.1]
disable_ddl_transaction!
end
使用例:
class AddUsersLowerUsernameEmailIndexes < Gitlab::Database::Migration[2.1]
disable_ddl_transaction!
def up
execute 'CREATE INDEX CONCURRENTLY index_on_users_lower_username ON users (LOWER(username));'
execute 'CREATE INDEX CONCURRENTLY index_on_users_lower_email ON users (LOWER(email));'
end
def down
remove_index :users, :index_on_users_lower_username
remove_index :users, :index_on_users_lower_email
end
end
データベースのカラムを確実に参照
ActiveRecord はデフォルトで、クエリされたデータベーステーブルのすべてのカラムを返します。場合によっては、返される行をカスタマイズする必要があるかもしれません:
- データベースから返されるデータ量を減らすために、いくつかのカラムだけを指定します。
-
JOIN
リレーションシップのカラムを含めます。 - 計算の実行 (
SUM
,COUNT
).
この例では、カラムは指定しますが、テーブルは指定しません:
-
path
projects
テーブルから -
user_id
merge_requests
テーブルから
クエリ:
# bad, avoid
Project.select("path, user_id").joins(:merge_requests) # SELECT path, user_id FROM "projects" ...
その後、新機能が追加され、projects
テーブルにカラムが追加されました:user_id
。デプロイ中に、データベースのマイグレーションはすでに実行されているが、アプリケーションコードの新しいバージョンはまだデプロイされていない、短い時間ウィンドウがあるかもしれません。この期間に上記のクエリを実行すると、以下のエラーメッセージが表示され、クエリが失敗します:PG::AmbiguousColumn: ERROR: column reference "user_id" is ambiguous
この問題は、データベースからの属性の選択方法に起因しています。user_id
列はusers
テーブルとmerge_requests
テーブルの両方に存在します。クエリ・プランナは、user_id
列を検索する際にどちらのテーブルを使用するかを決定できません。
カスタマイズされたSELECT
文を書く場合、テーブル名で列を明示的に指定する方が良いでしょう。
良い
Project.select(:path, 'merge_requests.user_id').joins(:merge_requests)
# SELECT "projects"."path", merge_requests.user_id as user_id FROM "projects" ...
Project.select(:path, :'merge_requests.user_id').joins(:merge_requests)
# SELECT "projects"."path", "merge_requests"."id" as user_id FROM "projects" ...
Arel (arel_table
) を使った例:
Project.select(:path, MergeRequest.arel_table[:user_id]).joins(:merge_requests)
# SELECT "projects"."path", "merge_requests"."user_id" FROM "projects" ...
生のSQLクエリを書く場合:
SELECT projects.path, merge_requests.user_id FROM "projects"...
生のSQLクエリがパラメータ化されている場合(エスケープが必要):
include ActiveRecord::ConnectionAdapters::Quoting
"""
SELECT
#{quote_table_name('projects')}.#{quote_column_name('path')},
#{quote_table_name('merge_requests')}.#{quote_column_name('user_id')}
FROM ...
"""
悪い(避ける)
Project.select('id, path, user_id').joins(:merge_requests).to_sql
# SELECT id, path, user_id FROM "projects" ...
Project.select("path", "user_id").joins(:merge_requests)
# SELECT "projects"."path", "user_id" FROM "projects" ...
# or
Project.select(:path, :user_id).joins(:merge_requests)
# SELECT "projects"."path", "user_id" FROM "projects" ...
カラムリストが指定されると、ActiveRecord はprojects
テーブルで定義されているカラムと引数をマッチさせようとし、テーブル名を自動的に前置します。この場合、id
列は問題ありませんが、user_id
列は予期しないデータを返す可能性があります:
Project.select(:id, :user_id).joins(:merge_requests)
# Before deployment (user_id is taken from the merge_requests table):
# SELECT "projects"."id", "user_id" FROM "projects" ...
# After deployment (user_id is taken from the projects table):
# SELECT "projects"."id", "projects"."user_id" FROM "projects" ...
IDの抜き取り
ActiveRecordのpluck
、他のクエリの引数として使用するためだけに、一連の値をメモリに取り込んではいけません。例えば、これは余計なデータベースクエリを実行し、不必要なデータを大量にメモリにロードします:
projects = Project.all.pluck(:id)
MergeRequest.where(source_project_id: projects)
その代わりに、サブクエリを使うことができます:
MergeRequest.where(source_project_id: Project.all.select(:id))
pluck
を使う必要があるのは、Ruby内部で実際に値をオペレーションする必要がある_場合だけ_です(たとえばファイルに書き出すなど)。それ以外のほとんどのケースでは、「サブクエリを使うだけではだめなのか?
CodeReuse/ActiveRecord
と同様に、pluck(:id)
やpluck(:user_id)
のような形式はモデルコード内部でのみ使用するようにしてください。前者の場合、ApplicationRecord
-provided.pluck_primary_key
ヘルパーメソッドを代わりに使うことができます。後者の場合は、関連するモデルに小さなヘルパーメソッドを追加してください。
pluck
を使う強い理由がある場合、取り出されるレコードの数を制限することは理にかなっているかもしれません。MAX_PLUCK
のデフォルトはApplicationRecord
の1_000
です。
ApplicationRecordからの継承
GitLab コードベースのほとんどのモデルは、ActiveRecord::Base
から継承するのではなく、ApplicationRecord
やCi::ApplicationRecord
から継承する必要があります。 これにより、ヘルパーメソッドを簡単に追加できるようになります。
このルールの例外は、データベースのマイグレーションで作成されたモデルです。これらはアプリケーションコードから内部分離されるべきなので、マイグレーションコンテキストでのみ利用可能なMigrationRecord
。
UNIONの使用
UNION
sはほとんどのRailsアプリケーションではあまり使われませんが、非常に強力で便利です。クエリは、JOIN
関連するデータや特定の条件に基づくデータを取得するために JOIN
多くのJOIN
sを JOIN
使用する傾向がJOIN
ありますが、 JOIN
関連するデータが大きくなるにつれてパフォーマンスがすぐに低下する可能性があります。
たとえば、名前に値が含まれる_、または_名前空間名に値が含まれるプロジェクトのリストを取得したい場合、ほとんどの人は次のようなクエリを書くでしょう:
SELECT *
FROM projects
JOIN namespaces ON namespaces.id = projects.namespace_id
WHERE projects.name ILIKE '%gitlab%'
OR namespaces.name ILIKE '%gitlab%';
大規模なデータベースを使用すると、このクエリの実行に800ミリ秒程度かかることがあります。大規模なデータベースを使用すると、このクエリの実行に約 800 ミリ秒かかります。UNION
を使用すると、代わりに次のように記述します:
SELECT projects.*
FROM projects
WHERE projects.name ILIKE '%gitlab%'
UNION
SELECT projects.*
FROM projects
JOIN namespaces ON namespaces.id = projects.namespace_id
WHERE namespaces.name ILIKE '%gitlab%';
このクエリは、全く同じレコードを返しながら、約15ミリ秒で完了します。
このことは、UNIONをどこでも使い始めるべきだということではありませんが、クエリで多くのJOINを使用し、結合されたデータに基づいてレコードをフィルタリングする際に覚えておくべきことです。
GitLab にはGitlab::SQL::Union
クラスが用意されており、これを使うと複数のActiveRecord::Relation
オブジェクトからUNION
を作ることができます。このクラスは、次のように使います:
union = Gitlab::SQL::Union.new([projects, more_projects, ...])
Project.from("(#{union.to_sql}) projects")
FromUnion
モデルコンサーンションは、上記と同じ結果を得るためのより便利な方法を提供します:
class Project
include FromUnion
...
end
Project.from_union(projects, more_projects, ...)
UNION
はコードベースを通して一般的ですが、EXCEPT
やINTERSECT
の他のSQLセットオペレーションを使うことも可能です:
class Project
include FromIntersect
include FromExcept
...
end
intersected = Project.from_intersect(all_projects, project_set_1, project_set_2)
excepted = Project.from_except(all_projects, project_set_1, project_set_2)
UNION
サブクエリにおける不揃いのカラム
UNION
クエリのSELECT
節に不揃いの列がある場合、データベースはエラーを返します。以下のUNION
クエリを考えてみましょう:
SELECT id FROM users WHERE id = 1
UNION
SELECT id, name FROM users WHERE id = 2
end
このクエリの結果は以下のエラー・メッセージになります:
each UNION query must have the same number of columns
この問題は明らかであり、開発者であれば簡単に修正できます。エッジケースの1つは、UNION
クエリが明示的な列リストと組み合わされ、そのリストがActiveRecord
スキーマキャッシュから来る場合です。
例(悪い例なので避けてください):
scope1 = User.select(User.column_names).where(id: [1, 2, 3]) # selects the columns explicitly
scope2 = User.where(id: [10, 11, 12]) # uses SELECT users.*
User.connection.execute(Gitlab::SQL::Union.new([scope1, scope2]).to_sql)
このコードがデプロイされたとき、すぐに問題が発生するわけではありません。別の開発者がusers
テーブルに新しいデータベースカラムを追加すると、このクエリは実運用で壊れ、ダウンタイムの原因となります。2番目のクエリ (SELECT users.*
) には新しく追加されたカラムが含まれますが、1番目のクエリには含まれません。column_names
メソッドは古い値(新しい列がない)を返します。これは、値がActiveRecord
スキーマ・キャッシュ内にキャッシュされているためです。これらの値は通常、アプリケーションの起動時に設定されます。
この時点では、スキーマキャッシュが更新されるようにアプリケーションを再起動するしかありません。GitLab 16.1 以降、スキーマキャッシュは自動的にリセットされ、その後のクエリが成功するようになります。このリセットを無効にするには、ops
機能フラグreset_column_information_on_statement_invalid
を無効にします。
この問題は、SELECT users.*
を常に使うか、カラムを常に明示的に定義することで回避できます。
SELECT users.*
の使用:
# Bad, avoid it
scope1 = User.select(User.column_names).where(id: [1, 2, 3])
scope2 = User.where(id: [10, 11, 12])
# Good, both queries generate SELECT users.*
scope1 = User.where(id: [1, 2, 3])
scope2 = User.where(id: [10, 11, 12])
User.connection.execute(Gitlab::SQL::Union.new([scope1, scope2]).to_sql)
明示的なカラムリストの定義
# Good, the SELECT columns are consistent
columns = User.cached_column_list # The helper returns fully qualified (table.column) column names (Arel)
scope1 = User.select(*columns).where(id: [1, 2, 3]) # selects the columns explicitly
scope2 = User.select(*columns).where(id: [10, 11, 12]) # uses SELECT users.*
User.connection.execute(Gitlab::SQL::Union.new([scope1, scope2]).to_sql)
作成日順
レコードが作成された時間に基づいてレコードを並べ替える場合、id
created_at
. created_at
id
ID で並べ替えるのではなく、id
カラムで id
並べ替えることができます。id
created_at
ID は常に一意であり、行が作成された順にインクリメントされるため、この方法でもまったく同じ結果が得られます。また created_at
id
、デフォルトですでにインデックスが設定されているので、id
一貫したパフォーマンスを保証するためにcreated_at
インデックスを追加する必要は created_at
id
ありません。
の代わりにWHERE EXISTS
を使用します。WHERE IN
WHERE IN
とWHERE EXISTS
を使用して同じデータを生成することができますが、可能な限りWHERE EXISTS
を使用することを推奨します。多くの場合、PostgreSQLはWHERE IN
を非常にうまく最適化することができますが、WHERE EXISTS
の方が(はるかに)性能が良い場合も多くあります。
Railsでは、SQLフラグメントを作成してこれを使う必要があります:
Project.where('EXISTS (?)', User.select(1).where('projects.creator_id = users.id AND users.foo = X'))
すると、以下のようなクエリが生成されます:
SELECT *
FROM projects
WHERE EXISTS (
SELECT 1
FROM users
WHERE projects.creator_id = users.id
AND users.foo = X
)
.find_or_create_by
はアトミックではありません
.find_or_create_by
や.first_or_create
などのメソッドの内部パターンは、アトミックではないということです。つまり、最初にSELECT
を実行し、結果がなければINSERT
を実行します。並行処理を念頭に置くと、2つの類似したレコードを挿入しようとするレースコンディションが発生する可能性があります。これは望ましくないかもしれませんし、例えば制約違反のためにクエリの1つが失敗するかもしれません。
トランザクションを使用しても、この問題は解決しません。
これを解決するために、ApplicationRecord.safe_find_or_create_by
.
このメソッドはfind_or_create_by
と同じように使用できますが、呼び出しを新しいトランザクション(またはサブトランザクション)でラップし、ActiveRecord::RecordNotUnique
のエラーで失敗した場合に再試行します。
このメソッドを使えるようにするには、このメソッドを使いたいモデルがApplicationRecord
を継承していることを確認してください。
Rails 6以降では、.create_or_find_by
メソッドがあります。このメソッドは.safe_find_or_create_by
メソッドとは異なり、INSERT
を実行し、その呼び出しが失敗した場合にのみSELECT
コマンドを実行します。
INSERT
が失敗すると、デッドタプルが残り、主キー列が(もしあれば)インクリメントされるなどの不都合が生じます。
最初にレコードが作成された後、そのレコードが再利用される一般的な経路であれば、.safe_find_or_create_by
。しかし、より一般的なパスが新しいレコードを作成することであり、エッジケース(例えば、ジョブのリトライ)で挿入される重複レコードを避けたいだけであれば、.create_or_find_by
を使用することで、SELECT
を節約することができます。
どちらのメソッドも、既存のトランザクションのコンテキスト内で実行された場合、内部でサブトランザクションを使用します。これは、特に1つのトランザクション内で64以上のライブサブトランザクションが使用されている場合、全体的なパフォーマンスに大きな影響を与える可能性があります。
.safe_find_or_create_by
は使えますか?
あなたのコードが一般的に内部で分離されており (例えばワーカーのみで実行される)、他のトランザクションにラップされていないのであれば、.safe_find_or_create_by
を使うことができます。しかし、トランザクション内で他の誰かがあなたのコードを呼び出した場合に、それを捕捉するツールはありません。.safe_find_or_create_by
を使うことは、現時点では完全に排除できないリスクを伴うことは間違いありません。
さらに、.safe_find_or_create_by
の使用を防ぐ RuboCop ルールPerformance/ActiveRecordSubtransactionMethods
があります。このルールは、# rubocop:disable Performance/ActiveRecordSubtransactionMethods
を使ってケースバイケースで無効にすることができます。
代替案1:UPSERT
.upsert
メソッドは、テーブルが一意なインデックスでバックアップされている場合の代替ソリューションです。
.upsert
メソッドの簡単な使い方:
BuildTrace.upsert(
{
build_id: build_id,
title: title
},
unique_by: :build_id
)
注意すべき点
- 主キーのシーケンスは、レコードが更新されただけでもインクリメントされます。
- 作成されたレコードは返されません。
returning
オプションは、INSERT
が発生した (新しいレコードが作成された) 場合にのみデータを返します。 -
ActiveRecord
バリデーションは実行されません。
.upsert
メソッドのバリデーションとレコードロードの例です:
params = {
build_id: build_id,
title: title
}
build_trace = BuildTrace.new(params)
unless build_trace.valid?
raise 'notify the user here'
end
BuildTrace.upsert(params, unique_by: :build_id)
build_trace = BuildTrace.find_by!(build_id: build_id)
# do something with build_trace here
.upsert
を呼び出す前にバリデーションを呼び出しているため、build_id
列にモデルレベルの一意性バリデーションがある場合、上記のスニペットはうまく動作しません。
これを回避するには、2つのオプションがあります:
-
ActiveRecord
モデルから単一性の検証を削除します。 -
on
キーワード を使用し、コンテキスト固有の検証を実装してください。
代替2:存在確認と救出
同じレコードが同時に作成される可能性が非常に低い場合、より単純な方法を使うことができます:
def my_create_method
params = {
build_id: build_id,
title: title
}
build_trace = BuildTrace
.where(build_id: params[:build_id])
.first
build_trace = BuildTrace.new(params) if build_trace.blank?
build_trace.update!(params)
rescue ActiveRecord::RecordInvalid => invalid
retry if invalid.record&.errors&.of_kind?(:build_id, :taken)
end
このメソッドは次のようにします:
- 一意なカラムによってモデルを検索します。
- レコードが見つからない場合は、新しいレコードを作成します。
- レコードを永続化します。
ルックアップ クエリとパーシスト クエリの間に短い競合条件があり、別のプロセスがレコードを挿入してActiveRecord::RecordInvalid
例外が発生する可能性があります。
コードはこの例外を回避し、オペレーションを再試行します。2回目の実行では、レコードは正常に配置されます。たとえば、PreventApprovalByAuthorService
のコードのブロックを確認してください。
本番環境でのSQLクエリの監視
GitLabのチームメンバーは、GitLab.comのPostgreSQLログを使って、遅いクエリやキャンセルされたクエリを監視することができます。
詳細はランブックをご覧ください。
一般的な表式を使用する場合
共通テーブル式 (CTE) を使用して、より複雑なクエリ内で一時的な結果セットを作成できます。また、再帰 CTE を使用して、クエリ自体の中で CTE の結果セットを参照することもできます。以下の例は、previous_personal_access_token_id
列で互いに参照し合うpersonal access tokens
のチェーンをクエリします。
WITH RECURSIVE "personal_access_tokens_cte" AS (
(
SELECT
"personal_access_tokens".*
FROM
"personal_access_tokens"
WHERE
"personal_access_tokens"."previous_personal_access_token_id" = 15)
UNION (
SELECT
"personal_access_tokens".*
FROM
"personal_access_tokens",
"personal_access_tokens_cte"
WHERE
"personal_access_tokens"."previous_personal_access_token_id" = "personal_access_tokens_cte"."id"))
SELECT
"personal_access_tokens".*
FROM
"personal_access_tokens_cte" AS "personal_access_tokens"
id | previous_personal_access_token_id
----+-----------------------------------
16 | 15
17 | 16
18 | 17
19 | 18
20 | 19
21 | 20
(6 rows)
CTE は一時的な結果セットなので、SELECT
文の内部で使用できます。CTE をUPDATE
やDELETE
で使用すると、予期しない動作が発生する可能性があります:
以下のメソッドを考えてみましょう:
def personal_access_token_chain(token)
cte = Gitlab::SQL::RecursiveCTE.new(:personal_access_tokens_cte)
personal_access_token_table = Arel::Table.new(:personal_access_tokens)
cte << PersonalAccessToken
.where(personal_access_token_table[:previous_personal_access_token_id].eq(token.id))
cte << PersonalAccessToken
.from([personal_access_token_table, cte.table])
.where(personal_access_token_table[:previous_personal_access_token_id].eq(cte.table[:id]))
PersonalAccessToken.with.recursive(cte.to_arel).from(cte.alias_to(personal_access_token_table))
end
データをクエリするために使用すると、期待どおりに動作します:
> personal_access_token_chain(token)
WITH RECURSIVE "personal_access_tokens_cte" AS (
(
SELECT
"personal_access_tokens".*
FROM
"personal_access_tokens"
WHERE
"personal_access_tokens"."previous_personal_access_token_id" = 11)
UNION (
SELECT
"personal_access_tokens".*
FROM
"personal_access_tokens",
"personal_access_tokens_cte"
WHERE
"personal_access_tokens"."previous_personal_access_token_id" = "personal_access_tokens_cte"."id"))
SELECT
"personal_access_tokens".*
FROM
"personal_access_tokens_cte" AS "personal_access_tokens"
しかし、#update_all
を使用すると、CTE は削除されます。その結果、このメソッドはテーブル全体を更新します:
> personal_access_token_chain(token).update_all(revoked: true)
UPDATE
"personal_access_tokens"
SET
"revoked" = TRUE
この動作を回避するには
-
レコードの
ids
をクエリします:> token_ids = personal_access_token_chain(token).pluck_primary_key => [16, 17, 18, 19, 20, 21]
-
この配列を使って、
PersonalAccessTokens
:PersonalAccessToken.where(id: token_ids).update_all(revoked: true)
あるいは、この2つのステップを組み合わせてください:
PersonalAccessToken
.where(id: personal_access_token_chain(token).pluck_primary_key)
.update_all(revoked: true)