Database
 sql >> Base de Dados >  >> RDS >> Database

Uma maneira de obter uma busca de índice para um %curinga principal


Um problema de desempenho que vejo com frequência é quando os usuários precisam corresponder parte de uma string com uma consulta como a seguinte:
... WHERE SomeColumn LIKE N'%SomePortion%';

Com um curinga inicial, esse predicado é "não SARGable" - apenas uma maneira elegante de dizer que não podemos encontrar as linhas relevantes usando uma busca em um índice em SomeColumn .

Uma solução sobre a qual ficamos meio confusos é a pesquisa de texto completo; no entanto, essa é uma solução complexa e requer que o padrão de pesquisa consista em palavras completas, não use palavras de parada e assim por diante. Isso pode ajudar se estivermos procurando por linhas em que uma descrição contenha a palavra "soft" (ou outras derivadas como "softer" ou "softly"), mas não ajuda quando estivermos procurando por strings que possam estar contidas dentro de palavras (ou que não sejam palavras, como todos os SKUs de produtos que contêm "X45-B" ou todas as placas de licença que contêm a sequência "7RA").

E se o SQL Server de alguma forma soubesse sobre todas as partes possíveis de uma string? Meu processo de pensamento segue as linhas de trigram / trigraph no PostgreSQL. O conceito básico é que o mecanismo tem a capacidade de fazer pesquisas de estilo de ponto em substrings, o que significa que você não precisa varrer a tabela inteira e analisar todos os valores completos.

O exemplo específico que eles usam é a palavra cat . Além da palavra completa, ela pode ser dividida em partes:c , ca e at (eles deixam de fora a e t por definição – nenhuma substring final pode ser menor que dois caracteres). No SQL Server, não precisamos que seja tão complexo; nós realmente precisamos apenas da metade do trigrama – se pensarmos em construir uma estrutura de dados que contenha todas as substrings de cat , precisamos apenas destes valores:
  1. gato
  2. em
  3. t

Não precisamos de c ou ca , porque qualquer pessoa procurando por LIKE '%ca%' poderia facilmente encontrar o valor 1 usando LIKE 'ca%' em vez de. Da mesma forma, qualquer pessoa pesquisando por LIKE '%at%' ou LIKE '%a%' poderia usar um curinga à direita apenas nesses três valores e encontrar aquele que corresponde (por exemplo, LIKE 'at%' encontrará o valor 2 e LIKE 'a%' também encontrará o valor 2, sem ter que encontrar essas substrings dentro do valor 1, que é de onde viria a verdadeira dor).

Então, dado que o SQL Server não tem nada assim embutido, como podemos gerar tal trigrama?

Uma tabela de fragmentos separada


Vou parar de fazer referência a "trigrama" aqui porque isso não é realmente análogo a esse recurso no PostgreSQL. Essencialmente, o que vamos fazer é construir uma tabela separada com todos os "fragmentos" da string original. (Assim, no cat Por exemplo, haveria uma nova tabela com essas três linhas e LIKE '%pattern%' as pesquisas seriam direcionadas para essa nova tabela como LIKE 'pattern%' predicados.)

Antes de começar a mostrar como minha solução proposta funcionaria, deixe-me ser absolutamente claro que esta solução não deve ser usada em todos os casos em que LIKE '%wildcard%' as buscas são lentas. Devido à forma como vamos "explodir" os dados de origem em fragmentos, é provável que seja limitado em praticidade a strings menores, como endereços ou nomes, em oposição a strings maiores, como descrições de produtos ou resumos de sessão.

Um exemplo mais prático do que cat é olhar para o Sales.Customer tabela no banco de dados de exemplo WideWorldImporters. Tem linhas de endereço (ambas nvarchar(60) ), com as valiosas informações de endereço na coluna DeliveryAddressLine2 (por razões desconhecidas). Alguém pode estar procurando alguém que mora em uma rua chamada Hudecova , então eles farão uma pesquisa como esta:
SELECT CustomerID, DeliveryAddressLine2
  FROM Sales.Customers
  WHERE DeliveryAddressLine2 LIKE N'%Hudecova%';
 
/* This returns two rows:
 
    CustomerID  DeliveryAddressLine2
    ----------  ----------------------
    61          1695 Hudecova Avenue
    181         1846 Hudecova Crescent
*/

