テーブルのバッチ反復処理

Railsにはin_batches というメソッドが用意されており、これを使うとバッチで行を反復処理することができます。たとえば

User.in_batches(of: 10) do |relation|
  relation.update_all(updated_at: Time.now)
end

残念ながら、このメソッドはクエリ的にもメモリ使用量的にもあまり効率的ではない方法で実装されています。

これを回避するには、EachBatch モジュールをモデルにインクルードして、each_batch クラスメソッドを使います。例えば

class User < ActiveRecord::Base
  include EachBatch
end

User.each_batch(of: 10) do |relation|
  relation.update_all(updated_at: Time.now)
end

これは次のようなクエリを生成します:

User Load (0.7ms)  SELECT  "users"."id" FROM "users" WHERE ("users"."id" >= 41654)  ORDER BY "users"."id" ASC LIMIT 1 OFFSET 1000
  (0.7ms)  SELECT COUNT(*) FROM "users" WHERE ("users"."id" >= 41654) AND ("users"."id" < 42687)

このメソッドのAPIはin_batches, in_batchesと似てin_batchesいますが、 in_batchesサポートしているin_batchesすべての引数をサポートしている in_batchesわけではありません。in_batches を使用する特別な必要が_ない限り_、常にeach_batch を使用する必要があります。

一意でないカラムに対する繰り返し処理

無限ループになる可能性があるため、(リレーションのコンテキストにおいて)一意でないカラムに対してeach_batch メソッドを使用すべきではありません。さらに、一意でないカラムに対して繰り返し処理を行うと、一貫性のないバッチサイズによってパフォーマンスのイシューが発生します。属性に対して反復処理を行う際に最大バッチサイズを適用しても、結果のバッチがそれを超えないという保証はありません。次のスニペットは、110,000の間にid を持つユーザーのCi::Build エントリを選択しようとすると、データベースは1 215 178 に一致する行を返すという状況を示しています。

[ gstg ] production> Ci::Build.where(user_id: (1..10_000)).size
=> 1215178

これは、構築されたリレーションが以下のクエリに変換されるために発生します:

[ gstg ] production> puts Ci::Build.where(user_id: (1..10_000)).to_sql
SELECT "ci_builds".* FROM "ci_builds" WHERE "ci_builds"."type" = 'Ci::Build' AND "ci_builds"."user_id" BETWEEN 1 AND 10000
=> nil

And 範囲によって一意でないカラムをフィルタリングするクエリWHERE "ci_builds"."user_id" BETWEEN ? AND ? 、範囲のサイズがある閾値に制限されているにもかかわらず ( 前の例では10,000 )、この閾値は返されるデータセットのサイズに変換されません。これは、n 属性の取り得る値を n取るときに、n それを含むレコードの数が. n

ルーズインデックススキャンdistinct_each_batch

一意ではないカラムを繰り返し処理する必要がある場合、distinct_each_batch ヘルパーメソッドを使います。このヘルパーはルースインデックススキャン(skip-index scan) のテクニックを使い、データベースのインデックス内で重複する値をスキップします。

例: イシュー・モデルのauthor_id を繰り返し処理する場合。

Issue.distinct_each_batch(column: :author_id, of: 1000) do |relation|
  users = User.where(id: relation.select(:author_id)).to_a
end

このテクニックは、データのディストリビューションに関係なく、バッチ間で安定したパフォーマンスを提供します。relation オブジェクトは、指定されたcolumn だけが利用可能な ActiveRecord スコープを返します。他のカラムはロードされません。

基礎となるデータベースクエリは再帰的なCTEを使用し、余分なオーバーヘッドを追加します。そのため、標準的なeach_batch の繰り返しで使用されるバッチサイズよりも小さいバッチサイズを使用することをお勧めします。

列の定義

EachBatch はデフォルトでモデルのプライマリキーを反復処理に使います。これはたいていの場合うまくいきますが、場合によってはイテレーションに別のカラムを使いたいこともあるでしょう。

