Pe canalul de ops al Runehold, marți la 09:14: „Hei, endpoint-ul de order-search a devenit lent peste noapte. A schimbat cineva ceva?” Jumătate din echipă neagă orice deployment. Cealaltă jumătate spune „eu doar am modificat CSS-ul.” DBA-ul deschide Query Store.
Query Store e o înregistrare istorică integrată a fiecărui query pe care l-a rulat serverul tău și a fiecărui plan pe care l-a folosit optimizatorul. E o mașină a timpului. Înainte să existe (înainte de SQL Server 2016), găsirea „planului care mergea săptămâna trecută” era muncă de detectiv. Cu Query Store, sunt trei click-uri.
Lecția asta acoperă plan cache-ul (memoria pe termen scurt a engine-ului) și Query Store (înregistrarea pe termen lung), cum se leagă și cum le folosești efectiv ca să rezolvi probleme de producție.
Plan cache-ul
Plan cache-ul stă în RAM. Fiecare plan compilat trăiește acolo, indexat după hash-ul textului query-ului. Când execuți un query, SQL Server verifică întâi cache-ul; dacă planul există, e reutilizat; altfel, se compilează unul proaspăt și se cache-uiește.
Conținutul cache-ului se golește când:
- SQL Server se restartează.
- Presiunea de memorie evict-ează planurile reci.
- Cineva rulează
DBCC FREEPROCCACHE. - Anumite modificări de schemă invalidează planurile.
-- Top 20 query-uri după CPU total din plan cache
SELECT TOP (20)
qs.execution_count,
qs.total_worker_time / 1000 AS total_cpu_ms,
qs.total_logical_reads,
SUBSTRING(st.text,
qs.statement_start_offset / 2 + 1,
(CASE qs.statement_end_offset
WHEN -1 THEN DATALENGTH(st.text)
ELSE qs.statement_end_offset
END - qs.statement_start_offset) / 2 + 1) AS statement_text,
qp.query_plan
FROM sys.dm_exec_query_stats AS qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st
CROSS APPLY sys.dm_exec_query_plan(qs.plan_handle) AS qp
ORDER BY qs.total_worker_time DESC;
Click pe query_plan în rezultate → planul se deschide în SSMS. Util, dar datele astea dispar la restart. Asta e limitarea pe care o rezolvă Query Store.
Query Store, activat
Query Store e un feature la nivel de bază de date. Activează per bază de date:
ALTER DATABASE Runehold SET QUERY_STORE = ON (
OPERATION_MODE = READ_WRITE,
CLEANUP_POLICY = (STALE_QUERY_THRESHOLD_DAYS = 30),
DATA_FLUSH_INTERVAL_SECONDS = 900,
MAX_STORAGE_SIZE_MB = 1000,
INTERVAL_LENGTH_MINUTES = 60,
QUERY_CAPTURE_MODE = AUTO
);
Ce face fiecare opțiune:
OPERATION_MODE = READ_WRITE— operare normală. Datele sunt colectate și planurile forțate folosite.STALE_QUERY_THRESHOLD_DAYS = 30— ține 30 de zile de istoric de query-uri.DATA_FLUSH_INTERVAL_SECONDS = 900— flush din memorie pe disc la fiecare 15 minute. Mai mic = mai puține date pierdute la crash, mai mult write overhead.MAX_STORAGE_SIZE_MB = 1000— quota de 1 GB. Redimensionează în funcție de volum.INTERVAL_LENGTH_MINUTES = 60— împarte statisticile în slot-uri orare pentru analiză de tendințe.QUERY_CAPTURE_MODE:AUTOe inteligent (capturează query-urile semnificative, sare peste cele triviale).ALLcapturează tot;NONEdezactivează capturarea, dar păstrează planurile forțate.
AUTO e default-ul corect pentru majoritatea workload-urilor.
Odată activat, Query Store rulează. Lasă-l să colecteze date o săptămână sau două înainte să te bazezi pe tendințe.
UI-ul Query Store în SSMS
Expandează baza de date în SSMS → folder-ul Query Store. Șapte rapoarte integrate:
- Regressed Queries — query-urile a căror performanță s-a înrăutățit.
- Overall Resource Consumption — ce metrici cresc.
- Top Resource Consuming Queries — suspecții obișnuiți.
- Queries With Forced Plans — ce ai fixat.
- Queries With High Variation — același query, runtime-uri foarte diferite (semn de parameter sniffing).
- Query Wait Statistics — defalcare pe wait-type.
- Tracked Queries — urmărește un query specific în timp.
Primul raport — Regressed Queries — e ce deschizi marți dimineața când s-a stricat ceva. Alegi o metrică (CPU, Duration, Logical Reads), alegi o fereastră de timp, iar SQL Server îți arată query-urile a căror medie s-a înrăutățit notabil. Dai click pe unul și vezi fiecare plan pe care l-a folosit acel query în timp. Identifici planul de dinainte de regresie, îl compari cu cel curent și decizi dacă forțezi planul vechi.
Forțarea unui plan
Două click-uri în UI: selectezi planul bun în view-ul Tracked Query, click „Force Plan”.
Programatic:
-- Găsește query-ul care te interesează
SELECT q.query_id, qt.query_sql_text
FROM sys.query_store_query_text AS qt
JOIN sys.query_store_query AS q ON q.query_text_id = qt.query_text_id
WHERE qt.query_sql_text LIKE '%GetOrdersForCustomer%';
-- Uită-te la planurile pentru acel query
SELECT p.plan_id, p.query_id, p.is_forced_plan, p.last_execution_time,
p.avg_duration, p.count_executions
FROM sys.query_store_plan AS p
WHERE p.query_id = 19
ORDER BY p.last_execution_time DESC;
-- Forțează planul bun
EXEC sp_query_store_force_plan @query_id = 19, @plan_id = 42;
-- Mai târziu, dacă te răzgândești
EXEC sp_query_store_unforce_plan @query_id = 19, @plan_id = 42;
Planul forțat se aplică automat la fiecare execuție ulterioară. Fără modificări de cod. Fără hint. Reversibil.
Când forțarea eșuează: dacă schema se schimbă astfel încât invalidează planul forțat (ștergi un index folosit de plan, de exemplu), Query Store îl unforce-uiește automat și îl marchează. Verifică periodic:
SELECT query_id, plan_id, force_failure_count, last_force_failure_reason_desc
FROM sys.query_store_plan
WHERE is_forced_plan = 1 AND force_failure_count > 0;
Query integrat de vânătoare a regresiilor
Query util: „query-urile care au devenit mai lente în ultima zi față de săptămâna anterioară.”
WITH recent AS (
SELECT p.query_id, p.plan_id,
AVG(rs.avg_duration) AS recent_avg_us
FROM sys.query_store_plan AS p
JOIN sys.query_store_runtime_stats AS rs ON rs.plan_id = p.plan_id
JOIN sys.query_store_runtime_stats_interval AS rsi ON rsi.runtime_stats_interval_id = rs.runtime_stats_interval_id
WHERE rsi.end_time >= DATEADD(HOUR, -24, GETDATE())
GROUP BY p.query_id, p.plan_id
),
baseline AS (
SELECT p.query_id, p.plan_id,
AVG(rs.avg_duration) AS baseline_avg_us
FROM sys.query_store_plan AS p
JOIN sys.query_store_runtime_stats AS rs ON rs.plan_id = p.plan_id
JOIN sys.query_store_runtime_stats_interval AS rsi ON rsi.runtime_stats_interval_id = rs.runtime_stats_interval_id
WHERE rsi.end_time BETWEEN DATEADD(DAY, -7, GETDATE()) AND DATEADD(DAY, -1, GETDATE())
GROUP BY p.query_id, p.plan_id
)
SELECT r.query_id, r.plan_id,
r.recent_avg_us / 1000 AS recent_ms,
b.baseline_avg_us / 1000 AS baseline_ms,
r.recent_avg_us * 1.0 / NULLIF(b.baseline_avg_us, 0) AS ratio
FROM recent AS r
JOIN baseline AS b ON b.query_id = r.query_id AND b.plan_id = r.plan_id
WHERE r.recent_avg_us > b.baseline_avg_us * 2
ORDER BY ratio DESC;
„Query-uri a căror medie pe ultimele 24 de ore e cel puțin 2× față de baseline” — alertă clasică de regresie. Conectează asta la o unealtă de monitorizare și vei vedea regresiile înainte ca support-ul să le observe.
Filtru de captură
Pe servere ocupate, Query Store poate să crească. Folosește QUERY_CAPTURE_POLICY (2019+) ca să sară peste query-uri cu frecvență mică:
ALTER DATABASE Runehold SET QUERY_STORE (
QUERY_CAPTURE_POLICY = (
STALE_CAPTURE_POLICY_THRESHOLD = 24 HOURS,
EXECUTION_COUNT = 30,
TOTAL_COMPILE_CPU_TIME_MS = 1000,
TOTAL_EXECUTION_CPU_TIME_MS = 100
)
);
Cere ca un query să atingă cel puțin unul dintre praguri ca să fie capturat. Reduce dramatic stocarea pentru sisteme cu volum mare fără să pierzi query-urile care contează cu adevărat.
Azure SQL Database
Pe Azure SQL Database, Query Store e on by default și nu poate fi oprit. Primești și automatic plan correction — Azure poate forța automat planul anterior când detectează o regresie. Opt-in:
ALTER DATABASE CURRENT SET AUTOMATIC_TUNING (FORCE_LAST_GOOD_PLAN = ON);
Uimitor de util. Mi-a salvat un weekend de on-call în 2023 când query planner-ul a rescris prost un plan după un update de stats, iar engine-ul Azure s-a auto-reparat înainte ca alerta să se declanșeze.
Rulează asta pe propria mașină
USE Runehold;
GO
-- 1. Activează Query Store dacă nu e deja
ALTER DATABASE Runehold SET QUERY_STORE = ON;
-- 2. Verifică că rulează
SELECT actual_state_desc, readonly_reason, current_storage_size_mb
FROM sys.database_query_store_options;
-- 3. Rulează niște query-uri ca să existe date de văzut
EXEC dbo.GetOrdersForCustomer @CustomerId = 1;
EXEC dbo.GetOrdersForCustomer @CustomerId = 2;
EXEC dbo.GetOrdersForCustomer @CustomerId = 42;
-- 4. Uită-te la cele mai scumpe query-uri din top
SELECT TOP (10)
qt.query_sql_text,
rs.avg_duration / 1000 AS avg_ms,
rs.count_executions
FROM sys.query_store_query_text AS qt
JOIN sys.query_store_query AS q ON q.query_text_id = qt.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
ORDER BY rs.avg_duration DESC;
-- 5. Forțează un plan (completează cu query_id și plan_id reale din UI sau query-urile de mai sus)
-- EXEC sp_query_store_force_plan @query_id = 1, @plan_id = 1;
-- 6. Obține statistici detaliate pentru un query
SELECT
rsi.start_time, rsi.end_time,
rs.count_executions,
rs.avg_duration / 1000 AS avg_ms,
rs.avg_cpu_time / 1000 AS avg_cpu_ms,
rs.avg_logical_io_reads
FROM sys.query_store_query AS q
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
JOIN sys.query_store_runtime_stats_interval AS rsi ON rsi.runtime_stats_interval_id = rs.runtime_stats_interval_id
WHERE q.query_id = 1
ORDER BY rsi.start_time DESC;
Explorează UI-ul Query Store în SSMS. Click dreapta pe o bază de date → Query Store → Top Resource Consuming Queries. Graficele spun o poveste. După ce l-ai folosit de două ori, nu vei mai debugui producție fără el.
Modulul 5 e gata. Planuri de execuție, statistici, parameter sniffing, Query Store — toolkit-ul de diagnostic de performanță. Restul cursului trece spre operațiuni de DBA: lecția 30 despre filegroups și fișierul de log, apoi lecțiile 31-36 despre backup-uri, SQL Agent, securitate, tempdb, Ola și DMV-uri.