SQL Server’da Execution Plan Nasıl Okunur?


1. Başlangıç

Önceki yazılarda Optimized Locking ve Native JSON üzerinden SQL Server 2025’in iç mekaniğine indik. Bu hafta odağımız biraz daha temel bir yetkinlikte: Execution Plan okuma. Yazılı bir SQL’i performanslı bir SQL’e çeviren şey, bu plan’ı doğru okuyup doğru yorumlayabilmek. Kötü bir plan = kötü performans, basitçe.

Yıllar içinde bir sürü sayısız kötü plan’la uğraştık. Aynı sorgu farklı zamanlarda 10 kat farklı sürede çalıştı; nedenini plan’da bulmak gerekti. Bu yazıda amacım iki şeyi yan yana koymak: bir tarafta plan operatörlerini ve klasik join stratejilerini pratiğe çevirmek, diğer tarafta SQL Server 2025’te gelen Intelligent Query Processing (IQP) yeniliklerini gerçek senaryolarda göstermek. OPPO, CE Feedback for Expressions, Optimized sp_executesql bunlar plan kalitesini ciddi şekilde etkileyen yeni katmanlar.

Dört workshop yapacağız. Workshop 1’de Index Seek, Index Scan ve Key Lookup üçlüsünü yan yana üretip aralarındaki performans farkını ölçeceğiz. Workshop 2’de aynı join’i Nested Loops, Merge ve Hash olarak çalıştırıp Cardinality Estimation’ın seçimi nasıl yönlendirdiğini göreceğiz. Workshop 3’te yeni gelen OPPO’yu reprodüce edip Multiplan infrastructure’ın iki ayrı plan ürettiğini izleyeceğiz. Workshop 4’te ise Adaptive Join’i Live Query Statistics üzerinden runtime’da gözleyeceğiz.

Yazıyı okuduktan sonra önümüze düşen bir slow query’nin plan’ını açtığınızda ‘Şu operatör neden burada, ne maliyet getiriyor, optimizer neden bunu seçti’ sorularına kendi başınıza cevap üretebilmenizi hedefliyorum. Plan okuyamamak DBA için pulp fiction izlerken alt yazıyı kaybetmek gibi bir şey, olay aktığını görüyorsun ama anlamıyorsun.

2. Execution Plan Nedir? Estimated, Actual, Cache

Bir T-SQL ifadesi geldiğinde engine onu doğrudan çalıştırmaz. Önce parse eder (syntax kontrolü), sonra algebrize eder (object resolution), sonra optimizer devreye girer ve operatörler üzerinden bir ‘plan’ kurar. Plan, engine’in hangi sırayla hangi index’e erişeceğini, hangi join algoritmasını kullanacağını, ne zaman sort yapacağını anlatan bir ağaçtır.

İki tip plan var: estimated ve actual. Estimated plan, sorgu çalışmadan önce optimizer’ın istatistiklere ve compatibility level’a göre öngördüğü ağaçtır, yani ‘cardinality estimate’leri tahmin değerleri. Actual plan, sorgu çalıştıktan sonra üretilen ağaçtır; tahminlerin yanı sıra gerçek (actual) row count’ları, gerçek execution count’ları, gerçek elapsed time’ları içerir. Slow query analizinde her zaman actual plan’ı isteyin; estimated plan, optimizer’ın ne düşündüğünü gösterir, actual plan ise gerçeğin ne olduğunu.

Plan üretimi pahalıdır — milisaniyeler değil, bazen yüzlerce milisaniye. Bu yüzden engine plan’ı plan cache’inde tutar ve aynı veya benzer sorgu tekrar geldiğinde yeniden derlemeden onu kullanır. Plan reuse, parameterized query’lerin neden bu kadar önemli olduğunun altındaki sebep. Ad-hoc literal SQL’ler (‘SELECT * FROM x WHERE id = 1’ gibi) cache miss üretip her seferinde recompile götürür; sp_executesql veya stored procedure üzerinden parametre geçirmek plan reuse sağlar.

3. Plan Nasıl Alınır? Üç Pratik Yol

İlk seçenek SSMS. Sorguyu yapıştırın, Ctrl+M ile Include Actual Execution Plan’ı açın, F5 ile çalıştırın. Sonuç sekmesinin yanında plan sekmesi açılır, üzerinde her operatöre tıklayıp properties pane’inden tüm metric’lere bakabilirsiniz.