Project.distinct.each_batch(column: :creator_id, of: 10) do |relation|
  puts User.where(id: relation.select(:creator_id)).map(&:id)
end

上のクエリはプロジェクトクリエイターを反復処理し、重複なく出力します。

note
カラムが一意でない (一意なインデックスが定義されていない) 場合は、distinct リレーションのメソッドを distinct呼び出す必要があります。一意でないカラムをdistinct 使用 distinctすると、each_batch 、次のイシューで説明するように無限ループに陥る可能性があります。

EachBatch データマイグレーションにおいて

データマイグレーションを扱うとき、大量のデータを繰り返し処理するのに適した方法はEachBatchを使うことです。

データマイグレーションの特別なケースとして、実際のデータ変更がバックグラウンドジョブで実行されるバッチバックグラウンドマイグレーションがあります。データ範囲(スライス)を決定し、バックグラウンドジョブをスケジュールするマイグレーションコードはeach_batch を使用します。

の効率的な使用法each_batch

EachBatch の効率的な使用は、大きなテーブルを反復処理するのに役立ちます。 EachBatchしかし、反復処理に関連するすべてのパフォーマンス問題を魔法のように解決できるわけではなく、シナリオによってはまったく役に立たないこともあることをEachBatch 強調して EachBatchおきます。データベースの観点からは、EachBatch をうまく動作させるためには、正しく設定されたデータベースインデックスも必要です。

例 1: 単純な反復

users テーブルを繰り返し処理し、User のレコードを標準出力に出力することを考えてみましょう。users テーブルには何百万ものレコードが含まれているため、ユーザーを取得するために1つのクエリを実行するとタイムアウトする可能性があります。

このテーブルは、users テーブルを簡略化したもので、いくつかの行を含んでいます。id この例をもう少し現実的なものにするために、カラムの id隙間を少し小さくしていますid (いくつかのレコードはすでに削除されています) id。このフィールドid にはインデックスが1つ存在 idします:

IDsign_in_countcreated_at:————-:———— 112020-01-01242020-01-01912020-01-0330052020-01-0330192020-01-0330282020-01-0330322020-01-0335012020-01-0335132020-01-0435202020-01-0535392020-01-1135432020-01-12

全ユーザーをメモリにロード(回避):

users = User.all

users.each { |user| puts user.inspect }

each_batch を使用:

# Note: for this example I picked 5 as the batch size, the default is 1_000
User.each_batch(of: 5) do |relation|
  relation.each { |user| puts user.inspect }
end

each_batch の仕組み

最初のステップとして、以下のデータベースクエリを実行することで、テーブルの最下位(id start id)を見つけます:

SELECT "users"."id" FROM "users" ORDER BY "users"."id" ASC LIMIT 1

Reading the start ID value

このクエリはインデックス (INDEX ONLY SCAN) からデータを読み取るだけで、テーブルにはアクセスしていないことに注意してください。データベースのインデックスはソートされているので、最初の項目を取り出すのは非常に安価なオペレーションです。

次のステップは、バッチサイズの設定に従った次の項目(id end id)を見つけることです。この例では、バッチサイズを5としました。EachBatchOFFSET 節を使用して、”シフトされた”id 値を取得します。

SELECT "users"."id" FROM "users" WHERE "users"."id" >= 1 ORDER BY "users"."id" ASC LIMIT 1 OFFSET 5

Reading the end ID value

ここでも、クエリはインデックスを調べるだけです。OFFSET 5 id このクエリは、テーブルサイズや反復回数に関係なく、インデックスから最大6つの項目を読み取ります。

この時点で、最初のバッチのid の範囲がわかりました。次に、relation ブロックのクエリを作成します。

SELECT "users".* FROM "users" WHERE "users"."id" >= 1 AND "users"."id" < 302

Reading the rows from the `users` table

< 。このクエリでは、最後の値が “除外 “となっています。このクエリでは、user の5行のディスク上の位置を取得するためにインデックスを調べ、テーブルから行を読み取ります。返された配列はRubyで処理されます。

