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 UPDATE
dichiarazione 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 name
anche 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.
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 @minId
e @maxId
che 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
Quindi creare un indice cluster su queste sottostringhe
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).
Infine, esegui l'istruzione di aggiornamento effettiva, usando a LEFT OUTER JOIN
per assegnare un conteggio di 0 a tutti i nomi per i quali non abbiamo trovato frasi sbagliate.
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*L
sottostringhe 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
SORT
le 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 .