Como seria de esperar, o SQL Server precisa realizar uma verificação para localizar essas duas linhas. Que deve ser simples; no entanto, devido à complexidade da tabela, essa consulta trivial gera um plano de execução muito confuso (a varredura é destacada e tem um aviso para E/S residual):



Blech. Para manter nossas vidas simples e ter certeza de que não perseguiremos um monte de pistas falsas, vamos criar uma nova tabela com um subconjunto das colunas e, para começar, apenas inseriremos essas mesmas duas linhas de cima:
CREATE TABLE Sales.CustomersCopy
(
  CustomerID int IDENTITY(1,1) CONSTRAINT PK_CustomersCopy PRIMARY KEY,
  CustomerName         nvarchar(100) NOT NULL,
  DeliveryAddressLine1 nvarchar(60) NOT NULL,
  DeliveryAddressLine2 nvarchar(60)
);
GO
 
INSERT Sales.CustomersCopy
(
  CustomerName,
  DeliveryAddressLine1,
  DeliveryAddressLine2
)
SELECT 
  CustomerName,
  DeliveryAddressLine1,
  DeliveryAddressLine2
FROM Sales.Customers
WHERE DeliveryAddressLine2 LIKE N'%Hudecova%';

Agora, se executarmos a mesma consulta que executamos na tabela principal, obteremos algo muito mais razoável para ser visto como ponto de partida. Isso ainda será uma varredura, não importa o que façamos - se adicionarmos um índice com DeliveryAddressLine2 como a coluna de chave principal, provavelmente obteremos uma verificação de índice, com uma pesquisa de chave dependendo se o índice cobre as colunas na consulta. Como está, obtemos uma varredura de índice clusterizado:



Em seguida, vamos criar uma função que irá "explodir" esses valores de endereço em fragmentos. Esperamos 1846 Hudecova Crescent , por exemplo, para ter o seguinte conjunto de fragmentos:
  • 1846 Hudecova Crescent
  • 846 Hudecova Crescent
  • 46 Hudecova Crescent
  • 6 Hudecova Crescent
  • Hudecova Crescent
  • Hudecova Crescent
  • udecova crescente
  • decova crescente
  • ecova crescente
  • Cova Crescente
  • Ova Crescente
  • vai crescente
  • um crescente
  • Crescente
  • Crescente
  • rescente
  • escente
  • cheiro
  • centavos
  • ent
  • n
  • t

É bastante trivial escrever uma função que produzirá essa saída – precisamos apenas de um CTE recursivo que possa ser usado para percorrer cada caractere ao longo do comprimento da entrada:
CREATE FUNCTION dbo.CreateStringFragments( @input nvarchar(60) )
RETURNS TABLE WITH SCHEMABINDING
AS
  RETURN 
  (
    WITH x(x) AS 
    (
      SELECT 1 UNION ALL SELECT x+1 FROM x WHERE x < (LEN(@input))
    )
    SELECT Fragment = SUBSTRING(@input, x, LEN(@input)) FROM x
  );
GO
 
SELECT Fragment FROM dbo.CreateStringFragments(N'1846 Hudecova Crescent');
-- same output as above bulleted list

Agora, vamos criar uma tabela que armazenará todos os fragmentos de endereço e a qual Cliente eles pertencem:
CREATE TABLE Sales.CustomerAddressFragments
(
  CustomerID  int          NOT NULL,
  Fragment    nvarchar(60) NOT NULL,
  CONSTRAINT FK_Customers FOREIGN KEY(CustomerID) REFERENCES Sales.CustomersCopy(CustomerID)
);
 
CREATE CLUSTERED INDEX s_cat ON Sales.CustomerAddressFragments(Fragment, CustomerID);

Então podemos preenchê-lo assim:
INSERT Sales.CustomerAddressFragments(CustomerID, Fragment)
SELECT c.CustomerID, f.Fragment
  FROM Sales.CustomersCopy AS c
  CROSS APPLY dbo.CreateStringFragments(c.DeliveryAddressLine2) AS f;

Para nossos dois valores, isso insere 42 linhas (um endereço tem 20 caracteres e o outro tem 22). Agora nossa consulta se torna:
SELECT c.CustomerID, c.DeliveryAddressLine2
  FROM Sales.CustomersCopy AS c
  INNER JOIN Sales.CustomerAddressFragments AS f
    ON  f.CustomerID = c.CustomerID
    AND f.Fragment LIKE N'Hudecova%';