最初の繰り返しが終了します。次の反復では、最後の値がid 次の終了 id値を見つけるid ために前の反復から再利用さ idれます。

SELECT "users"."id" FROM "users" WHERE "users"."id" >= 302 ORDER BY "users"."id" ASC LIMIT 1 OFFSET 5

Reading the second end ID value

これで、2回目の反復のためのusers クエリを簡単に構築できます。

SELECT "users".* FROM "users" WHERE "users"."id" >= 302 AND "users"."id" < 353

Reading the rows for the second iteration from the users table

例2: フィルターを使った繰り返し

前の例の上に、サインイン数がゼロのユーザーを表示したいとします。sign_in_count カラムにサインイン数を記録しているので、以下のコードを書きます:

users = User.where(sign_in_count: 0)

users.each_batch(of: 5) do |relation|
  relation.each { |user| puts user.inspect }
end

each_batch startid 値に対して以下のSQLクエリを生成します:

SELECT "users"."id" FROM "users" WHERE "users"."sign_in_count" = 0 ORDER BY "users"."id" ASC LIMIT 1

id 列のみを選択し、order by で並べ替えると、id データベースは id(主キーインデックス) 列のid インデックスを使用 idするようになりますが、sign_in_count 列の条件も追加されます。このカラムはインデックスの一部ではないので、データベースは最初のマッチする行を見つけるために実際のテーブルを調べる必要があります。

Reading the index with extra filter

note
スキャンされる行の数は、テーブル内のデータのディストリビューションに依存します。
  • 最良のシナリオ: 最初のユーザーは一度もログインしていません。データベースは1行しか読み込みません。
  • 最悪のシナリオ: すべてのユーザーが少なくとも一度はログインしていた場合。データベースはすべての行を読み取ります。

この例では、データベースは最初のid 。実際の」アプリケーションでは、フィルタリングが問題を引き起こすかどうかを予測するのは困難です。GitLabの場合、本番環境のレプリカでデータを検証するのは良いスタートです。しかし、GitLab.comでのデータのディストリビューションは、自分で管理するインスタンスとは異なる可能性があることを覚えておきましょう。

フィルタリングを改善するにはeach_batch

特殊な条件付きインデックス
CREATE INDEX index_on_users_never_logged_in ON users (id) WHERE sign_in_count = 0

テーブルと新しく作成されたインデックスはこのようになります:

Reading the specialized index

このインデックス定義は、idsign_in_count 列の条件をカバーし、each_batch クエリを非常に効果的にします(単純な反復の例と同様)。

ユーザーが一度もサインインしていないことは稀なので、インデックスサイズは予想より小さくなります。インデックス定義にid のみを含めることも、インデックスサイズを小さく保つのに役立ちます。

カラムへのインデックス

後日、sign_in_count の値が異なるテーブルを繰り返しフィルタリングしたくなるかもしれません。そのような場合、WHERE の条件が新しいフィルタ(sign_in_count > 10)と一致しないため、先に提案した条件付きインデックスを使用することはできません。

この問題にアドレスするには、2つのオプションがあります:

  • 新しいクエリに対応するために、別の条件付きインデックスを作成します。
  • インデックスをより一般的な設定に置き換えます。
note
同じテーブル、同じカラムに複数のインデックスを持つことは、データを書き込む際にパフォーマンスのボトルネックになる可能性があります。

次のようなインデックスを考えてみましょう(避ける):

CREATE INDEX index_on_users_never_logged_in ON users (id, sign_in_count)

インデックス定義がid 列から始まっているため、データ選択性の観点から非常に非効率的なインデックスになっています。

SELECT "users"."id" FROM "users" WHERE "users"."sign_in_count" = 0 ORDER BY "users"."id" ASC LIMIT 1

上記のクエリを実行すると、INDEX ONLY SCAN が返されます。 しかし、このクエリは、インデックス内の未知の数のエントリを繰り返し処理し、sign_in_count0 である最初の項目を見つける必要があります。

