サブクエリを使用した大きなテーブルでの更新が遅い


16

SourceTable> 15MMレコードとなるBad_Phrase> 3Kの記録を持つ、次のクエリは、SQL Server 2005のSP4上で実行するために、ほぼ10時間かかります。

UPDATE [SourceTable] 
SET 
    Bad_Count=
             (
               SELECT 
                  COUNT(*) 
               FROM Bad_Phrase 
               WHERE 
                  [SourceTable].Name like '%'+Bad_Phrase.PHRASE+'%'
             )

英語では、このクエリは、フィールドのサブているBad_Phraseに記載されている明確なフレーズの数カウントしているName中でSourceTable、その後のフィールドにその結果を置くことをBad_Count

このクエリを非常に高速に実行する方法についての提案をお願いします。


3
テーブルを3K回スキャンし、15MM行すべてを3K回すべて更新する可能性があり、高速であると期待していますか?
アーロンバートランド

1
名前列の長さは?テストデータを生成し、この非常に遅いクエリを私たちの誰もができる方法で再現するスクリプトまたはSQLフィドルを投稿できますか?たぶん私は楽観主義者かもしれませんが、10時間よりもはるかにうまくやれると感じています。これは計算コストの高い問題であると他のコメント者にも同意しますが、なぜそれを「かなり高速に」することを目標にできないのかはわかりません。
ジェフパターソン

3
マシュー、フルテキストインデックス作成を検討しましたか?CONTAINSなどを使用しても、その検索のインデックス作成の利点を活用できます。
-swasheck

この場合、行ベースのロジックを試すことをお勧めします(つまり、15MM行を1回更新する代わりに、SourceTableの各行を15MM更新するか、比較的小さなチャンクを更新します)。合計時間が速くなるわけではありませんが(この特定のケースでは可能ですが)、そのようなアプローチにより、システムの残りの部分が中断することなく動作し続けることができ、トランザクションログサイズを制御できます(たとえば、10000更新ごとにコミット)、中断以前の更新をすべて失うことなく、いつでも更新します
...-a1ex07

2
@swasheckフルテキストは検討することをお勧めします(2005年に新しくなったので、ここに適用できると思います)。しかし、フルテキストは単語をインデックス化するため、投稿者が要求したのと同じ機能を提供することはできません任意の部分文字列。別の言い方をすれば、フルテキストでは、「ファンタスティック」という単語内で「アリ」に一致するものが見つかりません。ただし、フルテキストが適用可能になるようにビジネス要件を変更できる可能性があります。
ジェフパターソン

回答:


21

これは計算コストの高い問題であると他のコメント者にも同意しますが、使用しているSQLを微調整することで改善の余地があると思います。説明のために、15MMの名前と3Kフレーズで偽のデータセットを作成し、古いアプローチを実行し、新しいアプローチを実行しました。

偽のデータセットを生成し、新しいアプローチを試すための完全なスクリプト

TL; DR

私のマシンとこの偽のデータセットでは、元のアプローチの実行には約4時間かかります。提案された新しいアプローチには約10分かかり、かなり改善されます。提案されたアプローチの短い要約はここにあります:

  • 各名前について、各文字オフセットで始まる部分文字列を生成します(最適化として、最長の不良フレーズの長さで制限します)
  • これらの部分文字列にクラスター化インデックスを作成します
  • 悪いフレーズごとに、これらの部分文字列を検索して、一致するものを特定します
  • 元の文字列ごとに、その文字列の1つ以上の部分文字列に一致する明確な不良フレーズの数を計算します


元のアプローチ:アルゴリズム分析

元のUPDATEステートメントの計画から、作業量は名前の数(15MM)とフレーズの数(3K)の両方に直線的に比例することがわかります。したがって、名前とフレーズの両方の数を10倍すると、全体の実行時間は最大で100倍遅くなります。

