Въведение
От въвеждането им в SQL Server 2005 прозорците функционират като ROW_NUMBER
и RANK
се оказаха изключително полезни при решаването на голямо разнообразие от често срещани проблеми с T-SQL. В опит да обобщят подобни решения, дизайнерите на бази данни често гледат да ги включат в изгледи, за да насърчат капсулирането и повторното използване на кода. За съжаление, ограничение в оптимизатора на заявки на SQL Server често означава, че изгледите, съдържащи функции на прозореца, не работят толкова добре, колкото се очаква. Тази публикация работи чрез илюстративен пример за проблема, подробно описва причините и предоставя редица решения.
Този проблем може да възникне и в извлечени таблици, общи изрази на таблици и вградени функции, но го виждам най-често при изгледи, защото те умишлено са написани, за да бъдат по-общи.
Прозоречни функции
Функциите на прозореца се отличават с наличието на OVER()
клауза и се предлагат в три разновидности:
- Функции на прозореца за класиране
ROW_NUMBER
RANK
DENSE_RANK
NTILE
- Обобщени функции на прозореца
MIN
,MAX
,AVG
,SUM
COUNT
,COUNT_BIG
CHECKSUM_AGG
STDEV
,STDEVP
,VAR
,VARP
- Функции на аналитичен прозорец
LAG
,LEAD
FIRST_VALUE
,LAST_VALUE
PERCENT_RANK
,PERCENTILE_CONT
,PERCENTILE_DISC
,CUME_DIST
Функциите за класиране и обобщени прозорци бяха въведени в SQL Server 2005 и значително разширени в SQL Server 2012. Функциите за анализ на прозореца са нови за SQL Server 2012.
Всички изброени по-горе функции на прозореца са податливи на ограниченията на оптимизатора, описани подробно в тази статия.
Пример
Използвайки примерната база данни на AdventureWorks, задачата е да напишете заявка, която връща всички транзакции на продукт #878, извършени на най-новата налична дата. Има всякакви начини за изразяване на това изискване в T-SQL, но ние ще изберем да напишем заявка, която използва функция за прозорец. Първата стъпка е да намерите записи за транзакции за продукт #878 и да ги класирате в низходящ ред на датите:
ИЗБЕРЕТЕ th.TransactionID, th.ReferenceOrderID, th.TransactionDate, th.Quantity, rnk =RANK() OVER ( ORDER BY th.TransactionDate DESC) ОТ Production.TransactionHistory КАТО thWHERE th.ProductIDID;87 BY7 предварително>
Резултатите от заявката са според очакванията, като шест транзакции са извършени на най-новата налична дата. Планът за изпълнение съдържа предупредителен триъгълник, който ни предупреждава за липсващ индекс:
Както обикновено за липсващи предложения за индекси, трябва да помним, че препоръката не е резултат от цялостен анализ на заявката – тя е по-скоро индикация, че трябва да помислим малко за това как тази заявка получава достъп до данните, от които се нуждае.
Предложеният индекс със сигурност би бил по-ефективен от цялостното сканиране на таблицата, тъй като би позволил търсене на индекс към конкретния продукт, който ни интересува. Индексът също би покрил всички необходими колони, но няма да избегне сортирането (по
TransactionDate
низходящ). Идеалният индекс за тази заявка би позволил търсене наProductID
, върнете избраните записи в обратенTransactionDate
поръчка и покриване на останалите върнати колони:СЪЗДАВАНЕ НА НЕКЛУСТРИРАН ИНДЕКС ixON Production.TransactionHistory (ProductID, TransactionDate DESC)INCLUDE (ReferenceOrderID, Quantity);С този индекс на място, планът за изпълнение е много по-ефективен. Сканирането на клъстерирания индекс е заменено с търсене на диапазон и вече не е необходимо изрично сортиране:
Последната стъпка за тази заявка е да ограничите резултатите само до тези редове, които се класират #1. Не можем да филтрираме директно в
WHERE
клауза на нашата заявка, защото функциите на прозореца може да се появяват само вSELECT
иORDER BY
клаузи.Можем да заобиколим това ограничение с помощта на производна таблица, общ табличен израз, функция или изглед. По този повод ще използваме общ израз на таблица (известен още като вграден изглед):
WITH RankedTransactions AS( SELECT th.TransactionID, th.ReferenceOrderID, th.TransactionDate, th.Quantity, rnk =RANK() OVER ( ORDER BY th.TransactionDate DESC) FROM Production.TransactionHistory AS th.7 WHER th. )ИЗБЕРЕТЕ TransactionID, ReferenceOrderID, TransactionDate, QuantityFROM RankedTransactionsWHERE rnk =1;Планът за изпълнение е същият като преди, с допълнителен филтър за връщане само на редове, класирани #1:
Заявката връща шестте еднакво класирани реда, които очакваме:
Обобщаване на заявката
Оказва се, че нашата заявка е много полезна, така че е взето решението да се обобщи и да се съхрани определението в изглед. За да работи това за всеки продукт, трябва да направим две неща:да върнем
ProductID
от изгледа и разделете функцията за класиране по продукт:СЪЗДАВАНЕ НА ИЗГЛЕД dbo.MostRecentTransactionsPerProductWITH SCHEMABINDINGASSELECT sq1.ProductID, sq1.TransactionID, sq1.ReferenceOrderID, sq1.TransactionDate, sq1.QuantityFROM ( SELECT th.ProductID, идентификатор на продукта, идентификатор на продукта, идентификатор на продукта, идентификатор на продукта, идентификатор на продукта, идентификатор на продукта, ден. rnk =RANK() НАД ( PARTITION BY th.ProductID ORDER BY th.TransactionDate DESC) ОТ Production.TransactionHistory AS th) AS sq1WHERE sq1.rnk =1;Избирането на всички редове от изгледа води до следния план за изпълнение и правилни резултати:
Вече можем да намерим най-новите транзакции за продукт 878 с много по-проста заявка в изгледа:
ИЗБЕРЕТЕ mrt.ProductID, mrt.TransactionID, mrt.ReferenceOrderID, mrt.TransactionDate, mrt.QuantityFROM dbo.MostRecentTransactionsPerProduct КАТО mrt WHERE mrt.ProductID =878;Нашето очакване е, че планът за изпълнение на тази нова заявка ще бъде точно същият, както преди да създадем изгледа. Оптимизаторът на заявки трябва да може да прокара филтъра, посочен в
WHERE
клауза надолу в изгледа, което води до търсене на индекс.Трябва обаче да спрем и да помислим малко на този етап. Оптимизаторът на заявки може да произвежда само планове за изпълнение, които гарантирано ще дадат същите резултати като спецификацията на логическата заявка – безопасно ли е да избутаме нашия
WHERE
клауза в изгледа?<Отговорът е да, стига колоната, по която филтрираме, се появява вPARTITION BY
клауза на функцията прозорец в изгледа. Причината е, че елиминирането на пълни групи (раздели) от функцията на прозореца няма да повлияе на класирането на редовете, върнати от заявката. Въпросът е дали оптимизаторът на заявки на SQL Server знае това? Отговорът зависи от това коя версия на SQL Server използваме.План за изпълнение на SQL Server 2005
Поглед към свойствата на филтъра в този план показва, че той прилага два предиката:
ProductID = 878
предикатът не е избутан надолу в изгледа, което води до план, който сканира нашия индекс, като класира всеки ред в таблицата преди филтриране за продукт #878 и редове, класирани #1.Оптимизаторът на заявки на SQL Server 2005 не може да прокара подходящи предикати покрай функция на прозорец в по-нисък обхват на заявка (изглед, общ табличен израз, вградена функция или извлечена таблица). Това ограничение важи за всички компилации на SQL Server 2005.
План за изпълнение на SQL Server 2008+
Това е планът за изпълнение на същата заявка на SQL Server 2008 или по-нова версия:
ProductID
предикатът беше успешно изместен покрай операторите за класиране, заменяйки сканирането на индекса с ефективно търсене на индекс.Оптимизаторът на заявки от 2008 включва ново правило за опростяване
SelOnSeqPrj
(изберете при проект за последователност), който е в състояние да изтласка безопасни предикати на външния обхват на минали функции на прозореца. За да създадем по-малко ефективния план за тази заявка в SQL Server 2008 или по-нова версия, трябва временно да деактивираме тази функция за оптимизиране на заявки:ИЗБЕРЕТЕ mrt.ProductID, mrt.TransactionID, mrt.ReferenceOrderID, mrt.TransactionDate, mrt.QuantityFROM dbo.MostRecentTransactionsPerProduct КАТО mrt WHERE mrt.ProductID =878OPTION (QUERYRULEOFF SelOnSeqPrj);
За съжаление,
SelOnSeqPrj
правилото за опростяванеработи само когато предикатът извършва съпоставкас константа . Поради тази причина следната заявка произвежда неоптималния план на SQL Server 2008 и по-късно:ДЕКЛАРИРАНЕ @ProductID INT =878; ИЗБЕРЕТЕ mrt.ProductID, mrt.TransactionID, mrt.ReferenceOrderID, mrt.TransactionDate, mrt.QuantityFROM dbo.MostRecentTransactionsPerProduct КАТО mrt WHERE mrt.ProductID =@ProductID;
Проблемът все още може да възникне дори когато предикатът използва постоянна стойност. SQL Server може да реши да параметризира автоматично тривиални заявки (такива, за които съществува очевидно най-добрият план). Ако автоматичното параметризиране е успешно, оптимизаторът вижда параметър вместо константа и
SelOnSeqPrj
правилото не се прилага.За заявки, при които не се прави опит за автоматично параметризиране (или когато е определено, че е опасно), оптимизацията все още може да не успее, ако опцията за база данни за
FORCED PARAMETERIZATION
е включено. Нашата тестова заявка (с константна стойност 878) не е безопасна за автоматично параметризиране, но настройката за принудително параметриране отменя това, което води до неефективния план:ПРОМЕНЯВАНЕ НА ПАРАМЕТЕРИЗАЦИЯТА НА БАЗА ДАННИ AdventureWorksSET;GOSELECT mrt.ProductID, mrt.TransactionID, mrt.ReferenceOrderID, mrt.TransactionDate, mrt.QuantityFROM dbo.MostRecentTransactionsPerProduct AS.MostRecentTransactionsPerProduct AS.MostRecentTransactionsPerProduct КАТО mrt.ProductID, mrt.TransactionID, mrt.ReferenceOrderID, mrt.TransactionDate, mrt.QuantityFROM dbo.MostRecentTransactionsPerProduct AS.MostRecentTransactionsPerProduct AS. mrt.ProductID; mrt.TransactionID;>
SQL Server 2008+ Заобиколно решение
За да позволим на оптимизатора да „вижда“ константна стойност за заявка, която препраща към локална променлива или параметър, можем да добавим
OPTION (RECOMPILE)
намек за заявка:ДЕКЛАРИРАНЕ @ProductID INT =878; ИЗБЕРЕТЕ mrt.ProductID, mrt.TransactionID, mrt.ReferenceOrderID, mrt.TransactionDate, mrt.QuantityFROM dbo.MostRecentTransactionsPerProduct КАТО mrt WHERE mrt.ProductID =@ProductIDOPTION (RECOMPILE);Забележка: Планът за предварително изпълнение („оценен“) все още показва индексно сканиране, тъй като стойността на променливата все още не е зададена. Когато заявката се изпълни , обаче, планът за изпълнение показва желания план за търсене на индекс:
SelOnSeqPrj
правило не съществува в SQL Server 2005, така чеOPTION (RECOMPILE)
не може да помогне там. В случай, че се чудите,OPTION (RECOMPILE)
заобикалянето води до търсене, дори ако опцията на базата данни за принудителна параметризация е включена.Всички версии заобиколно решение №1
В някои случаи е възможно да се замени проблемният изглед, общ израз на таблица или извлечена таблица с параметризирана функция с стойност на таблица:
СЪЗДАВАНЕ НА ФУНКЦИЯ dbo.MostRecentTransactionsForProduct( @ProductID integer) ВРЪЩА ТАБЛИЦА С ОБВЪЗВАНЕ НА СХЕМА ASRETURN SELECT sq1.ProductID, sq1.TransactionID, sq1.ReferenceOrderID, sq1.TransactionDate, sq1.TransactionDate, sq1.TransactionDate, sq1.QuSELECT ReferenceOrderID, th.TransactionDate, th.Quantity, rnk =RANK() OVER ( PARTITION BY th.ProductID ORDER BY th.TransactionDate DESC) ОТ Production.TransactionHistory КАТО th WHERE th.ProductID =sqqID WHER ) 1;Тази функция изрично поставя
ProductID
предикат в същия обхват като функцията прозорец, като се избягва ограничението на оптимизатора. Написана за използване на вградената функция, нашата примерна заявка става:ИЗБЕРЕТЕ mrt.ProductID, mrt.TransactionID, mrt.ReferenceOrderID, mrt.TransactionDate, mrt.QuantityFROM dbo.MostRecentTransactionsForProduct(878) КАТО mrt;Това създава желания план за търсене на индекс във всички версии на SQL Server, които поддържат функции на прозорци. Това заобикаляне създава търсене, дори когато предикатът препраща към параметър или локална променлива –
OPTION (RECOMPILE)
не се изисква.<Тялото на функцията, разбира се, може да бъде опростено, за да се премахне сега излишниятPARTITION BY
клауза и да не връща повечеProductID
колона. Оставих дефиницията същата като изгледа, който заменя, за да илюстрирам по-ясно причината за разликите в плана за изпълнение.Заобиколно решение за всички версии №2
Второто заобиколно решение се прилага само за функциите на прозореца за класиране, които се филтрират, за да връщат редове, номерирани или класирани #1 (използвайки
ROW_NUMBER
,RANK
, илиDENSE_RANK
). Това обаче е много често срещана употреба, така че си струва да се спомене.Допълнително предимство е, че това решение може да създаде планове, които са дори по-ефективни отколкото плановете за търсене на индекса, виждани по-рано. Като напомняне, предишният най-добър план изглеждаше така:
Този план за изпълнение се класира 1918 редове, въпреки че в крайна сметка връща само 6 . Можем да подобрим този план за изпълнение, като използваме функцията прозорец в
ORDER BY
клауза вместо класиране на редове и след това филтриране за ранг #1:ИЗБЕРЕТЕ ВЪРХА (1) С ВРЪЗКИ th.TransactionID, th.ReferenceOrderID, th.TransactionDate, th.QuantityFROM Production.TransactionHistory КАТО thWHERE th.ProductID =878ORDER BY RANK() НАД (ПОРЪЧКИ BYDa); /предварително>
Тази заявка добре илюстрира използването на прозоречна функция в
ORDER BY
клауза, но можем да направим още по-добре, елиминирайки напълно функцията на прозореца:ИЗБЕРЕТЕ ВЪРХУ (1) С ВРЪЗКИ th.TransactionID, th.ReferenceOrderID, th.TransactionDate, th.QuantityFROM Production.TransactionHistory КАТО thWHERE th.ProductID =878ORDER BY th.TransactionDate DESC;
Този план чете само 7 реда от таблицата, за да върне същия резултат от 6 реда. Защо 7 реда? Операторът Top работи в
WITH TIES
режим:
Той продължава да изисква един ред в даден момент от своето поддърво, докато TransactionDate се промени. Седмият ред е необходим, за да се гарантира, че няма повече редове с обвързана стойност.
Можем да разширим логиката на заявката по-горе, за да заменим проблемната дефиниция на изглед:
ИЗГЛЕЖДАНЕ dbo.MostRecentTransactionsPerProductWITH SCHEMABINDINGASSELECT p.ProductID, Ranked1.TransactionID, Ranked1.ReferenceOrderID, Ranked1.TransactionDate, Ranked1.QuantityFROM -- Списък с идентификатори на продукти (SELECT ProductID FROM Production.CROSSNS) КАТО Производство. #1 резултати за всеки идентификатор на продукт ИЗБЕРЕТЕ TOP (1) С ВРЪЗКИ th.TransactionID, th.ReferenceOrderID, th.TransactionDate, th.Quantity ОТ Production.TransactionHistory КАТО th WHERE th.ProductID =p.ProductID ORDER BY Date th.DeSCa) AS Ranked1;Сега изгледът използва
CROSS APPLY
за да комбинирате резултатите от нашия оптимизиранORDER BY
запитване за всеки продукт. Нашата тестова заявка е непроменена:DECLARE @ProductID integer;SET @ProductID =878; ИЗБЕРЕТЕ mrt.ProductID, mrt.TransactionID, mrt.ReferenceOrderID, mrt.TransactionDate, mrt.QuantityFROM dbo.MostRecentTransactionsPerProduct КАТО mrt WHERE mrt.ProductID =@ProductID;И двата плана преди и след изпълнение показват търсене на индекс без нужда от
OPTION (RECOMPILE)
намек за заявка. Следва план след изпълнение („действителен“):
Ако изгледът е използвал
ROW_NUMBER
вместоRANK
, заместващият изглед просто би пропусналWITH TIES
клауза заTOP (1)
. Разбира се, новият изглед може да бъде написан и като параметризирана функция с стойност на таблица.Може да се твърди, че първоначалният план за търсене на индекс с
rnk = 1
предикатът също може да бъде оптимизиран за тестване само на 7 реда. В крайна сметка, оптимизаторът трябва да знае, че класирането се произвежда от оператора на Sequence Project в строг възходящ ред, така че изпълнението може да приключи веднага щом се види ред с ранг, по-голям от един. Днес обаче оптимизаторът не съдържа тази логика.Последни мисли
Хората често са разочаровани от представянето на изгледи, които включват функции на прозореца. Причината често може да се проследи до ограничението на оптимизатора, описано в тази публикация (или може би защото дизайнерът на изглед не е преценил, че предикатите, приложени към изгледа, трябва да се показват в
PARTITION BY
клаузата да бъде безопасно избутана надолу).Искам да подчертая, че това ограничение не важи само за изгледи, нито е ограничено до
ROW_NUMBER
,RANK
иDENSE_RANK
. Трябва да сте наясно с това ограничение, когато използвате която и да е функция сOVER
клауза в изглед, общ табличен израз, производна таблица или функция с стойност на таблица.Потребителите на SQL Server 2005, които се сблъскват с този проблем, са изправени пред избора да пренапишат изгледа като параметризирана функция със стойност на таблица или да използват
APPLY
техника (където е приложимо).Потребителите на SQL Server 2008 имат допълнителната опция да използват
OPTION (RECOMPILE)
намек за заявка, ако проблемът може да бъде решен, като се позволи на оптимизатора да види константа вместо препратка към променлива или параметър. Не забравяйте обаче да проверите плановете след изпълнение, когато използвате този намек:планът за предварително изпълнение обикновено не може да показва оптималния план.