Reading an ineffective index

インデックス定義のカラムを入れ替えることで、このクエリを大幅に改善することができます。

CREATE INDEX index_on_users_never_logged_in ON users (sign_in_count, id)

Reading a good index

以下のインデックス定義はeach_batch (avoid)ではうまく動作しません。

CREATE INDEX index_on_users_never_logged_in ON users (sign_in_count)

each_batchid 列に基づいて範囲クエリを構築するので、このインデックスは効率的に使用できません。DB はテーブルから行を読み込むか、主キーインデックスも読み込むビットマップ検索を使用します。

「遅い “反復

ゆっくりとした反復とは、優れたインデックス設定を使用してテーブルを反復し、得られたリレーションにフィルタリングを適用することを意味します。

User.each_batch(of: 5) do |relation|
  relation.where(sign_in_count: 0).each { |user| puts user inspect }
end

イテレーションは主キーインデックス(id 列)を使用し、ステートメントのタイムアウトを防ぎます。フィルタ(sign_in_count: 0)は、id が既に制約(範囲)されているrelation に適用されます。行数は制限されています。

遅い反復は、一般的に終了までに時間がかかります。反復回数が多くなり、1回の反復で得られるレコード数がバッチサイズより少なくなる可能性があります。反復によってレコードが0件になることもあります。これは最適な解決策ではありませんが、場合によっては(特に大きなテーブルを扱う場合)これが唯一の有効な選択肢となります。

サブクエリの使用

each_batch クエリでサブクエリを使用することは、ほとんどの場合うまくいきません。次の例を見てください:

projects = Project.where(creator_id: Issue.where(confidential: true).select(:author_id))

projects.each_batch do |relation|
  # do something
end

反復はprojects テーブルのid 列を使用します。バッチ処理はサブクエリには影響しません。つまり、各繰り返しで、サブクエリがデータベースによって実行されます。このため、クエリに一定の “負荷 “がかかり、ステートメントがタイムアウトすることがよくあります。私たちには未知の機密事項があり、実行時間とアクセスされるデータベース行数はissues テーブルのデータディストリビューションに依存します。

note
サブクエリの使用は、サブクエリが少数の行を返す場合にのみ有効です。

サブクエリの改良

creator_id 、生成されたrelation オブジェクトの一部とすることができます。

projects = Project.all

projects.each_batch do |relation|
  relation.where(creator_id: Issue.where(confidential: true).select(:author_id))
end

issues テーブルのクエリが十分な性能を持たない場合、入れ子ループが構築される可能性があります。可能な限り避けてください。

projects = Project.all

projects.each_batch do |relation|
  issues = Issue.where(confidential: true)

  issues.each_batch do |issues_relation|
    relation.where(creator_id: issues_relation.select(:author_id))
  end
end

issues テーブルがprojects よりも多くの行を持つことがわかっている場合、クエリを反転させ、issues テーブルを最初にバッチ処理することは理にかなっています。

JOINEXISTS

JOINS を使用する場合:

  • テーブル間に1:1または1:Nのリレーションシップがあり、結合レコードが(ほとんど)常に存在することがわかっている場合。これは、”拡張のような “テーブルに対して効果的です:
    • projects -project_settings
    • users -user_details
    • users -user_statuses
  • LEFT JOIN はこの場合うまく機能します。ジョインテッドテーブルの条件は、イテレーションがジョインテッドテーブルのデータディストリビューションに影響されないように、イールドリレーションに行く必要があります。

使用例:

users = User.joins("LEFT JOIN personal_access_tokens on personal_access_tokens.user_id = users.id")

users.each_batch do |relation|
  relation.where("personal_access_tokens.name = 'name'")
end

EXISTS クエリはeach_batch クエリの内部relation にのみ追加する必要があります:

User.each_batch do |relation|
  relation.where("EXISTS (SELECT 1 FROM ...")
end

リレーションオブジェクトに対する複雑なクエリ

