Aggiornamento lento su tabella di grandi dimensioni con subquery


16

Con SourceTablerecord> 15MM e record Bad_Phrase> 3K, l'esecuzione della query seguente richiede quasi 10 ore su SQL Server 2005 SP4.

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

In inglese, questa query conta il numero di frasi distinte elencate in Bad_Phrase che sono una sottostringa del campo Namenel SourceTablee quindi posizionando quel risultato nel campo Bad_Count.

Vorrei alcuni suggerimenti su come eseguire questa query molto più velocemente.


3
Quindi stai analizzando la tabella 3K volte e potenzialmente aggiornando tutte le righe 15MM tutte le 3K volte e ti aspetti che sia veloce?
Aaron Bertrand

1
Qual è la lunghezza della colonna del nome? Puoi pubblicare uno script o un violino SQL che genera dati di test e riproduce questa query molto lenta in modo che qualcuno di noi possa giocare? Forse sono solo un ottimista, ma sento che possiamo fare molto meglio di 10 ore. Concordo con gli altri commentatori sul fatto che si tratta di un problema computazionalmente costoso, ma non vedo perché non possiamo ancora mirare a renderlo "considerevolmente più veloce".
Geoff Patterson,

3
Matthew, hai considerato l'indicizzazione del testo completo? Puoi usare cose come CONTAINS e ottenere comunque il vantaggio di indicizzare quella ricerca.
swasheck

In questo caso, suggerirei di provare la logica basata su riga (ovvero invece di 1 aggiornamento di righe da 15 MM esegui aggiornamenti da 15 MM ogni riga in SourceTable o aggiorna alcuni blocchi relativamente piccoli). Il tempo totale non sarà più veloce (anche se è possibile in questo caso particolare), ma un approccio di questo tipo consente al resto del sistema di continuare a funzionare senza interruzioni, ti dà il controllo sulla dimensione del registro delle transazioni (diciamo commit ogni 10k aggiornamenti), interrompi aggiornare in qualsiasi momento senza perdere tutti gli aggiornamenti precedenti ...
a1ex07

2
@swasheck Il testo completo è una buona idea da considerare (credo sia nuovo nel 2005, quindi potrebbe essere applicabile qui), ma non sarebbe possibile fornire la stessa funzionalità richiesta dal poster poiché il testo intero indicizza le parole e non sottostringhe arbitrarie. Detto in altro modo, il testo completo non troverebbe una corrispondenza per "formica" all'interno della parola "fantastico". Ma è possibile che i requisiti aziendali possano essere modificati in modo da rendere applicabile il testo completo.
Geoff Patterson,

Risposte:


21

Mentre sono d'accordo con altri commentatori sul fatto che si tratta di un problema computazionalmente costoso, penso che ci sia molto margine di miglioramento modificando l'SQL che si sta utilizzando. Per illustrare, creo un set di dati falso con nomi 15MM e frasi 3K, ho eseguito il vecchio approccio e ho adottato un nuovo approccio.

Script completo per generare un set di dati falso e provare il nuovo approccio

TL; DR

Sulla mia macchina e su questo set di dati falso, l' approccio originale richiede circa 4 ore per l'esecuzione. Il nuovo approccio proposto richiede circa 10 minuti , un notevole miglioramento. Ecco un breve riassunto dell'approccio proposto:

  • Per ogni nome, genera la sottostringa a partire da ogni offset di carattere (e limitato alla lunghezza della frase più lunga, come ottimizzazione)
  • Creare un indice cluster su queste sottostringhe
  • Per ogni frase sbagliata, eseguire una ricerca in queste sottostringhe per identificare eventuali corrispondenze
  • Per ogni stringa originale, calcola il numero di frasi errate distinte che corrispondono a una o più sottostringhe di quella stringa


Approccio originale: analisi algoritmica

Dal piano della UPDATEdichiarazione originale , possiamo vedere che la quantità di lavoro è linearmente proporzionale sia al numero di nomi (15MM) che al numero di frasi (3K). Quindi, se moltiplichiamo il numero di nomi e frasi per 10, il tempo di esecuzione complessivo sarà ~ 100 volte più lento.

