Database
 sql >> Datenbank >  >> RDS >> Database

Gefilterte Indizes und erzwungene Parametrisierung (redux)

Nachdem ich darüber gebloggt habe, wie gefilterte Indizes leistungsfähiger sein könnten, und kürzlich darüber, wie sie durch erzwungene Parametrisierung unbrauchbar gemacht werden können, greife ich das Thema gefilterte Indizes/Parametrisierung erneut auf. Eine scheinbar zu einfache Lösung tauchte kürzlich bei der Arbeit auf und ich musste sie teilen.

Nehmen Sie das folgende Beispiel, in dem wir eine Verkaufsdatenbank haben, die eine Tabelle mit Bestellungen enthält. Manchmal möchten wir nur eine Liste (oder eine Zählung) der noch zu versendenden Bestellungen – die im Laufe der Zeit (hoffentlich!) einen immer kleineren Prozentsatz der Gesamttabelle ausmachen:

CREATE DATABASE Sales;
GO
USE Sales;
GO
 
-- simplified, obviously:
CREATE TABLE dbo.Orders
(
    OrderID   int IDENTITY(1,1) PRIMARY KEY,
    OrderDate datetime  NOT NULL,
    filler    char(500) NOT NULL DEFAULT '',
    IsShipped bit       NOT NULL DEFAULT 0
);
GO
 
-- let's put some data in there; 7,000 shipped orders, and 50 unshipped:
 
INSERT dbo.Orders(OrderDate, IsShipped)
  -- random dates over two years
  SELECT TOP (7000) DATEADD(DAY, ABS(object_id % 730), '20171101'), 1 
  FROM sys.all_columns
UNION ALL 
  -- random dates from this month
  SELECT TOP (50)   DATEADD(DAY, ABS(object_id % 30),  '20191201'), 0 
  FROM sys.all_columns;

In diesem Szenario kann es sinnvoll sein, einen gefilterten Index wie den folgenden zu erstellen (der schnell alle Abfragen bearbeitet, die versuchen, an diese nicht versendeten Bestellungen zu gelangen):

CREATE INDEX ix_OrdersNotShipped 
  ON dbo.Orders(IsShipped, OrderDate) 
  WHERE IsShipped = 0;

Wir können eine schnelle Abfrage wie diese ausführen, um zu sehen, wie der gefilterte Index verwendet wird:

SELECT OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0;

Der Ausführungsplan ist ziemlich einfach, aber es gibt eine Warnung zu UnmatchedIndexes:

Der Name der Warnung ist leicht irreführend – der Optimierer konnte letztendlich den Index verwenden, schlägt aber vor, dass er ohne Parameter „besser“ wäre (was wir nicht explizit verwendet haben), obwohl die Anweisung so aussieht, als wäre sie parametrisiert worden:

Wenn Sie wirklich wollen, können Sie die Warnung ohne Unterschied in der tatsächlichen Leistung entfernen (es wäre nur kosmetischer Natur). Eine Möglichkeit besteht darin, ein wirkungsloses Prädikat wie AND (1 > 0) hinzuzufügen :

SELECT wadd = OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0 AND (1 > 0);

Eine andere (wahrscheinlich häufigere) Möglichkeit besteht darin, OPTION (RECOMPILE) hinzuzufügen :

SELECT wrecomp = OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0 OPTION (RECOMPILE);

Diese beiden Optionen ergeben denselben Plan (eine Suche ohne Warnungen):

So weit, ist es gut; unser gefilterter Index wird (wie erwartet) verwendet. Dies sind natürlich nicht die einzigen Tricks; siehe die Kommentare unten für andere, die bereits von Lesern eingereicht wurden.

Dann die Komplikation

Da die Datenbank einer großen Anzahl von Ad-hoc-Abfragen ausgesetzt ist, schaltet jemand die erzwungene Parametrisierung ein und versucht, die Kompilierung zu reduzieren und zu verhindern, dass Pläne mit geringer und einmaliger Verwendung den Plan-Cache verunreinigen:

ALTER DATABASE Sales SET PARAMETERIZATION FORCED;

Jetzt kann unsere ursprüngliche Abfrage den gefilterten Index nicht verwenden; es wird gezwungen, den gruppierten Index zu scannen:

SELECT OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0;

Die Warnung zu nicht übereinstimmenden Indizes kehrt zurück, und wir erhalten neue Warnungen zu Rest-I/O. Beachten Sie, dass die Anweisung parametrisiert ist, aber etwas anders aussieht:

Dies ist beabsichtigt, da der gesamte Zweck der erzwungenen Parametrisierung darin besteht, Abfragen wie diese zu parametrisieren. Aber es macht den Zweck unseres gefilterten Index zunichte, da dieser einen einzelnen Wert im Prädikat unterstützen soll, nicht einen Parameter, der sich ändern kann.

Dummkopf

Unsere "Trick"-Abfrage, die das zusätzliche Prädikat verwendet, ist ebenfalls nicht in der Lage, den gefilterten Index zu verwenden, und endet mit einem etwas komplizierteren Plan zum Booten:

SELECT OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0 AND (1 > 0);

OPTION (NEU KOMPILIEREN)