relation オブジェクトにいくつかの追加条件がある場合、実行計画が “不安定 “になる可能性があります。

使用例:

Issue.each_batch do |relation|
  relation
    .joins(:metrics)
    .joins(:merge_requests_closing_issues)
    .where("id IN (SELECT ...)")
    .where(confidential: true)
end

ここでは、relation 問い合わせがユーザレコードのBATCH_SIZE を読み込み、提供されたクエリに従って結果を絞り込むことを想定しています。プランナは、confidential 列のインデックスを使用したビットマップインデックス検索を使用することが、クエリを実行するためのより良い方法であると判断するかもしれません。これは予期せぬ大量の行の読み込みを引き起こし、クエリがタイムアウトする可能性があります。

問題: リレーションが最大BATCH_SIZE のレコードを返すことを確実に知っていますが、プランナはこれを知りません。

一般的なテーブル式(CTE) 範囲クエリを最初に実行させるトリック:

Issue.each_batch(of: 1000) do |relation|
  cte = Gitlab::SQL::CTE.new(:batched_relation, relation.limit(1000))

  scope = cte
    .apply_to(Issue.all)
    .joins(:metrics)
    .joins(:merge_requests_closing_issues)
    .where("id IN (SELECT ...)")
    .where(confidential: true)

  puts scope.to_a
end

レコード数のカウント

データ量の多いテーブルでは、クエリでレコードをカウントするとタイムアウトになることがあります。EachBatch モジュールは、レコードを繰り返しカウントする代替方法を提供します。each_batch を使うことの欠点は、生成されたリレーションオブジェクトに対して実行される追加のカウントクエリです。

each_batch_count メソッドは、余分なカウントクエリが不要になる、より効率的なアプローチです。このメソッドを呼び出すことで、反復処理を一時停止し、必要に応じて再開することができます。この機能は、Sidekiqワーカー内でカウントオペレーションを実行する場合など、5分後にエラーバジェット違反がトリガーされる状況で特に有用です。

例として、EachBatch を使ってレコードをカウントするには、次のように追加のカウントクエリを呼び出します:

count = 0

Issue.each_batch do |relation|
  count += relation.count
end

puts count

一方、each_batch_count メソッドでは、追加のカウントクエリを呼び出すことなく、カウント処理をより効率的に実行することができます(カウントは反復クエリの一部です):

count, _last_value = Issue.each_batch_count # last value can be ignored here

さらに、each_batch_count メソッドでは、カウント処理を一時停止し、任意の時点で再開することができます。この機能は以下のコードスニペットで示されています:

stop_at = Time.current + 3.minutes

count, last_value = Issue.each_batch_count do
  Time.current > stop_at # condition for stopping the counting
end

# Continue the counting later
stop_at = Time.current + 3.minutes

count, last_value = Issue.each_batch_count(last_count: count, last_value: last_value) do
  Time.current > stop_at
end

EachBatch vsBatchCount

Service Ping に新しいカウンタを追加する場合、Gitlab::Database::BatchCount クラスを使用してレコードをカウントする方法が推奨されます。BatchCount に実装されている反復ロジックは、EachBatch と同様のパフォーマンス特性を持っています。BatchCount 上記で述べた BatchCount改善のためのヒントや提案のほとんどは、こちらにもBatchCount 当てはまります BatchCount

キーセットのページネーションによる反復処理

EachBatch EachBatch は、1 つのカラム (通常は主キー) を必要とします。そのため、タイムスタンプのカラムや複合主キーのテーブルでは反復処理を行うことができません。

EachBatch が動作しない場合は、キーセットのページ分割を使用してテーブルまたは行の範囲を反復処理することができます。スケーリングとパフォーマンス特性はEachBatch に非常に似ています。

例:

  • 例: 並べ替えを行うユーザーが一意な値を含まないカラムを持つ場合、タイブレーカーと組み合わせて、特定の順序(タイムスタンプ列)でテーブルを繰り返します。
  • 複合主キーを持つテーブルを繰り返します。

