Comment éviter d'utiliser des variables dans la clause WHERE

16

Étant donné une procédure stockée (simplifiée) comme celle-ci:

CREATE PROCEDURE WeeklyProc(@endDate DATE)
AS
BEGIN
  DECLARE @startDate DATE = DATEADD(DAY, -6, @endDate)
  SELECT
    -- Stuff
  FROM Sale
  WHERE SaleDate BETWEEN @startDate AND @endDate
END

Si la Saletable est grande, l' SELECTexécution peut prendre beaucoup de temps, apparemment parce que l'optimiseur ne peut pas optimiser en raison de la variable locale. Nous avons testé l'exécution de la SELECTpartie avec des variables puis des dates codées en dur et le temps d'exécution est passé de ~ 9 minutes à ~ 1 seconde.

Nous avons de nombreuses procédures stockées qui interrogent en fonction de plages de dates "fixes" (semaine, mois, 8 semaines, etc.), de sorte que le paramètre d'entrée est simplement @endDate et @startDate est calculé à l'intérieur de la procédure.

La question est, quelle est la meilleure pratique pour éviter les variables dans une clause WHERE afin de ne pas compromettre l'optimiseur?

Les possibilités que nous avons trouvées sont présentées ci-dessous. Existe-t-il une de ces meilleures pratiques ou existe-t-il une autre méthode?

Utilisez une procédure wrapper pour transformer les variables en paramètres.

Les paramètres n'affectent pas l'optimiseur de la même manière que les variables locales.

CREATE PROCEDURE WeeklyProc(@endDate DATE)
AS
BEGIN
   DECLARE @startDate DATE = DATEADD(DAY, -6, @endDate)
   EXECUTE DateRangeProc @startDate, @endDate
END

CREATE PROCEDURE DateRangeProc(@startDate DATE, @endDate DATE)
AS
BEGIN
  SELECT
    -- Stuff
  FROM Sale
  WHERE SaleDate BETWEEN @startDate AND @endDate
END

Utilisez du SQL dynamique paramétré.

CREATE PROCEDURE WeeklyProc(@endDate DATE)
AS
BEGIN
  DECLARE @startDate DATE = DATEADD(DAY, -6, @endDate)
  DECLARE @sql NVARCHAR(4000) = N'
    SELECT
      -- Stuff
    FROM Sale
    WHERE SaleDate BETWEEN @startDate AND @endDate
  '
  DECLARE @param NVARCHAR(4000) = N'@startDate DATE, @endDate DATE'
  EXECUTE sp_executesql @sql, @param, @startDate = @startDate, @endDate = @endDate
END

Utilisez du SQL dynamique "codé en dur".

CREATE PROCEDURE WeeklyProc(@endDate DATE)
AS
BEGIN
  DECLARE @startDate DATE = DATEADD(DAY, -6, @endDate)
  DECLARE @sql NVARCHAR(4000) = N'
    SELECT
      -- Stuff
    FROM Sale
    WHERE SaleDate BETWEEN @startDate AND @endDate
  '
  SET @sql = REPLACE(@sql, '@startDate', CONVERT(NCHAR(10), @startDate, 126))
  SET @sql = REPLACE(@sql, '@endDate', CONVERT(NCHAR(10), @endDate, 126))
  EXECUTE sp_executesql @sql
END

Utilisez la DATEADD()fonction directement.

Je ne suis pas enthousiaste à ce sujet car l'appel de fonctions dans le WHERE affecte également les performances.

CREATE PROCEDURE WeeklyProc(@endDate DATE)
AS
BEGIN
  SELECT
    -- Stuff
  FROM Sale
  WHERE SaleDate BETWEEN DATEADD(DAY, -6, @endDate) AND @endDate
END

Utilisez un paramètre facultatif.

Je ne sais pas si l'attribution aux paramètres aurait le même problème que l'attribution aux variables, donc cela pourrait ne pas être une option. Je n'aime pas vraiment cette solution mais l'inclure pour être complet.

CREATE PROCEDURE WeeklyProc(@endDate DATE, @startDate DATE = NULL)
AS
BEGIN
  SET @startDate = DATEADD(DAY, -6, @endDate)
  SELECT
    -- Stuff
  FROM Sale
  WHERE SaleDate BETWEEN @startDate AND @endDate
END

-- Mise à jour --

Merci pour vos suggestions et commentaires. Après les avoir lus, j'ai effectué des tests de chronométrage avec les différentes approches. J'ajoute les résultats ici comme référence.