İkinci seçenek SET STATISTICS XML. SSMS yoksa veya sorguyu otomasyondan koşacaksanız kullanışlı. Plan XML olarak çıkar, .sqlplan uzantısıyla kaydedip SSMS’te açabilirsiniz:

SET STATISTICS XML ON;
SELECT TOP (10) *
FROM Sales.SalesOrderHeader
WHERE OrderDate >= '2014-01-01';
SET STATISTICS XML OFF;

Üçüncü seçenek Query Store. Production’da çalışan canlı sorguların plan history’sini saklamak için harika. Bir sorgu farklı zamanlarda farklı plan üretmişse Query Store hepsini tutar; aralarında karşılaştırma yapabilir, regression yaşamış bir plan’ı eskiye force edebilirsiniz. Production izleme bölümünde Query Store sorgularına döneceğiz.

-- Query Store aç (database level)
ALTER DATABASE CURRENT
SET QUERY_STORE = ON
(OPERATION_MODE = READ_WRITE,
CLEANUP_POLICY = (STALE_QUERY_THRESHOLD_DAYS = 30),
DATA_FLUSH_INTERVAL_SECONDS = 900,
INTERVAL_LENGTH_MINUTES = 60);

Plan’ı paylaşırken iki şeyi unutmayın: (1) Plan’da database name, table name, literal değerler tutuluyor; hassas veri varsa anonymize etmeniz gerekebilir. (2) Plan’lar tek başına anlamlı değil — sorgu metni, istatistik bilgileri ve table boyutuyla beraber okumak gerekir. Slack üzerinde ‘plan’ı paylaş’ deyip XML atan biri varsa şirkette, diğer detayları da rica edin.

4. Operatorler Plan’da En Sık Karşılaşacaklarınız

Plan ağacında onlarca operatör çıkabilir ama gerçekte günlük tuning’in %90’ı bir düzine operatör etrafında döner. Aşağıdaki diyagram bunları kategorize ediyor.

Plan operatör ailesi. Erişim, join, agregasyon, modifikasyon ve yardımcı operatörler.

Erişim tarafında dört temel: Index Seek (B-tree üzerinde belirli bir noktaya gitmek, WHERE’in selektif olduğu durumda en hızlı), Index Scan (tüm index’i okumak, WHERE selektif değilse veya covering index varsa), Table Scan (heap üzerinde tam okuma, clustered index olmayan tablolarda), Key Lookup (nonclustered index’te bulunan satır için clustered’a gidip extra kolonları çekmek, index covering değilse). Slow query’lerde Key Lookup’ı görüyorsanız muhtemelen index’inize INCLUDE kolonları eklemek gerekiyor.

Join tarafında üç fiziksel algoritma: Nested Loops (outer set’in her satırı için inner set’te lookup; outer set küçükse ve inner indexed ise mükemmel), Merge Join (iki sıralı set’i ‘zipper’ gibi birleştirmek; iki taraf da sorted ise minimum maliyet), Hash Join (build phase’de hash table inşa et, probe phase’de eşleştir; büyük unsorted set’ler için en güçlü). Optimizer cardinality tahminlerine göre bu üçü arasında seçim yapar.

Agregasyon tarafında Stream Aggregate (sıralı veri üzerinde GROUP BY) ve Hash Aggregate (unsorted veri üzerinde, hash table tutarak) en sık görülenler. Sort operatörü plan’da görüyorsanız ya bir ORDER BY var ya engine join veya distinct için sort yapıyor, sort pahalıdır, mümkün mertebe ortadan kaldırmaya çalışın (uygun index ile).

Filter ve Compute Scalar yardımcı operatörler. Filter, WHERE’in elimine edemediği ek koşulları satır satır kontrol eder. Compute Scalar, ekspresyon hesaplar (örneğin JSON_VALUE çağrıları). İkisi de planda büyük arrow’larla görünüyorsa CPU yiyor demektir.

5. Join Stratejileri Optimizer Hangi Algoritmayı Neden Seçer?

