Efter att ha bloggat om hur filtrerade index kan vara mer kraftfulla, och på senare tid om hur de kan göras oanvändbara genom påtvingad parametrisering, återkommer jag till ämnet för filtrerade index/parameterisering. En till synes för enkel lösning dök upp på jobbet nyligen, och jag var tvungen att dela.
Ta följande exempel, där vi har en försäljningsdatabas som innehåller en tabell över beställningar. Ibland vill vi bara ha en lista (eller ett antal) över de beställningar som ännu inte har skickats – som med tiden (förhoppningsvis!) representerar en mindre och mindre andel av den totala tabellen:
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;
Det kan vara vettigt i det här scenariot att skapa ett filtrerat index som det här (som gör det snabbt att hantera alla frågor som försöker få tag på dessa ej skickade beställningar):
CREATE INDEX ix_OrdersNotShipped ON dbo.Orders(IsShipped, OrderDate) WHERE IsShipped = 0;
Vi kan köra en snabb fråga som denna för att se hur den använder det filtrerade indexet:
SELECT OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0;
Utförandeplanen är ganska enkel, men det finns en varning om UnmatchedIndex:
Namnet på varningen är något missvisande - optimeraren kunde i slutändan använda indexet, men antyder att det skulle vara "bättre" utan parametrar (som vi inte uttryckligen använde), även om uttalandet ser ut som om det var parametriserat:
Om du verkligen vill kan du eliminera varningen, utan skillnad i faktisk prestanda (det skulle bara vara kosmetiskt). Ett sätt är att lägga till ett nollpåverkanspredikat, som AND (1 > 0)
:
SELECT wadd = OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0 AND (1 > 0);
En annan (förmodligen vanligare) är att lägga till OPTION (RECOMPILE)
:
SELECT wrecomp = OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0 OPTION (RECOMPILE);
Båda dessa alternativ ger samma plan (en sökning utan varningar):
Än så länge är allt bra; vårt filtrerade index används (som förväntat). Dessa är inte de enda knepen, naturligtvis; se kommentarerna nedan för andra som läsare redan har skickat in.
Då, komplikationen
Eftersom databasen är föremål för ett stort antal ad hoc-förfrågningar, aktiverar någon påtvingad parametrering, försöker minska kompileringen och eliminera låg- och engångsplaner från att förorena planens cache:
ALTER DATABASE Sales SET PARAMETERIZATION FORCED;
Nu kan vår ursprungliga fråga inte använda det filtrerade indexet; den är tvungen att skanna det klustrade indexet:
SELECT OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0;
Varningen om omatchade index återkommer, och vi får nya varningar om resterande I/O. Observera att satsen är parametriserad, men den ser lite annorlunda ut:
Detta är genom design, eftersom hela syftet med forcerad parameterisering är att parametrisera frågor som denna. Men det motverkar syftet med vårt filtrerade index, eftersom det är tänkt att stödja ett enda värde i predikatet, inte en parameter som kan ändras.
Tomfoolery
Vår "trick"-fråga som använder det extra predikatet kan inte heller använda det filtrerade indexet och slutar med en något mer komplicerad plan att starta upp:
SELECT OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0 AND (1 > 0);
ALTERNATIV (OMKOMPILERA)
Den typiska reaktionen i det här fallet, precis som med att ta bort varningen tidigare, är att lägga till OPTION (RECOMPILE)
till uttalandet. Detta fungerar och gör att det filtrerade indexet kan väljas för en effektiv sökning...
SELECT OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0 OPTION (RECOMPILE);
…men lägger till OPTION (RECOMPILE)
och att ta denna extra kompileringsträff mot varje körning av frågan kommer inte alltid att vara acceptabelt i högvolymmiljöer (speciellt om de redan är CPU-bundna).
Tips
Någon föreslog att uttryckligen antyda det filtrerade indexet för att undvika kostnaderna för omkompilering. I allmänhet är detta ganska skört, eftersom det förlitar sig på att indexet överlever koden; Jag brukar använda detta som en sista utväg. I det här fallet är det ändå inte giltigt. När parametreringsregler hindrar optimeraren från att välja det filtrerade indexet automatiskt, hindrar de dig också från att välja det manuellt. Samma problem med en generisk FORCESEEK
tips:
SELECT OrderID, OrderDate FROM dbo.Orders WITH (INDEX (ix_OrdersNotShipped)) WHERE IsShipped = 0; SELECT OrderID, OrderDate FROM dbo.Orders WITH (FORCESEEK) WHERE IsShipped = 0;
Båda ger detta fel:
Msg 8622, Level 16, State 1Frågeprocessor kunde inte skapa en frågeplan på grund av tipsen som definieras i denna fråga. Skicka frågan igen utan att ange några tips och utan att använda SET FORCEPLAN.
Och detta är vettigt, eftersom det inte finns något sätt att veta att det okända värdet för IsShipped
parametern kommer att matcha det filtrerade indexet (eller stödja en sökoperation på vilket index som helst).
Dynamisk SQL?
Jag föreslog att du kunde använda dynamisk SQL, för att åtminstone bara betala den omkompileringsträffen när du vet att du vill träffa det mindre indexet:
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;
Detta leder till samma effektiva plan som ovan. Om du ändrade variabeln till @IsShipped = 1
, då får du den dyrare klustrade indexskanningen du kan förvänta dig:
Men ingen gillar att använda dynamisk SQL i ett edge-fall som detta - det gör koden svårare att läsa och underhålla, och även om den här koden fanns ute i applikationen är det fortfarande ytterligare logik som måste läggas till där, vilket gör det mindre önskvärt .
Något enklare
Vi pratade kort om att implementera en planguide, vilket verkligen inte är enklare, men sedan föreslog en kollega att du kunde lura optimeraren genom att "gömma" den parametriserade satsen i en lagrad procedur, vy eller inline-tabellvärderad funktion. Det var så enkelt, jag trodde inte att det skulle fungera.
Men sedan provade jag det:
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
Alla dessa tre frågor utför den effektiva sökningen mot det filtrerade indexet:
EXEC dbo.GetUnshippedOrders; GO SELECT OrderID, OrderDate FROM dbo.vUnshippedOrders; GO SELECT OrderID, OrderDate FROM dbo.fnUnshippedOrders();
Slutsats
Jag blev förvånad över att detta var så effektivt. Detta kräver naturligtvis att du ändrar applikationen; om du inte kan ändra appkoden för att anropa en lagrad procedur eller referera till vyn eller funktionen (eller till och med lägga till OPTION (RECOMPILE)
), måste du fortsätta leta efter andra alternativ. Men om du kan ändra applikationskoden kan det vara rätt väg att stoppa in predikatet i en annan modul.