Le run 1 est sans plan. L'exécution 2 est immédiatement après l'exécution 1 avec exactement les mêmes paramètres, elle utilisera donc le plan de l'exécution 1.

Les heures NoProc permettent d'exécuter manuellement les requêtes SELECT dans SSMS en dehors d'une procédure stockée.

TestProc1-7 sont les requêtes de la question d'origine.

TestProcA-B est basé sur la suggestion de Mikael Eriksson . La colonne de la base de données est une DATE, j'ai donc essayé de passer le paramètre en tant que DATETIME et de l'exécuter avec la conversion implicite (testProcA) et la conversion explicite (testProcB).

TestProcC-D est basé sur la suggestion de Kenneth Fisher . Nous utilisons déjà une table de recherche de dates pour d'autres choses, mais nous n'en avons pas avec une colonne spécifique pour chaque plage de périodes. La variation que j'ai essayée utilise toujours BETWEEN mais le fait sur la table de recherche plus petite et se joint à la table plus grande. Je vais étudier plus avant si nous pouvons utiliser des tables de recherche spécifiques, bien que nos périodes soient fixes, il y en a plusieurs.

    Total des lignes dans le tableau de vente: 136 424 366

                       Exécuter 1 (ms) Exécuter 2 (ms)
    Procédure CPU Elapsed CPU Elapsed Commentaire
    Constantes NoProc 6567 62199 2870 719 Requête manuelle avec constantes
    Variables NoProc 9314 62424 3993 998 Requête manuelle avec variables
    testProc1 6801 62919 2871736 Plage codée en dur
    testProc2 8955 63190 3915 979 Paramètres et plage variable
    testProc3 8985 63152 3932 987 Procédure d'emballage avec plage de paramètres
    testProc4 9142 63939 3931 977 SQL dynamique paramétré
    testProc5 7269 62933 2933 728 SQL dynamique codé en dur
    testProc6 9266 63421 3915 984 Utiliser DATEADD le DATE
    testProc7 2044 13950 1092 1087 Paramètre factice
    testProcA 12120 61493 5491 1875 Utiliser DATEADD sur DATETIME sans CAST
    testProcB 8612 61949 3932 978 Utiliser DATEADD à DATETIME avec CAST
    testProcC 8861 61651 3917 993 Utiliser la table de recherche, Vente d'abord
    testProcD 8625 61740 3994 1031 Utiliser la table de correspondance, dernière vente

Voici le code de test.

------ SETUP ------

IF OBJECT_ID(N'testDimDate', N'U') IS NOT NULL DROP TABLE testDimDate
IF OBJECT_ID(N'testProc1', N'P') IS NOT NULL DROP PROCEDURE testProc1
IF OBJECT_ID(N'testProc2', N'P') IS NOT NULL DROP PROCEDURE testProc2
IF OBJECT_ID(N'testProc3', N'P') IS NOT NULL DROP PROCEDURE testProc3
IF OBJECT_ID(N'testProc3a', N'P') IS NOT NULL DROP PROCEDURE testProc3a
IF OBJECT_ID(N'testProc4', N'P') IS NOT NULL DROP PROCEDURE testProc4
IF OBJECT_ID(N'testProc5', N'P') IS NOT NULL DROP PROCEDURE testProc5
IF OBJECT_ID(N'testProc6', N'P') IS NOT NULL DROP PROCEDURE testProc6
IF OBJECT_ID(N'testProc7', N'P') IS NOT NULL DROP PROCEDURE testProc7
IF OBJECT_ID(N'testProcA', N'P') IS NOT NULL DROP PROCEDURE testProcA
IF OBJECT_ID(N'testProcB', N'P') IS NOT NULL DROP PROCEDURE testProcB
IF OBJECT_ID(N'testProcC', N'P') IS NOT NULL DROP PROCEDURE testProcC
IF OBJECT_ID(N'testProcD', N'P') IS NOT NULL DROP PROCEDURE testProcD
GO

CREATE TABLE testDimDate
(
   DateKey DATE NOT NULL,
   CONSTRAINT PK_DimDate_DateKey UNIQUE NONCLUSTERED (DateKey ASC)
)
GO

DECLARE @dateTimeStart DATETIME = '2000-01-01'
DECLARE @dateTimeEnd DATETIME = '2100-01-01'
;WITH CTE AS
(
   --Anchor member defined
   SELECT @dateTimeStart FullDate
   UNION ALL
   --Recursive member defined referencing CTE
   SELECT FullDate + 1 FROM CTE WHERE FullDate + 1 <= @dateTimeEnd
)
SELECT
   CAST(FullDate AS DATE) AS DateKey