クエリは実際には長さにも比例しnameます。これはクエリプランには少し隠されていますが、テーブルスプールをシークするための「実行数」に含まれています。実際のプランでは、これはに1回だけではなくname、実際に内の文字オフセットごとに1 回発生することがわかりますname。したがって、このアプローチは、実行時の複雑さにおいてO(# names* # phrases* name length)です。

ここに画像の説明を入力してください


新しいアプローチ:コード

このコードは完全なペーストビンでも使用できますが、便宜上ここにコピーしました。pastebinには完全なプロシージャ定義もあります。これには、現在のバッチの境界を定義するために以下に示す変数@minId@maxId変数が含まれます。

-- For each name, generate the string at each offset
DECLARE @maxBadPhraseLen INT = (SELECT MAX(LEN(phrase)) FROM Bad_Phrase)
SELECT s.id, sub.sub_name
INTO #SubNames
FROM (SELECT * FROM SourceTable WHERE id BETWEEN @minId AND @maxId) s
CROSS APPLY (
    -- Create a row for each substring of the name, starting at each character
    -- offset within that string.  For example, if the name is "abcd", this CROSS APPLY
    -- will generate 4 rows, with values ("abcd"), ("bcd"), ("cd"), and ("d"). In order
    -- for the name to be LIKE the bad phrase, the bad phrase must match the leading X
    -- characters (where X is the length of the bad phrase) of at least one of these
    -- substrings. This can be efficiently computed after indexing the substrings.
    -- As an optimization, we only store @maxBadPhraseLen characters rather than
    -- storing the full remainder of the name from each offset; all other characters are
    -- simply extra space that isn't needed to determine whether a bad phrase matches.
    SELECT TOP(LEN(s.name)) SUBSTRING(s.name, n.n, @maxBadPhraseLen) AS sub_name 
    FROM Numbers n
    ORDER BY n.n
) sub
-- Create an index so that bad phrases can be quickly compared for a match
CREATE CLUSTERED INDEX IX_SubNames ON #SubNames (sub_name)

-- For each name, compute the number of distinct bad phrases that match
-- By "match", we mean that the a substring starting from one or more 
-- character offsets of the overall name starts with the bad phrase
SELECT s.id, COUNT(DISTINCT b.phrase) AS bad_count
INTO #tempBadCounts
FROM dbo.Bad_Phrase b
JOIN #SubNames s
    ON s.sub_name LIKE b.phrase + '%'
GROUP BY s.id

-- Perform the actual update into a "bad_count_new" field
-- For validation, we'll compare bad_count_new with the originally computed bad_count
UPDATE s
SET s.bad_count_new = COALESCE(b.bad_count, 0)
FROM dbo.SourceTable s
LEFT JOIN #tempBadCounts b
    ON b.id = s.id
WHERE s.id BETWEEN @minId AND @maxId


新しいアプローチ:クエリプラン

最初に、各文字オフセットで始まる部分文字列を生成します

ここに画像の説明を入力してください

次に、これらの部分文字列にクラスター化インデックスを作成します

ここに画像の説明を入力してください

さて、悪いフレーズごとに、これらの部分文字列を検索して、一致するものを識別します。次に、その文字列の1つ以上の部分文字列に一致する明確な不良フレーズの数を計算します。これは本当に重要なステップです。部分文字列にインデックスを付ける方法のため、不適切なフレーズと名前の完全なクロス積をチェックする必要がなくなりました。実際の計算を行うこのステップは、実際のランタイムの約10%のみを占めます(残りはサブストリングの前処理です)。

ここに画像の説明を入力してください

最後に、a LEFT OUTER JOINを使用して実際の更新ステートメントを実行し、不良フレーズが検出されなかった名前にカウント0を割り当てます。

ここに画像の説明を入力してください


新しいアプローチ:アルゴリズム分析

新しいアプローチは、前処理とマッチングの2つのフェーズに分けることができます。次の変数を定義しましょう。

  • N =名前の数
  • B =悪いフレーズの数
  • L =名前の平均長、文字数

前処理段階ではO(N*L * LOG(N*L))N*Lサブストリングを作成してからソートします。

実際のマッチングはO(B * LOG(N*L))、各不良フレーズの部分文字列を探すためです。

このようにして、不良フレーズの数に比例してスケーリングしないアルゴリズムを作成しました。これは、3Kフレーズ以上にスケーリングする際に重要なパフォーマンスのロックを解除します。別の言い方をすれば、元の実装では、300の悪いフレーズから3Kの悪いフレーズに行く限り、およそ10倍かかります。同様に、3Kの不適切なフレーズから30Kに変更する場合、さらに10倍の時間がかかります。ただし、新しい実装では、サブリニアにスケールアップし、実際には、30Kの不良フレーズにスケールアップした場合、3Kの不良フレーズで測定した時間の2倍未満しかかかりません。


前提条件/注意事項

  • 全体の作業を適度なサイズのバッチに分割しています。これはおそらくどちらのアプローチにとっても良いアイデアですがSORT、サブストリングのonが各バッチに依存せず、メモリに簡単に収まるように、新しいアプローチでは特に重要です。必要に応じてバッチサイズを操作できますが、1つのバッチで15MM行すべてを試すのは賢明ではありません。
  • SQL 2005マシンにアクセスできないため、SQL 2005ではなくSQL 2014を使用しています。SQL 2005で使用できない構文を使用しないように注意しましたが、SQL 2012+のtempdb遅延書き込み機能とSQL 2014の並列SELECT INTO機能の恩恵を受ける可能性があります。
  • 新しいアプローチでは、名前とフレーズの両方の長さが非常に重要です。悪いフレーズは実世界のユースケースと一致する可能性が高いため、通常は短いフレーズであると想定しています。名前は悪いフレーズよりもかなり長いですが、数千文字ではないと想定されています。これは公正な仮定であり、名前の文字列が長いと元のアプローチも遅くなると思います。
  • 改善の一部(すべてに近いものはありません)は、新しいアプローチが(シングルスレッドで実行される)古いアプローチよりも効率的に並列処理を活用できるという事実によるものです。私はクアッドコアラップトップを使用しているため、これらのコアを使用できるアプローチをとることは素晴らしいことです。


関連ブログ投稿

Aaron Bertrandは、このタイプのソリューションについて、彼のブログ投稿でより詳細に調査しています主要な%wildcardのインデックスシークを取得する1つの方法です。


6

アーロンバートランドが提起した明らかな問題について、コメントで棚上げしましょう。

テーブルを3K回スキャンし、15MM行すべてを3K回すべて更新する可能性があり、高速であると期待していますか?

サブクエリが両側でワイルドカードを使用するという事実は、可算性に劇的に影響します。そのブログ投稿から引用を取るには:

つまり、SQL ServerはProductテーブルのすべての行を読み取り、名前に「nut」が含まれているかどうかを確認し、結果を返す必要があります。

の各「悪い単語」と「製品」の単語「ナット」を交換し、SourceTableそれをアーロンのコメントと組み合わせると、現在のアルゴリズムを使用して迅速に実行するのが非常に難しい(読めない)理由がわかるはずです。

いくつかのオプションがあります:

  1. ビジネスに説得力のあるモンスターサーバーを購入するよう説得します。(それは起こりそうにないので、他のオプションの方が良いでしょう)
  2. 既存のアルゴリズムを使用して、痛みを一度受け入れてから広げます。これには、挿入時の不良単語の計算が含まれます。これにより、挿入が遅くなり、新しい不良単語が入力/検出されたときにのみテーブル全体が更新されます。
  3. ジェフの答えを受け入れます。これは素晴らしいアルゴリズムであり、私が思いついたものよりもはるかに優れています。
  4. オプション2を実行しますが、アルゴリズムをGeoffに置き換えます。

要件に応じて、オプション3または4をお勧めします。


0

最初は単なる奇妙な更新です

Update [SourceTable]  
   Set [SourceTable].[Bad_Count] = [fix].[count]
  from [SourceTable] 
  join ( Select count(*) 
           from [Bad_Phrase]  
          where [SourceTable].Name like '%' + [Bad_Phrase].[PHRASE] + '%')

'%' + [Bad_Phrase]。[PHRASE]があなたを殺している
ので、インデックスを使用できません

データ設計が速度に最適ではありません
[Bad_Phrase]。[PHRASE]を単一のフレーズ/単語に分割できますか?
同じフレーズ/単語が複数表示されている場合、カウントを増やしたい場合は複数回入力できます。
したがって、悪いフレーズの行数が増え
ます。できれば、これははるかに高速です

Update [SourceTable]  
   Set [SourceTable].[Bad_Count] = [fix].[count]
  from [SourceTable] 
  join ( select [PHRASE], count(*) as count 
           from [Bad_Phrase] 
          group by [PHRASE] 
       ) as [fix]
    on [fix].[PHRASE] = [SourceTable].[name]  
 where [SourceTable].[Bad_Count] <> [fix].[count]

2005でサポートされているかどうかはわかりませんが、フルテキストインデックスを使用して


1
私はOPが悪い単語テーブルの悪い単語のインスタンスを数えたくないと思います。私は彼らがソーステーブルに隠された悪い単語の数を数えたいと思います。たとえば、元のコードは、おそらく「shitass」の名のために2のカウントを与えるだろうが、あなたのコードは、0のカウント与えるだろう
エリック

1
@Erik「[Bad_Phrase]。[PHRASE]を1つのフレーズに分割できますか?」本当に、データ設計が修正になるとは思わないのですか?目的が悪いものを見つけることである場合、1つ以上のカウントを持つ「eriK」で十分です。
パパラッチ
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.