Mert Özen Her Satırda Daha İleri
Veritabanı SQL Server Derinlemesine — #08

Query Store ve Intelligent Query Processing: Regresyonu Bulmak, Plan Zorlamak ve Kod Değiştirmeden Hint Vermek

Mert Özen 8 Kas 2026 10 dk 4 görüntülenme
Query Store ve Intelligent Query Processing: Regresyonu Bulmak, Plan Zorlamak ve Kod Değiştirmeden Hint Vermek

Query Store sorgu metinlerini, planları ve çalışma istatistiklerini veritabanında kalıcı tutar; böylece plan cache'in unuttuğu "dün nasıldı" sorusunu cevaplar, kötüleşen planı zorlamaya ve kodu değiştirmeden hint eklemeye izin verir. Regresyon sorgusu, plan zorlamanın bedeli, FORCE_LAST_GOOD_PLAN, EF Core sorgusuna Query Store hint'iyle RECOMPILE, 2025'in ABORT_QUERY_EXECUTION'ı ve IQP ailesi.

Sürüm notu: Bu yazıdaki T-SQL (Transact-SQL, Microsoft'un SQL (Structured Query Language) lehçesi) örnekleri SQL Server 2025 CU10 (17.0.5035.5, Enterprise Developer Edition, compatibility level 170) üzerinde, C# örnekleri .NET 10, Entity Framework Core (EF Core) 10.0.14 ve Dapper 2.1.89 ile denenmiştir.

Önce Tahmin Et

Pazartesi sabahı müşteri panelinin "siparişlerim" endpoint'i yavaşladı. Hafta sonu deploy yok, index değişmedi, veri aynı. Önceki yazıdaki dbo.GetCustomerOrders procedure'ü (bu deneyde de PSP (Parameter Sensitive Plan) optimization kapalı) 42 numaralı müşteri için on kez çalışmış; sonra hafta sonu bir bakım job'ı procedure'ü recompile'a işaretlemiş, ilk gelen istek 1 numaralı müşteriden (200.000 sipariş) gelmiş ve ardından küçük müşteriler yine on kez çağırmış. Plan cache'e bakarsan yalnızca şu anki planı görürsün. Peki "dün nasıldı" sorusunun cevabı nerede? Tahmin et, sonra Query Store'un cevabına bak:

query_id plan_id is_forced_plan executions avg_logical_reads max_logical_reads
-------- ------- -------------- ---------- ----------------- -----------------
1        1       0              10         1513.2            1536
1        2       0              11         7707.0            7707

Aynı sorgu (query_id = 1), iki plan. Plan 1 ortalama 1.513 logical read ile on kez çalışmış; plan 2 recompile'dan sonra gelmiş ve küçük müşteriler dahil her çağrıda 7.707 okumuş. Plan cache restart'ta, bellek baskısında ve recompile'da silinir; Query Store ise sorgu metinlerini, planları ve çalışma istatistiklerini veritabanının içinde kalıcı olarak tutar. Bu yazıda Query Store'un yapısını, regresyonu bulmayı, plan zorlamayı, automatic tuning'i, kod değiştirmeden hint vermeyi ve bütün bunların üzerine kurulduğu IQP (Intelligent Query Processing) ailesini konuşuyoruz.

Query Store'un Yapısı ve Varsayılanları

SQL Server 2022'den beri yeni veritabanlarında Query Store varsayılan olarak açık. Bizim sqls_08 veritabanımızın ayarları:

SELECT actual_state_desc, query_capture_mode_desc, size_based_cleanup_mode_desc, max_storage_size_mb,
       interval_length_minutes, flush_interval_seconds, stale_query_threshold_days,
       max_plans_per_query, wait_stats_capture_mode_desc
FROM sys.database_query_store_options;
actual_state_desc query_capture_mode_desc size_based_cleanup_mode_desc max_storage_size_mb interval_length_minutes
----------------- ----------------------- ---------------------------- ------------------- -----------------------
READ_WRITE        AUTO                    AUTO                         1000                60

flush_interval_seconds stale_query_threshold_days max_plans_per_query wait_stats_capture_mode_desc
---------------------- -------------------------- ------------------- ----------------------------
900                    30                         200                 ON

Query Store üç şey toplar. Sorgu metni ve sorgu (sys.query_store_query_text, sys.query_store_query), her sorgu için derlenen planlar (sys.query_store_plan) ve her plan için zaman aralıklarına (burada 60 dakika) bölünmüş çalışma istatistikleri (sys.query_store_runtime_stats ve sys.query_store_wait_stats): çalışma sayısı, süre, CPU (Central Processing Unit), logical reads, satır sayısı, memory grant ve wait kategorileri. Veriler bellekte toplanır ve 900 saniyede bir diske yazılır; deneylerde beklememek için sys.sp_query_store_flush_db çağırdım. AUTO capture mode önemsiz, çok ucuz ya da tek seferlik sorguları atlar; böylece ad hoc sorgu yağdıran bir uygulama Query Store'u doldurmaz. 1.000 MB'a ulaşınca en eski veriler temizlenir; dolup temizleyemezse Query Store kendini READ_ONLY'ye çeker, bunu izlemek gerekir.

Regresyonu Bulmak

SSMS'in (SQL Server Management Studio) "Regressed Queries" ve "Top Resource Consuming Queries" raporları bu view'ların üzerine kurulu. Raporu açamadığın bir ortamda aynı bilgiyi kendin sorgulayabilirsin; açılıştaki tabloyu üreten view:

CREATE OR ALTER VIEW dbo.QsPlans AS
SELECT q.query_id, p.plan_id, p.is_forced_plan,
       SUM(rs.count_executions) AS executions,
       CAST(SUM(rs.avg_logical_io_reads * rs.count_executions) / SUM(rs.count_executions) AS decimal(12,1)) AS avg_logical_reads,
       MAX(rs.max_logical_io_reads) AS max_logical_reads
FROM sys.query_store_query AS q
JOIN sys.query_store_query_text AS qt ON qt.query_text_id = q.query_text_id
JOIN sys.query_store_plan AS p ON p.query_id = q.query_id
JOIN sys.query_store_runtime_stats AS rs ON rs.plan_id = p.plan_id
WHERE q.object_id = OBJECT_ID('dbo.GetCustomerOrders')
GROUP BY q.query_id, p.plan_id, p.is_forced_plan;

Regresyon dediğimiz, aynı query_id için yeni gelen planın eskisinden belirgin şekilde pahalı olması. Burada plan 2 ortalamada beş kat daha pahalı. Dikkat: Query Store'un saydığı logical reads, STATISTICS IO'nun gösterdiğinden biraz farklı olabiliyor (plan 1 için 1.513'e karşılık 1.460); karşılaştırmayı aynı kaynak içinde yap. Stored procedure'lerde object_id ile, ORM'den (Object-Relational Mapping) gelen sorgularda ise sorgu metniyle ya da query_hash ile ararsın; ikincisinin örneğini aşağıda EF Core'la göreceğiz.

Bir sorgunun Query Store'daki kimliğini de bilmek gerekiyor. query_id, sorgu metni ile o sorgunun derlendiği bağlamın (SET seçenekleri, veritabanı, parametre tanımları) birleşimidir. Aynı metni ARITHABORT ayarı farklı iki istemciden çalıştırırsan iki ayrı query_id görürsün; SSMS ile uygulamanın aynı sorgu için farklı plan almasının klasik sebebi budur. Metinde yalnızca literal'leri farklı olan sorgular ise ortak bir query_hash taşır; ad hoc sorgu yağdıran bir uygulamada toplam maliyeti query_hash'e göre gruplamak, binlerce tekil sorgunun arkasındaki tek kalıbı ortaya çıkarır.

Plan Zorlamak

En hızlı müdahale, iyi bilinen planı zorlamak:

EXEC sys.sp_query_store_force_plan @query_id = 1, @plan_id = 1;

SET STATISTICS IO ON;
EXEC dbo.GetCustomerOrders 42;
EXEC dbo.GetCustomerOrders 1;
Table 'Orders'. Scan count 1, logical reads 1460
Table 'Orders'. Scan count 1, logical reads 612850

plan_id is_forced_plan force_failure_count last_force_failure_reason_desc plan_forcing_type_desc
------- -------------- ------------------- ------------------------------ ----------------------
1       1              0                   NONE                           MANUAL
2       0              0                   NONE                           NONE

Küçük müşteriler eski hızına döndü. Ama plan zorlamak bir karar, ücretsiz değil: müşteri 1 artık her çağrıda 612.850 logical read yapıyor, çünkü zorlanan plan onun için kötü. Parameter sniffing kaynaklı regresyonlarda plan zorlamak bir tarafı seçmek demek; iş yükünün çoğunluğu küçük müşterilerse bu doğru bir seçim olabilir, ama bilerek yap. Zorlanan plan geçersiz hâle gelirse (örneğin kullandığı index silinirse) zorlama başarısız olur, sorgu yine çalışır ve force_failure_count ile last_force_failure_reason_desc artar; bunları izlemelisin.

SQL Server 2022 ile gelen optimized plan forcing, zorlanan planın derlemesini hızlandırmak için planın derleme adımlarını bir "compile replay script" olarak saklar; sys.query_store_plan.has_compile_replay_script bunu gösterir. Bizim basit sorgumuzda derleme ucuz olduğu için script oluşmadı (değer 0).

Automatic Tuning: FORCE_LAST_GOOD_PLAN

Query Store, bir sorgunun yeni planı belirgin şekilde kötüleştiğinde son iyi planı otomatik olarak zorlayabilir:

SELECT name, desired_state_desc, actual_state_desc, reason_desc FROM sys.database_automatic_tuning_options;
name                 desired_state_desc actual_state_desc reason_desc
-------------------- ------------------ ----------------- ---------------
FORCE_LAST_GOOD_PLAN DEFAULT            OFF               AUTO_CONFIGURED

SQL Server'da varsayılan kapalı; ALTER DATABASE CURRENT SET AUTOMATIC_TUNING (FORCE_LAST_GOOD_PLAN = ON); ile açılır. Açıkken motor regresyonu CPU süresi üzerinden tespit eder, son iyi planı zorlar ve zorlamanın gerçekten iyileştirip iyileştirmediğini doğrular; iyileştirmezse zorlamayı geri alır. Önerileri sys.dm_db_tuning_recommendations'ta, kapalıyken bile, görebilirsin. Edition tablosuna göre automatic tuning yalnızca Enterprise'da. Süreye dayandığı için emülasyonla çalışan bu container'da deterministik bir deney göstermiyorum.

Query Store Hints: Kod Değiştirmeden Hint

Plan zorlamak bir tarafı seçiyordu. Önceki yazıda sniffing'e en doğru cevabın bu sorgu için OPTION (RECOMPILE) olduğunu görmüştük. Procedure'ün koduna dokunmadan bu hint'i Query Store üzerinden ekleyebiliriz:

EXEC sys.sp_query_store_unforce_plan @query_id = 1, @plan_id = 1;
EXEC sys.sp_query_store_set_hints @query_id = 1, @query_hints = N'OPTION (RECOMPILE)';

SELECT query_hint_id, query_id, query_hint_text, source_desc FROM sys.query_store_query_hints;
query_hint_id query_id query_hint_text    source_desc
------------- -------- ------------------ -----------
1             1        OPTION (RECOMPILE) User

Table 'Orders'. Scan count 1, logical reads 1460     /* müşteri 42 */
Table 'Orders'. Scan count 11, logical reads 7707    /* müşteri 1 */

İki müşteri de kendi en iyi planını aldı. Asıl değeri ise kodunu değiştiremediğin sorgularda: EF Core'un ürettiği, bir NuGet paketinin içinden gelen ya da deploy beklemeden düzeltmen gereken sorgular. Aynı senaryoyu EF Core sorgusuyla, PSP kapalıyken tekrarladım. Uygulama kodu baştan sona aynı; hint'i Query Store'da sorgu metninden bulup ekliyoruz:

async Task<int> LoadAsync(int customerId) =>
    (await ctx.Orders.Where(o => o.CustomerId == customerId)
        .OrderByDescending(o => o.CreatedAt)
        .Select(o => new { o.OrderId, o.OrderNumber, o.Status, o.CreatedAt, o.TotalAmount })
        .ToListAsync()).Count;

await LoadAsync(1);   /* büyük müşteri planı derliyor */
Console.WriteLine($"customer 42: {await LoadAsync(42)} rows, {await LastReadsAsync(conn)} logical reads");

long queryId = await conn.QuerySingleAsync<long>("""
    SELECT TOP (1) q.query_id
    FROM sys.query_store_query AS q
    JOIN sys.query_store_query_text AS qt ON qt.query_text_id = q.query_text_id
    WHERE qt.query_sql_text LIKE '%WHERE \[o\].\[CustomerId\] = @customerId%' ESCAPE '\'
    ORDER BY q.query_id DESC
    """);
await conn.ExecuteAsync("EXEC sys.sp_query_store_set_hints @query_id = @queryId, @query_hints = N'OPTION (RECOMPILE)'",
    new { queryId });
customer 42: 473 rows, 7707 logical reads
query_id 8: OPTION (RECOMPILE) attached
customer 42: 473 rows, 1536 logical reads
customer 1:  200000 rows, 7707 logical reads

Logical reads değerlerini Query Store'un last_logical_io_reads kolonundan okudum. Hint'ten önce küçük müşteri büyük müşterinin planıyla 7.707 page okuyordu; hint'ten sonra 1.536. EF Core tarafında hiçbir şey değişmedi. Query Store hint'leri RECOMPILE, MAXDOP, USE HINT ve compatibility level'ı sorgu bazında değiştiren QUERY_OPTIMIZER_COMPATIBILITY_LEVEL gibi hint'leri destekler. Bir uyarı: hint sorgu metnine bağlıdır. EF Core sürümü değişip ürettiği SQL'de bir boşluk bile değişirse yeni bir query_id oluşur ve hint artık uygulanmaz. Hint'leri bir yerde kayıt altında tut ve yükseltmelerden sonra kontrol et.

ABORT_QUERY_EXECUTION: Sorunlu Sorguyu Durdurmak

SQL Server 2025 Query Store hint'lerine yeni bir seçenek ekledi: belirli bir sorgunun gelecekteki çalıştırmalarını engellemek. Örneğin mesai saatinde birinin bir BI (Business Intelligence) aracından tekrar tekrar çalıştırdığı ağır bir rapor:

EXEC sys.sp_query_store_set_hints @query_id = @qid,
     @query_hints = N'OPTION (USE HINT(''ABORT_QUERY_EXECUTION''))';

SELECT b.Category, SUM(oi.Quantity * oi.UnitPrice) AS Revenue
FROM dbo.OrderItems AS oi JOIN dbo.Books AS b ON b.BookId = oi.BookId
GROUP BY b.Category;
Msg 8778, Level 16, State 1, Line 1
Query execution has been aborted because the ABORT_QUERY_EXECUTION hint was specified.

Sorgu artık çalışmıyor ve istemciye anlaşılır bir hata dönüyor. Bu bir acil durum freni: sorunu çözmez ama çözene kadar sistemi korur. sp_query_store_clear_hints ile kaldırılır.

Intelligent Query Processing Ailesi

Önceki yazılarda gördüğümüz feedback özelliklerinin çoğu Query Store'a dayanıyor ve hepsi IQP adlı ailenin parçası. Hangi özelliğin hangi sürümle ve hangi compatibility level'la geldiği:

08-iqp-family-tr.png

Bu tablonun pratik anlamı: bu özelliklerin çoğu yalnızca compatibility level'ı yükselterek, kod değiştirmeden gelir; ama aynı yükseltme bazı sorguların planlarını da değiştirir. Query Store hem bu değişimi görmenin hem de geri almanın aracıdır; serinin son yazısındaki yükseltme planı tam olarak buna dayanıyor.

SQL Server 2019/2022'de farkı: Query Store 2016'dan beri var, ama yeni veritabanlarında varsayılan açık olması 2022 ile geldi; 2019'da elle açman gerekir. Query Store hint'leri ve optimized plan forcing 2022 ile geldi; 2019'da hint'i koda dokunmadan eklemenin yolu plan guide'lardı. ABORT_QUERY_EXECUTION ve readable secondary'lerde varsayılan açık Query Store yalnızca 2025'te.

Üretimde Dikkat

Query Store'un sağlığını izle. actual_state_desc'in READ_WRITE olduğundan ve current_storage_size_mb'nin sınıra dayanmadığından emin ol; sessizce READ_ONLY'ye düşmüş bir Query Store, regresyonu tam ihtiyacın olduğu gün kaydetmemiş olur.

Zorlanan planları ve hint'leri envanterle. sys.query_store_plan.is_forced_plan ve sys.query_store_query_hints düzenli gözden geçirilmeli. Bir yıl önce zorlanmış bir plan, verinin değiştiği bugün en kötü plan olabilir.

Deploy öncesi ve sonrası karşılaştır. Index değişikliği, compatibility level yükseltmesi ya da ORM sürüm yükseltmesi gibi plan etkileyen her değişiklikte Query Store bir A/B testi aracıdır: aynı query_hash'in önceki ve sonraki maliyetini karşılaştır.

Capture mode'u iş yüküne göre seç. AUTO çoğu sistem için doğru varsayılan; ama çok sayıda kısa ve tekil sorgu üreten bir sistemde CUSTOM capture mode ile çalışma sayısı, toplam derleme süresi ve toplam CPU eşiklerini kendin belirleyebilirsin. ALL ise yalnızca kısa süreli teşhis oturumları için; sürekli açık bırakılırsa Query Store'u hızla doldurur.

Sık Düşülen Tuzaklar

Plan zorlamayı kalıcı çözüm sanmak. Zorlama bir yamadır; sebebi (eksik index, sniffing, eskimiş statistics) çözmezsen veri değiştikçe aynı sorun başka bir biçimde döner.

Query Store'u performans endişesiyle kapatmak. AUTO capture mode ile maliyeti çoğu iş yükünde küçüktür; kapatırsan en değerli teşhis aracını ve feedback özelliklerinin kalıcılığını kaybedersin.

ORM sorgularına hint ekleyip unutmak. EF Core ya da Dapper'ın ürettiği metin değiştiğinde hint sessizce devre dışı kalır; yükseltmeden sonra sys.query_store_query_hints'teki query_id'lerin hâlâ çalışıp çalışmadığına bak.

Regresyonu tek bir ölçüye bakarak ilan etmek. Ortalama logical reads'in yanında çalışma sayısını ve maksimumu da gör: bizim örnekte plan 2'nin ortalaması büyük müşterinin tek bir çalıştırmasıyla değil, küçük müşterilerin on çalıştırmasıyla bozuldu.

Küçük Bir Deneme

★☆☆ Kendi veritabanında dbo.QsPlans view'ını oluştur ve GetCustomerOrders'ı farklı müşterilerle çalıştırıp kaç plan oluştuğuna bak. PSP açıkken tablo nasıl değişiyor?

★★☆ sys.query_store_wait_stats'tan plan 2'nin wait kategorilerini oku. Paralel taramanın hangi wait kategorisine yazıldığını bul.

★★★ GetCustomerOrders için Query Store hint'i olarak OPTION (USE HINT(''QUERY_OPTIMIZER_COMPATIBILITY_LEVEL_150'')) ekle. Plan ve PSP davranışı nasıl değişti? Bu tekniği bir compatibility level yükseltmesinde nasıl kullanırdın?

Buraya kadar hep okuma tarafındaydık. Bir sonraki yazıda yazma tarafının motoruna geçiyoruz: transaction'lar, transaction log, XACT_ABORT, savepoint'ler ve Accelerated Database Recovery.