INTO #DimDate
FROM CTE
OPTION (MAXRECURSION 0)

INSERT INTO testDimDate (DateKey)
SELECT DateKey FROM #DimDate ORDER BY DateKey ASC

DROP TABLE #DimDate
GO

-- Hard coded date range.
CREATE PROCEDURE testProc1 AS
BEGIN
   SET NOCOUNT ON
   SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN '2012-12-09' AND '2012-12-10'
END
GO

-- Parameter and variable date range.
CREATE PROCEDURE testProc2(@endDate DATE) AS
BEGIN
   SET NOCOUNT ON
   DECLARE @startDate DATE = DATEADD(DAY, -1, @endDate)
   SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN @startDate AND @endDate
END
GO

-- Parameter date range.
CREATE PROCEDURE testProc3a(@startDate DATE, @endDate DATE) AS
BEGIN
   SET NOCOUNT ON
   SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN @startDate AND @endDate
END
GO

-- Wrapper procedure.
CREATE PROCEDURE testProc3(@endDate DATE) AS
BEGIN
   SET NOCOUNT ON
   DECLARE @startDate DATE = DATEADD(DAY, -1, @endDate)
   EXEC testProc3a @startDate, @endDate
END
GO

-- Parameterized dynamic SQL.
CREATE PROCEDURE testProc4(@endDate DATE) AS
BEGIN
   SET NOCOUNT ON
   DECLARE @startDate DATE = DATEADD(DAY, -1, @endDate)
   DECLARE @sql NVARCHAR(4000) = N'SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN @startDate AND @endDate'
   DECLARE @param NVARCHAR(4000) = N'@startDate DATE, @endDate DATE'
   EXEC sp_executesql @sql, @param, @startDate = @startDate, @endDate = @endDate
END
GO

-- Hard coded dynamic SQL.
CREATE PROCEDURE testProc5(@endDate DATE) AS
BEGIN
   SET NOCOUNT ON
   DECLARE @startDate DATE = DATEADD(DAY, -1, @endDate)
   DECLARE @sql NVARCHAR(4000) = N'SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN ''@startDate'' AND ''@endDate'''
   SET @sql = REPLACE(@sql, '@startDate', CONVERT(NCHAR(10), @startDate, 126))
   SET @sql = REPLACE(@sql, '@endDate', CONVERT(NCHAR(10), @endDate, 126))
   EXEC sp_executesql @sql
END
GO

-- Explicitly use DATEADD on a DATE.
CREATE PROCEDURE testProc6(@endDate DATE) AS
BEGIN
   SET NOCOUNT ON
   SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN DATEADD(DAY, -1, @endDate) AND @endDate
END
GO

-- Dummy parameter.
CREATE PROCEDURE testProc7(@endDate DATE, @startDate DATE = NULL) AS
BEGIN
   SET NOCOUNT ON
   SET @startDate = DATEADD(DAY, -1, @endDate)
   SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN @startDate AND @endDate
END
GO

-- Explicitly use DATEADD on a DATETIME with implicit CAST for comparison with SaleDate.
-- Based on the answer from Mikael Eriksson.
CREATE PROCEDURE testProcA(@endDateTime DATETIME) AS
BEGIN
   SET NOCOUNT ON
   SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN DATEADD(DAY, -1, @endDateTime) AND @endDateTime
END
GO

-- Explicitly use DATEADD on a DATETIME but CAST to DATE for comparison with SaleDate.
-- Based on the answer from Mikael Eriksson.
CREATE PROCEDURE testProcB(@endDateTime DATETIME) AS
BEGIN
   SET NOCOUNT ON
   SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN CAST(DATEADD(DAY, -1, @endDateTime) AS DATE) AND CAST(@endDateTime AS DATE)
END
GO

-- Use a date lookup table, Sale first.
-- Based on the answer from Kenneth Fisher.
CREATE PROCEDURE testProcC(@endDate DATE) AS
BEGIN
   SET NOCOUNT ON
   DECLARE @startDate DATE = DATEADD(DAY, -1, @endDate)
   SELECT SUM(Value) FROM Sale J INNER JOIN testDimDate D ON D.DateKey = J.SaleDate WHERE D.DateKey BETWEEN @startDate AND @endDate
END
GO

