Funkcje zdefiniowane przez użytkownika w SQL Server (UDF) to kluczowe obiekty, o których każdy deweloper powinien wiedzieć. Chociaż są one bardzo przydatne w wielu scenariuszach (klauzule WHERE, kolumny obliczane i ograniczenia sprawdzające), nadal mają pewne ograniczenia i złe praktyki, które mogą powodować problemy z wydajnością. Wieloinstrukcyjne funkcje UDF mogą mieć znaczny wpływ na wydajność, a ten artykuł szczegółowo omówi te scenariusze.
Funkcje nie są implementowane w taki sam sposób, jak w językach obiektowych, chociaż wbudowane funkcje z wartościami przechowywanymi w tabeli mogą być używane w scenariuszach, w których potrzebne są widoki parametryczne, nie dotyczy to funkcji zwracających skalary lub tabele. Z funkcji tych należy korzystać ostrożnie, ponieważ mogą powodować wiele problemów z wydajnością. Jednak w wielu przypadkach są one niezbędne, dlatego będziemy musieli zwrócić większą uwagę na ich implementacje. Funkcje są używane w instrukcjach SQL w partiach, procedurach, wyzwalaczach lub widokach, w zapytaniach SQL ad hoc lub jako część zapytań raportowania generowanych przez narzędzia, takie jak PowerBI lub Tableau, w polach obliczeniowych i ograniczeniach sprawdzających. Chociaż funkcje skalarne mogą być rekurencyjne do 32 poziomów, funkcje tabelowe nie obsługują rekurencji.
Typy funkcji w SQL Server
W SQL Server mamy trzy typy funkcji:funkcje skalarne zdefiniowane przez użytkownika (SF), które zwracają pojedynczą wartość skalarną, funkcje z wartościami tabelarycznymi zdefiniowane przez użytkownika (TVF), które zwracają tabelę, oraz wbudowane funkcje z wartościami tabelarycznymi (ITVF), które nie mają ciała funkcji. Funkcje tabel mogą być wbudowane lub wielowyrazowe. Funkcje wbudowane nie mają zmiennych zwracanych, zwracają tylko funkcje wartości. Funkcje składające się z wielu instrukcji są zawarte w blokach kodu BEGIN-END i mogą zawierać wiele instrukcji T-SQL, które nie powodują żadnych skutków ubocznych (takich jak modyfikowanie zawartości tabeli).
Każdy typ funkcji pokażemy na prostym przykładzie:
/**
inline table function
**/
CREATE FUNCTION dbo.fnInline( @P1 INT, @P2 VARCHAR(50) )
RETURNS TABLE
AS
RETURN ( SELECT @P1 AS P_OUT_1, @P2 AS P_OUT_2 )
/**
multi-statement table function
**/
CREATE FUNCTION dbo.fnMultiTable( @P1 INT, @P2 VARCHAR(50) )
RETURNS @r_table TABLE ( OUT_1 INT, OUT_2 VARCHAR(50) )
AS
BEGIN
INSERT @r_table SELECT @P1, @P2;
RETURN;
END;
/**
scalar function
**/
CREATE FUNCTION dbo.fnScalar( @P1 INT, @P2 INT )
RETURNS INT
AS
BEGIN
RETURN @P1 + @P2
END
Ograniczenia funkcji serwera SQL
Jak wspomniano we wstępie, istnieją pewne ograniczenia w korzystaniu z funkcji, a poniżej omówię tylko kilka. Pełną listę można znaleźć w Microsoft Docs :
- Nie ma koncepcji funkcji tymczasowych
- Nie możesz utworzyć funkcji w innej bazie danych, ale w zależności od uprawnień masz do niej dostęp
- Dzięki UDF nie możesz wykonywać żadnych działań, które zmieniają stan bazy danych,
- Wewnątrz UDF nie można wywołać procedury, z wyjątkiem rozszerzonej procedury składowanej
- UDF nie może zwrócić zestawu wyników, a jedynie typ danych tabeli
- Nie możesz używać dynamicznego SQL lub tabel tymczasowych w UDF
- UDF mają ograniczone możliwości obsługi błędów – nie obsługują RAISERROR ani TRY…CATCH i nie można pobrać danych ze zmiennej systemowej @ERROR
Co jest dozwolone w funkcjach wielowyrazowych?
Dozwolone są tylko następujące rzeczy:
- Oświadczenie o przypisaniu
- Wszystkie instrukcje kontroli przepływu, z wyjątkiem bloku TRY…CATCH
- Wywołania DECLARE, używane do tworzenia lokalnych zmiennych i kursorów
- Możesz użyć zapytań SELECT, które mają listy z wyrażeniami i przypisać te wartości do zmiennych zadeklarowanych lokalnie
- Kursory mogą odwoływać się tylko do tabel lokalnych i muszą być otwierane i zamykane w treści funkcji. FETCH może tylko przypisywać lub zmieniać wartości zmiennych lokalnych, a nie pobierać ani zmieniać danych z bazy danych
Czego należy unikać w funkcjach wielowyrazowych, chociaż jest to dozwolone?
- Powinieneś unikać scenariuszy, w których używasz kolumn obliczeniowych z funkcjami skalarnymi – spowoduje to przebudowę indeksu i powolne aktualizacje, które wymagają ponownego obliczenia
- Weź pod uwagę, że każda funkcja zawierająca wiele instrukcji ma swój plan wykonania i wpływ na wydajność
- Wieloinstancyjny UDF z wartościami przechowywanymi w tabeli, jeśli zostanie użyty w wyrażeniu SQL lub instrukcji join, będzie powolny z powodu nieoptymalnego planu wykonania
- Nie używaj funkcji skalarnych w instrukcjach WHERE i klauzulach ON, chyba że masz pewność, że wyśle zapytanie do małego zestawu danych, a ten zestaw danych pozostanie mały w przyszłości
Nazwy i parametry funkcji
Jak każda inna nazwa obiektu, nazwy funkcji muszą być zgodne z regułami identyfikatorów i muszą być unikalne w ramach ich schematu. Jeśli tworzysz funkcje skalarne, możesz je uruchomić za pomocą instrukcji EXECUTE. W takim przypadku nie musisz umieszczać nazwy schematu w nazwie funkcji. Zobacz przykład wywołania funkcji EXECUTE poniżej (tworzymy funkcję, która zwraca wystąpienie N-tego dnia w miesiącu, a następnie pobiera te dane):
CREATE FUNCTION dbo.fnGetDayofWeekInMonth
(
@YearInput VARCHAR(50),
@MonthInput VARCHAR(50), -- English months ( 'Jan', 'Feb', ... )
@WeekDayInput VARCHAR(50)='Mon', -- Mon, Tue, Wed, Thu, Fri, Sat, Sun
@CountN INT=1 -- 1 for the first date, 2 for the second occurrence, 3 for the third
)
RETURNS DATETIME
AS
BEGIN
RETURN DATEADD(MONTH, DATEDIFF(MONTH, 0,
CONVERT(DATE,'1 '[email protected]+' '[email protected],113)), 0)+ (7*@CountN)-1
-
(DATEPART (WEEKDAY, DATEADD(MONTH, DATEDIFF(MONTH, 0,
CONVERT(DATE,'1 '[email protected]+' '[email protected],113)), 0))
[email protected]@DateFirst+(CHARINDEX(@WeekDayInput,'FriThuWedTueMonSunSat')-1)/3)%7
END
-- In SQL Server 2012 and later versions, you can use the EXECUTE command or the SELECT command to run a UDF, or use a standard approach
DECLARE @ret DateTime
EXEC @ret = fnGetDayofWeekInMonth '2020', 'Jan', 'Mon',2
SELECT @ret AS Third_Monday_In_January_2020
SELECT dbo.fnGetDayofWeekInMonth('2020', 'Jan', DEFAULT, DEFAULT)
AS 'Using default',
dbo.fnGetDayofWeekInMonth('2020', 'Jan', 'Mon', 2) AS 'explicit'
Możemy zdefiniować wartości domyślne parametrów funkcji, muszą być one poprzedzone znakiem „@” i zgodne z zasadami nazewnictwa identyfikatorów. Parametry mogą być tylko wartościami stałymi, nie mogą być używane w zapytaniach SQL zamiast tabel, widoków, kolumn lub innych obiektów bazy danych, a wartości nie mogą być wyrażeniami, nawet deterministycznymi. Dozwolone są wszystkie typy danych, z wyjątkiem typu danych TIMESTAMP, i nie można używać nieskalarnych typów danych, z wyjątkiem parametrów wycenianych w tabeli. W „standardowych” wywołaniach funkcji musisz określić atrybut DEFAULT, jeśli chcesz dać użytkownikowi końcowemu możliwość uczynienia parametru opcjonalnym. W nowych wersjach, używając składni EXECUTE, nie jest to już wymagane, po prostu nie wpisujesz tego parametru w wywołaniu funkcji. Jeśli używamy niestandardowych typów tabel, muszą one być oznaczone jako READONLY, co oznacza, że nie możemy zmienić początkowej wartości wewnątrz funkcji, ale można ich używać w obliczeniach i definiowaniu innych parametrów.
Wydajność funkcji serwera SQL
Ostatnim tematem, który omówimy w tym artykule, wykorzystując funkcje z poprzedniego rozdziału, jest wykonanie funkcji. Rozbudujemy tę funkcję i będziemy monitorować czas realizacji oraz jakość planów realizacji. Zaczynamy od stworzenia innych wersji funkcji i kontynuujemy ich porównywanie:
CREATE FUNCTION dbo.fnGetDayofWeekInMonthBound
(
@YearInput VARCHAR(50),
@MonthInput VARCHAR(50), -- English months ( 'Jan', 'Feb', ... )
@WeekDayInput VARCHAR(50)='Mon', -- Mon, Tue, Wed, Thu, Fri, Sat, Sun
@CountN INT=1 -- 1 for the first date, 2 for the second occurrence, 3 for the third
)
RETURNS DATETIME
WITH SCHEMABINDING
AS
BEGIN
RETURN DATEADD(MONTH, DATEDIFF(MONTH, 0, CONVERT(DATE,'1 '[email protected]+' '[email protected],113)), 0)+ (7*@CountN)-1
-(DATEPART (WEEKDAY, DATEADD(MONTH, DATEDIFF(MONTH, 0, CONVERT(DATE,'1 '[email protected]+' '[email protected],113)), 0))
[email protected]@DateFirst+(CHARINDEX(@WeekDayInput,'FriThuWedTueMonSunSat')-1)/3)%7
END
GO
CREATE FUNCTION dbo.fnNthDayOfWeekOfMonthInline (
@YearInput VARCHAR(50),
@MonthInput VARCHAR(50), -- English months ( 'Jan', 'Feb', ... )
@WeekDayInput VARCHAR(50)='Mon', -- Mon, Tue, Wed, Thu, Fri, Sat, Sun
@CountN INT=1 -- 1 for the first date, 2 for the second occurence, 3 for the third
)
RETURNS TABLE
WITH SCHEMABINDING
AS
RETURN (SELECT DATEADD(MONTH, DATEDIFF(MONTH, 0, CONVERT(DATE,'1 '[email protected]+' '[email protected],113)), 0)+ (7*@CountN)-1
-(DATEPART (WEEKDAY, DATEADD(MONTH, DATEDIFF(MONTH, 0, CONVERT(DATE,'1 '[email protected]+' '[email protected],113)), 0))
[email protected]@DateFirst+(CHARINDEX(@WeekDayInput,'FriThuWedTueMonSunSat')-1)/3)%7 AS TheDate)
GO
CREATE FUNCTION dbo.fnNthDayOfWeekOfMonthTVF (
@YearInput VARCHAR(50),
@MonthInput VARCHAR(50), -- English months ( 'Jan', 'Feb', ... )
@WeekDayInput VARCHAR(50)='Mon', -- Mon, Tue, Wed, Thu, Fri, Sat, Sun
@CountN INT=1 -- 1 for the first date, 2 for the second occurence, 3 for the third
)
RETURNS @When TABLE (TheDate DATETIME)
WITH schemabinding
AS
Begin
INSERT INTO @When(TheDate)
SELECT DATEADD(MONTH, DATEDIFF(MONTH, 0, CONVERT(DATE,'1 '[email protected]+' '[email protected],113)), 0)+ (7*@CountN)-1
-(DATEPART (WEEKDAY, DATEADD(MONTH, DATEDIFF(MONTH, 0, CONVERT(DATE,'1 '[email protected]+' '[email protected],113)), 0))
[email protected]@DateFirst+(CHARINDEX(@WeekDayInput,'FriThuWedTueMonSunSat')-1)/3)%7
RETURN
end
GO
Utwórz kilka połączeń testowych i przypadków testowych
Zaczynamy od wersji stołowych:
SELECT * FROM dbo.fnNthDayOfWeekOfMonthTVF('2020','Feb','Tue',2)
SELECT TheYear, CONVERT(NCHAR(11),(SELECT TheDate FROM dbo.fnNthDayOfWeekOfMonthTVF(TheYear,'Feb','Tue',2)),113) FROM (VALUES ('2014'),('2015'),('2016'),('2017'),('2018'),('2019'),('2020'),('2021'))years(TheYear)
SELECT TheYear, CONVERT(NCHAR(11),TheDate,113) FROM (VALUES ('2014'),('2015'),('2016'),('2017'),('2018'),('2019'),('2020'),('2021'))years(TheYear)
OUTER apply dbo.fnNthDayOfWeekOfMonthTVF(TheYear,'Feb','Tue',2)
Tworzenie danych testowych:
IF EXISTS(SELECT * FROM tempdb.sys.tables WHERE name LIKE '#DataForTest%')
DROP TABLE #DataForTest
GO
SELECT *
INTO #DataForTest
FROM (VALUES ('2014'),('2015'),('2016'),('2017'),('2018'),('2019'),('2020'),('2021'))years(TheYear)
CROSS join (VALUES ('jan'),('feb'),('mar'),('apr'),('may'),('jun'),('jul'),('aug'),('sep'),('oct'),('nov'),('dec'))months(Themonth)
CROSS join (VALUES ('Mon'),('Tue'),('Wed'),('Thu'),('Fri'),('Sat'),('Sun'))day(TheDay)
CROSS join (VALUES (1),(2),(3),(4))nth(nth)
Wydajność testu:
DECLARE @TableLog TABLE (OrderVal INT IDENTITY(1,1), Reason VARCHAR(500), TimeOfEvent DATETIME2 DEFAULT GETDATE())
Początek pomiaru czasu:
INSERT INTO @TableLog(Reason) SELECT 'Starting My_Section_of_code' --place at the start
Po pierwsze, nie używamy żadnego typu funkcji, aby uzyskać linię bazową:
SELECT DATEADD(MONTH, DATEDIFF(MONTH, 0, CONVERT(DATE,'1 '+TheMonth+' '+TheYear,113)), 0)+ (7*Nth)-1
-(DATEPART (WEEKDAY, DATEADD(MONTH, DATEDIFF(MONTH, 0, CONVERT(DATE,'1 '+TheMonth+' '+TheYear,113)), 0))
[email protected]@DateFirst+(CHARINDEX(TheDay,'FriThuWedTueMonSunSat')-1)/3)%7 AS TheDate
INTO #Test0
FROM #DataForTest
INSERT INTO @TableLog(Reason) SELECT 'Using the code entirely unwrapped';
Używamy teraz wbudowanej funkcji o wartości tabeli, która ma zastosowanie krzyżowe:
SELECT TheYear, CONVERT(NCHAR(11),TheDate,113) AS itsdate
INTO #Test1
FROM #DataForTest
CROSS APPLY dbo.fnNthDayOfWeekOfMonthTVF(TheYear,TheMonth,TheDay,nth)
INSERT INTO @TableLog(Reason) SELECT 'Inline function cross apply'
Używamy wbudowanej funkcji o wartościach z tabeli, która ma zastosowanie krzyżowe:
SELECT TheYear, CONVERT(NCHAR(11),(SELECT TheDate FROM dbo.fnNthDayOfWeekOfMonthTVF(TheYear,TheMonth,TheDay,nth)),113) AS itsDate
INTO #Test2
FROM #DataForTest
INSERT INTO @TableLog(Reason) SELECT 'Inline function Derived table'
Aby porównać niezaufane, używamy funkcji skalarnej z wiązaniem schematów:
SELECT TheYear, CONVERT(NCHAR(11), dbo.fnGetDayofWeekInMonthBound(TheYear,TheMonth,TheDay,nth))itsdate
INTO #Test3
FROM #DataForTest
INSERT INTO @TableLog(Reason) SELECT 'Trusted (Schemabound) scalar function'
Następnie używamy funkcji skalarnej bez wiązania schematu:
SELECT TheYear, CONVERT(NCHAR(11), dbo.fnGetDayofWeekInMonth(TheYear,TheMonth,TheDay,nth))itsdate
INTO #Test6
FROM #DataForTest
INSERT INTO @TableLog(Reason) SELECT 'Untrusted scalar function'
Następnie wyprowadzono funkcję tabeli z wieloma instrukcjami:
SELECT TheYear, CONVERT(NCHAR(11),(SELECT TheDate FROM dbo.fnNthDayOfWeekOfMonthTVF(TheYear,TheMonth,TheDay,nth)),113) AS itsdate
INTO #Test4
FROM #DataForTest
INSERT INTO @TableLog(Reason) SELECT 'multi-statement table function derived'
Wreszcie, tabela zawierająca wiele instrukcji została zastosowana krzyżowo:
SELECT TheYear, CONVERT(NCHAR(11),TheDate,113) AS itsdate
INTO #Test5
FROM #DataForTest
CROSS APPLY dbo.fnNthDayOfWeekOfMonthTVF(TheYear,TheMonth,TheDay,nth)
INSERT INTO @TableLog(Reason) SELECT 'multi-statement cross APPLY'--where the routine you want to time ends
Wymień wszystkie czasy:
SELECT ending.Reason AS Test, DateDiff(ms, starting.TimeOfEvent,ending.TimeOfEvent) [AS Time (ms)] FROM @TableLog starting
INNER JOIN @TableLog ending ON ending.OrderVal=starting.OrderVal+1
DROP table #Test0
DROP table #Test1
DROP table #Test2
DROP table #Test3
DROP table #Test4
DROP table #Test5
DROP table #Test6
DROP TABLE #DataForTest
Powyższa tabela wyraźnie pokazuje, że podczas korzystania z funkcji zdefiniowanych przez użytkownika należy rozważyć wydajność w porównaniu z funkcjonalnością.
Wniosek
Funkcje są lubiane przez wielu programistów, głównie dlatego, że są „konstrukcjami logicznymi”. Możesz łatwo tworzyć przypadki testowe, są deterministyczne i hermetyzujące, ładnie integrują się z przepływem kodu SQL i zapewniają elastyczność w parametryzacji. Są dobrym wyborem, gdy trzeba zaimplementować złożoną logikę, która musi być wykonana na mniejszym lub już przefiltrowanym zestawie danych, który trzeba będzie ponownie wykorzystać w wielu scenariuszach. Widoki tabeli wbudowanej mogą być używane w widokach, które wymagają parametrów, zwłaszcza z wyższych warstw (aplikacje skierowane do klienta). Z drugiej strony funkcje skalarne świetnie nadają się do pracy z XML lub innymi formatami hierarchicznymi, ponieważ można je wywoływać rekurencyjnie.
Zdefiniowane przez użytkownika funkcje wielu instrukcji są doskonałym dodatkiem do zestawu narzędzi programistycznych, ale musisz zrozumieć, jak działają, jakie są ich ograniczenia i wyzwania związane z wydajnością. Ich niewłaściwe użycie może zniszczyć wydajność dowolnej bazy danych, ale jeśli wiesz, jak korzystać z tych funkcji, mogą one przynieść wiele korzyści w zakresie ponownego wykorzystania i enkapsulacji kodu.