Bu konuyu derinleştirmek istiyorum çünkü join seçimi plan kalitesinin omurgası. Aşağıdaki karar diyagramı optimizer’ın düşünce akışını özetliyor.

Join stratejisi seçimi: outer/inner cardinality, sort durumu, ve memory grant’a göre optimizer’ın kararı.

Nested Loops kuralı basit: outer küçük + inner indexed = nested loops mükemmeldir. Outer’ın her satırı için inner’da bir seek yapar; eğer inner’da binlerce row bulması gerekiyorsa pahalıya patlar. Optimizer outer set’in 10-100 satır civarı olduğunu tahmin ediyorsa nested loops genelde tercih edilir.

Merge Join: iki taraf da join key’i üzerinde sıralıysa (clustered index aynı kolon üzerine kuruluysa veya bir tarafta SORT operatörü zaten varsa) zipper gibi linear geçer. CPU dostudur. Ama sort yoksa ve engine merge için sort eklerse, hash join muhtemelen daha ucuz olur.

Hash Join: büyük, unsorted, eşit boyutlu setler için bir numara. Build phase’de küçük tarafı hash table’a koyar (memory grant burada kritik), probe phase’de büyük tarafı tarar. Memory grant yetersizse spill’e (tempdb) düşer ve performans çöker, hash spill plan’da uyarı işareti olarak görünür. Bu, çok-row’lu data warehouse sorgularında en sık karşılaştığım sıkıntıdır.

SQL Server 2017’den beri Adaptive Join var. Optimizer, build tarafının cardinality tahminine güvenmiyorsa Adaptive Join operatörü koyar; runtime’da bir threshold’a bakar, eşiğin altındaysa Nested Loops’a, üstündeyse Hash Join’e geçer. Workshop 4’te bunu Live Query Statistics ile gözleyeceğiz.

6. Plan Cache ve Parameter Sniffing Klasik Tuzak

Aynı stored procedure farklı zamanlarda farklı performans veriyorsa nedeni muhtemelen parameter sniffing. Engine, ilk derleme sırasında gelen parametreyi ‘sniff’ eder, plan’ı o değer için optimize eder, cache’e kor. Sonraki çağrılarda parametre farklı bir profile sahipse, örneğin ilk seferinde 5 satır dönen, ikinci seferinde 5 milyon satır dönen, cache’deki plan o senaryoda berbat performans verir.

Klasik çözümler: OPTION (RECOMPILE) ile her çalışmada yeni plan ürettirmek (CPU yiyor), OPTIMIZE FOR (@p = …) ile manuel hint vermek (yeni veri dağılımına uymayabiliyor), parameter masking (yerel değişkene atayıp onu kullanmak, engine sniff’i kaybediyor, average density tahmini kullanıyor). Hiçbiri ideal değil; her birinin trade-off’u var.

SQL Server 2022’de Parameter Sensitive Plan Optimization (PSPO) geldi: engine tek statement için birden çok plan tutabiliyor (Multiplan infrastructure), incoming parameter değerine göre uygun olanı seçiyor. Compatibility level 160 ve sonrasında default açık. SQL Server 2025’te bu altyapı OPPO ile genişledi: NULL-aware durumları da Multiplan ile çözülüyor.

7. SQL Server 2025 IQP Yenilikleri

Intelligent Query Processing aile timeline’ı: SQL 2017’den 2025’e kadar gelen optimizasyonlar.

IQP ailesi her major sürümde büyüyor. SQL Server 2025’te öne çıkan üç yenilik: Optional Parameter Plan Optimization (OPPO), CE Feedback for Expressions, ve Optimized sp_executesql. Üçü birlikte çoğu workload’da plan kalitesini ölçülebilir şekilde yukarı çekiyor.

OPPO, parameter null check pattern’lerinin yarattığı plan dejenerasyonunu çözüyor. Klasik bir desen: ‘WHERE bedrooms = @p OR @p IS NULL’. Engine eskiden bu sorgu için tek bir plan üretiyordu, @p NULL geldiğinde scan’e ihtiyaç duyduğu için muhafazakar bir scan plan’ı seçip @p dolu geldiğinde de aynı scan’i kullanıyordu. Sonuç: dolu @p durumunda gereksiz I/O. OPPO, Multiplan infrastructure’ı kullanarak iki ayrı query variant üretiyor: biri @p NULL için scan, diğeri @p NOT NULL için seek. Runtime’da dispatcher hangisini kullanacağına karar veriyor.