プロジェクト内のイシューを作成日順に反復処理します。

キーセットのページネーシ ョ ン を使用す る と 、 任意のデー タ ベース 列を特定の順序で反復処理す る こ と がで き ます (た と えば、created_at DESC )。created_at同じ値を持つ返されたレコードの順序を一貫したものにするには、一意な値を持つタイブレーカ列を使用します (例:id)。

issues テーブルに以下のインデックスがあるとします:

idx_issues_on_project_id_and_created_at_and_id" btree (project_id, created_at, id)

さらなる処理のためのレコードのフェッチ

以下のスニペットは、指定された順序(created_at, id)を使用してプロジェクト内のイシュー・レコードを反復処理します。

scope = Issue.where(project_id: 278964).order(:created_at, :id) # id is the tie-breaker

iterator = Gitlab::Pagination::Keyset::Iterator.new(scope: scope)

iterator.each_batch(of: 100) do |records|
  puts records.map(&:id)
end

クエリにフィルタを追加することもできます。この例では、過去30日間に作成されたイシューIDのみをリストしています:

scope = Issue.where(project_id: 278964).where('created_at > ?', 30.days.ago).order(:created_at, :id) # id is the tie-breaker

iterator = Gitlab::Pagination::Keyset::Iterator.new(scope: scope)

iterator.each_batch(of: 100) do |records|
  puts records.map(&:id)
end

バッチ内のレコードの更新

複雑なActiveRecord クエリの場合、.update_all メソッドはうまく動作しません。UPDATE ステートメントが正しく生成されないからです。レコードの一括更新には生の SQL を使用できます:

scope = Issue.where(project_id: 278964).order(:created_at, :id) # id is the tie-breaker

iterator = Gitlab::Pagination::Keyset::Iterator.new(scope: scope)

iterator.each_batch(of: 100) do |records|
  ApplicationRecord.connection.execute("UPDATE issues SET updated_at=NOW() WHERE issues.id in (#{records.dup.reselect(:id).to_sql})")
end
note
反復を安定させ、予測しやすくするために、ORDER BY 節の列の更新は避けてください。

merge_request_diff_commits

merge_request_diff_commits テーブルは複合主キー (merge_request_diff_id, relative_order) を使用しているため、EachBatch を効率的に使用することができません。

merge_request_diff_commits テーブルをページ分割するには、以下のスニペットを使用します:

# Custom order object configuration:
order = Gitlab::Pagination::Keyset::Order.build([
  Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
    attribute_name: 'merge_request_diff_id',
    order_expression: MergeRequestDiffCommit.arel_table[:merge_request_diff_id].asc,
    nullable: :not_nullable,
    distinct: false,
  ),
  Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
    attribute_name: 'relative_order',
    order_expression: MergeRequestDiffCommit.arel_table[:relative_order].asc,
    nullable: :not_nullable,
    distinct: false,
  )
])
MergeRequestDiffCommit.include(FromUnion) # keyset pagination generates UNION queries

scope = MergeRequestDiffCommit.order(order)

iterator = Gitlab::Pagination::Keyset::Iterator.new(scope: scope)

iterator.each_batch(of: 100) do |records|
  puts records.map { |record| [record.merge_request_diff_id, record.relative_order] }.inspect
end

オーダーオブジェクトの設定

キーセットのページネーションは、単純なActiveRecord order スコープでうまく動作します(最初の例。しかし、特殊なケースでは、ORDER BY 基礎となるキーセットパジネーションライブラリのために、節に ORDER BYカラムを記述する必要がありますORDER BY (2番目の例) ORDER BY。設定がキーセットページ分割ライブラリによって自動的に決定されないORDER BY 場合 ORDER BY、エラーが発生します。

Gitlab::Pagination::Keyset::Order およびGitlab::Pagination::Keyset::ColumnOrderDefinition クラスのコード・コメントに、ORDER BY 節を設定するために可能なオプションの概要が示されています。また、キーセットページネーションのドキュメントにいくつかのコード例があります。