Function'lar ve Trigger'lar: Scalar UDF Inlining, Inline TVF, Multi-Statement TVF ve Trigger Tuzakları
Inline edilmeyen bir scalar UDF satır başına ayrı modül olarak çalışır: 17.359 siparişlik bir sorguda function 34.718 kez çağrıldı, 365.498 logical read STATISTICS IO'da hiç görünmedi ve plan paralel olamadı. SQL Server 2019 inlining'i, is_inlineable, inline TVF ile MSTVF'nin 100 satırlık sabit tahmini ve interleaved execution, trigger'da tek satır varsayımı, EF Core'un OUTPUT kısıtı ve audit için temporal table gerçek ölçümlerle.
Sürüm notu: Bu yazıdaki SQL (Structured Query Language) örnekleri SQL Server 2025 (17.0.5035.5, Cumulative Update 10) üzerinde çalıştırılmıştır; karşılaştırmalar için veritabanının compatibility level'ı 130, 140 ve 170 arasında değiştirilmiştir.
Önce Tahmin Et: Bu Function Kaç Kez Çalışır?
Bookstore'da sipariş tutarını hesaplayan küçük bir scalar function var. Kod tekrarını önlemek için yazılmış, raporlarda her yerde kullanılıyor:
CREATE OR ALTER FUNCTION dbo.OrderTotal (@OrderId int)
RETURNS decimal(12,2)
AS
BEGIN
RETURN (SELECT SUM(Quantity * UnitPrice) FROM dbo.OrderItems WHERE OrderId = @OrderId);
END;
GO
SET STATISTICS IO ON;
SELECT COUNT(*) AS orders, SUM(dbo.OrderTotal(o.OrderId)) AS revenue
FROM dbo.Orders AS o
WHERE o.CustomerId = 2;
2 numaralı müşterinin 17.359 siparişi var. Veritabanı compatibility level 140'ta (SQL Server 2017 davranışı). Function kaç kez çalışır ve STATISTICS IO çıktısında kaç logical read görürsün?
orders|revenue
------|-------
17359|1370933.10
Table 'Orders'. Scan count 1, logical reads 34, physical reads 0, ...
function_name|execution_count|total_logical_reads
-------------|---------------|-------------------
OrderTotal|34718|365498
STATISTICS IO yalnız Orders'ın 34 page'ini gösteriyor. Ama sys.dm_exec_function_stats function'ın 34.718 kez çalıştığını ve 365.498 logical read yaptığını söylüyor: sorgunun gerçek maliyetinin binde birinden azı görünürde. Üstelik 34.718, sipariş sayısının tam iki katı. Bu yazı scalar function'ların neden böyle davrandığını, SQL Server 2019 ile gelen inlining'i, table-valued function'ların iki türünü ve trigger'ların tuzaklarını gerçek ölçümlerle anlatıyor.
Scalar UDF'nin Gizli Maliyeti
T-SQL scalar UDF (User-Defined Function), compatibility level 150'nin altında, satır başına çağrılan ayrı bir modüldür. Optimizer onun içini görmez; her çağrı kendi bağlamını kurar, kendi sorgusunu çalıştırır ve sonucu döndürür. Planda bunu görebilirsin:
|--Stream Aggregate(DEFINE:([Expr1003]=Count(*), [Expr1004]=COUNT_BIG([sqls_15].[dbo].[OrderTotal]([sqls_15].[dbo].[Orders].[OrderId] as [o].[OrderId])), [Expr1005]=SUM([sqls_15].[dbo].[OrderTotal]([sqls_15].[dbo].[Orders].[OrderId] as [o].[OrderId]))))
|--Index Seek(OBJECT:([sqls_15].[dbo].[Orders].[IX_Orders_CustomerId] AS [o]), SEEK:([o].[CustomerId]=(2)) ORDERED FORWARD)
İki kat çalışmanın sebebi burada: SUM, boş küme ile NULL'ı ayırmak için içeride bir COUNT_BIG da hesaplıyor ve function ifadesi iki aggregate'te ayrı ayrı değerlendiriliyor. Optimizer function'ın ne kadar pahalı olduğunu bilmediği için bunu sorun görmüyor.
Üç maliyet daha var. Function'ın içindeki sorgunun okumaları STATISTICS IO'da görünmez, yani "bu sorgu 34 page okuyor" diye yanlış teşhis koyarsın. Function'ın kendi planı ayrı derlenir ve dış sorgunun tahminlerine katılmaz. Ve T-SQL scalar UDF içeren bir sorgu paralel plan kullanamaz: bu plan tek thread'de çalıştı.
Scalar UDF Inlining
SQL Server 2019, compatibility level 150 ile scalar UDF inlining'i getirdi: uygun function'ların gövdesi, çağrıldığı sorguya bir ifade ya da subquery olarak gömülür ve optimizer bütünü tek sorgu gibi optimize eder. Aynı sorgu compatibility level 170'te:
Table 'OrderItems'. Scan count 17359, logical reads 182749, physical reads 0, ...
Table 'Orders'. Scan count 5, logical reads 36, physical reads 0, ...
function_name|execution_count|total_logical_reads
-------------|---------------|-------------------
|--Stream Aggregate(DEFINE:([globalagg1014]=SUM([partialagg1013]), ...))
|--Parallelism(Gather Streams)
|--Stream Aggregate(DEFINE:([partialagg1013]=Count(*), ...))
|--Nested Loops(Inner Join, OUTER REFERENCES:([o].[OrderId]))
|--Index Seek(OBJECT:([sqls_15].[dbo].[Orders].[IX_Orders_CustomerId] AS [o]), SEEK:([o].[CustomerId]=(2)) ORDERED FORWARD)
|--Compute Scalar(DEFINE:([Expr1004]=CASE WHEN [Expr1019]=(0) THEN NULL ELSE [Expr1020] END))
|--Stream Aggregate(DEFINE:([Expr1019]=COUNT_BIG([Expr1008]), [Expr1020]=SUM([Expr1008])))
|--Index Seek(OBJECT:([sqls_15].[dbo].[OrderItems].[IX_OrderItems_OrderId]), ...)
Dört şey değişti. Function istatistiklerinde satır yok, çünkü function artık çağrılmıyor. OrderItems'ın okumaları STATISTICS IO'da görünüyor: 182.749, yani eski gizli maliyetin yarısı, çünkü ifade artık tek kez değerlendiriliyor. Plan paralel. Ve function'ın içindeki aggregate dış sorgunun nested loops'unun içine girmiş: optimizer artık bütünü görüyor.
Her function inline edilemez. sys.sql_modules.is_inlineable hangilerinin uygun olduğunu söyler:
CREATE OR ALTER FUNCTION dbo.OrderAgeDays (@CreatedAt datetime2(0))
RETURNS int
AS
BEGIN
RETURN DATEDIFF(DAY, @CreatedAt, SYSUTCDATETIME());
END;
GO
SELECT OBJECT_NAME(object_id) AS function_name, is_inlineable, inline_type
FROM sys.sql_modules
WHERE object_id IN (OBJECT_ID('dbo.OrderTotal'), OBJECT_ID('dbo.OrderAgeDays'));
function_name|is_inlineable|inline_type
-------------|-------------|-----------
OrderTotal|1|1
OrderAgeDays|0|0
Basit görünen OrderAgeDays inline edilemiyor, çünkü SYSUTCDATETIME() gibi zamana bağlı bir intrinsic function çağırıyor. Dokümandaki diğer engeller arasında yan etkili intrinsic function'lar, table variable ya da table-valued parameter kullanımı ve EXECUTE AS var; liste her Cumulative Update ile güncelleniyor, bu yüzden tahmin etme, is_inlineable'a bak. Inlining'i function bazında WITH INLINE = OFF, sorgu bazında OPTION (USE HINT('DISABLE_TSQL_SCALAR_UDF_INLINING')), veritabanı bazında ALTER DATABASE SCOPED CONFIGURATION SET TSQL_SCALAR_UDF_INLINING = OFF ile kapatabilirsin.
Inlining her zaman daha az okuma demek değil; bizim örnekte okuma yarıya indi çünkü çift değerlendirme ortadan kalktı, yoksa plan hâlâ sipariş başına bir seek yapıyor. En iyi sonuç çoğu zaman function'ı hiç kullanmadan, set-based bir JOIN ve GROUP BY yazmaktan gelir. Inlining, değiştiremeyeceğin eski kod için bir güvenlik ağı.
Inline TVF: Parameter'lı View
Table-valued function'ın iki türü var ve isimleri benzese de optimizer için tamamen farklılar. Inline TVF (Table-Valued Function) tek bir SELECT'ten oluşur:
CREATE OR ALTER FUNCTION dbo.CustomerOrdersInline (@CustomerId int)
RETURNS TABLE
AS
RETURN
SELECT OrderId, CreatedAt, TotalAmount FROM dbo.Orders WHERE CustomerId = @CustomerId;
Optimizer bunu bir view gibi açar: function'ın sorgusu çağrıldığı sorguya yerleşir, statistics ve index'ler normal şekilde kullanılır. "Parameter alan view" en doğru tanımıdır. Tekrar eden filtre ve projection'ları paylaşmak için güvenle kullanabilirsin; Row-Level Security'nin predicate function'ları da bu türdendir (17. yazı).
Multi-Statement TVF ve Interleaved Execution
Multi-statement TVF (MSTVF) ise bir table variable'ı doldurup döndüren küçük bir programdır:
CREATE OR ALTER FUNCTION dbo.CustomerOrdersMulti (@CustomerId int)
RETURNS @result TABLE (OrderId int PRIMARY KEY, CreatedAt datetime2(0), TotalAmount decimal(12,2))
AS
BEGIN
INSERT @result (OrderId, CreatedAt, TotalAmount)
SELECT OrderId, CreatedAt, TotalAmount FROM dbo.Orders WHERE CustomerId = @CustomerId;
RETURN;
END;
İki function'ı aynı sorguda, sipariş kalemleriyle join ederek kullanıyoruz ve actual plan'dan (SET STATISTICS XML ON) tahmin ve gerçek satır sayılarını okuyoruz:
SELECT COUNT(*) AS lines, SUM(i.Quantity) AS books
FROM dbo.CustomerOrdersMulti(2) AS co
JOIN dbo.OrderItems AS i ON i.OrderId = co.OrderId;
function compat estimated rows actual rows join OrderItems logical reads
CustomerOrdersMulti 130 100 17359 Nested Loops 188756
CustomerOrdersMulti 170 17359 17359 Hash Match 9964
CustomerOrdersInline 130 17359 17359 Hash Match 9964
CustomerOrdersInline 170 17359 17359 Hash Match 9964
Compatibility level 130'da MSTVF için tahmin sabit: 100 satır. Optimizer 100 satır için nested loops ve key lookup'lı bir plan seçti; gerçekte 17.359 satır geldi ve OrderItems 188.756 logical read ile okundu. Inline TVF ise aynı veritabanında doğru tahminle hash join'e gitti: 9.964 read, yaklaşık 19 kat az.
Compatibility level 140'tan itibaren interleaved execution devrede: optimizer MSTVF'ye geldiğinde optimizasyonu durdurur, function'ı çalıştırır, gerçek satır sayısını öğrenir ve planın geri kalanını buna göre derler. Plan XML'inde (Extensible Markup Language) bunu ContainsInterleavedExecutionCandidates="1" ve TVF operator'ünde IsInterleavedExecuted="1" olarak görürsün. Sonuç inline TVF ile aynı plan. Ama sınırları var: yalnız SELECT statement'larında ve yalnız ilk derlemede çalışır; plan cache'teki plan, ilk çağrıdaki satır sayısıyla kalır. 2 numaralı müşteri için derlenen plan, 15018 numaralı tek siparişli müşteri için de kullanılır. Ayrıca MSTVF'nin döndürdüğü table variable'ın statistics'i yoktur; satır sayısı dışında hiçbir dağılım bilgisi taşımaz. Seçme hakkın varsa inline TVF yaz.
Trigger'lar: inserted, deleted ve Tek Satır Varsayımı
Bookstore'da sipariş kalemi eklendiğinde stok rezervasyonunu bir trigger yapıyor. İlk versiyon, tek tek eklenen satırlarla test edilmiş:
CREATE OR ALTER TRIGGER dbo.OrderItems_ReserveStock
ON dbo.OrderItems
AFTER INSERT
AS
BEGIN
SET NOCOUNT ON;
DECLARE @BookId int, @Quantity smallint;
SELECT @BookId = BookId, @Quantity = Quantity FROM inserted;
UPDATE dbo.StockItems SET QuantityReserved += @Quantity WHERE BookId = @BookId;
END;
GO
INSERT dbo.OrderItems (OrderId, BookId, Quantity, UnitPrice)
VALUES (999001, 10, 1, 9.99), (999001, 11, 2, 9.99), (999001, 12, 3, 9.99);
SELECT BookId, QuantityReserved FROM dbo.StockItems WHERE BookId IN (10, 11, 12);
BookId|QuantityReserved
------|----------------
10|1
11|0
12|0
Üç kalem eklendi, yalnız biri rezerve edildi. Trigger her satır için değil, her statement için bir kez çalışır; inserted (ve UPDATE/DELETE'te deleted) o statement'ın etkilediği bütün satırları içeren pseudo table'lardır. Değişkene atama, satırlardan birini (hangisi olduğu garanti değil) alıp gerisini sessizce atar. Entity Framework Core (EF Core) SaveChanges'ta birden fazla satırı tek statement ile gönderebildiği için bu hata gerçek uygulamada ilk toplu siparişte ortaya çıkar. Doğrusu set-based:
CREATE OR ALTER TRIGGER dbo.OrderItems_ReserveStock
ON dbo.OrderItems
AFTER INSERT
AS
BEGIN
SET NOCOUNT ON;
IF NOT EXISTS (SELECT 1 FROM inserted) RETURN;
UPDATE s
SET QuantityReserved += i.Quantity
FROM dbo.StockItems AS s
JOIN (SELECT BookId, SUM(Quantity) AS Quantity FROM inserted GROUP BY BookId) AS i
ON i.BookId = s.BookId;
END;
BookId|QuantityReserved
------|----------------
10|1
11|2
12|3
GROUP BY, aynı kitabın aynı statement'ta iki kez gelmesi durumunu da doğru toplar. IF NOT EXISTS ise sıfır satır etkileyen statement'larda da trigger'ın çalıştığını hatırlatır.
Trigger'ın uygulamayı kıran bir yan etkisi daha var:
INSERT dbo.OrderItems (OrderId, BookId, Quantity, UnitPrice)
OUTPUT inserted.OrderItemId
VALUES (999002, 13, 1, 9.99);
Msg 334, Level 16, State 1, Server b8be943222fa, Line 1
The target table 'dbo.OrderItems' of the DML statement cannot have any enabled triggers if the statement contains an OUTPUT clause without INTO clause.
EF Core 7'den beri SQL Server provider'ı yeni identity değerlerini tam olarak bu şekilde, INTO'suz OUTPUT ile okur. Trigger'lı bir tabloya EF Core ile yazıyorsan, tabloyu modelde ToTable(t => t.HasTrigger("OrderItems_ReserveStock")) ile işaretlemen gerekir; EF Core o tablo için daha yavaş ama trigger uyumlu bir SQL üretir.
AFTER trigger'lar DML (Data Manipulation Language) statement'ı tamamlandıktan sonra, aynı transaction içinde çalışır; trigger'daki hata bütün statement'ı geri alır. INSTEAD OF trigger'lar statement'ın yerine çalışır ve çoğunlukla güncellenemeyen view'lara yazmak için kullanılır. Bir trigger başka bir tabloya yazarak oradaki trigger'ı tetikleyebilir: nested triggers server ayarı varsayılan olarak 1'dir (32 seviyeye kadar). Aynı tablonun trigger'ının kendini tetiklemesi ise RECURSIVE_TRIGGERS veritabanı seçeneğine bağlıdır ve varsayılan kapalıdır; laboratuvarda ikisini de varsayılan değerlerinde gördük.
Audit İçin Trigger Yerine
Trigger'ların en yaygın kullanımı "kim neyi ne zaman değiştirdi" kaydı. Bunun için SQL Server'ın hazır araçları daha az hata yaptırır. Temporal table, tablonun her versiyonunu otomatik olarak bir history tablosunda tutar:
ALTER TABLE dbo.Books ADD
ValidFrom datetime2 GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
CONSTRAINT DF_Books_ValidFrom DEFAULT SYSUTCDATETIME(),
ValidTo datetime2 GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
CONSTRAINT DF_Books_ValidTo DEFAULT CONVERT(datetime2, '9999-12-31 23:59:59.9999999'),
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
ALTER TABLE dbo.Books SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.BooksHistory));
GO
UPDATE dbo.Books SET Price = 12.50 WHERE BookId = 1;
UPDATE dbo.Books SET Price = 11.90 WHERE BookId = 1;
GO
SELECT BookId, Price, ValidFrom, ValidTo
FROM dbo.Books FOR SYSTEM_TIME ALL
WHERE BookId = 1
ORDER BY ValidFrom;
BookId|Price|ValidFrom|ValidTo
------|-----|---------|-------
1|32.77|2026-11-08 12:12:39.2073938|2026-11-08 12:12:39.2622087
1|12.50|2026-11-08 12:12:39.2622087|2026-11-08 12:12:39.2675017
1|11.90|2026-11-08 12:12:39.2675017|9999-12-31 23:59:59.9999999
Fiyat geçmişi, tek satır kod yazmadan tutuluyor; FOR SYSTEM_TIME AS OF ile herhangi bir andaki fiyatı sorgulayabilirsin. HIDDEN period kolonları SELECT *'ta görünmez, bu yüzden mevcut sorgular ve ORM (Object-Relational Mapping) eşlemeleri bozulmaz. Eksik olan "kim" bilgisi; temporal table kullanıcıyı tutmaz, bunun için tabloya bir UpdatedBy kolonu eklemen gerekir.
Diğer seçenekler: Change Tracking hangi satırın değiştiğini (değeri değil) hafif şekilde tutar ve senkronizasyon senaryoları için uygundur. CDC (Change Data Capture) transaction log'dan okuyarak değişen değerleri ayrı tablolara yazar; olay akışı ve veri ambarı beslemek için kullanılır. Uygulama seviyesinde ise EF Core interceptor'ları, EF Core serisinin interceptor yazısında gördüğümüz gibi, audit kolonlarını ve kullanıcı bilgisini SaveChanges sırasında doldurur. Kural: iş mantığını trigger'a gömme. Stok rezervasyonu gibi bir iş kuralı trigger'dayken, kodu okuyan biri onun varlığından haberdar olmaz; procedure'de ya da uygulamada açıkça görünür olmalı.
SQL Server 2019/2022'de Farkı
Scalar UDF inlining SQL Server 2019 ile, compatibility level 150'de geldi; 2019 ve 2022'de aynı şekilde çalışır ama inline edilebilirlik kuralları Cumulative Update'lerle daraltılıp düzeltildi, bu yüzden aynı function bir sürümde is_inlineable = 1, diğerinde 0 olabilir. Compatibility level 140 ve altında çalışan bir veritabanında, SQL Server 2022'de bile, ilk örnekteki 34.718 çağrıyı görürsün. Interleaved execution 2017'den (compatibility level 140) beri var. Temporal table 2016'dan beri var. Trigger'lar ve OUTPUT kısıtı sürümler arasında değişmedi.
Üretimde Dikkat
Gizli maliyeti ölç. sys.dm_exec_function_stats, en çok çağrılan ve en çok okuyan function'ları gösterir; bir sorgunun STATISTICS IO'su düşük ama süresi uzunsa ilk bakacağın yer. Query Store'da scalar UDF'nin kendi statement'ları ayrı sorgular olarak görünür.
Compatibility level yükseltmesinde function'ları kontrol et. Inlining planı değiştirir; nadiren de olsa daha kötü bir plan ya da farklı bir sonuç (örneğin function'daki bir hata artık başka bir sırayla ortaya çıkar) görebilirsin. Önce is_inlineable listesini çıkar, kritik raporları Query Store ile karşılaştır (19. yazı).
Trigger'ları envantere al. sys.triggers'ı sorgula ve her trigger'ın ne yaptığını, çok satırlı DML'de doğru çalışıp çalışmadığını test et. Trigger, EF Core'un OUTPUT kısıtı ve @@ROWCOUNT'un trigger içindeki statement'lardan etkilenmesi gibi sürprizlerle gelir.
Trigger'ı kısa tut. Trigger, tetikleyen statement'ın transaction'ında çalışır; içindeki her okuma ve yazma, kullanıcının INSERT'ünün lock süresini uzatır. Uzun iş (e-posta, dış sistem) trigger'da değil, bir queue üzerinden yapılır.
Sık Düşülen Tuzaklar
Scalar UDF'yi WHERE'e koymak. WHERE dbo.OrderAgeDays(CreatedAt) > 30 hem function'ı her satır için çağırır hem de CreatedAt üzerindeki index'i kullanılamaz hâle getirir. WHERE CreatedAt < DATEADD(DAY, -30, SYSUTCDATETIME()) aynı işi seek ile yapar.
MSTVF'yi "daha esnek" diye seçmek. Birden fazla statement gerekiyormuş gibi görünen çoğu MSTVF, UNION ALL, CASE ve CTE (Common Table Expression) ile tek bir SELECT'e, yani inline TVF'ye dönüştürülebilir.
Trigger'da inserted'dan tek satır okumak. Bu yazının en sık görülen hatası; code review'da trigger içindeki her SELECT @x = ... FROM inserted bir uyarı işaretidir.
Audit trigger'ını kullanıcıyla değil connection'la doldurmak. Uygulama tek bir SQL kullanıcısıyla bağlanıyorsa SUSER_SNAME() her zaman aynı adı verir. Gerçek kullanıcıyı SESSION_CONTEXT ile ya da uygulama tarafında yazman gerekir.
Küçük Bir Deneme
★☆☆ dbo.OrderTotal'ı WITH INLINE = OFF ile yeniden oluştur ve compatibility level 170'te ilk sorguyu çalıştır. execution_count kaç? Plan paralel mi?
★★☆ İlk sorguyu function kullanmadan, Orders ile OrderItems'ı join edip GROUP BY ile yaz. STATISTICS IO'yu inline edilmiş versiyonla karşılaştır.
★★★ dbo.CustomerOrdersMulti'yi önce CustomerId = 15018 (tek sipariş), sonra CustomerId = 2 ile, plan cache'i temizlemeden çalıştır. İkinci çağrıda tahmin kaç? Interleaved execution'ın ilk derlemeye bağlı olmasının sonucunu açıkla.
Table variable'ların tahmin sorununa MSTVF üzerinden dokunduk. Bir sonraki yazıda tempdb'nin kendisine iniyoruz: temp table, table variable ve CTE ne zaman hangisi, table variable deferred compilation ve tempdb contention.