La query è in realtà proporzionale alla lunghezza nameanche di; mentre questo è un po 'nascosto nel piano di query, viene visualizzato nel "numero di esecuzioni" per cercare nello spool della tabella. Nel piano reale, possiamo vedere che ciò si verifica non solo una volta per name, ma in realtà una volta per offset di carattere all'interno di name. Quindi questo approccio è O ( # names* # phrases* name length) nella complessità di runtime.

inserisci qui la descrizione dell'immagine


Nuovo approccio: codice

Questo codice è disponibile anche nel pastebin completo ma l'ho copiato qui per comodità. Il pastebin ha anche la definizione della procedura completa, che include le variabili @minIde @maxIdche vedi sotto per definire i confini del batch corrente.

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


Nuovo approccio: piani di query

Innanzitutto, generiamo la sottostringa a partire da ogni offset di carattere

inserisci qui la descrizione dell'immagine

Quindi creare un indice cluster su queste sottostringhe

inserisci qui la descrizione dell'immagine

Ora, per ogni brutta frase cerchiamo in queste sottostringhe per identificare eventuali corrispondenze. Quindi calcoliamo il numero di distinte frasi errate che corrispondono a una o più sottostringhe di quella stringa. Questo è davvero il passaggio chiave; a causa del modo in cui abbiamo indicizzato le sottostringhe, non dobbiamo più controllare un prodotto incrociato completo di frasi e nomi errati. Questo passaggio, che esegue il calcolo effettivo, rappresenta solo circa il 10% del tempo di esecuzione effettivo (il resto è la pre-elaborazione delle sottostringhe).

inserisci qui la descrizione dell'immagine

Infine, esegui l'istruzione di aggiornamento effettiva, usando a LEFT OUTER JOINper assegnare un conteggio di 0 a tutti i nomi per i quali non abbiamo trovato frasi sbagliate.

inserisci qui la descrizione dell'immagine


Nuovo approccio: analisi algoritmica

Il nuovo approccio può essere suddiviso in due fasi, pre-elaborazione e abbinamento. Definiamo le seguenti variabili:

  • N = # di nomi
  • B = # di frasi cattive
  • L = lunghezza media del nome, in caratteri

La fase di pre-elaborazione è O(N*L * LOG(N*L))per creare N*Lsottostringhe e poi ordinarle.

La corrispondenza effettiva è O(B * LOG(N*L))al fine di cercare nelle sottostringhe per ogni frase negativa.

In questo modo, abbiamo creato un algoritmo che non si ridimensiona in modo lineare con il numero di frasi sbagliate, uno sblocco delle prestazioni chiave quando scaliamo a frasi 3K e oltre. Detto in altro modo, l'implementazione originale richiede circa 10 volte il tempo che passiamo da 300 frasi cattive a 3K frasi cattive. Allo stesso modo, impiegheremmo altri 10 volte se dovessimo passare da 3K frasi sbagliate a 30K. La nuova implementazione, tuttavia, si ridimensionerà in modo sublineare e in effetti impiega meno del doppio del tempo misurato su 3K di frasi sbagliate quando viene ridimensionato fino a 30K di frasi sbagliate.


Presupposti / Avvertenze

  • Divido il lavoro complessivo in lotti di dimensioni modeste. Questa è probabilmente una buona idea per entrambi gli approcci, ma è particolarmente importante per il nuovo approccio in modo che SORTle sottostringhe siano indipendenti per ogni batch e si adattino facilmente alla memoria. È possibile manipolare le dimensioni del batch in base alle esigenze, ma non sarebbe saggio provare tutte le righe da 15 MM in un batch.
  • Sono su SQL 2014, non su SQL 2005, poiché non ho accesso a una macchina SQL 2005. Ho fatto attenzione a non utilizzare alcuna sintassi non disponibile in SQL 2005, ma potrei comunque trarre vantaggio dalla funzionalità di scrittura pigra tempdb in SQL 2012+ e dalla funzione SELECT INTO parallela in SQL 2014.
  • La lunghezza di entrambi i nomi e le frasi è abbastanza importante per il nuovo approccio. Suppongo che le cattive frasi siano in genere piuttosto brevi poiché è probabile che corrispondano ai casi d'uso reali. I nomi sono un po 'più lunghi delle brutte frasi, ma si presume che non siano migliaia di caratteri. Penso che questo sia un presupposto equo e stringhe di nomi più lunghe rallenterebbero anche il tuo approccio originale.
  • Una parte del miglioramento (ma in nessun posto vicino a tutto questo) è dovuta al fatto che il nuovo approccio può sfruttare il parallelismo in modo più efficace rispetto al vecchio approccio (che funziona a thread singolo). Sono su un laptop quad core, quindi è bello avere un approccio che può mettere questi core da usare.


Post di blog correlati

Aaron Bertrand esplora questo tipo di soluzione in modo più dettagliato nel suo post sul blog Un modo per ottenere un indice cercare un jolly% iniziale .


6

Mettiamo da parte l'ovvio problema sollevato da Aaron Bertrand nei commenti per un secondo:

Quindi stai analizzando la tabella 3K volte e potenzialmente aggiornando tutte le righe 15MM tutte le 3K volte e ti aspetti che sia veloce?

Il fatto che la tua subquery utilizzi i jolly su entrambi i lati influisce notevolmente sulla sargability . Per prendere una citazione da quel post sul blog:

Ciò significa che SQL Server deve leggere ogni riga dalla tabella del prodotto, verificare se ha "noce" in qualsiasi punto del nome e quindi restituire i risultati.

Scambia la parola "noce" per ogni "parolaccia" e "Prodotto" per SourceTable, quindi combinalo con il commento di Aaron e dovresti iniziare a capire perché è estremamente difficile (leggi impossibile) farlo funzionare rapidamente usando il tuo attuale algoritmo.

Vedo alcune opzioni:

  1. Convincere le aziende ad acquistare un monster server che ha così tanta potenza da superare la domanda con la forza bruta di taglio. (Ciò non accadrà, quindi incrocia le dita le altre opzioni sono migliori)
  2. Usando il tuo algoritmo esistente, accetta il dolore una volta e poi diffondilo. Ciò comporterebbe il calcolo delle parolacce sull'inserto che rallenterà gli inserti e aggiornerebbe l'intera tabella solo quando viene inserita / scoperta una nuova parolaccia.
  3. Abbraccia la risposta di Geoff . Questo è un ottimo algoritmo e molto meglio di qualsiasi cosa mi sarei inventato.
  4. Fai l'opzione 2 ma sostituisci l'algoritmo con quello di Geoff.

A seconda delle vostre esigenze, consiglierei l'opzione 3 o 4.


0

per prima cosa è solo uno strano aggiornamento

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

Come '%' + [Bad_Phrase]. [PHRASE] ti sta uccidendo.
Non puoi usare un indice

Il design dei dati non è ottimale per la velocità
Riesci a suddividere [Bad_Phrase]. [PHRASE] in singole frasi / parole?
Se la stessa frase / parola appare più di una puoi inserirla più di una volta se vuoi che abbia un conteggio più alto
Quindi il numero di righe in pharase errato salirà
Se puoi allora sarà molto più veloce

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]

Non sono sicuro che il 2005 lo supporti, ma l'indice di testo completo e usa Contiene


1
Non penso che l'OP voglia contare le istanze della parolaccia nella tabella delle parolacce. Penso che vogliano contare il numero di parolacce nascoste nella tabella delle fonti. Ad esempio il codice originale darebbe probabilmente un conteggio di 2 per un nome di "shitass" ma il tuo codice darebbe un conteggio di 0.
Erik

1
@Erik "puoi dividere [Bad_Phrase]. [PHRASE] in singole frasi?" Davvero non pensi che un progetto di dati potrebbe essere la soluzione? Se lo scopo è trovare cose cattive, allora "eriK" con un conteggio di uno o più è sufficiente.
paparazzo,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.