Önemli prerequisite: OPPO için database compatibility level 170 olmalı. Default OPTIONAL_PARAMETER_OPTIMIZATION = ON, ama compatibility 160 veya altındaki database’lerde devre dışı kalır.

-- OPPO için compat level + database scoped configuration
ALTER DATABASE [<your_db>]
SET COMPATIBILITY_LEVEL = 170;
ALTER DATABASE SCOPED CONFIGURATION
SET OPTIONAL_PARAMETER_OPTIMIZATION = ON;
-- Tek sorgu için kapatmak için:
-- OPTION (USE HINT('DISABLE_OPTIONAL_PARAMETER_OPTIMIZATION'))

CE Feedback for Expressions, 2022’de gelen Cardinality Estimation Feedback’in expression seviyesine genişlemiş hali. Engine bir query’nin önceki çalışmalarındaki cardinality skew’larını öğrenip aynı expression pattern’inde hangi CE model varsayımının daha iyi sonuç verdiğini hatırlıyor; bir sonraki çalışmada otomatik olarak uyguluyor. Compatibility level 160 yetiyor.

Optimized sp_executesql, compilation storm sorunlarına çare. Compilation storm, binlerce client aynı anda yeni bir parametreli sorgu gönderdiğinde engine’in spinlock’a takıldığı durumdur, özellikle Web tier’larda yaygın. Bu feature ile sp_executesql çağrıları artık stored procedure gibi davranıyor compilation perspektifinden, paralel derlemeyi serileştiriyor.

8. Workshop 1 Index Seek vs Scan vs Key Lookup

Üç farklı erişim deseni göreceğiz. Test için AdventureWorks2022 veya basit bir synthetic tablo kullanabilirsiniz.

-- Test tablosu
DROP TABLE IF EXISTS dbo.Customers;
CREATE TABLE dbo.Customers
(
CustomerID INT IDENTITY PRIMARY KEY,
Name NVARCHAR(100),
City NVARCHAR(50),
Email NVARCHAR(200),
SignupDate DATETIME2(0)
);
-- 100K satır random veri
INSERT INTO dbo.Customers (Name, City, Email, SignupDate)
SELECT TOP (100000)
CONCAT('User_', ROW_NUMBER() OVER (ORDER BY (SELECT NULL))),
CHOOSE(ABS(CHECKSUM(NEWID())) % 5 + 1,
N'Istanbul', N'Ankara', N'Izmir', N'Bursa', N'Antalya'),
CONCAT('user', ROW_NUMBER() OVER (ORDER BY (SELECT NULL)), '@example.com'),
DATEADD(DAY, -ABS(CHECKSUM(NEWID())) % 1000, GETDATE())
FROM sys.all_columns a CROSS JOIN sys.all_columns b;
-- City üzerine nonclustered index
CREATE NONCLUSTERED INDEX ix_customers_city ON dbo.Customers (City);
UPDATE STATISTICS dbo.Customers WITH FULLSCAN;

Şimdi üç farklı sorguyu çalıştırıp plan’ları karşılaştıralım. Ctrl+M ile actual plan’ı açmayı unutmayın:

-- 1. INDEX SEEK + KEY LOOKUP — selectif filter, kolon listesi index'te yok
SELECT CustomerID, Name, Email
FROM dbo.Customers
WHERE City = N'Antalya';
-- 2. INDEX SCAN — non-selectif, optimizer scan tercih eder
SELECT COUNT(*)
FROM dbo.Customers
WHERE Name LIKE N'User_%';
-- 3. CLUSTERED INDEX SEEK — primary key direct lookup
SELECT *
FROM dbo.Customers
WHERE CustomerID = 50000;

Birinci sorguda plan’da iki operatör görmelisiniz: Index Seek (City üzerinde, ix_customers_city kullanılır) + Key Lookup (Name ve Email kolonlarını clustered index’ten çekmek için). Properties pane’inde Key Lookup’ın Estimated Number of Executions’ına bakın, ‘Antalya’ için kaç satır varsa o kadar çağrılıyor. Selectivity yüksekse (az satır) sorun yok; binlerce satır dönüyorsa Key Lookup scan’den daha pahalıya çıkabilir.