-- Use a date lookup table, Sale last.
-- Based on the answer from Kenneth Fisher.
CREATE PROCEDURE testProcD(@endDate DATE) AS
BEGIN
   SET NOCOUNT ON
   DECLARE @startDate DATE = DATEADD(DAY, -1, @endDate)
   SELECT SUM(Value) FROM testDimDate D INNER JOIN Sale J ON J.SaleDate = D.DateKey WHERE D.DateKey BETWEEN @startDate AND @endDate
END
GO

------ TEST ------

SET STATISTICS TIME OFF

DECLARE @endDate DATE = '2012-12-10'
DECLARE @startDate DATE = DATEADD(DAY, -1, @endDate)

DBCC FREEPROCCACHE WITH NO_INFOMSGS
DBCC DROPCLEANBUFFERS WITH NO_INFOMSGS

RAISERROR('Run 1: NoProc with constants', 0, 0) WITH NOWAIT
SET STATISTICS TIME ON
SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN '2012-12-09' AND '2012-12-10'
SET STATISTICS TIME OFF

RAISERROR('Run 2: NoProc with constants', 0, 0) WITH NOWAIT
SET STATISTICS TIME ON
SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN '2012-12-09' AND '2012-12-10'
SET STATISTICS TIME OFF

DBCC FREEPROCCACHE WITH NO_INFOMSGS
DBCC DROPCLEANBUFFERS WITH NO_INFOMSGS

RAISERROR('Run 1: NoProc with variables', 0, 0) WITH NOWAIT
SET STATISTICS TIME ON
SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN @startDate AND @endDate
SET STATISTICS TIME OFF

RAISERROR('Run 2: NoProc with variables', 0, 0) WITH NOWAIT
SET STATISTICS TIME ON
SELECT SUM(Value) FROM Sale WHERE SaleDate BETWEEN @startDate AND @endDate
SET STATISTICS TIME OFF

DECLARE @sql NVARCHAR(4000)

DECLARE _cursor CURSOR LOCAL FAST_FORWARD FOR
   SELECT
      procedures.name,
      procedures.object_id
   FROM sys.procedures
   WHERE procedures.name LIKE 'testProc_'
   ORDER BY procedures.name ASC

OPEN _cursor

DECLARE @name SYSNAME
DECLARE @object_id INT

FETCH NEXT FROM _cursor INTO @name, @object_id
WHILE @@FETCH_STATUS = 0
BEGIN
   SET @sql = CASE (SELECT COUNT(*) FROM sys.parameters WHERE object_id = @object_id)
      WHEN 0 THEN @name
      WHEN 1 THEN @name + ' ''@endDate'''
      WHEN 2 THEN @name + ' ''@startDate'', ''@endDate'''
   END

   SET @sql = REPLACE(@sql, '@name', @name)
   SET @sql = REPLACE(@sql, '@startDate', CONVERT(NVARCHAR(10), @startDate, 126))
   SET @sql = REPLACE(@sql, '@endDate', CONVERT(NVARCHAR(10), @endDate, 126))

   DBCC FREEPROCCACHE WITH NO_INFOMSGS
   DBCC DROPCLEANBUFFERS WITH NO_INFOMSGS

   RAISERROR('Run 1: %s', 0, 0, @sql) WITH NOWAIT
   SET STATISTICS TIME ON
   EXEC sp_executesql @sql
   SET STATISTICS TIME OFF

   RAISERROR('Run 2: %s', 0, 0, @sql) WITH NOWAIT
   SET STATISTICS TIME ON
   EXEC sp_executesql @sql
   SET STATISTICS TIME OFF

   FETCH NEXT FROM _cursor INTO @name, @object_id
END

CLOSE _cursor
DEALLOCATE _cursor
WileCau
la source

Réponses:

9

Le reniflage de paramètres est votre ami presque tout le temps et vous devez écrire vos requêtes afin qu'il puisse être utilisé. Le reniflage de paramètres vous aide à créer le plan à l'aide des valeurs de paramètres disponibles lors de la compilation de la requête. Le côté sombre du reniflage de paramètres est lorsque les valeurs utilisées lors de la compilation de la requête ne sont pas optimales pour les requêtes à venir.

La requête dans une procédure stockée est compilée lorsque la procédure stockée est exécutée, pas lorsque la requête est exécutée, de sorte que les valeurs que SQL Server doit traiter ici ...

CREATE PROCEDURE WeeklyProc(@endDate DATE)
AS
BEGIN
  DECLARE @startDate DATE = DATEADD(DAY, -6, @endDate)
  SELECT
    -- Stuff
  FROM Sale
  WHERE SaleDate BETWEEN @startDate AND @endDate
