След блогове за това как филтрираните индекси могат да бъдат по-мощни и напоследък за това как могат да бъдат направени безполезни чрез принудителна параметризация, преразглеждам темата за филтрирани индекси/параметризация. Наскоро на работа се появи едно наглед твърде просто решение и трябваше да споделя.
Вземете следния пример, където имаме база данни за продажби, съдържаща таблица с поръчки. Понякога просто искаме списък (или брой) само на поръчките, които все още не са изпратени – които с течение на времето (надявам се!) представляват все по-малък и по-малък процент от общата таблица:
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;
Може да има смисъл в този сценарий да създадете филтриран индекс като този (който прави бърза работа на всички заявки, които се опитват да получат тези неизпратени поръчки):
CREATE INDEX ix_OrdersNotShipped ON dbo.Orders(IsShipped, OrderDate) WHERE IsShipped = 0;
Можем да изпълним бърза заявка като тази, за да видим как използва филтрирания индекс:
SELECT OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0;
Планът за изпълнение е доста прост, но има предупреждение за UnmatchedIndexes:
Името на предупреждението е леко подвеждащо - оптимизаторът в крайна сметка успя да използва индекса, но предполага, че би било "по-добре" без параметри (които не използвахме изрично), въпреки че изявлението изглежда сякаш е параметризирано:
Ако наистина искате, можете да премахнете предупреждението, без разлика в действителната производителност (това би било просто козметично). Един от начините е да добавите предикат с нулево въздействие, като AND (1 > 0)
:
SELECT wadd = OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0 AND (1 > 0);
Друго (вероятно по-често срещано) е да добавите OPTION (RECOMPILE)
:
SELECT wrecomp = OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0 OPTION (RECOMPILE);
И двете опции дават един и същ план (търсене без предупреждения):
Дотук добре; нашият филтриран индекс се използва (както се очаква). Това не са единствените трикове, разбира се; вижте коментарите по-долу за други, които читателите вече са изпратили.
След това усложнението
Тъй като базата данни е обект на голям брой ad hoc заявки, някой включва принудителна параметризация, опитвайки се да намали компилацията и да елиминира плановете за ниска и еднократна употреба от замърсяване на кеша на плана:
ALTER DATABASE Sales SET PARAMETERIZATION FORCED;
Сега нашата оригинална заявка не може да използва филтрирания индекс; той е принуден да сканира клъстерирания индекс:
SELECT OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0;
Предупреждението за несъответстващи индекси се връща и получаваме нови предупреждения за остатъчни I/O. Имайте предвид, че изразът е параметризиран, но изглежда малко по-различно:
Това е по проект, тъй като цялата цел на принудителната параметризация е да параметризира заявки като тази. Но това побеждава целта на нашия филтриран индекс, тъй като той е предназначен да поддържа една стойност в предиката, а не параметър, който може да се промени.
Глупости
Нашата „трикова“ заявка, която използва допълнителния предикат, също не може да използва филтрирания индекс и завършва с малко по-сложен план за зареждане:
SELECT OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0 AND (1 > 0);
ОПЦИЯ (ПРЕКОМПИЛИРАНЕ)
Типичната реакция в този случай, точно както при премахването на предупреждението по-рано, е да добавите OPTION (RECOMPILE)
към изявлението. Това работи и позволява филтрираният индекс да бъде избран за ефективно търсене...
SELECT OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0 OPTION (RECOMPILE);
…но добавяне на OPTION (RECOMPILE)
и приемането на този допълнителен компилационен удар срещу всяко изпълнение на заявката не винаги ще бъде приемливо в среди с голям обем (особено ако те вече са свързани с процесора).
Съвети
Някой предложи изрично да се намекне филтрираният индекс, за да се избегнат разходите за повторно компилиране. Като цяло, това е доста крехко, защото разчита на индекса, който надживява кода; Склонен съм да използвам това в краен случай. В този случай така или иначе не е валидно. Когато правилата за параметризиране пречат на оптимизатора да избере автоматично филтрирания индекс, те също така ви пречат да го изберете ръчно. Същият проблем с общ FORCESEEK
намек:
SELECT OrderID, OrderDate FROM dbo.Orders WITH (INDEX (ix_OrdersNotShipped)) WHERE IsShipped = 0; SELECT OrderID, OrderDate FROM dbo.Orders WITH (FORCESEEK) WHERE IsShipped = 0;
И двете дават тази грешка:
Съобщение 8622, ниво 16, състояние 1Обработчикът на заявки не можа да създаде план за заявка поради намеците, дефинирани в тази заявка. Изпратете отново заявката, без да указвате никакви намеци и без да използвате SET FORCEPLAN.
И това има смисъл, защото няма начин да разберете, че неизвестната стойност за IsShipped
параметърът ще съвпада с филтрирания индекс (или ще поддържа операция за търсене на всеки индекс).
Динамичен SQL?
Предложих, че бихте могли да използвате динамичен SQL, за да платите поне това прекомпилиране само когато знаете, че искате да ударите по-малкия индекс:
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;
Това води до същия ефективен план, както по-горе. Ако сте променили променливата на @IsShipped = 1
, тогава получавате по-скъпото сканиране на клъстерен индекс, което трябва да очаквате:
Но никой не обича да използва динамичен SQL в крайни случаи като този - това прави кода по-труден за четене и поддържане и дори ако този код беше в приложението, това все още е допълнителна логика, която трябва да се добави там, което го прави по-малко от желателно .
Нещо по-просто
Говорихме накратко за внедряване на ръководство за план, което със сигурност не е по-просто, но след това един колега предложи, че можете да заблудите оптимизатора, като „скриете“ параметризирания израз в съхранена процедура, изглед или вградена функция със стойност на таблица. Беше толкова просто, че не вярвах, че ще работи.
Но след това го опитах:
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
И трите от тези заявки извършват ефективно търсене спрямо филтрирания индекс:
EXEC dbo.GetUnshippedOrders; GO SELECT OrderID, OrderDate FROM dbo.vUnshippedOrders; GO SELECT OrderID, OrderDate FROM dbo.fnUnshippedOrders();
Заключение
Бях изненадан, че това беше толкова ефективно. Разбира се, това изисква да промените приложението; ако не можете да промените кода на приложението, за да извикате съхранена процедура или да препратите към изгледа или функцията (или дори да добавите OPTION (RECOMPILE)
), ще трябва да продължите да търсите други опции. Но ако можете да промените кода на приложението, запълването на предиката в друг модул може да бъде просто начинът.