Още през 2013 г. писах за грешка в оптимизатора, където 2-ри и 3-ти аргументи на DATEDIFF()
могат да бъдат разменени – което може да доведе до неправилни оценки за броя на редовете и от своя страна до лош избор на план за изпълнение:
- Изненади и предположения за ефективността:DATEDIFF
Миналия уикенд научих за подобна ситуация и веднага предположих, че това е същият проблем. В крайна сметка симптомите изглеждаха почти идентични:
- Имаше функция за дата/час в
WHERE
клауза.- Този път беше
DATEADD()
вместоDATEDIFF()
.
- Този път беше
- Имаше очевидно неправилна оценка на броя на редовете от 1 в сравнение с действителен брой редове от над 3 милиона.
- Това всъщност беше приблизителна оценка от 0, но SQL Server винаги закръгля тези оценки до 1.
- Беше направен лош избор на план (в този случай беше избрано свързване на цикъл) поради ниската оценка.
Нарушителният модел изглеждаше така:
WHERE [datetime2(7) column] >= DATEADD(DAY, -365, SYSUTCDATETIME());
Потребителят опита няколко варианта, но нищо не се промени; те в крайна сметка успяха да заобиколят проблема, като промениха предиката на:
WHERE DATEDIFF(DAY, [column], SYSUTCDATETIME()) <= 365;
Това получи по-добра оценка (типичното предположение за 30% неравенство); така че не е съвсем правилно. И докато елиминира присъединяването на цикъл, има два основни проблема с този предикат:
- Това не същата заявка, тъй като сега се търси преминаване на границите от 365 дни, за разлика от това, че са по-големи от определен момент от време преди 365 дни. Статистически значим? Може би не. Но прагът, технически, не е същият.
- Прилагането на функцията спрямо колоната прави целия израз неподлежащ на саргиране – което води до пълно сканиране. Когато таблицата съдържа данни само за малко повече от година, това не е голяма работа, но когато таблицата стане по-голяма или предикатът стане по-тесен, това ще се превърне в проблем.
Отново стигнах до заключението, че DATEADD()
операцията беше проблемът и препоръча подход, който не разчита на DATEADD()
– изграждане на datetime
от всички части на текущото време, което ми позволява да извадя година без да използвам DATEADD()
:
WHERE [column] >= DATETIMEFROMPARTS( DATEPART(YEAR, SYSUTCDATETIME())-1, DATEPART(MONTH, SYSUTCDATETIME()), DATEPART(DAY, SYSUTCDATETIME()), DATEPART(HOUR, SYSUTCDATETIME()), DATEPART(MINUTE, SYSUTCDATETIME()), DATEPART(SECOND, SYSUTCDATETIME()), 0);
Освен че беше обемист, това имаше някои собствени проблеми, а именно, че трябваше да се добави куп логика, за да се отчетат правилно високосните години. Първо, за да не се провали, ако се случи да работи на 29 февруари, и второ, да включва точно 365 дни във всички случаи (вместо 366 през годината след високосна). Лесни поправки, разбира се, но правят логиката много по-грозна – особено защото заявката трябваше да съществува в изглед, където междинни променливи и множество стъпки не са възможни.
Междувременно ОП подаде елемент на Connect, уплашен от оценката за 1 ред:
- Свързване #2567628 :Ограничение с DateAdd() не дава добри прогнози
Тогава се появи Пол Уайт (@SQL_Kiwi) и, както много пъти преди, хвърли допълнителна светлина върху проблема. Той сподели свързан елемент за Connect, подаден от Erland Sommarskog през 2011 г.:
- Свързване #685903 :Неправилна оценка, когато sysdatetime се появи в израз dateadd()
По същество проблемът е, че лоша оценка може да се направи не просто когато SYSDATETIME()
(или SYSUTCDATETIME()
) се появява, както Erland първоначално съобщи, но когато произтича datetime2
изразът участва в предиката (и може би само когато DATEADD()
също се използва). И може да върви и в двете посоки – ако сменим >=
за <=
, оценката става цялата таблица, така че изглежда, че оптимизаторът гледа SYSDATETIME()
стойност като константа и напълно игнорира всякакви операции като DATEADD()
които се извършват срещу него.
Пол сподели, че решението е просто да се използва datetime
еквивалентно при изчисляване на датата, преди да я преобразувате в правилния тип данни. В този случай можем да заменим SYSUTCDATETIME()
и го променете на GETUTCDATE()
:
WHERE [column] >= CONVERT(datetime2(7), DATEADD(DAY, -365, GETUTCDATE()));
Да, това води до малка загуба на прецизност, но също така може и частица прах да забави пръста ви по пътя към натискане на
Показанията са подобни, тъй като таблицата съдържа данни почти изключително от миналата година, така че дори търсенето се превръща в сканиране на диапазон на по-голямата част от таблицата. Броят на редовете не е идентичен, тъй като (а) втората заявка прекъсва в полунощ и (б) третата заявка включва допълнителен ден с данни поради високосния ден по-рано тази година. Във всеки случай, това все още показва как можем да се доближим до правилните оценки, като елиминираме DATEADD()
, но правилното решение е да премахнете директната комбинация на DATEADD()
и datetime2
.
За по-нататъшна илюстрация как оценките се бъркат, можете да видите, че ако предадем различни аргументи и насоки към оригиналната заявка и пренаписването на Пол, броят на приблизителните редове за първия винаги се основава на текущото време – те не не се променя с броя на изминалите дни (докато този на Пол е относително точен всеки път):
Действителните редове за първата заявка са малко по-ниски, тъй като това е изпълнено след продължителна дрямка
Оценките не винаги ще бъдат толкова добри; моята таблица просто има относително стабилно разпределение. Попълних го със следната заявка и след това актуализирах статистиката с fullscan, в случай че искате да изпробвате това сами:
-- 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 */;
Коментирах новия елемент на Connect и вероятно ще се върна и ще коригирам отговора си на Stack Exchange.
Моралът на историята
Опитайте се да избягвате комбинирането на DATEADD()
с изрази, които дават datetime2
, особено на по-стари версии на SQL Server (това беше на SQL Server 2012). Това също може да бъде проблем, дори в SQL Server 2016, когато се използва по-стар модел за оценка на мощността (поради по-ниско ниво на съвместимост или изрично използване на флаг за проследяване 9481). Проблеми като този са фини и не винаги са очевидни, така че да се надяваме, че това служи като напомняне (може би дори за мен следващия път, когато попадна на подобен сценарий). Както предложих в последната публикация, ако имате модели на заявки като този, проверете дали получавате правилни оценки и направете бележка някъде, за да ги проверите отново, когато има някакви сериозни промени в системата (като надстройка или сервизен пакет).