Cláusula SARGable WHERE para duas colunas de data

24

Eu tenho o que é, para mim, uma pergunta interessante sobre SARGability. Nesse caso, trata-se de usar um predicado na diferença entre duas colunas de data. Aqui está a configuração:

USE [tempdb]
SET NOCOUNT ON  

IF OBJECT_ID('tempdb..#sargme') IS NOT NULL
BEGIN
DROP TABLE #sargme
END

SELECT TOP 1000
IDENTITY (BIGINT, 1,1) AS ID,
CAST(DATEADD(DAY, [m].[severity] * -1, GETDATE()) AS DATE) AS [DateCol1],
CAST(DATEADD(DAY, [m].[severity], GETDATE()) AS DATE) AS [DateCol2]
INTO #sargme
FROM sys.[messages] AS [m]

ALTER TABLE [#sargme] ADD CONSTRAINT [pk_whatever] PRIMARY KEY CLUSTERED ([ID])
CREATE NONCLUSTERED INDEX [ix_dates] ON [#sargme] ([DateCol1], [DateCol2])

O que vou ver com bastante frequência é algo como isto:

/*definitely not sargable*/
SELECT
    * ,
    DATEDIFF(DAY, [s].[DateCol1], [s].[DateCol2])
FROM
    [#sargme] AS [s]
WHERE
    DATEDIFF(DAY, [s].[DateCol1], [s].[DateCol2]) >= 48;

... o que definitivamente não é SARGable. Isso resulta em uma varredura de índice, lê todas as 1000 linhas, nada bom. Linhas estimadas cheiram mal. Você nunca colocou isso em produção.

Não senhor, eu não gostei.

Seria bom se pudéssemos materializar CTEs, porque isso nos ajudaria a tornar isso, bem, mais SARGable-er, tecnicamente falando. Mas não, temos o mesmo plano de execução que o topo.

/*would be nice if it were sargable*/
WITH    [x] AS ( SELECT
                * ,
                DATEDIFF(DAY, [s].[DateCol1], [s].[DateCol2]) AS [ddif]
               FROM
                [#sargme] AS [s])
     SELECT
        *
     FROM
        [x]
     WHERE
        [x].[ddif] >= 48;

E, é claro, como não estamos usando constantes, esse código não muda nada e nem sequer é metade da SARGable. Não é divertido. Mesmo plano de execução.

/*not even half sargable*/
SELECT
    * ,
    DATEDIFF(DAY, [s].[DateCol1], [s].[DateCol2])
FROM
    [#sargme] AS [s]
WHERE
    [s].[DateCol2] >= DATEADD(DAY, 48, [s].[DateCol1])

Se você estiver com sorte e obedecendo a todas as opções de ANSI SET nas cadeias de conexão, você pode adicionar uma coluna computada e pesquisá-la ...

ALTER TABLE [#sargme] ADD [ddiff] AS 
DATEDIFF(DAY, DateCol1, DateCol2) PERSISTED

CREATE NONCLUSTERED INDEX [ix_dates2] ON [#sargme] ([ddiff], [DateCol1], [DateCol2])

SELECT [s].[ID] ,
       [s].[DateCol1] ,
       [s].[DateCol2]
FROM [#sargme] AS [s]
WHERE [ddiff] >= 48

Isso fornecerá uma busca de índice com três consultas. O estranho é que adicionamos 48 dias ao DateCol1. A consulta com DATEDIFFna WHEREcláusula, a CTEe a consulta final com um predicado na coluna computada oferecem um plano muito mais agradável, com estimativas muito mais agradáveis ​​e tudo mais.

Eu poderia viver com isso.

O que me leva à pergunta: em uma única consulta, existe uma maneira SARGable de realizar essa pesquisa?

Sem tabelas temporárias, sem variáveis ​​de tabela, sem alterar a estrutura da tabela e sem visualizações.

Estou bem com auto-junções, CTEs, subconsultas ou várias passagens sobre os dados. Pode funcionar com qualquer versão do SQL Server.

Evitar a coluna computada é uma limitação artificial, porque estou mais interessado em uma solução de consulta do que qualquer outra coisa.

Erik Darling
fonte

Respostas:

16

Basta adicionar isso rapidamente para que exista como resposta (embora eu saiba que não é a resposta que você deseja).

Uma coluna computada indexada geralmente é a solução certa para esse tipo de problema.

Isto:

  • torna o predicado uma expressão indexável
  • permite que estatísticas automáticas sejam criadas para melhor estimativa da cardinalidade
  • não precisa ocupar espaço na tabela base

Para esclarecer esse último ponto, não é necessário que a coluna computada persista neste caso:

-- Note: not PERSISTED, metadata change only
ALTER TABLE #sargme
ADD DayDiff AS DATEDIFF(DAY, DateCol1, DateCol2);

-- Index the expression
CREATE NONCLUSTERED INDEX index_name
ON #sargme (DayDiff)
INCLUDE (DateCol1, DateCol2);

Agora a consulta:

SELECT
    S.ID,
    S.DateCol1,
    S.DateCol2,
    DATEDIFF(DAY, S.DateCol1, S.DateCol2)
FROM
    #sargme AS S
WHERE
    DATEDIFF(DAY, S.DateCol1, S.DateCol2) >= 48;

... fornece o seguinte plano trivial :

Plano de execução

Como Martin Smith disse, se você tiver conexões usando as opções de conjunto erradas, poderá criar uma coluna regular e manter o valor calculado usando gatilhos.

Tudo isso realmente importa (com exceção do desafio de código) se houver um problema real a ser resolvido, é claro, como Aaron diz em sua resposta .

É divertido pensar nisso, mas não sei como alcançar o que você deseja razoavelmente, dadas as restrições da pergunta. Parece que qualquer solução ideal exigiria uma nova estrutura de dados de algum tipo; quanto mais próxima estivermos da aproximação do 'índice de função' fornecida por um índice em uma coluna computada não persistente, como acima.

Paul White diz que a GoFundMonica
fonte
12

Arriscando o ridículo de alguns dos maiores nomes da comunidade do SQL Server, vou mostrar o que estou dizendo.

Para que sua consulta seja SARGable, você precisa basicamente construir uma consulta que possa identificar uma linha inicial em um intervalo de linhas consecutivas em um índice. Com o índice ix_dates, as linhas não são ordenadas pela diferença de data entre DateCol1e DateCol2, portanto, as linhas de destino podem ser espalhadas em qualquer lugar do índice.

As junções automáticas, várias passagens etc. etc. têm em comum o fato de incluir pelo menos uma verificação de índice, embora uma junção (loop aninhado) possa muito bem usar uma busca de índice. Mas não vejo como seria possível eliminar a verificação.

Quanto à obtenção de estimativas de linha mais precisas, não há estatísticas sobre a diferença de data.

A construção CTE recursiva bastante feia a seguir elimina tecnicamente a varredura de toda a tabela, embora introduza uma junção de loop aninhada e um número (potencialmente muito grande) de pesquisas de índice.

DECLARE @from date, @count int;
SELECT TOP 1 @from=DateCol1 FROM #sargme ORDER BY DateCol1;
SELECT TOP 1 @count=DATEDIFF(day, @from, DateCol1) FROM #sargme WHERE DateCol1<=DATEADD(day, -48, {d '9999-12-31'}) ORDER BY DateCol1 DESC;

WITH cte AS (
    SELECT 0 AS i UNION ALL
    SELECT i+1 FROM cte WHERE i<@count)

SELECT b.*
FROM cte AS a
INNER JOIN #sargme AS b ON
    b.DateCol1=DATEADD(day, a.i, @from) AND
    b.DateCol2>=DATEADD(day, 48+a.i, @from)
OPTION (MAXRECURSION 0);

Ele cria um spool de índice que contém todos DateCol1os itens da tabela e, em seguida, executa uma busca de índice (varredura de intervalo) para cada uma delas DateCol1e DateCol2com pelo menos 48 dias de antecedência.

Mais pedidos de veiculação, tempo de execução um pouco mais longo, estimativa de linha ainda está muito distante e chance zero de paralelização por causa da recursão: acho que essa consulta pode ser útil se você tiver um número muito grande de valores em relativamente poucos e distintos e consecutivos DateCol1(mantendo baixo o número de pesquisas).

Plano de consulta CTE recursivo louco

Daniel Hutmacher
fonte
9

Tentei um monte de variações malucas, mas não encontrei nenhuma versão melhor do que uma sua. O principal problema é que seu índice se parece com isso em termos de como data1 e data2 são classificadas juntas. A primeira coluna ficará em uma bela linha arquivada, enquanto a diferença entre eles será muito irregular. Você deseja que isso pareça mais um funil do que realmente será:

Date1    Date2
-----    -------
*             *
*             *
*              *
 *       * 
 *        *
 *         *
  *      *
  *           *

Não há realmente nenhuma maneira de tornar isso possível para um determinado delta (ou intervalo de deltas) entre os dois pontos. E eu quero dizer uma única busca executada uma vez + uma varredura de intervalo, não uma busca executada para cada linha. Isso envolverá uma verificação e / ou uma classificação em algum momento, e essas são coisas que você deseja evitar obviamente. Pena que você não pode usar expressões como DATEADD/DATEDIFF em índices filtrados ou executar possíveis modificações de esquema que permitiriam uma classificação no produto da diferença de datas (como calcular o delta no momento da inserção / atualização). Como é, parece ser um daqueles casos em que uma varredura é realmente o método de recuperação ideal.

Você disse que essa consulta não era divertida, mas se você olhar mais de perto, essa é de longe a melhor (e seria ainda melhor se você deixasse de fora a saída escalar de computação):

SELECT
    * ,
    DATEDIFF(DAY, [s].[DateCol1], [s].[DateCol2])
FROM
    [#sargme] AS [s]
WHERE
    [s].[DateCol2] >= DATEADD(DAY, 48, [s].[DateCol1])

O motivo é que, ao evitar o DATEDIFFpotencial de barbear alguma CPU em comparação com um cálculo contra apenas a coluna de chave não líder no índice, também evita algumas conversões implícitas desagradáveis datetimeoffset(7)(não me pergunte por que existem, mas estão). Aqui está a DATEDIFFversão:

<Predicate>
<ScalarOperator ScalarString = "datado (dia, CONVERT_IMPLICIT (datetimeoffset (7), [splunge]. [Dbo]. [Sargme]. [Sargme]. [DateCol1] como [s]. [DateCol1], 0), CONVERT_IMPLICIT (datetimeoffset ( 7), [splunge]. [Dbo]. [Sargme]. [DateCol2] como [s]. [DateCol2], 0))> = (48) ">

E aqui está o sem DATEDIFF:

<Predicate>
<ScalarOperator ScalarString = "[splunge]. [Dbo]. [Sargme]. [DateCol2] como [s]. [DateCol2]> = dateadd (dia, (48), [splunge]. [Dbo]. [ sargme]. [DateCol1] como [s]. [DateCol1]) ">

Também encontrei resultados um pouco melhores em termos de duração quando alterei o índice para incluir apenas DateCol2(e quando ambos os índices estavam presentes, o SQL Server sempre escolhia aquele com uma chave e uma coluna de inclusão versus várias chaves). Para esta consulta, como temos que varrer todas as linhas para encontrar o intervalo de qualquer maneira, não há nenhum benefício em ter a segunda coluna de data como parte da chave e classificada de qualquer maneira. E embora eu saiba que não podemos procurar aqui, há algo inerentemente bom em não prejudicar a capacidade de obter um, forçando cálculos contra a coluna principal, e realizando-os apenas em colunas secundárias ou incluídas.

Se fosse eu, e desisti de encontrar a solução sargable, sei qual escolheria - aquela que faz o SQL Server fazer a menor quantidade de trabalho (mesmo que o delta seja quase inexistente). Ou melhor, eu relaxaria minhas restrições sobre a mudança de esquema e coisas do gênero.

E quanto isso importa? Eu não sei. Fiz 10 milhões de linhas na tabela e todas as variações de consulta acima ainda foram concluídas em menos de um segundo. E isso é em uma VM em um laptop (concedido, com SSD).

Aaron Bertrand
fonte
3

Todas as maneiras pelas quais pensei em tornar a cláusula WHERE sargable são complexas e parecem trabalhar para o índice como uma meta final e não como um meio. Então, não, não acho que seja (pragmaticamente) possível.

Eu não tinha certeza se "não alterar a estrutura da tabela" significava nenhum índice adicional. Aqui está uma solução que evita completamente as verificações de índice, mas resulta em muitas buscas de índice separadas, ou seja, uma para cada data possível DateCol1 no intervalo mínimo / máximo de valores de data na tabela. (Diferente do de Daniel, que resulta em uma busca por cada data distinta que realmente aparece na tabela). Teoricamente, é um candidato ao paralelismo porque evita recursões. Mas, honestamente, é difícil ver uma distribuição de dados em que isso é mais rápido do que apenas digitalizar e executar o DATEDIFF. (Talvez um DOP realmente alto?) E ... o código é feio. Eu acho que esse esforço conta como um "exercício mental".

--Add this index to avoid the scan when determining the @MaxDate value
--CREATE NONCLUSTERED INDEX [ix_dates2] ON [#sargme] ([DateCol2]);
DECLARE @MinDate DATE, @MaxDate DATE;
SELECT @MinDate=DateCol1 FROM (SELECT TOP 1 DateCol1 FROM #sargme ORDER BY DateCol1 ASC) ss;
SELECT @MaxDate=DateCol2 FROM (SELECT TOP 1 DateCol2 FROM #sargme ORDER BY DateCol2 DESC) ss;

--Used 44 just to get a few more rows to test my logic
DECLARE @DateDiffSearchValue INT = 44, 
    @MinMaxDifference INT = DATEDIFF(DAY, @MinDate, @MaxDate);

--basic data profile in the table
SELECT [MinDate] = @MinDate, 
        [MaxDate] = @MaxDate, 
        [MinMaxDifference] = @MinMaxDifference, 
        [LastDate1SearchValue] = DATEADD(DAY, 0-@DateDiffSearchValue, @MaxDate);

;WITH rn_base AS (
SELECT [col1] = 0
        UNION ALL SELECT 0
        UNION ALL SELECT 0
        UNION ALL SELECT 0
),
rn_1 AS (
    SELECT t0.col1 FROM rn_base t0
        CROSS JOIN rn_base t1
        CROSS JOIN rn_base t2
        CROSS JOIN rn_base t3
),
rn_2 AS (
    SELECT rn = ROW_NUMBER() OVER (ORDER BY (SELECT NULL))
    FROM rn_1 t0
        CROSS JOIN rn_1 t1
),
candidate_searches AS (
    SELECT 
        [Date1_EqualitySearch] = DATEADD(DAY, t.rn-1, @MinDate),
        [Date2_RangeSearch] = DATEADD(DAY, t.rn-1+@DateDiffSearchValue, @MinDate)
    FROM rn_2 t
    WHERE DATEADD(DAY, t.rn-1, @MinDate) <= DATEADD(DAY, 0-@DateDiffSearchValue, @MaxDate)
    /* Of course, ignore row-number values that would result in a
       Date1_EqualitySearch value that is < @DateDiffSearchValue days before @MaxDate */
)
--select * from candidate_searches

SELECT c.*, xapp.*, dd_rows = DATEDIFF(DAY, xapp.DateCol1, xapp.DateCol2)
FROM candidate_searches c
    cross apply (
        SELECT t.*
        FROM #sargme t
        WHERE t.DateCol1 = c.date1_equalitysearch
        AND t.DateCol2 >= c.date2_rangesearch
    ) xapp
ORDER BY xapp.ID asc --xapp.DateCol1, xapp.DateCol2 
Aaron Morelli
fonte
3

Resposta do Community Wiki originalmente adicionada pelo autor da pergunta como uma edição da pergunta

Depois de deixar isso descansar um pouco, e algumas pessoas realmente inteligentes participando, meu pensamento inicial parece correto: não há uma maneira sã e SARGable de escrever essa consulta sem adicionar uma coluna, computada ou mantida por outro mecanismo, a saber gatilhos.

Tentei algumas outras coisas e tenho outras observações que podem ou não ser interessantes para quem está lendo.

Primeiro, reexecutando a instalação usando uma tabela regular em vez de uma tabela temporária

  • Embora eu conheça a reputação deles, eu queria experimentar estatísticas com várias colunas. Eles eram inúteis.
  • Eu queria ver quais estatísticas foram usadas

Aqui está a nova configuração:

USE [tempdb]
SET NOCOUNT ON  

DBCC FREEPROCCACHE

IF OBJECT_ID('tempdb..sargme') IS NOT NULL
BEGIN
DROP TABLE sargme
END

SELECT TOP 1000
IDENTITY (BIGINT, 1,1) AS ID,
CAST(DATEADD(DAY, [m].[severity] * -1, GETDATE()) AS DATE) AS [DateCol1],
CAST(DATEADD(DAY, [m].[severity], GETDATE()) AS DATE) AS [DateCol2]
INTO sargme
FROM sys.[messages] AS [m]

ALTER TABLE [sargme] ADD CONSTRAINT [pk_whatever] PRIMARY KEY CLUSTERED ([ID])
CREATE NONCLUSTERED INDEX [ix_dates] ON [sargme] ([DateCol1], [DateCol2])

CREATE STATISTICS [s_sargme] ON [sargme] ([DateCol1], [DateCol2])

Em seguida, executando a primeira consulta, ele usa o índice ix_dates e verifica, como antes. Nenhuma mudança aqui. Isso parece redundante, mas fique comigo.

SELECT
    * ,
    DATEDIFF(DAY, [s].[DateCol1], [s].[DateCol2])
FROM
    [sargme] AS [s]
WHERE
    DATEDIFF(DAY, [s].[DateCol1], [s].[DateCol2]) >= 48

Execute a consulta CTE novamente, ainda a mesma ...

WITH    [x] AS ( SELECT
                * ,
                DATEDIFF(DAY, [s].[DateCol1], [s].[DateCol2]) AS [ddif]
               FROM
                [sargme] AS [s])
     SELECT
        *
     FROM
        [x]
     WHERE
        [x].[ddif] >= 48;

Bem! Execute a consulta nem sequer meio sargável novamente:

SELECT
    * ,
    DATEDIFF(DAY, [s].[DateCol1], [s].[DateCol2])
FROM
    [sargme] AS [s]
WHERE
    [s].[DateCol2] >= DATEADD(DAY, 48, [s].[DateCol1])

Agora adicione a coluna computada e execute novamente as três, juntamente com a consulta que atinge a coluna computada:

ALTER TABLE [sargme] ADD [ddiff] AS 
DATEDIFF(DAY, DateCol1, DateCol2) PERSISTED

CREATE NONCLUSTERED INDEX [ix_dates2] ON [sargme] ([ddiff], [DateCol1], [DateCol2])

SELECT [s].[ID] ,
       [s].[DateCol1] ,
       [s].[DateCol2]
FROM [sargme] AS [s]
WHERE [ddiff] >= 48

Se você ficou comigo aqui, obrigado. Esta é a parte interessante de observação do post.

A execução de uma consulta com um sinalizador de rastreamento não documentado por Fabiano Amorim para ver quais estatísticas cada consulta usou é muito interessante. Ver que nenhum plano tocou um objeto de estatística até a coluna computada ser criada e indexada parecia estranha.

O que o sangue

Caramba, mesmo a consulta que atingiu a coluna computada SOMENTE não tocou em um objeto de estatística até eu executá-lo algumas vezes e obter uma parametrização simples. Portanto, embora todos tenham examinado inicialmente o índice ix_dates, eles usaram estimativas de cardinalidade codificadas (30% da tabela) em vez de qualquer objeto estatístico disponível para eles.

Um outro ponto que levantou uma sobrancelha aqui é que, quando adicionei apenas o índice não clusterizado, a consulta planeja todos examinar o HEAP, em vez de usar o índice não clusterizado nas duas colunas da data.

Obrigado a todos que responderam. Vocês são todos maravilhosos.

Paul White diz que a GoFundMonica
fonte