Die typische Reaktion in diesem Fall ist, genau wie beim Entfernen der Warnung zuvor, das Hinzufügen von OPTION (RECOMPILE) zur Aussage. Dies funktioniert und ermöglicht die Auswahl des gefilterten Indexes für eine effiziente Suche …

SELECT OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0 OPTION (RECOMPILE);

…aber Hinzufügen von OPTION (RECOMPILE) und diesen zusätzlichen Kompilierungstreffer gegen jede Ausführung der Abfrage zu nehmen, wird in Umgebungen mit hohem Volumen nicht immer akzeptabel sein (insbesondere wenn sie bereits CPU-gebunden sind).

Tipps

Jemand schlug vor, explizit auf den gefilterten Index hinzuweisen, um die Kosten einer Neukompilierung zu vermeiden. Im Allgemeinen ist dies ziemlich spröde, da es darauf angewiesen ist, dass der Index den Code überdauert; Ich neige dazu, dies als letzten Ausweg zu verwenden. In diesem Fall ist es sowieso nicht gültig. Wenn Parametrisierungsregeln den Optimierer daran hindern, den gefilterten Index automatisch auszuwählen, hindern sie Sie auch daran, ihn manuell auszuwählen. Dasselbe Problem mit einem generischen FORCESEEK Hinweis:

SELECT OrderID, OrderDate FROM dbo.Orders WITH (INDEX (ix_OrdersNotShipped)) WHERE IsShipped = 0;
 
SELECT OrderID, OrderDate FROM dbo.Orders WITH (FORCESEEK) WHERE IsShipped = 0;

Beide ergeben diesen Fehler:

Nachricht 8622, Ebene 16, Status 1
Der Abfrageprozessor konnte aufgrund der in dieser Abfrage definierten Hinweise keinen Abfrageplan erstellen. Senden Sie die Abfrage erneut, ohne Hinweise anzugeben und ohne SET FORCEPLAN zu verwenden.

Und das macht Sinn, denn es gibt keine Möglichkeit zu wissen, dass der unbekannte Wert für IsShipped ist -Parameter stimmt mit dem gefilterten Index überein (oder unterstützt eine Suchoperation für einen beliebigen Index).

Dynamisches SQL?

Ich habe vorgeschlagen, dass Sie dynamisches SQL verwenden könnten, um zumindest diesen Neukompilierungstreffer nur dann zu bezahlen, wenn Sie wissen, dass Sie den kleineren Index treffen möchten:

DECLARE @IsShipped bit = 0;
 
DECLARE @sql nvarchar(max) = N'SELECT dynsql = OrderID, OrderDate FROM dbo.Orders'
  + CASE WHEN @IsShipped IS NOT NULL THEN N' WHERE IsShipped = @IsShipped'
    ELSE N'' END
  + CASE WHEN @IsShipped = 0 THEN N' OPTION (RECOMPILE)' ELSE N'' END;
 
EXEC sys.sp_executesql @sql, N'@IsShipped bit', @IsShipped;

Dies führt zu demselben effizienten Plan wie oben. Wenn Sie die Variable in @IsShipped = 1 geändert haben , dann erhalten Sie den teureren Clustered-Index-Scan, den Sie erwarten sollten:

Aber niemand verwendet gerne dynamisches SQL in einem Grenzfall wie diesem – es macht Code schwieriger zu lesen und zu warten, und selbst wenn dieser Code in der Anwendung wäre, wäre es immer noch zusätzliche Logik, die dort hinzugefügt werden müsste, was es weniger als wünschenswert macht .

Etwas Einfacheres

Wir haben kurz über die Implementierung einer Plananleitung gesprochen, was sicherlich nicht einfacher ist, aber dann schlug ein Kollege vor, dass Sie den Optimierer täuschen könnten, indem Sie die parametrisierte Anweisung in einer gespeicherten Prozedur, Ansicht oder Inline-Tabellenwertfunktion „verstecken“. Es war so einfach, ich hätte nicht geglaubt, dass es funktionieren würde.

Aber dann habe ich es versucht:

CREATE PROCEDURE dbo.GetUnshippedOrders
AS
BEGIN
  SET NOCOUNT ON;
  SELECT OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0;
END
GO
 
CREATE VIEW dbo.vUnshippedOrders
AS
  SELECT OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0;
GO
 
CREATE FUNCTION dbo.fnUnshippedOrders()
RETURNS TABLE
AS
  RETURN (SELECT OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0);
GO

Alle drei dieser Abfragen führen die effiziente Suche nach dem gefilterten Index durch:

EXEC dbo.GetUnshippedOrders;
GO
SELECT OrderID, OrderDate FROM dbo.vUnshippedOrders;
GO
SELECT OrderID, OrderDate FROM dbo.fnUnshippedOrders();

Schlussfolgerung

Ich war überrascht, dass dies so effektiv war. Dazu müssen Sie natürlich die Anwendung ändern; wenn Sie den App-Code nicht ändern können, um eine gespeicherte Prozedur aufzurufen oder auf die Ansicht oder Funktion zu verweisen (oder sogar OPTION (RECOMPILE) hinzuzufügen ), müssen Sie nach anderen Optionen suchen. Aber wenn Sie den Anwendungscode ändern können, ist das Einfügen des Prädikats in ein anderes Modul möglicherweise der richtige Weg.