Redan 2013 skrev jag om ett fel i optimeraren där det andra och tredje argumentet till DATEDIFF()
kan bytas ut – vilket kan leda till felaktiga radräkningsuppskattningar och i sin tur dåligt val av genomförandeplan:
- Prestanda överraskningar och antaganden:DATEDIFF
Den gångna helgen fick jag veta om en liknande situation och gjorde det omedelbara antagandet att det var samma problem. Trots allt verkade symtomen nästan identiska:
- Det fanns en datum/tid-funktion i
WHERE
klausul.- Den här gången var det
DATEADD()
istället förDATEDIFF()
.
- Den här gången var det
- Det var en uppenbarligen felaktig uppskattning av radantal på 1, jämfört med ett faktisk radantal på över 3 miljoner.
- Detta var faktiskt en uppskattning av 0, men SQL Server rundar alltid upp sådana uppskattningar till 1.
- Ett dåligt planval gjordes (i det här fallet valdes en loopkoppling) på grund av den låga uppskattningen.
Det kränkande mönstret såg ut så här:
WHERE [datetime2(7) column] >= DATEADD(DAY, -365, SYSUTCDATETIME());
Användaren försökte flera varianter, men ingenting förändrades; de lyckades till slut lösa problemet genom att ändra predikatet till:
WHERE DATEDIFF(DAY, [column], SYSUTCDATETIME()) <= 365;
Detta fick en bättre uppskattning (den typiska gissningen på 30 % ojämlikhet); så inte helt rätt. Och även om det eliminerade loop-anslutningen, finns det två stora problem med detta predikat:
- Det är inte samma fråga, eftersom den nu letar efter 365 dagars gränser som har passerat, i motsats till att vara större än en specifik tidpunkt för 365 dagar sedan. Statistiskt säkerställt? Kanske inte. Men rent tekniskt sett är det inte samma sak.
- Om du använder funktionen mot kolumnen blir hela uttrycket inte sargerbart – vilket leder till en fullständig genomsökning. När tabellen bara innehåller lite mer än ett år av data är detta ingen stor sak, men när tabellen blir större, eller predikatet blir smalare, kommer detta att bli ett problem.
Återigen drog jag slutsatsen att DATEADD()
operationen var problemet och rekommenderade ett tillvägagångssätt som inte förlitade sig på DATEADD()
– bygga en datetime
från alla delar av den aktuella tiden, vilket gör att jag kan subtrahera ett år utan att använda DATEADD()
:
WHERE [column] >= DATETIMEFROMPARTS( DATEPART(YEAR, SYSUTCDATETIME())-1, DATEPART(MONTH, SYSUTCDATETIME()), DATEPART(DAY, SYSUTCDATETIME()), DATEPART(HOUR, SYSUTCDATETIME()), DATEPART(MINUTE, SYSUTCDATETIME()), DATEPART(SECOND, SYSUTCDATETIME()), 0);
Förutom att det var skrymmande hade detta några egna problem, nämligen att en massa logik skulle behöva läggas till för att korrekt kunna ta hänsyn till skottår. För det första så att den inte misslyckas om den råkar köra den 29 februari, och för det andra att inkludera exakt 365 dagar i alla fall (istället för 366 under året efter en skottdag). Enkla korrigeringar, naturligtvis, men de gör logiken mycket fulare – särskilt eftersom frågan behövde existera i en vy, där mellanliggande variabler och flera steg inte är möjliga.
Under tiden lämnade OP ett Connect-objekt, bestört över uppskattningen på en rad:
- Anslut #2567628:Begränsning med DateAdd() ger inte bra uppskattningar
Sedan kom Paul White (@SQL_Kiwi) och, som många gånger tidigare, kastade han lite extra ljus över problemet. Han delade ett relaterat Connect-objekt som lämnats in av Erland Sommarskog redan 2011:
- Connect #685903 :Felaktig uppskattning när sysdatetime visas i ett dateadd()-uttryck
I grund och botten är problemet att en dålig uppskattning inte bara kan göras när SYSDATETIME()
(eller SYSUTCDATETIME()
) visas, som Erland ursprungligen rapporterade, men när någon datetime2
uttryck är involverat i predikatet (och kanske bara när DATEADD()
används också). Och det kan gå åt båda hållen – om vi byter >=
för <=
, blir uppskattningen hela tabellen, så det verkar som om optimeraren tittar på SYSDATETIME()
värde som en konstant och helt ignorerar alla operationer som DATEADD()
som utförs mot det.
Paul berättade att lösningen helt enkelt är att använda en datetime
motsvarande vid beräkning av datumet, innan det konverteras till rätt datatyp. I det här fallet kan vi byta ut SYSUTCDATETIME()
och ändra den till GETUTCDATE()
:
WHERE [column] >= CONVERT(datetime2(7), DATEADD(DAY, -365, GETUTCDATE()));
Ja, detta resulterar i en liten förlust av precision, men det kan också en dammpartikel som saktar ner ditt finger på väg att trycka på
Läsningarna är likartade eftersom tabellen nästan uteslutande innehåller data från det senaste året, så även en sökning blir en räckviddsskanning av större delen av tabellen. Antalet rader är inte identiska eftersom (a) den andra frågan avbryts vid midnatt och (b) den tredje frågan innehåller en extra dag med data på grund av skottdagen tidigare i år. I vilket fall som helst visar detta fortfarande hur vi kan komma närmare korrekta uppskattningar genom att eliminera DATEADD()
, men den korrekta lösningen är att ta bort den direkta kombinationen av DATEADD()
och datetime2
.
För att ytterligare illustrera hur uppskattningarna gör fel kan du se att om vi skickar olika argument och riktningar till den ursprungliga frågan och Pauls omskrivning, så baseras antalet uppskattade rader för den förstnämnda alltid på den aktuella tiden – de gör det ändras inte med antalet dagar som gått (medan Pauls är relativt korrekt varje gång):
Faktiska rader för den första frågan är något lägre eftersom den kördes efter en lång tupplur
Uppskattningarna kommer inte alltid att vara så bra; mitt bord har bara relativt stabil distribution. Jag fyllde i den med följande fråga och uppdaterade sedan statistik med fullscan, ifall du vill prova detta på egen hand:
-- OP's table definition: CREATE TABLE dbo.DateaddRepro ( SessionId int IDENTITY(1, 1) NOT NULL PRIMARY KEY, CreatedUtc datetime2(7) NOT NULL DEFAULT SYSUTCDATETIME() ); GO CREATE NONCLUSTERED INDEX [IX_User_Session_CreatedUtc] ON dbo.DateaddRepro(CreatedUtc) INCLUDE (SessionId); GO INSERT dbo.DateaddRepro(CreatedUtc) SELECT dt FROM ( SELECT TOP (3150000) dt = DATEADD(HOUR, (s1.[precision]-ROW_NUMBER() OVER (PARTITION BY s1.[object_id] ORDER BY s2.[object_id])) / 15, GETUTCDATE()) FROM sys.all_columns AS s1 CROSS JOIN sys.all_objects AS s2 ) AS x; UPDATE STATISTICS dbo.DateaddRepro WITH FULLSCAN; SELECT DISTINCT SessionId FROM dbo.DateaddRepro WHERE /* pick your WHERE clause to test */;
Jag kommenterade det nya Connect-objektet och kommer troligen att gå tillbaka och uppdatera mitt Stack Exchange-svar.
Berättelsens moral
Försök att undvika att kombinera DATEADD()
med uttryck som ger datetime2
, särskilt på äldre versioner av SQL Server (detta var på SQL Server 2012). Det kan också vara ett problem, även på SQL Server 2016, när man använder den äldre modellen för uppskattning av kardinalitet (på grund av lägre kompatibilitetsnivå eller explicit användning av spårningsflagga 9481). Sådana problem är subtila och inte alltid direkt uppenbara, så förhoppningsvis fungerar detta som en påminnelse (kanske till och med för mig nästa gång jag stöter på ett liknande scenario). Som jag föreslog i förra inlägget, om du har frågemönster som detta, kontrollera att du får korrekta uppskattningar, och gör en anteckning någonstans för att kontrollera dem igen när något större förändringar i systemet (som en uppgradering eller ett service pack).