END

est une valeur connue pour @endDateet une valeur inconnue pour @startDate. Cela laissera SQL Server deviner 30% des lignes renvoyées pour le filtre sur@startDate combinaison avec tout ce que les statistiques lui indiquent @endDate. Si vous avez une grande table avec beaucoup de lignes qui pourraient vous donner une opération d'analyse où vous bénéficieriez le plus d'une recherche.

Votre solution de procédure d'encapsuleur s'assure que SQL Server voit les valeurs lorsqu'il DateRangeProcest compilé afin qu'il puisse utiliser des valeurs connues pour @endDateet@startDate .

Vos deux requêtes dynamiques mènent à la même chose, les valeurs sont connues au moment de la compilation.

Celui avec une valeur nulle par défaut est un peu spécial. Les valeurs connues de SQL Server au moment de la compilation sont des valeurs connues pour @endDateet nullpour @startDate. Utilisant unnull d'un entre vous donnera 0 lignes mais SQL Server suppose toujours 1 dans ces cas. Cela peut être une bonne chose dans ce cas, mais si vous appelez la procédure stockée avec un grand intervalle de dates où une analyse aurait été le meilleur choix, elle pourrait finir par faire un tas de recherches.

J'ai laissé "Utiliser la fonction DATEADD () directement" à la fin de cette réponse car c'est celle que j'utiliserais et il y a aussi quelque chose d'étrange.

Tout d'abord, SQL Server n'appelle pas la fonction plusieurs fois lorsqu'elle est utilisée dans la clause where. DATEADD est considéré comme une constante d'exécution .

Et je pense que cela DATEADDest évalué lorsque la requête est compilée afin que vous obteniez une bonne estimation du nombre de lignes renvoyées. Mais ce n'est pas le cas dans ce cas.
Estimations de SQL Server basées sur la valeur du paramètre indépendamment de ce que vous faites DATEADD(testé sur SQL Server 2012), dans votre cas, l'estimation correspondra au nombre de lignes enregistrées @endDate. Pourquoi cela fait-il que je ne sais pas, mais cela a à voir avec l'utilisation du type de données DATE. Le passage à DATETIMEdans la procédure stockée et le tableau et l'estimation sera précis, ce qui signifie qu'il DATEADDest pris en compte au moment de la compilation pour DATETIMEpas pourDATE .

Donc, pour résumer cette réponse assez longue, je recommanderais la solution de procédure de wrapper. Il permettra toujours à SQL Server d'utiliser les valeurs fournies lors de la compilation de la requête sans les tracas de l'utilisation de SQL dynamique.

PS:

Dans les commentaires, vous avez reçu deux suggestions.

OPTION (OPTIMIZE FOR UNKNOWN)vous donnera une estimation de 9% des lignes renvoyées et OPTION (RECOMPILE)fera voir à SQL Server les valeurs des paramètres puisque la requête est recompilée à chaque fois.

Mikael Eriksson
la source
3

Ok, j'ai deux solutions possibles pour vous.

Je me demande d'abord si cela permettra d'augmenter le paramétrage. Je n'ai pas eu l'occasion de le tester, mais cela pourrait fonctionner.

CREATE PROCEDURE WeeklyProc(@endDate DATE, @startDate DATE)
AS
BEGIN
  IF @startDate IS NULL
    SET @startDate = DATEADD(DAY, -6, @endDate)
  SELECT
    -- Stuff
  FROM Sale
  WHERE SaleDate BETWEEN @startDate AND @endDate
END

L'autre option tire parti du fait que vous utilisez des délais fixes. Créez d'abord une table DateLookup. Quelque chose comme ça

CurrentDate    8WeekStartDate    8WeekEndDate    etc

Remplissez-le pour chaque date entre maintenant et le siècle prochain. Ce n'est que ~ 36500 lignes, donc une table assez petite. Ensuite, changez votre requête comme ceci

IF @Range = '8WeekRange' 
    SELECT
      -- Stuff
    FROM Sale
    JOIN DateLookup
        ON SaleDate BETWEEN [8WeekStartDate] AND [8WeekEndDate]
    WHERE DateLookup.CurrentDate = GetDate()

Évidemment, ce n'est qu'un exemple et pourrait certainement être mieux écrit, mais j'ai eu beaucoup de chance avec ce type de tableau. D'autant plus qu'il s'agit d'une table statique et peut être indexée comme un fou.

Kenneth Fisher
la source