Aqui está um plano muito melhor - dois índices clusterizados *procura* e uma junção de loops aninhados:



Também podemos fazer isso de outra maneira, usando EXISTS :
SELECT c.CustomerID, c.DeliveryAddressLine2
  FROM Sales.CustomersCopy AS c
  WHERE EXISTS
  (
    SELECT 1 FROM Sales.CustomerAddressFragments
    WHERE CustomerID = c.CustomerID
      AND Fragment LIKE N'Hudecova%'
  );

Aqui está esse plano, que parece ser mais caro – ele escolhe escanear a tabela CustomersCopy. Veremos em breve por que essa é a melhor abordagem de consulta:



Agora, neste enorme conjunto de dados de 42 linhas, as diferenças vistas na duração e E/S são tão minúsculas que são irrelevantes (e, de fato, nesse tamanho pequeno, a varredura na tabela base parece mais barata em termos de I/O). O que os outros dois planos que usam a tabela de fragmentos):



E se preenchêssemos essas tabelas com muito mais dados? Minha cópia de Sales.Customers tem 663 linhas, então se cruzarmos isso contra si mesmo, chegaríamos perto de 440.000 linhas. Então, vamos misturar 400K e gerar uma tabela muito maior:
TRUNCATE TABLE Sales.CustomerAddressFragments;
DELETE Sales.CustomersCopy;
DBCC FREEPROCCACHE WITH NO_INFOMSGS;
 
INSERT Sales.CustomersCopy WITH (TABLOCKX) (CustomerName, DeliveryAddressLine1, DeliveryAddressLine2)
SELECT TOP (400000) c1.CustomerName, c1.DeliveryAddressLine1, c2.DeliveryAddressLine2
  FROM Sales.Customers c1 CROSS JOIN Sales.Customers c2
  ORDER BY NEWID(); -- fun for distribution
 
-- this will take a bit longer - on my system, ~4 minutes
-- probably because I didn't bother to pre-expand files
INSERT Sales.CustomerAddressFragments WITH (TABLOCKX) (CustomerID, Fragment)
SELECT c.CustomerID, f.Fragment
  FROM Sales.CustomersCopy AS c
  CROSS APPLY dbo.CreateStringFragments(c.DeliveryAddressLine2) AS f;
 
-- repeated for compressed version of the table
-- 7.25 million rows, 278MB (79MB compressed; saving those tests for another day)

Agora, para comparar os planos de desempenho e execução com uma variedade de parâmetros de pesquisa possíveis, testei as três consultas acima com estes predicados:
Consulta Predicados
ONDE DeliveryLineAddress2 GOSTA… N'%Hudecova%' N'%cova%' N'%ova%' N'%va%'
JOIN … WHERE Fragment LIKE … N'Hudecova%' N'cova%' Nova%' N'va%'
ONDE EXISTE (… ONDE Fragmento LIKE…)