Çözüm index’e INCLUDE ekleyerek covering index yapmak:

DROP INDEX ix_customers_city ON dbo.Customers;
CREATE NONCLUSTERED INDEX ix_customers_city
ON dbo.Customers (City)
INCLUDE (Name, Email);
-- Aynı sorguyu tekrar çalıştır
SELECT CustomerID, Name, Email
FROM dbo.Customers
WHERE City = N'Antalya';

Şimdi plan’da Key Lookup yok, sadece Index Seek var. Logical reads sayısı önceki vs şimdi: ölçtüğümüzde tipik olarak 5-10x düşüş görüyorsunuz. INCLUDE kolonları, sorgunun ihtiyaç duyduğu kolonların index’e eklenmesi anlamına geliyor; B-tree key boyutu artmıyor (sadece leaf level), key lookup ortadan kalkıyor.

9. Workshop 2 Nested Loops vs Merge vs Hash

İkinci workshop’ta join algoritmalarını karşılaştıracağız. Aynı join’i farklı cardinality profilleriyle çalıştırarak optimizer’ın seçimini değiştireceğiz.

-- İki tablo: Orders (1M satır) ve Customers (100K satır)
DROP TABLE IF EXISTS dbo.Orders;
CREATE TABLE dbo.Orders
(
OrderID INT IDENTITY PRIMARY KEY,
CustomerID INT NOT NULL,
Amount DECIMAL(10,2),
OrderDate DATETIME2(0)
);
INSERT INTO dbo.Orders (CustomerID, Amount, OrderDate)
SELECT TOP (1000000)
ABS(CHECKSUM(NEWID())) % 100000 + 1,
ABS(CHECKSUM(NEWID())) % 1000,
DATEADD(DAY, -ABS(CHECKSUM(NEWID())) % 365, GETDATE())
FROM sys.all_columns a CROSS JOIN sys.all_columns b;
CREATE NONCLUSTERED INDEX ix_orders_customer ON dbo.Orders (CustomerID);
-- Senaryo A: Tek müşterinin siparişleri — outer set küçük → Nested Loops
SELECT c.Name, o.OrderID, o.Amount
FROM dbo.Customers c
INNER JOIN dbo.Orders o
ON c.CustomerID = o.CustomerID
WHERE c.CustomerID = 50000;
-- Senaryo B: Tüm Antalya müşterilerinin siparişleri — orta-büyük outer → Hash
SELECT c.Name, COUNT(o.OrderID), SUM(o.Amount)
FROM dbo.Customers c
INNER JOIN dbo.Orders o
ON c.CustomerID = o.CustomerID
WHERE c.City = N'Antalya'
GROUP BY c.Name;
-- Senaryo C: Sıralı veri üzerinde range — Merge mümkün olabilir
SELECT c.Name, o.OrderID
FROM dbo.Customers c
INNER JOIN dbo.Orders o
ON c.CustomerID = o.CustomerID
WHERE c.CustomerID BETWEEN 1 AND 100
ORDER BY c.CustomerID;

Senaryo A’da plan Nested Loops gösterir: outer (Customers) tek satır, inner (Orders) ix_orders_customer üzerinde seek. Logical reads minimum. Senaryo B’de plan Hash Match’e döner: 20K civarı Antalya müşterisi outer, her biri için seek pahalıya patlardı; engine hash table inşa edip probe etmeyi tercih eder. Senaryo C’de PK üzerinde sıralı küçük range; engine bazen Merge Join, bazen Nested Loops seçer cardinality tahminine göre.

Eğer optimizer’ın seçimini sorgulamak istiyorsanız hint ile zorlayabilirsiniz:

-- Optimizer Hash seçti, ben Loops görmek istiyorum diyelim
SELECT c.Name, COUNT(o.OrderID), SUM(o.Amount)
FROM dbo.Customers c
INNER LOOP JOIN dbo.Orders o
ON c.CustomerID = o.CustomerID
WHERE c.City = N'Antalya'
GROUP BY c.Name
OPTION (LOOP JOIN);
-- Üç hint seçeneği:
-- INNER LOOP JOIN, INNER MERGE JOIN, INNER HASH JOIN

