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 のデフォルトはApplicationRecord1_000 です。

ApplicationRecordからの継承

GitLab コードベースのほとんどのモデルは、ActiveRecord::Base から継承するのではなく、ApplicationRecordCi::ApplicationRecord から継承する必要があります。 これにより、ヘルパーメソッドを簡単に追加できるようになります。

このルールの例外は、データベースのマイグレーションで作成されたモデルです。これらはアプリケーションコードから内部分離されるべきなので、マイグレーションコンテキストでのみ利用可能なMigrationRecord

UNIONの使用

UNIONsはほとんどのRailsアプリケーションではあまり使われませんが、非常に強力で便利です。クエリは、JOIN関連するデータや特定の条件に基づくデータを取得するために JOIN多くのJOINsを 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 はコードベースを通して一般的ですが、EXCEPTINTERSECT の他の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 idID で並べ替えるのではなく、id カラムで id並べ替えることができます。id created_atID は常に一意であり、行が作成された順にインクリメントされるため、この方法でもまったく同じ結果が得られます。また created_at id、デフォルトですでにインデックスが設定されているので、id 一貫したパフォーマンスを保証するためにcreated_atインデックスを追加する必要は created_at idありません。

の代わりにWHERE EXISTS を使用します。WHERE IN

WHERE INWHERE 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

このメソッドは次のようにします:

  1. 一意なカラムによってモデルを検索します。
  2. レコードが見つからない場合は、新しいレコードを作成します。
  3. レコードを永続化します。

ルックアップ クエリとパーシスト クエリの間に短い競合条件があり、別のプロセスがレコードを挿入して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 をUPDATEDELETE で使用すると、予期しない動作が発生する可能性があります:

以下のメソッドを考えてみましょう:

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

この動作を回避するには

  1. レコードのids をクエリします:

    > token_ids = personal_access_token_chain(token).pluck_primary_key
    => [16, 17, 18, 19, 20, 21]
    
  2. この配列を使って、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)
note
制限のない大量のデータの更新は避けてください。データにアプリケーションの制限がない場合、またはデータ量が不明な場合は、バッチでデータを更新する必要があります。