À medida que removemos letras do padrão de pesquisa, eu esperaria ver mais saídas de linhas e talvez diferentes estratégias escolhidas pelo otimizador. Vamos ver como foi para cada padrão:

    Hudecova%


    Para esse padrão, a varredura ainda era a mesma para a condição LIKE; no entanto, com mais dados, o custo é muito maior. As buscas na tabela de fragmentos realmente compensam nessa contagem de linhas (1.206), mesmo com estimativas muito baixas. O plano EXISTS adiciona uma classificação distinta, que você esperaria adicionar ao custo, embora neste caso acabe fazendo menos leituras:


    Planeje o JOIN para a tabela de fragmentos Planejar o EXISTS em relação à tabela de fragmentos

    cova%


    À medida que retiramos algumas letras do nosso predicado, vemos as leituras na verdade um pouco mais altas do que com a varredura de índice clusterizado original, e agora sobre -estimar as linhas. Mesmo assim, nossa duração permanece significativamente menor com ambas as consultas de fragmento; a diferença desta vez é mais sutil - ambos têm tipos (apenas EXISTS é distinto):


    Planejar o JOIN para a tabela de fragmentos Planejar o EXISTS em relação à tabela de fragmentos

    % de óvulos


    Tirar uma letra adicional não mudou muito; embora valha a pena notar o quanto as linhas estimadas e reais saltam aqui – o que significa que isso pode ser um padrão de substring comum. A consulta LIKE original ainda é um pouco mais lenta que as consultas de fragmento.


    Planeje o JOIN para a tabela de fragmentos Planejar o EXISTS em relação à tabela de fragmentos

    va%


    Com duas letras, isso introduz nossa primeira discrepância. Observe que o JOIN produz mais linhas do que a consulta original ou EXISTS. Por que seria isso?


    Planeje o JOIN para a tabela de fragmentos Planeje o EXISTS em relação à tabela de fragmentos Não precisamos procurar muito. Lembre-se de que há um fragmento começando de cada caractere sucessivo no endereço original, o que significa algo como 899 Valentova Road terá duas linhas na tabela de fragmentos que começam com va (diferenciação de maiúsculas e minúsculas à parte). Então você vai combinar em ambos Fragment = N'Valentova Road' e Fragment = N'va Road' . Vou poupar a pesquisa e fornecer um único exemplo de muitos:
    SELECT TOP (2) c.CustomerID, c.DeliveryAddressLine2, f.Fragment
    FROM Sales.CustomersCopy AS c
    INNER JOIN Sales.CustomerAddressFragments AS f
    ON c.CustomerID = f.CustomerID
    WHERE f.Fragment LIKE N'va%'
    AND c.DeliveryAddressLine2 = N'899 Valentova Road'
    AND f.CustomerID = 351;
     
    /*
    CustomerID  DeliveryAddressLine2  Fragment
    ----------  --------------------  --------------
    351         899 Valentova Road    va Road
    351         899 Valentova Road    Valentova Road
    */

    Isso explica prontamente por que o JOIN produz mais linhas; se você quiser corresponder à saída da consulta LIKE original, você deve usar EXISTS. O fato de que os resultados corretos geralmente também podem ser obtidos de maneira menos intensiva em recursos é apenas um bônus. (Eu ficaria nervoso se as pessoas escolhessem o JOIN se essa fosse a opção repetidamente mais eficiente – você deve sempre favorecer os resultados corretos em vez de se preocupar com o desempenho.)

Resumo


É claro que neste caso específico – com uma coluna de endereço de nvarchar(60) e um comprimento máximo de 26 caracteres – dividir cada endereço em fragmentos pode trazer algum alívio para pesquisas de "carácter curinga líder" que de outra forma seriam caras. O melhor retorno parece acontecer quando o padrão de busca é maior e, como resultado, mais exclusivo. Também demonstrei por que EXISTS é melhor em cenários em que várias correspondências são possíveis - com um JOIN, você obterá uma saída redundante, a menos que adicione alguma lógica de "maior n por grupo".

Advertências


Serei o primeiro a admitir que esta solução é imperfeita, incompleta e não totalmente desenvolvida:
  • Você precisará manter a tabela de fragmentos sincronizada com a tabela base usando gatilhos – o mais simples seria para inserções e atualizações, excluir todas as linhas para esses clientes e reinseri-las, e para exclusões, obviamente, remover as linhas dos fragmentos tabela.
  • Como mencionado, esta solução funcionou melhor para esse tamanho de dados específico e pode não funcionar tão bem para outros comprimentos de string. Isso justificaria mais testes para garantir que seja apropriado para o tamanho da coluna, o comprimento médio do valor e o comprimento típico do parâmetro de pesquisa.
  • Como teremos muitas cópias de fragmentos como "Crescent" e "Street" (não importa todos os nomes de ruas e números de casas iguais ou semelhantes), poderíamos normalizá-lo ainda mais armazenando cada fragmento único em uma tabela de fragmentos, e, em seguida, outra tabela que atua como uma tabela de junção muitos-para-muitos. Isso seria muito mais complicado de configurar, e não tenho certeza se haveria muito a ganhar.
  • Ainda não investiguei completamente a compactação de página, parece que – pelo menos em teoria – isso poderia trazer algum benefício. Também tenho a sensação de que uma implementação de columnstore também pode ser benéfica em alguns casos.

De qualquer forma, tudo o que foi dito, eu só queria compartilhá-lo como uma ideia em desenvolvimento e solicitar qualquer feedback sobre sua praticidade para casos de uso específicos. Posso entrar em mais detalhes para um post futuro se você puder me informar quais aspectos desta solução lhe interessam.