Hint’i koymadan önce iki kez düşünün. Optimizer’ın istatistik bilgisi sizden fazladır; manuel zorlama, gelecekte data dağılımı değiştiğinde performans regresyonuna yol açar. Production’da hint’leri Query Store hint’i olarak vermek daha sağlıklı — kodu değiştirmeden gerektiğinde geri alabilirsiniz.

10. Workshop 3 Optional Parameter Optimization ile NULL-Aware Plan Üretmek

Şimdi en heyecanlı yeniliklerden birine, OPPO’ya geliyoruz. Klasik ‘optional parameter’ deseni:

-- OPPO açık olduğundan emin ol
SELECT name,
value
FROM sys.database_scoped_configurations
WHERE name IN (N'OPTIONAL_PARAMETER_OPTIMIZATION');
-- Compat level 170 mi?
SELECT compatibility_level
FROM sys.databases
WHERE name = DB_NAME();
-- Klasik optional parameter sorgusu
CREATE OR ALTER PROCEDURE dbo.GetOrders
@CustomerID INT = NULL
AS
BEGIN
SET NOCOUNT ON;
SELECT OrderID, CustomerID, Amount, OrderDate
FROM dbo.Orders
WHERE (CustomerID = @CustomerID OR @CustomerID IS NULL);
END
GO
-- İlk çağrı: NULL parametre — optimizer scan plan üretir
EXEC dbo.GetOrders @CustomerID = NULL;
-- İkinci çağrı: dolu parametre — OPPO ikinci variant'ı seçer
EXEC dbo.GetOrders @CustomerID = 50000;

OPPO akış diyagramı: NULL kontrolü, dispatcher plan, query variant seçimi, ve plan cache reuse.

OPPO açıkken iki çağrı için iki ayrı query variant cache’lenir. Her birinin Showplan XML’inde ‘PLAN PER VALUE’ hint’i ve ‘optional_predicate’ attribute’ü olur. Cache’i kontrol edebilirsiniz:

SELECT
qs.execution_count,
qs.total_logical_reads,
qs.total_elapsed_time / qs.execution_count AS avg_elapsed,
SUBSTRING(qt.text, qs.statement_start_offset / 2 + 1,
(CASE WHEN qs.statement_end_offset = -1 THEN LEN(qt.text)
ELSE qs.statement_end_offset / 2 + 1 END
- qs.statement_start_offset / 2)) AS query_text,
qp.query_plan
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) qt
CROSS APPLY sys.dm_exec_query_plan(qs.plan_handle) qp
WHERE qt.text LIKE '%GetOrders%';

Aynı procedure için iki farklı plan satırı görmelisiniz; biri scan-bazlı (NULL için), diğeri seek-bazlı (NOT NULL için). OPPO öncesinde tek bir konservatif scan plan’ı vardı; şimdi engine, parametre profile’ına göre uygun olanı seçiyor. Test ortamımda dolu @CustomerID için elapsed time 110 ms’den 6 ms’e düştü.

OPPO eligibility için bilmemiz gerekenler:

  • Local variable yerine parameter olmalı (engine sniff edebilmesi için).
  • OPTION (RECOMPILE) kullanılmamalı, bu hint zaten her seferinde yeni plan üretir.
  • SET ANSI_NULLS OFF olan session’larda OPPO çalışmıyor.
  • Auto-parameterized statement’larda (literal SQL) OPPO devreye girmiyor, sp_executesql veya stored procedure kullan.
  • Compatibility level 170 zorunlu.

Bunlardan biri tutmazsa engine OPPO’yu skip eder ve klasik tek-plan davranışına döner. Sebebini optional_parameter_optimization_skipped_reason extended event’inden okuyabilirsiniz.

11. Workshop 4 Adaptive Join’i Live Query Statistics ile Gözle

Adaptive Join, optimizer’ın cardinality tahmininden emin olmadığı durumda runtime’da seçim yapmasını sağlayan bir operatör. SQL 2017’den beri var ama bilen az. SSMS’te Live Query Statistics açıkken canlı görmek mümkün.

