tempdb, Temp Table ve Table Variable: Hangisi Ne Zaman?
Compatibility level 150'nin altında table variable 1 satır tahmin edilir ve 17.359 satırlık bir join 182.749 logical read'e çıkar; deferred compilation satır sayısını düzeltir ama kolon statistics'i olmadığı için filtre tahmini yine yanlıştır. Temp table, table variable ve CTE'nin statistics, rollback ve tekrar değerlendirme farkları, tempdb contention ve data file'lar, TVP ile toplu veri ve SQL Server 2025'in tempdb space resource governance'ı.
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'ı 140 ile 170 arasında değiştirilmiştir.
Önce Tahmin Et: Table Variable'da Kaç Satır Var?
Bir raporun ilk adımı, 2 numaralı müşterinin siparişlerini bir ara tabloya almak; ikinci adım bu siparişlerin kalemlerini saymak. Aynı işi bir table variable ve bir temp table ile yapıyoruz:
DECLARE @CustomerOrders TABLE (OrderId int PRIMARY KEY);
INSERT @CustomerOrders (OrderId) SELECT OrderId FROM dbo.Orders WHERE CustomerId = 2;
SELECT COUNT(*) AS lines, SUM(i.Quantity) AS books
FROM @CustomerOrders AS co
JOIN dbo.OrderItems AS i ON i.OrderId = co.OrderId;
GO
CREATE TABLE #CustomerOrders (OrderId int PRIMARY KEY);
INSERT #CustomerOrders (OrderId) SELECT OrderId FROM dbo.Orders WHERE CustomerId = 2;
SELECT COUNT(*) AS lines, SUM(i.Quantity) AS books
FROM #CustomerOrders AS co
JOIN dbo.OrderItems AS i ON i.OrderId = co.OrderId;
İkisinde de 17.359 satır var. Optimizer, join'i planlarken table variable'da kaç satır olduğunu düşünür? Veritabanı compatibility level 140'tayken ve 170'teyken cevap aynı mı? Actual plan'dan okuduğumuz değerler:
compat source estimated rows actual rows join OrderItems logical reads
140 @CustomerOrders 1 17359 Nested Loops 182749
140 #CustomerOrders 17359 17359 Hash Match 9964
170 @CustomerOrders 17359 17359 Hash Match 9964
170 #CustomerOrders 17359 17359 Hash Match 9964
Compatibility level 140'ta table variable için tahmin 1 satır. Optimizer bir satır için en ucuz yolu, nested loops ve sipariş başına bir seek'i seçti; 17.359 seek, 182.749 logical read. Aynı sorgu temp table ile 9.964 read'de bitti: 18 kat fark. Compatibility level 170'te ise table variable da doğru tahmin aldı. Bu yazı bu farkın kaynağını, temp table, table variable ve CTE'nin (Common Table Expression) gerçekte nasıl davrandığını ve hepsinin yaşadığı yer olan tempdb'yi anlatıyor.
Table Variable Deferred Compilation
Klasik davranışta table variable içeren bir statement, batch derlenirken, yani table variable henüz boşken derlenir. Optimizer'ın elinde satır sayısı yoktur ve sabit bir tahmin kullanır: 1. SQL Server 2019, compatibility level 150 ile table variable deferred compilation'ı getirdi: table variable'a referans veren statement'ın derlenmesi, statement ilk kez çalışacağı ana kadar ertelenir ve o andaki gerçek satır sayısı kullanılır. Temp table'lar zaten böyle çalışıyordu; bu yüzden 170'teki iki satır aynı.
Deferred compilation yalnız satır sayısını çözer. Table variable'ın kolonları için statistics yoktur. Kolon değerine göre filtrelediğinde farkı görürsün:
DECLARE @Orders TABLE (OrderId int PRIMARY KEY, Status varchar(20) NOT NULL);
INSERT @Orders (OrderId, Status) SELECT OrderId, Status FROM dbo.Orders WHERE CustomerId = 2;
CREATE TABLE #Orders (OrderId int PRIMARY KEY, Status varchar(20) NOT NULL);
INSERT #Orders (OrderId, Status) SELECT OrderId, Status FROM dbo.Orders WHERE CustomerId = 2;
SELECT COUNT(*) AS cancelled FROM @Orders WHERE Status = 'Cancelled';
SELECT COUNT(*) AS cancelled FROM #Orders WHERE Status = 'Cancelled';
SELECT s.name AS stats_name, s.auto_created
FROM tempdb.sys.stats AS s
WHERE s.object_id = OBJECT_ID('tempdb..#Orders');
source estimated rows actual rows
@Orders 132 886
#Orders 886 886
stats_name|auto_created
----------|------------
PK__#Orders___C3905BCF79A520FD|0
_WA_Sys_00000002_B9AB0B8D|1
Table variable için tahmin 132: bu, 17.359'un kareköküne çok yakın, yani optimizer'ın statistics olmadan eşitlik için kullandığı tahmin. Temp table'da ise Status kolonu için otomatik bir statistics (_WA_Sys_...) oluşturuldu ve tahmin tam 886. Bir sonraki join ya da memory grant bu tahmine göre kurulur. Statistics'in nasıl okunduğunu serinin 4. yazısında (Statistics ve Cardinality Estimation) görmüştük.
Temp Table: Statistics, Index ve Recompile
Local temp table (#) gerçek bir tablodur, yalnız tempdb'de ve session'a özel yaşar. Bu üç şey kazandırır: kolon statistics'i, istediğin kadar index (CREATE INDEX ile sonradan da) ve paralel plan. Bedeli de yine bu üçünden gelir. Statistics güncellenince ya da içerik belirli bir eşiğin üzerinde değişince temp table'a referans veren statement yeniden derlenir. Bir procedure'ün içinde döngüyle doldurulan bir temp table, her turda recompile tetikleyebilir. Çoğu zaman doğru plan için ödenmeye değer bir bedeldir; nadiren, saniyede binlerce kez çalışan kısa bir procedure'de CPU (Central Processing Unit) sorununa dönüşür.
Global temp table (##) tüm session'lara görünür ve onu oluşturan session kapandıktan, son kullanan da bıraktıktan sonra silinir. İsim çakışmasına ve beklenmedik paylaşıma açık olduğu için uygulama kodunda nadiren doğru seçimdir.
Transaction Davranışı ve CTE
İkisi arasındaki en çok unutulan fark, rollback'te ne olduğu:
DECLARE @Log TABLE (Step varchar(30));
CREATE TABLE #Log (Step varchar(30));
BEGIN TRANSACTION;
INSERT @Log VALUES ('reserve stock');
INSERT #Log VALUES ('reserve stock');
ROLLBACK;
SELECT (SELECT COUNT(*) FROM @Log) AS table_variable_rows, (SELECT COUNT(*) FROM #Log) AS temp_table_rows;
table_variable_rows|temp_table_rows
-------------------|---------------
1|0
Table variable kullanıcı transaction'ına katılmaz; rollback onun içeriğini geri almaz. Bu bir hata değil, bir özellik: CATCH bloğunda, rollback'ten sonra da kalması gereken bir hata günlüğünü table variable'da tutup sonra kalıcı bir tabloya yazabilirsin.
CTE ise hiçbir yerde saklanmaz. İsmi olan bir subquery'dir; her referans ayrı ayrı açılır:
WITH CustomerTotals AS
(
SELECT CustomerId, SUM(TotalAmount) AS Total
FROM dbo.Orders
WHERE CreatedAt >= '2026-10-01'
GROUP BY CustomerId
)
SELECT (SELECT COUNT(*) FROM CustomerTotals) AS customers,
(SELECT MAX(Total) FROM CustomerTotals) AS top_total;
customers|top_total
---------|---------
9059|228152.48
Table 'Orders'. Scan count 22, logical reads 15414, physical reads 0, ...
Orders'ın clustered index'i yaklaşık 7.600 page; 15.414 logical read onun iki kez tarandığını gösteriyor (paralel plan olduğu için scan count 22). CreatedAt üzerinde index olmadığından her referans tam tarama yapıyor. Aynı ara sonucu birden fazla kez kullanıyorsan, onu bir temp table'a yazmak tek tarama demektir.
Hangisi Ne Zaman?
| Özellik | #temp | @table | CTE |
|---|---|---|---|
| Satır sayısı tahmini | Gerçek | Compat 150+ gerçek, öncesinde 1 | Altındaki sorgudan |
| Kolon statistics'i | Var (otomatik) | Yok | Altındaki tablolardan |
| Sonradan index | Evet | Hayır (yalnız tanımda inline) | Yok |
| Rollback'ten etkilenir | Evet | Hayır | Saklanmaz |
| Recompile tetikler | Evet, değişiklik eşiğinde | Hayır | Hayır |
| Tipik kullanım | Büyük ara sonuç, çok adımlı rapor | Küçük liste, hata günlüğü, TVP (Table-Valued Parameter) | Okunabilirlik, recursion |
Kaba kural: birkaç yüz satırın üzerindeyse ve sonraki adımlar kolon değerlerine göre filtreliyor ya da join ediyorsa temp table. Küçükse ya da yalnız bir anahtar listesiyse table variable. CTE'yi sorgunun okunabilirliği için kullan, bir "materialization" aracı olarak değil.
tempdb'nin İçi: Contention, Data File'lar ve Metadata
Temp table'lar, table variable'lar, sort ve hash spill'leri, cursor'lar, RCSI'ın (Read Committed Snapshot Isolation) version store'u: hepsi tempdb'de yaşar. Bu yüzden tempdb'nin kendisi bir darboğaz olabilir. Kendi session'ımızın ne kadar yer kullandığını görebiliriz:
CREATE TABLE #BigOrders (OrderId int PRIMARY KEY, CustomerId int, TotalAmount decimal(12,2));
INSERT #BigOrders SELECT OrderId, CustomerId, TotalAmount FROM dbo.Orders WHERE CustomerId = 1;
GO
SELECT user_objects_alloc_page_count, user_objects_dealloc_page_count
FROM sys.dm_db_session_space_usage WHERE session_id = @@SPID;
SELECT SUM(used_page_count) AS used_pages
FROM tempdb.sys.dm_db_partition_stats WHERE object_id = OBJECT_ID('tempdb..#BigOrders');
user_objects_alloc_page_count|user_objects_dealloc_page_count
-----------------------------|-------------------------------
800|144
used_pages
----------
655
200.000 satırlık temp table 655 page kullanıyor; session sayaçları ise yalnız tamamlanmış task'ları gösterir, bu yüzden sorgu araya bir GO koyduktan sonra çalıştı. Her yeni nesne için tempdb, allocation page'lerine dokunur: PFS (Page Free Space), GAM (Global Allocation Map) ve SGAM (Shared Global Allocation Map). Çok sayıda session aynı anda küçük temp table oluşturup sildiğinde bu page'lerde latch beklemesi oluşur; wait type PAGELATCH_UP ya da PAGELATCH_EX, wait resource 2:1:1 gibi tempdb page'leridir. Klasik çözüm birden fazla, eşit boyutlu data file'dır. Laboratuvar sunucusunda 8 data file var:
SELECT name, type_desc, size * 8 / 1024 AS size_mb FROM sys.master_files WHERE database_id = 2;
SELECT SERVERPROPERTY('IsTempdbMetadataMemoryOptimized') AS memory_optimized_tempdb_metadata;
name|type_desc|size_mb
----|---------|-------
tempdev|ROWS|8
templog|LOG|8
tempdev2|ROWS|8
tempdev3|ROWS|8
tempdev4|ROWS|8
tempdev5|ROWS|8
tempdev6|ROWS|8
tempdev7|ROWS|8
tempdev8|ROWS|8
memory_optimized_tempdb_metadata
--------------------------------
0
Kurulum, 8'e kadar mantıksal işlemci için işlemci sayısı kadar, üzerinde 8 data file önerir; contention sürerse dörder dörder artırılır. Contention bu sefer system tablolarında (tempdb'nin metadata'sında) ise, SQL Server 2019 ile gelen memory-optimized tempdb metadata bu tabloları latch'siz, bellek içi yapılara taşır. Bu bir server seviyesi ayardır ve restart ister; ortak laboratuvar sunucusunda kapalı (0) ve dokunmadık.
TVP ile Toplu Veri Göndermek
Table variable'ın en kullanışlı akrabası TVP: uygulamadan bir procedure'e tek round trip'te tablo göndermek. Önce bir table type tanımlıyorsun:
CREATE TYPE dbo.BookIdList AS TABLE (BookId int PRIMARY KEY);
GO
CREATE OR ALTER PROCEDURE dbo.GetStock
@Books dbo.BookIdList READONLY
AS
BEGIN
SET NOCOUNT ON;
SELECT s.BookId, s.QuantityOnHand, s.QuantityReserved
FROM dbo.StockItems AS s
JOIN @Books AS b ON b.BookId = s.BookId
ORDER BY s.BookId;
END;
GO
DECLARE @Books dbo.BookIdList;
INSERT @Books (BookId) VALUES (1), (2), (3);
EXEC dbo.GetStock @Books = @Books;
BookId|QuantityOnHand|QuantityReserved
------|--------------|----------------
1|36|0
2|140|0
3|185|0
Sepetteki kitapların stoğunu sorgulamak, IN (@p0, @p1, ...) ile her sepet boyutu için ayrı bir sorgu metni ve ayrı bir plan üretmek yerine, tek bir sorgu metni ve tek bir plan demektir. TVP içeride bir table variable gibi davranır; tahmini de compatibility level 150 ve üzerinde deferred compilation'dan faydalanır. Dapper'da AsTableValuedParameter ile nasıl gönderildiğini Dapper serisinin SQL Server yazısında görmüştük.
SQL Server 2019/2022'de Farkı
Table variable deferred compilation ve memory-optimized tempdb metadata SQL Server 2019 ile geldi; 2019 ve 2022'de compatibility level 150 ve üzeri gerekir. Eski bir veritabanını 2022'ye taşıyıp compatibility level'ı 140'ta bırakırsan açılıştaki 1 satırlık tahmini görmeye devam edersin. SQL Server 2022, GAM ve SGAM page'lerindeki latch contention'ı azaltan iyileştirmeler getirdi; bu yüzden aynı iş yükü 2022'de 2019'a göre daha az PAGELATCH beklemesi gösterebilir. SQL Server 2025'in yeniliği tempdb space resource governance: Resource Governor ile bir workload group'un tempdb'de kullanabileceği alanı sınırlarsın ve sınırı aşmaya çalışan sorgu 1138 hatasıyla kesilir:
ALTER WORKLOAD GROUP [default] WITH (GROUP_MAX_TEMPDB_DATA_MB = 20480);
ALTER RESOURCE GOVERNOR RECONFIGURE;
Bu server seviyesinde bir ayar olduğu için ortak laboratuvarda çalıştırmadık. Doküman, default workload group'u ayrı bir sınıflandırma kurmadan sınırlamamayı öneriyor; aksi hâlde tempdb'de boş yer varken sıradan sorgular 1138 alabilir. Version store bu sınırın dışında kalır.
Üretimde Dikkat
tempdb'yi izle. sys.dm_db_session_space_usage ve sys.dm_db_task_space_usage hangi session'ın ne kadar yer kullandığını; internal_objects_alloc_page_count ise spill, sort ve worktable kullanımını gösterir. Version store büyüklüğü için sys.dm_tran_version_store_space_usage'a bak; uzun açık bir transaction, version store'un temizlenmesini engelleyip tempdb'yi doldurabilir.
tempdb'yi önceden boyutlandır. Otomatik büyüme sırasında tempdb kısa süre durur; data file'ları eşit boyutta ve beklenen kullanıma göre baştan büyüt. Eşit olmayan dosyalarda SQL Server en büyüğe daha çok yazar ve contention'ı dağıtma etkisi kaybolur.
Compatibility level'ı kontrol et. Table variable'larla yazılmış eski raporlar, compatibility level 150 ve üzerine geçince plan değiştirir; çoğu zaman iyileşir, ama 1 satırlık tahminle tesadüfen iyi çalışan bir plan da değişebilir. Yükseltme stratejisini 19. yazıda Query Store ile kuracağız.
Sık Düşülen Tuzaklar
"Table variable bellekte, temp table diskte" efsanesi. İkisi de tempdb'de yaşar ve ikisi de küçükse bellekteki page'lerde kalır. Aradaki fark depolama değil; statistics, transaction ve recompile davranışı.
CTE'yi önbellek sanmak. Aynı CTE'yi üç kez referans veren bir sorgu, altındaki işi üç kez yapabilir. Ara sonucu tekrar kullanacaksan temp table'a yaz.
Temp table'ı döngüde doldurmak. WHILE içinde satır satır INSERT, hem tempdb'ye çok sayıda küçük log kaydı yazar hem de recompile eşiklerini tetikler. Tek bir INSERT ... SELECT ile doldur.
Procedure içindeki temp table'a sonradan index eklemek. CREATE TABLE'dan sonra ayrı bir CREATE INDEX, temp table caching'i (procedure çağrıları arasında tempdb nesnesinin yeniden kullanılması) bozar. Index'leri CREATE TABLE içinde inline tanımla.
Küçük Bir Deneme
★☆☆ Açılıştaki table variable sorgusunu compatibility level 170'te OPTION (USE HINT('DISABLE_DEFERRED_COMPILATION_TV')) ile çalıştır. Tahmin ve OrderItems logical read'leri kaç?
★★☆ @Orders table variable'ına Status üzerinde inline bir index ekle (INDEX IX_Status (Status)). Status = 'Cancelled' tahmini değişiyor mu? Neden?
★★★ Sepet stok sorgusunu Dapper ile önce WHERE BookId IN @Ids, sonra dbo.BookIdList TVP'si ile 1, 5 ve 20 kitaplık sepetler için çalıştır. sys.dm_exec_query_stats'ta kaç farklı sorgu metni ve plan oluştuğunu karşılaştır.
Tempdb'deki beklemelerden, wait type'lardan ve DMV'lerden (Dynamic Management View) bu yazıda yer yer söz ettik. Bir sonraki yazıda bunları bir teşhis yöntemine dönüştürüyoruz: "veritabanı yavaş" şikâyetini wait statistics, DMV'ler ve Extended Events ile adım adım çözmek.