テーブルのバッチ反復処理
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
メソッドを使用すべきではありません。さらに、一意でないカラムに対して繰り返し処理を行うと、一貫性のないバッチサイズによってパフォーマンスのイシューが発生します。属性に対して反復処理を行う際に最大バッチサイズを適用しても、結果のバッチがそれを超えないという保証はありません。次のスニペットは、1
と10,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
上のクエリはプロジェクトクリエイターを反復処理し、重複なく出力します。
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
します:
ID | sign_in_count | created_at | – | :————-: | ———— | 1 | 1 | 2020-01-01 | 2 | 4 | 2020-01-01 | 9 | 1 | 2020-01-03 | 300 | 5 | 2020-01-03 | 301 | 9 | 2020-01-03 | 302 | 8 | 2020-01-03 | 303 | 2 | 2020-01-03 | 350 | 1 | 2020-01-03 | 351 | 3 | 2020-01-04 | 352 | 0 | 2020-01-05 | 353 | 9 | 2020-01-11 | 354 | 3 | 2020-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
このクエリはインデックス (INDEX ONLY SCAN
) からデータを読み取るだけで、テーブルにはアクセスしていないことに注意してください。データベースのインデックスはソートされているので、最初の項目を取り出すのは非常に安価なオペレーションです。
次のステップは、バッチサイズの設定に従った次の項目(id
end id
)を見つけることです。この例では、バッチサイズを5としました。EachBatch
はOFFSET
節を使用して、”シフトされた”id
値を取得します。
SELECT "users"."id" FROM "users" WHERE "users"."id" >= 1 ORDER BY "users"."id" ASC LIMIT 1 OFFSET 5
ここでも、クエリはインデックスを調べるだけです。OFFSET 5
id
このクエリは、テーブルサイズや反復回数に関係なく、インデックスから最大6つの項目を読み取ります。
この時点で、最初のバッチのid
の範囲がわかりました。次に、relation
ブロックのクエリを作成します。
SELECT "users".* FROM "users" WHERE "users"."id" >= 1 AND "users"."id" < 302
<
。このクエリでは、最後の値が “除外 “となっています。このクエリでは、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
これで、2回目の反復のためのusers
クエリを簡単に構築できます。
SELECT "users".* FROM "users" WHERE "users"."id" >= 302 AND "users"."id" < 353
例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
列の条件も追加されます。このカラムはインデックスの一部ではないので、データベースは最初のマッチする行を見つけるために実際のテーブルを調べる必要があります。
- 最良のシナリオ: 最初のユーザーは一度もログインしていません。データベースは1行しか読み込みません。
- 最悪のシナリオ: すべてのユーザーが少なくとも一度はログインしていた場合。データベースはすべての行を読み取ります。
この例では、データベースは最初のid
。実際の」アプリケーションでは、フィルタリングが問題を引き起こすかどうかを予測するのは困難です。GitLabの場合、本番環境のレプリカでデータを検証するのは良いスタートです。しかし、GitLab.comでのデータのディストリビューションは、自分で管理するインスタンスとは異なる可能性があることを覚えておきましょう。
フィルタリングを改善するにはeach_batch
特殊な条件付きインデックス
CREATE INDEX index_on_users_never_logged_in ON users (id) WHERE sign_in_count = 0
テーブルと新しく作成されたインデックスはこのようになります:
このインデックス定義は、id
とsign_in_count
列の条件をカバーし、each_batch
クエリを非常に効果的にします(単純な反復の例と同様)。
ユーザーが一度もサインインしていないことは稀なので、インデックスサイズは予想より小さくなります。インデックス定義にid
のみを含めることも、インデックスサイズを小さく保つのに役立ちます。
カラムへのインデックス
後日、sign_in_count
の値が異なるテーブルを繰り返しフィルタリングしたくなるかもしれません。そのような場合、WHERE
の条件が新しいフィルタ(sign_in_count > 10
)と一致しないため、先に提案した条件付きインデックスを使用することはできません。
この問題にアドレスするには、2つのオプションがあります:
- 新しいクエリに対応するために、別の条件付きインデックスを作成します。
- インデックスをより一般的な設定に置き換えます。
次のようなインデックスを考えてみましょう(避ける):
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_count
が0
である最初の項目を見つける必要があります。
インデックス定義のカラムを入れ替えることで、このクエリを大幅に改善することができます。
CREATE INDEX index_on_users_never_logged_in ON users (sign_in_count, id)
以下のインデックス定義はeach_batch
(avoid)ではうまく動作しません。
CREATE INDEX index_on_users_never_logged_in ON users (sign_in_count)
each_batch
はid
列に基づいて範囲クエリを構築するので、このインデックスは効率的に使用できません。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
テーブルのデータディストリビューションに依存します。
サブクエリの改良
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
テーブルを最初にバッチ処理することは理にかなっています。
JOIN
とEXISTS
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
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
節を設定するために可能なオプションの概要が示されています。また、キーセットページネーションのドキュメントにいくつかのコード例があります。