-- Live Query Statistics'i açın (SSMS toolbar'dan)
-- Sonra şu sorguyu çalıştırın
SELECT
o.OrderID,
c.Name,
c.City
FROM dbo.Orders o
INNER JOIN dbo.Customers c
ON o.CustomerID = c.CustomerID
WHERE o.Amount BETWEEN 100 AND 200
OPTION (RECOMPILE);

Bu sorguyu farklı Amount aralıklarıyla çalıştırırsak optimizer önce bir threshold belirler (örneğin 78 row’a kadar Loops, ondan sonra Hash). Adaptive Join operatörü build phase’ini scan eder; eğer build row count threshold’u geçerse Hash, geçmezse Loops’a düşer. Live Query Statistics’te seçilen yolun yeşil aktif çizgi ile, seçilmeyenin gri kesik çizgi ile gösterildiğini görürsünüz.

Önemli: Adaptive Join sadece batch mode execution’da çalışır. Yani columnstore index’i olan veya 2019+ batch mode on rowstore aktif olan tablolarda. Klasik rowstore-only senaryolarda Adaptive Join görmezsiniz; standart Loops/Hash kararını compile time’da verir.

12. Plan Hint’leri ve Query Store ile Plan Force

Slow query’nin plan’ı kötü ve sebep parameter sniffing değil, optimizer’ın bir şeyi yanlış tahmin etmesiyse plan’ı zorlamak gerekebilir. İki yol var: kod içinde hint, ya da Query Store üzerinden plan force.

-- Kod içinde hint
SELECT *
FROM dbo.Orders
WHERE CustomerID = 50000
OPTION (USE HINT('FORCE_LEGACY_CARDINALITY_ESTIMATION'),
OPTIMIZE FOR (@CustomerID = 50000));
-- Query Store üzerinden plan force (kod değiştirmeden)
-- Önce sorgunun plan'larını listele
SELECT
qsp.plan_id,
qsp.query_id,
qsp.is_forced_plan,
qrs.avg_duration / 1000.0 AS avg_ms
FROM sys.query_store_plan qsp
JOIN sys.query_store_runtime_stats qrs
ON qsp.plan_id = qrs.plan_id
ORDER BY qrs.avg_duration DESC;
-- İstediğin plan'ı force et
EXEC sp_query_store_force_plan @query_id = 1234, @plan_id = 5678;
-- Geri al
-- EXEC sp_query_store_unforce_plan @query_id = 1234, @plan_id = 5678;

Plan force’un avantajı uygulama kodunu değiştirmeden müdahale edebilmek. Production’da hot bir sorgu regresyon yaşadıysa Query Store ile eski plan’ı force edip nefes alma süresini uzatabilirsiniz; bu sürede gerçek sebebi araştırırsınız (istatistik update, schema change vs.).

Yeni gelen ABORT_QUERY_EXECUTION hint’i de cephanede yer almalı. Production’da biri yanlışlıkla devasa bir sorgu çalıştırıyorsa bu hint ile gelecekteki execution’ları engelleyebilirsiniz, error mesajıyla red. Query Store üzerinden de set edilebiliyor.

13. Production İzleme Query Store Pratikleri

Query Store’u sadece açmak yetmez; doğru izlemek gerekir. Aşağıdaki sorgular günlük dashboard’umda olan, slow query avı için en sık baktığım üçlü.

-- Top 20 en yavaş sorgu (avg duration)
SELECT TOP (20)
qsq.query_id,
SUBSTRING(qsqt.query_sql_text, 1, 200) AS query_text,
qrs.avg_duration / 1000.0 AS avg_ms,
qrs.count_executions,
qrs.avg_logical_io_reads
FROM sys.query_store_query qsq
JOIN sys.query_store_query_text qsqt
ON qsq.query_text_id = qsqt.query_text_id
JOIN sys.query_store_plan qsp
ON qsq.query_id = qsp.query_id
JOIN sys.query_store_runtime_stats qrs
ON qsp.plan_id = qrs.plan_id
ORDER BY qrs.avg_duration DESC;
-- Plan değişen sorgular (regression candidate)
SELECT
qsq.query_id,
COUNT(DISTINCT qsp.plan_id) AS distinct_plans,
MIN(qsp.first_execution_time) AS first_seen,
MAX(qsp.last_execution_time) AS last_seen
FROM sys.query_store_query qsq
JOIN sys.query_store_plan qsp
ON qsq.query_id = qsp.query_id
GROUP BY qsq.query_id
HAVING COUNT(DISTINCT qsp.plan_id) > 1
ORDER BY distinct_plans DESC;

