Optimizatorul SQL Server e un ghicitor. Înainte să-ți ruleze query-ul, întreabă: „Câte rânduri va potrivi filtrul ăsta? Câte rânduri va produce join-ul ăsta?” Răspunsurile lui dictează fiecare alegere de plan — seek sau scan, hash sau merge sau nested-loop, spill în tempdb sau ținut în memorie.
Ghicirile se bazează pe statistici — histograme și sample-uri de densitate stocate alături de fiecare index și uneori pe coloane individuale. Când stats-urile sunt proaspete și reprezentative, ghicirile optimizatorului sunt precise și planul e bun. Când stats-urile sunt învechite sau dezechilibrate, ghicirile sunt greșite, iar tu ajungi cu un plan care mergea splendid pe 1.000 de rânduri să se prăbușească pe 10 milioane.
Asta e lecția 26. E cea care transformă „de ce e lent query-ul ăsta?” în „aha, optimizatorul a ghicit 1 rând și a primit 2 milioane.”
Cum arată statisticile
Rulează asta ca să vezi o histogramă:
DBCC SHOW_STATISTICS ('Sales.Orders', 'IX_Orders_CustomerId');
Se întorc trei rezultate:
- Header — când au fost actualizate ultima oară stats-urile, câte rânduri, câte eșantionate, câte valori distincte.
- Density vector — selectivitatea medie pentru prefixe ale cheii.
- Histogram — până la 200 de bucket-uri, fiecare cu numere de rânduri pentru un range „equal” și unul „înconjurător”.
Histograma e partea interesantă. Pentru o coloană CustomerId, ar putea spune: „Pentru valorile CustomerId de la 1 la 100, sunt cam 50 de rânduri fiecare. Pentru 101 până la 500, cam 10 rânduri fiecare.” Când rulezi WHERE CustomerId = 42, optimizatorul estimează ~50 de rânduri. Dacă ai 1.000 de rânduri reale cu acel CustomerId (pentru că 42 e cel mai mare client al tău), estimarea e de 20× greșită, iar planul va reflecta acea greșeală.
Estimated vs actual rows
Asta e instrumentul de diagnostic numărul 1 în citirea planurilor (lecția 25):
- Activează „Include Actual Execution Plan” (
Ctrl+M) în SSMS. - Rulează query-ul.
- Treci cu mouse-ul peste fiecare operator.
- Compară „Estimated Number of Rows” cu „Actual Number of Rows.”
Diferență de 2×? Bă. De 10×? Suspect. De 100×? Aproape sigur stats-urile sunt cauza query-ului tău lent.
Când stats-urile devin învechite
SQL Server actualizează statisticile automat când:
- 500 + 20% din rândurile tabelului s-au schimbat de la ultima actualizare (pragul vechi).
- SQL Server 2016+ cu compatibility level 130+ folosește un prag dinamic care se declanșează mai des pe tabele mari.
Problema: pe un tabel de un miliard de rânduri cu pragul de 20%, trebuie să se schimbe 200 de milioane de rânduri înainte să se actualizeze stats-urile. E mult drift.
Rulează UPDATE STATISTICS ... WITH FULLSCAN pe un orar. Am menționat asta în lecția 24. Pentru tabele hot, stats full-scan în fiecare noapte merită costul de mentenanță.
-- Un tabel, full scan
UPDATE STATISTICS Sales.Orders WITH FULLSCAN;
-- Toate stats-urile pe tabel
UPDATE STATISTICS Sales.Orders; -- sample default
-- Toate tabelele din baza de date
EXEC sp_updatestats;
IndexOptimize de la Ola are default-uri inteligente care eșantionează majoritatea tabelelor și fac full-scan pe cele critice. Folosește-l.
Auto create și auto update
Două setări de bază de date controlează comportamentul automat al statisticilor:
SELECT name,
is_auto_create_stats_on,
is_auto_update_stats_on,
is_auto_update_stats_async_on
FROM sys.databases
WHERE name = 'Runehold';
AUTO_CREATE_STATISTICS— on by default; engine-ul creează statistici single-column on the fly când un query are nevoie.AUTO_UPDATE_STATISTICS— on by default; engine-ul reîmprospătează stats-urile când se trece pragul.AUTO_UPDATE_STATISTICS_ASYNC— off by default; când e on, query-urile nu așteaptă update-ul de stats, rulează cu cele vechi în timp ce update-ul se întâmplă în fundal.
Recomandare pentru majoritatea workload-urilor OLTP: auto-update on, auto-update-async on. Query-urile nu se blochează așteptând engine-ul de statistici.
Statistici multi-coloană
Stats single-column sunt OK pentru predicate single-column. Pentru predicate multi-coloană, SQL Server poate crea stats multi-coloană automat (pe coloanele leading ale unui index) sau le poți crea manual:
CREATE STATISTICS STAT_Orders_CountryStatus
ON Sales.Orders (CountryCode, Status);
Utile când o combinație de două coloane e dezechilibrată — majoritatea comenzilor cu CountryCode = 'DE' au Status = 1, dar majoritatea celor cu CountryCode = 'IT' au Status = 3. Un obiect de stats care captează ambele coloane ajută optimizatorul să estimeze selectivitatea combinată.
De obicei o nevoie de nișă. Nu le crea by default; adaugă-le dacă estimarea de cardinalitate a unui query specific e greșită.
Cardinality Estimator legacy vs modern
SQL Server 2014 a rescris Cardinality Estimator-ul (CE). Comportamentul s-a schimbat în multe feluri subtile. Unele query-uri au devenit mai rapide, altele mai lente. Compatibility level-ul bazei de date controlează ce CE e folosit:
- Compatibility level 110 (SQL 2012) și mai jos: Legacy CE.
- Compatibility level 120 (SQL 2014) și mai sus: Modern CE.
Verifică și setează:
SELECT name, compatibility_level FROM sys.databases WHERE name = 'Runehold';
-- Schimbă (cu grijă, testează întâi)
ALTER DATABASE Runehold SET COMPATIBILITY_LEVEL = 160;
Upgrade-ul compatibility level-ului — adesea făcut după migrarea pe o versiune SQL Server mai nouă — poate cauza regresii de plan. Folosește Query Store (lecția 28) ca să le detectezi și să fixezi planurile bune.
Ca să forțezi CE-ul legacy pentru un query specific, adaugă un hint:
SELECT ... OPTION (USE HINT ('FORCE_LEGACY_CARDINALITY_ESTIMATION'));
Sau la nivel de bază de date:
ALTER DATABASE SCOPED CONFIGURATION
SET LEGACY_CARDINALITY_ESTIMATION = ON;
Folosește-l ca fix chirurgical pentru query-uri cunoscute ca regresate, nu ca default general.
Când optimizatorul chiar nu poate estima
Unele cazuri îl fac pe optimizator să ghicească orbește:
- Table-valued parameters — tratate ca estimări de 1 rând dacă nu folosești un hint de recompile.
OPTION (RECOMPILE)— plan proaspăt de fiecare dată, dar compile costisitor.OPTIMIZE FOR UNKNOWN— folosește densitatea medie în loc de valori specifice.- WHERE-uri foarte complexe — optimizatorul renunță la o estimare default de 10%.
Când știi că o valoare specifică va produce numere de rânduri sălbatic diferite, ia în considerare:
- Parametrizare diferită (proceduri separate pentru range-uri diferite).
- Folosirea
OPTION (OPTIMIZE FOR (@p = 'DE'))pentru a sugera o valoare reprezentativă. - Forțarea unui plan specific prin Query Store.
Vom acoperi aceste pattern-uri temeinic în lecția următoare despre parameter sniffing.
Cum diagnostichezi „estimarea e greșită”
Workflow standard:
- Rulează query-ul cu Actual Execution Plan on.
- Găsește operatorul unde estimated vs actual divergează cel mai mult.
- Uită-te la ce stats stau în spatele acelui operator (indexul sau coloana pe care face seek/scan).
DBCC SHOW_STATISTICSpe acel index.- Verifică când au fost actualizate ultima oară stats-urile.
UPDATE STATISTICS ... WITH FULLSCANpe acel tabel.- Rulează din nou. S-a îmbunătățit estimarea? Dacă da, ai găsit problema.
Dacă stats-urile sunt proaspete și precise dar estimarea e încă greșită, s-ar putea să ai:
- O distribuție dezechilibrată pe care histograma nu o poate reprezenta (200 de bucket-uri ar putea să nu fie suficiente pentru o coloană cu 100 de milioane de valori).
- O corelație între coloane pe care stats-urile single-column nu o văd.
- O dependență funcțională despre care optimizatorul nu știe.
Pentru aceste cazuri: stats multi-coloană, filtered stats (stats cu o clauză WHERE, ca filtered indexes) sau rescrierea query-ului.
Rulează asta pe propria mașină
USE Runehold;
GO
-- 1. Uită-te la stats-urile pe un index
DBCC SHOW_STATISTICS ('Sales.Orders', 'pk_Orders');
-- 2. Găsește tabele cu stats antice
SELECT
OBJECT_NAME(s.object_id) AS table_name,
s.name AS stat_name,
STATS_DATE(s.object_id, s.stats_id) AS last_updated,
sp.rows,
sp.rows_sampled,
sp.modification_counter AS rows_changed_since
FROM sys.stats AS s
CROSS APPLY sys.dm_db_stats_properties(s.object_id, s.stats_id) AS sp
WHERE s.object_id > 100
ORDER BY STATS_DATE(s.object_id, s.stats_id);
-- 3. Forțează un refresh complet pe un tabel specific
UPDATE STATISTICS Sales.Orders WITH FULLSCAN;
-- 4. Activează auto-update-stats-async (de obicei recomandat)
ALTER DATABASE Runehold SET AUTO_UPDATE_STATISTICS_ASYNC ON;
-- 5. Vezi estimarea de cardinalitate în acțiune
-- Înainte: tabel mic, estimare grosieră
SELECT Name FROM Sales.Customer WHERE Country = 'IT';
-- Verifică planul, treci peste Index Scan / Seek, uită-te la „Estimated Number of Rows”
-- vs „Actual Number of Rows” în planul efectiv.
Când începi să fii atent la estimated-vs-actuals pe fiecare query lent, vei găsi mai multe bug-uri decât te așteptai, mai repede decât cu orice alt diagnostic.
Lecția următoare: parameter sniffing — problema „rulează rapid în SSMS, lent în aplicație”, de ce se întâmplă și fix-urile moderne.