İkinci sorgu özellikle değerli: bir sorgunun farklı plan’lara geçmesi genelde regression habercisi. Plan history’sini görür, hangi plan eski hangisi yeni buluş, performans karşılaştırması yapıp karar verirsiniz.

Yeni gelen Query Store for readable secondaries default açık olarak geliyor. Always On AG’de secondary üzerinde de plan history toplanıyor primary’ye stream ediyor. Daha önce secondary üzerinde plan analizi yapmak için ek tooling gerekiyordu, artık built-in.

14. Kısıtlamalar ve Uyarılar

  • Estimated plan ile actual plan’ı karıştırmayın, slow query analizinde her zaman actual’ı isteyin.
  • Plan paylaşımında hassas veriyi anonimleştirin (literal değerler plan içinde duruyor).
  • Adaptive Join sadece batch mode’da çalışır; rowstore-only senaryolar için klasik join kararı uygulanır.
  • OPPO için compatibility level 170 zorunlu, OPTION (RECOMPILE) ile kullanılan sorgularda devreye girmez.
  • Query Store hint’leri kalıcı; deployment’tan sonra hâlâ aktif kalır, Git’e dahil edilmediği için unutulmaya müsait. Prod hint envanterini ayrı listede tut.
  • Plan force etmek geçici çözüm, sebebini bulup düzeltmediğiniz sürece teknik borç birikir.
  • Live Query Statistics permission gerektirir (SHOWPLAN); read-only DBA’lara ek grant yapmadan açamaz.

15. Bitiriyoruz

Execution plan okumak temel bir DBA yetkinliği ama her sürümde yeni bir katman ekleniyor. SQL Server 2025 ile gelen OPPO, CE Feedback for Expressions ve Optimized sp_executesql, plan kalitesini ölçülebilir şekilde yukarı çekiyor. Workshop’lardaki örnekleri kendi laboratuvarınızda çalıştırırsanız hem klasik operatörlerin (Index Seek, Key Lookup, Hash Match, Adaptive Join) davranışını hem yeni IQP feature’larının runtime etkisini gözlemlersiniz.

Plan okurken üç soruyu kendinize sorun:

1 – Optimizer hangi cardinality tahminlerine güveniyor, gerçek sayılar ne kadar uzakta?

2 – En pahalı operatör hangisi, neden o seçildi?

3 – Bu plan parameter sniffing’in kurbanı mı olmuş, yoksa istatistik bayatlığı mı söz konusu? Bu üç soru çoğu zaman çözüme götürüyor.

Workshop 1-4’ü kendi sisteminizde çalıştırırken karşılaştığınız ilginç plan’ları yorumlara yazın. Özellikle Adaptive Join’in ne zaman threshold’u geçtiği, OPPO’nun skip ettiği sorgularda sebep ne, bu deneyimleri toplarsam ikinci bölümde vaka analizi olarak işleyebilirim.


Yazar hakkında

Yavuz Filizlibay — Database Solution Architect

SQL Server ekosisteminde uzun yıllardır performans, güvenlik, yüksek erişilebilirlik üzerine çalışıyorum. SQL Server Administration, Querying, Performans ve Security eğitimleri ile danışmanlık hizmeti veriyorum. Yeni makalelerden haberdar olmak için LinkedIn’de bağlanabilir, eğitim veya danışmanlık için iletişime geçebilirsiniz.


Yavuz Filizlibay sitesinden daha fazla şey keşfedin

Subscribe to get the latest posts sent to your email.

,

Bir Cevap Yazın

Bu site istenmeyenleri azaltmak için Akismet kullanır. Yorum verilerinizin nasıl işlendiğini öğrenin.

Yavuz Filizlibay sitesinden daha fazla şey keşfedin

Okumaya devam etmek ve tüm arşive erişim kazanmak için hemen abone olun.

Okumaya Devam Edin

Yavuz Filizlibay sitesinden daha fazla şey keşfedin

Okumaya devam etmek ve tüm arşive erişim kazanmak için hemen abone olun.

Okumaya Devam Edin