Індекси й плани запитів
Як SQL Server знаходить рядки: будова індексів, чому умова не потрапляє в індекс, як читати план виконання і звідки береться «швидко в SSMS, повільно з застосунку». Приклади — на T-SQL; як те саме влаштовано в PostgreSQL, розібрано в темі PostgreSQL проти SQL Server.
Перед цією темою: SQL і T-SQL.
Що таке індекс і чим кластерний індекс відрізняється від некластерного?
Section titled “Що таке індекс і чим кластерний індекс відрізняється від некластерного?”Що це
Індекс схожий на алфавітний покажчик у книжці: значення ключа зберігаються відсортованими, і сервер знаходить потрібне за кілька кроків по дереву. Такий пошук називають seek, а читання всієї таблиці підряд — scan.
Чим відрізняються
| Кластерний | Некластерний | |
|---|---|---|
| Що в листках | самі рядки таблиці | ключ і посилання на рядок |
| Скільки на таблицю | один | багато |
| Чим створюється за замовчуванням | PRIMARY KEY |
UNIQUE, CREATE INDEX |
Типова помилка
Створювати індекс на кожен стовпець «про всяк випадок». Кожен індекс сповільнює INSERT, UPDATE і DELETE: сервер оновлює його разом із таблицею.
Як влаштовано
- Індекс зберігається в сторінках по 8 КБ, зібраних у B+-дерево: корінь, проміжні рівні, листки. Навіть у таблиці на мільйони рядків до потрібного листка 3–4 читання.
- У листках кластерного індексу лежать рядки повністю, тому таблиця зберігається в порядку його ключа.
- У листках некластерного — ключ і вказівник на рядок. Решту стовпців сервер дочитує з таблиці за вказівником.
- Купа — таблиця без кластерного індексу. Рядки лежать без порядку, вказівник на рядок — RID: файл, сторінка, слот.
Коли що обирати
| Ситуація | Рішення |
|---|---|
| Майже будь-яка OLTP-таблиця | кластерний індекс за вузьким зростаючим ключем |
| Пошук і з’єднання за іншими стовпцями | некластерні індекси під ці запити |
| Проміжна таблиця для завантаження | купа допустима |
Пастки
- Ціна запису.
INSERTіDELETEзмінюють усі індекси,UPDATE— ті, де є змінені стовпці. - Купа й
UPDATE. Рядок, що виріс, переїжджає на іншу сторінку, а на старому місці лишається forwarding pointer — зайве читання під час кожного звернення. - Неунікальний кластерний ключ. Сервер додає до нього прихований 4-байтовий uniqueifier.
Під капотом
- Ключ кластеризації — вказівник на рядок у всіх некластерних індексах. У неунікальному некластерному індексі він дописується в кінець ключа, в унікальному — в листки як неключові стовпці.
- Звідси вимоги до нього: вузький (копіюється в кожен індекс), унікальний (інакше uniqueifier), незмінний (зміна ключа переписує рядок у всіх індексах), зростаючий (вставки йдуть у кінець).
- Випадковий GUID із
NEWID()вставляє рядки в середину індексу. Сторінки розщеплюються (page split), заповнюються наполовину, ростуть журнал і обсяг.
Ціна
- Кожен індекс — копія частини даних: місце на диску, в буферному пулі й у бекапах.
- Кожна модифікація пише в журнал зміни всіх зачеплених індексів і блокує їхні рядки.
- Зростаючий ключ за великої кількості паралельних вставок упирається в останню сторінку (last page insert contention). Із SQL Server 2019 для цього є опція індексу
OPTIMIZE_FOR_SEQUENTIAL_KEY.
Компроміси
IDENTITY— вузький зростаючий ключ без сенсу для бізнесу. GUID зручно генерувати на клієнті, але він широкий: його роблять унікальним некластерним ключем, а кластеризують заIDENTITY, або берутьNEWSEQUENTIALID().- Кластеризація за датою документа прискорює звіти за періоди, але неунікальний ключ отримає uniqueifier.
Як перевірити в продакшені
sys.dm_db_index_usage_stats: індекси з великимuser_updatesі майже нульовимиuser_seeks,user_scans,user_lookups— кандидати на видалення. Лічильники обнуляються під час перезапуску сервера.sys.dm_db_index_physical_statsпоказує кількість сторінок і їхню заповненість.
Підсумок: ключ кластеризації впливає на всі індекси таблиці, а кожен зайвий індекс — податок на кожен запис.
DROP TABLE IF EXISTS dbo.Orders;CREATE TABLE dbo.Orders( OrderId int IDENTITY PRIMARY KEY, -- PRIMARY KEY: кластерний індекс за замовчуванням CustomerId int NOT NULL, OrderDate date NOT NULL, Amount decimal(12,2) NOT NULL);
INSERT dbo.Orders (CustomerId, OrderDate, Amount)SELECT TOP (100000) n % 5000, DATEADD(day, n % 730, '20240101'), n % 1000FROM (SELECT CAST(ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS int) AS n FROM sys.all_objects AS a CROSS JOIN sys.all_objects AS b) AS t;
-- Некластерний індекс: ключ CustomerId, до нього додано OrderId — ключ кластеризаціїCREATE INDEX IX_Orders_CustomerId ON dbo.Orders (CustomerId);
SET STATISTICS IO ON;SELECT OrderId FROM dbo.Orders WHERE CustomerId = 42; -- Index Seek: кілька читаньSELECT OrderId FROM dbo.Orders WHERE Amount = 42; -- Clustered Index Scan: уся таблиця- Скільки кластерних індексів може бути в таблиці? — Один: рядки зберігаються лише в одному порядку.
- Чи обов’язково первинний ключ кластерний? — Ні, це типова поведінка;
PRIMARY KEY NONCLUSTEREDзалишає кластерний індекс для іншого стовпця. - Що зберігається в некластерному індексі, крім його стовпців? — Вказівник на рядок: ключ кластеризації або RID у купи.
- Чим поганий випадковий GUID як кластерний ключ? — 16 байт у кожному індексі та вставки у випадкові місця з розщепленням сторінок.
- Як знайти зайві індекси? —
sys.dm_db_index_usage_stats: багато оновлень і немає читань.
Що таке покривний індекс, навіщо INCLUDE і як вибрати порядок стовпців у складеному індексі?
Section titled “Що таке покривний індекс, навіщо INCLUDE і як вибрати порядок стовпців у складеному індексі?”Що це
Покривний індекс — індекс, у якому є всі стовпці запиту: з WHERE, JOIN, ORDER BY і SELECT. Сервер читає лише його й не ходить у таблицю.
Як користуватися
- Стовпці, за якими шукають і сортують, — у ключ індексу.
- Стовпці, які лише виводять, — в
INCLUDE. - Приклад:
CREATE INDEX IX_Orders_Customer ON dbo.Orders (CustomerId, OrderDate) INCLUDE (Amount).
Типова помилка
Писати SELECT *. Такий запит покриває лише кластерний індекс, і будь-якому некластерному доведеться дочитувати рядки з таблиці.
Як влаштовано
- Без покриття план — Index Seek по некластерному індексу і Key Lookup на кожен знайдений рядок, з’єднані Nested Loops.
- 10 рядків — дешево, 50 000 — це 50 000 випадкових пошуків, і оптимізатор вибере scan таблиці. Поріг (tipping point) часто менший за кілька відсотків рядків.
- Включені стовпці лежать лише на листковому рівні. Вони не сортуються, не беруть участі в пошуку й не входять до ліміту розміру ключа.
Порядок стовпців ключа
- Пошук іде лише за префіксом ключа: індекс
(A, B)шукає заAабо заAіB, але не за самимB. - Спершу стовпці з рівністю, потім стовпець із діапазоном. В індексі
(OrderDate, CustomerId)умоваCustomerId = @c AND OrderDate >= @dпрочитає весь діапазон дат і перевіритьCustomerIdу кожному рядку. - Серед стовпців із рівністю Microsoft радить першим ставити селективніший — той, що відбирає менше рядків.
Пастки
Пропозиції missing index у плані не задають порядок стовпців і часто дублюють наявні індекси.
Правило
У ключ — рівності, потім діапазон або сортування; стовпці лише для виведення — в INCLUDE.
Під капотом
- В Index Seek у плані дві властивості: Seek Predicates звужують діапазон читання по B-дереву, Predicate — залишкова перевірка кожного прочитаного рядка. Добрий індекс переносить максимум умови в Seek Predicates.
- Гістограма статистики будується лише за першим стовпцем ключа, для решти є тільки середня щільність префіксів. Провідний стовпець впливає й на точність оцінок.
- Індекс у порядку
ORDER BYабоGROUP BYприбираєSortразом із пам’яттю під сортування, аTOP (n)зупиняється після n рядків.
Ціна
- Широкий
INCLUDEмайже дублює таблицю й подвоює обсяг запису. UPDATEвключеного стовпця оновлює й індекс.
Компроміси
- Один індекс під кілька запитів кращий за п’ять схожих:
(CustomerId),(CustomerId, OrderDate)і(CustomerId) INCLUDE (Amount)зводяться до(CustomerId, OrderDate) INCLUDE (Amount). sys.dm_db_missing_index_detailsділить стовпці на equality, inequality та included, але не враховує наявні індекси й ціну запису.
Як перевірити в продакшені
- Key Lookup із великим Actual Number of Rows у фактичному плані — кандидат на покриття.
user_lookupsуsys.dm_db_index_usage_statsпоказує, скільки lookup-ів припадає на кластерний індекс.
Підсумок: індекс проєктують під набір запитів: префікс ключа — для пошуку, порядок — для сортування, INCLUDE — для покриття, і кожен стовпець оплачує запис.
DROP TABLE IF EXISTS dbo.Orders;CREATE TABLE dbo.Orders( OrderId int IDENTITY PRIMARY KEY, CustomerId int NOT NULL, OrderDate date NOT NULL, Amount decimal(12,2) NOT NULL);INSERT dbo.Orders (CustomerId, OrderDate, Amount)SELECT TOP (100000) n % 5000, DATEADD(day, n % 730, '20240101'), n % 1000FROM (SELECT CAST(ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS int) AS n FROM sys.all_objects AS a CROSS JOIN sys.all_objects AS b) AS t;
-- Рівність (CustomerId) перед діапазоном (OrderDate); Amount лише виводитьсяCREATE INDEX IX_Orders_Customer_Date ON dbo.Orders (CustomerId, OrderDate) INCLUDE (Amount);
SELECT OrderDate, AmountFROM dbo.OrdersWHERE CustomerId = 42 AND OrderDate >= '20250101'ORDER BY OrderDate; -- Index Seek без Key Lookup і без Sort-- Без INCLUDE (Amount) у плані був би Key Lookup на кожен рядок- Чим
INCLUDEвідрізняється від стовпця в ключі? — Включений стовпець лежить лише в листках: не сортується, не бере участі в пошуку, не входить до ліміту ключа. - Чому оптимізатор не бере індекс, хоча той підходить за
WHERE? — Забагато Key Lookup: дешевше прочитати таблицю. - Чи допоможе індекс
(A, B)запитуWHERE B = 1? — Seek — ні; хіба що scan вужчого індексу замість таблиці. - Навіщо
DESCу ключі індексу? — Для змішаного сортування на кшталтORDER BY A, B DESC; один напрямок сервер читає й у зворотний бік. - Створити всі індекси з missing index DMV? — Ні: вони без порядку стовпців, дублюють одне одного й не враховують ціну запису.
Що таке SARGable-умова і чому функції, LIKE '%x' та неявні перетворення типів ламають пошук за індексом?
Section titled “Що таке SARGable-умова і чому функції, LIKE '%x' та неявні перетворення типів ламають пошук за індексом?”Що це
SARGable-умова — така, за якою сервер може шукати в індексі, а не перевіряти кожен рядок. Стовпець стоїть у ній без функцій і обчислень.
Як користуватися
| Не SARGable | SARGable |
|---|---|
YEAR(OrderDate) = 2025 |
OrderDate >= '20250101' AND OrderDate < '20260101' |
Price * 1.2 > 120 |
Price > 100 |
LEFT(Name, 2) = 'Jo' |
Name LIKE 'Jo%' |
ISNULL(Discount, 0) = 0 |
(Discount = 0 OR Discount IS NULL) |
Типова помилка
LIKE '%son' із % на початку. Індекс відсортований за першими символами, тому такий пошук завжди читає індекс повністю.
Як влаштовано
- Індекс відсортований за значеннями стовпця, і seek можливий, якщо умова задає діапазон цих значень.
YEAR(OrderDate)— уже інша величина. Оптимізатор не виводить її порядок з індексу й обчислює функцію для кожного рядка.- Якщо типи сторін порівняння різні, SQL Server приводить тип із меншим пріоритетом до більшого. Коли приводиться стовпець, у плані видно
CONVERT_IMPLICITнад ним.
Класика з .NET
SqlParameterіз рядковим значенням отримує типNVarChar, аAddWithValueбере довжину із самого значення.varchar-стовпець молодший заnvarcharі конвертується в кожному рядку: scan замість seek.- Рядки різної довжини дають оголошення
nvarchar(5),nvarchar(6)і окремий план на кожне.
Пастки
- Зворотний випадок безпечний:
varchar-параметр протиnvarchar-стовпця конвертує параметр, а не стовпець. varchar-стовпець із кодами протиint-параметра:intстарший, конвертується стовпець — scan і помилка на першому нечисловому значенні.
Правило
Тип і довжина параметра збігаються зі стовпцем: SqlDbType.VarChar і Size, у Dapper — DbString { IsAnsi = true }, в EF Core — мапінг IsUnicode(false).
Під капотом
- Для seek оптимізатор будує межі діапазону в B-дереві. Усе, що не вдалося перетворити на межі, стає залишковим предикатом і перевіряється в кожному прочитаному рядку.
- Частину виразів оптимізатор переписує сам:
CAST(CreatedAt AS date) = @dдляdatetime2перетворюється на динамічний діапазон (GetRangeThroughConvert). - Для
nvarcharпротиvarcharусе вирішує collation. За Windows-collation (Cyrillic_General_CI_AS,Latin1_General_CI_AS) порядок рядків в обох типів однаковий, і сервер шукає за обчисленим діапазоном. За SQL-collation (SQL_Latin1_General_CP1_CI_AS) порядок різний, і лишається scan.
Ціна
Навіть «врятований» seek отримує погану оцінку кількості рядків. У плані з’являється попередження Type conversion in expression ... may affect "CardinalityEstimate", а за ним неправильні з’єднання й виділення пам’яті.
Компроміси
- Переписати запит найдешевше: діапазон замість
YEAR(), явна перевіркаNULLзамістьISNULL. - Якщо вираз потрібен як є — обчислюваний стовпець з індексом: оптимізатор зіставить із ним такий самий вираз у запиті.
LIKE '%x'B-деревом не лікується: повнотекстовий індекс, стовпець з перевернутим рядком або зовнішній пошук.
Як перевірити в продакшені
Шукайте PlanAffectingConvert в XML планів із sys.dm_exec_query_stats і sys.dm_exec_query_plan, відсортованих за логічними читаннями.
Підсумок: SARGability вирішує, чи стане умова межею пошуку, чи перевіркою кожного рядка, а неявні перетворення — найчастіша невидима причина scan у .NET-застосунках.
DROP TABLE IF EXISTS dbo.Products;CREATE TABLE dbo.Products( ProductId int IDENTITY PRIMARY KEY, Sku varchar(20) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL, CreatedAt datetime2 NOT NULL);INSERT dbo.Products (Sku, CreatedAt)SELECT TOP (100000) CONCAT('SKU-', n), DATEADD(minute, n * 10, '20240101')FROM (SELECT CAST(ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS int) AS n FROM sys.all_objects AS a CROSS JOIN sys.all_objects AS b) AS t;CREATE INDEX IX_Products_Sku ON dbo.Products (Sku);CREATE INDEX IX_Products_CreatedAt ON dbo.Products (CreatedAt);
DECLARE @n nvarchar(20) = N'SKU-42', @v varchar(20) = 'SKU-42';SELECT ProductId FROM dbo.Products WHERE Sku = @n; -- Index Scan: CONVERT_IMPLICIT над SkuSELECT ProductId FROM dbo.Products WHERE Sku = @v; -- Index Seek
SELECT ProductId FROM dbo.Products WHERE YEAR(CreatedAt) = 2025; -- Index ScanSELECT ProductId FROM dbo.ProductsWHERE CreatedAt >= '20250101' AND CreatedAt < '20260101'; -- Index Seekusing System.Data;using Microsoft.Data.SqlClient;
await using var connection = new SqlConnection( "Server=.;Database=Shop;Integrated Security=true;TrustServerCertificate=true");await connection.OpenAsync();const string sql = "SELECT ProductId FROM dbo.Products WHERE Sku = @sku";
// Погано: string → nvarchar(6), varchar-стовпець конвертується в кожному рядкуawait using var bad = new SqlCommand(sql, connection);bad.Parameters.AddWithValue("@sku", "SKU-42");
// Добре: тип і довжина як у стовпця — seek і один план для будь-яких значеньawait using var good = new SqlCommand(sql, connection);good.Parameters.Add("@sku", SqlDbType.VarChar, 20).Value = "SKU-42";
Console.WriteLine(await good.ExecuteScalarAsync());- Чому
WHERE YEAR(OrderDate) = 2025повільний? — Функція над стовпцем обчислюється для кожного рядка; потрібен діапазон дат. - Що гірше:
nvarchar-параметр доvarchar-стовпця чи навпаки? — Перше: конвертується стовпець; у другому конвертується параметр, і seek зберігається. - Як прискорити
LIKE '%term%'? — Звичайним індексом ніяк; повнотекстовий індекс або зовнішній пошук. - Чим поганий
AddWithValue? — Тип виводиться зі значення (string→nvarchar), довжина — з довжини рядка: неправильний тип і роздутий кеш планів. - Як знайти неявні перетворення на сервері? — Попередження
PlanAffectingConvertв XML закешованих планів.
Як читати план виконання: seek, scan і lookup, оцінковий і фактичний план, оператори з’єднання, попередження?
Section titled “Як читати план виконання: seek, scan і lookup, оцінковий і фактичний план, оператори з’єднання, попередження?”Що це
План виконання — схема, за якою SQL Server виконує запит. У SSMS Ctrl+L показує оцінковий план без виконання, а Ctrl+M вмикає фактичний для наступного запуску.
Основні оператори доступу
| Оператор | Що робить |
|---|---|
| Index Seek | шукає за ключем індексу — дешево |
| Index Scan, Clustered Index Scan, Table Scan | читає індекс або таблицю повністю |
| Key Lookup, RID Lookup | дочитує стовпці з таблиці для кожного рядка |
Як користуватися
- Читайте справа наліво: дані течуть від таблиць до
SELECT. - Товщина стрілки — кількість рядків.
- Жовтий трикутник — попередження: наведіть курсор на оператор.
Типова помилка
Вважати будь-який scan поганим. Маленьку таблицю або майже всі рядки scan прочитає швидше, ніж тисячі seek.
Як влаштовано
- Оптимізатор оцінює кількість рядків кожного оператора за статистикою й вибирає найдешевший із розглянутих планів.
- Оцінковий план — результат компіляції. Фактичний додає Actual Number of Rows, Number of Executions, час і читання по операторах.
- Головний сигнал — Estimated проти Actual Rows. Помилка на порядок означає застарілу статистику, несаргабельну умову або план, скомпільований під інші значення параметрів.
Коли що обирати
| З’єднання | Коли добре | Ціна |
|---|---|---|
| Nested Loops | зовнішній вхід малий, у внутрішнього є індекс | пошук у внутрішньому на кожен зовнішній рядок |
| Hash Match | великі несортовані входи | пам’ять під хеш-таблицю, чекає побудови таблиці |
| Merge Join | обидва входи відсортовані за ключем | Sort, якщо готового порядку немає |
Пастки
- Відсотки вартості — оцінка навіть у фактичному плані.
- На внутрішній стороні Nested Loops оцінка рядків дана на одне виконання, а факт — сума за всі. Діліть факт на Number of Executions.
Правило
Спершу оператори з найбільшою розбіжністю оцінки й факту та з найбільшою кількістю читань, потім попередження.
Під капотом
- Керування йде зліва направо: кореневий оператор запитує рядки в дочірніх, а дані течуть справа наліво. Тому
TOPліворуч може зупинити scan праворуч після перших рядків. - Sort і Hash Match отримують виділення пам’яті до старту, за оцінкою рядків. Недооцінка веде до скидання в tempdb, переоцінка — до очікування пам’яті іншими запитами (
RESOURCE_SEMAPHORE). - Із SQL Server 2017 у пакетному режимі (batch mode) є Adaptive Join: вибір між Hash і Nested Loops за фактичною кількістю рядків.
Попередження, які варто знати
PlanAffectingConvert— неявне перетворення заважає seek або оцінці.- Spill to tempdb — забракло виділеної пам’яті.
- No Join Predicate — пропущено умову з’єднання.
- Columns With No Statistics, Excessive Grant і підказка Missing Index.
Як перевірити в продакшені
- План із кешу (
sys.dm_exec_query_plan) — оцінковий, без фактичних рядків. - Із SQL Server 2019 lightweight profiling увімкнено за замовчуванням. Із
LAST_QUERY_PLAN_STATS = ONостанній фактичний план віддаєsys.dm_exec_query_plan_stats. - Для запиту, що виконується, —
sys.dm_exec_query_statistics_xmlабо Live Query Statistics.
Підсумок: читайте фактичний план як історію оцінок: де оцінка розійшлася з фактом, там неправильні з’єднання, пам’ять і порядок операцій.
DROP TABLE IF EXISTS dbo.OrderLines, dbo.Orders;CREATE TABLE dbo.Orders (OrderId int PRIMARY KEY, CustomerId int NOT NULL);CREATE TABLE dbo.OrderLines( OrderId int NOT NULL, LineNum int NOT NULL, Qty int NOT NULL, PRIMARY KEY (OrderId, LineNum));INSERT dbo.Orders (OrderId, CustomerId)SELECT TOP (50000) n, n % 1000FROM (SELECT CAST(ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS int) AS n FROM sys.all_objects AS a CROSS JOIN sys.all_objects AS b) AS t;INSERT dbo.OrderLines (OrderId, LineNum, Qty)SELECT OrderId, 1, CustomerId % 7 FROM dbo.Orders;
SET STATISTICS IO, TIME ON; -- у SSMS увімкніть фактичний план: Ctrl+M-- Підказки — лише для експерименту: один запит, три алгоритми з'єднанняSELECT SUM(l.Qty) FROM dbo.Orders AS o JOIN dbo.OrderLines AS l ON l.OrderId = o.OrderIdOPTION (LOOP JOIN); -- 50 000 seek у внутрішній індекс: найбільше читаньSELECT SUM(l.Qty) FROM dbo.Orders AS o JOIN dbo.OrderLines AS l ON l.OrderId = o.OrderIdOPTION (MERGE JOIN); -- два впорядковані scan, без сортуванняSELECT SUM(l.Qty) FROM dbo.Orders AS o JOIN dbo.OrderLines AS l ON l.OrderId = o.OrderIdOPTION (HASH JOIN); -- хеш-таблиця в пам'яті: Memory Grant у властивостях SELECT- Чому вартість у відсотках у фактичному плані може вводити в оману? — Це оцінка оптимізатора, а не вимірювання.
- Чим Key Lookup відрізняється від RID Lookup? — Перший шукає за ключем кластеризації, другий — за фізичною адресою рядка в купі.
- Коли Hash Match кращий за Nested Loops? — Коли обидва входи великі й на внутрішній стороні немає відповідного індексу.
- Що означає попередження про скидання в tempdb? — Операторові забракло виділеної пам’яті: оцінку рядків занижено.
- Як отримати фактичний план запиту з продакшену? —
sys.dm_exec_query_plan_statsізLAST_QUERY_PLAN_STATS(SQL Server 2019+) або Extended Events.
Що таке parameter sniffing і кеш планів, як діагностувати й лікувати проблему?
Section titled “Що таке parameter sniffing і кеш планів, як діагностувати й лікувати проблему?”Що це
- Кеш планів — пам’ять, де SQL Server зберігає скомпільовані плани, щоб не компілювати запит заново.
- Parameter sniffing — під час першої компіляції оптимізатор «підглядає» значення параметрів і будує план під них.
Чому це проблема
У клієнта A п’ять замовлень, у клієнта B — пів мільйона. Якщо першим прийшов A, у кеші опиниться план із пошуком за індексом і Key Lookup. Для B цей план зробить пів мільйона lookup, хоча швидше було б прочитати таблицю повністю.
Типова помилка
Лікувати проблему перезапуском сервера або DBCC FREEPROCCACHE без параметрів. Це скине всі плани, а поганий повернеться під час наступного «невдалого» першого виклику.
Як влаштовано
- ADO.NET і EF Core надсилають параметризований запит через
sp_executesql. Текст запиту разом з оголошеннями параметрів — частина ключа кешу. - Під час першого виклику оптимізатор будує план за гістограмою для переданих значень і кладе його в кеш.
- Виклики з тим самим текстом, типами параметрів і SET-параметрами сесії беруть готовий план.
- Після оновлення статистики або витіснення з кешу план компілюється заново — під значення того виклику, що прийде першим.
Коли що обирати
| Засіб | Коли |
|---|---|
OPTION (RECOMPILE) |
запит рідкісний і важкий, компіляція дешевша за поганий план |
OPTIMIZE FOR (@p = ...) |
є типове значення, план під нього годиться всім |
OPTIMIZE FOR UNKNOWN |
потрібен середній план за щільністю, а не за першим значенням |
| Закріплення плану | код змінювати не можна, а добрий план уже був |
Пастки
- Локальна змінна замість параметра (
DECLARE @c int = @CustomerId) діє якOPTIMIZE FOR UNKNOWN: план будується за середньою щільністю. - У SSMS за замовчуванням
ARITHABORT ON, а в ADO.NET —OFF. Це різні записи в кеші, тому в SSMS запит компілюється заново під ваші значення й «літає».
Під капотом
- В XML фактичного плану кожен параметр має
ParameterCompiledValueіParameterRuntimeValue. Їхня розбіжність — прямий доказ. - Кілька
query_plan_hashна одинquery_hashі великий розкидmin_elapsed_timeіmax_elapsed_timeуsys.dm_exec_query_stats— ознака чутливості до параметрів. - Query Store з’явився в SQL Server 2016 і для нових баз увімкнений за замовчуванням із 2022. Він зберігає плани та їхню статистику виконання, регресію видно у звіті Regressed Queries.
Компроміси
| Засіб | Ціна |
|---|---|
RECOMPILE |
CPU на компіляцію під час кожного виклику |
sp_query_store_force_plan |
план зафіксований за будь-якого зростання даних |
| Підказки Query Store, 2022+ | RECOMPILE або OPTIMIZE FOR без зміни коду, зручно для запитів EF |
| PSP optimization, 2022+, рівень 160 | кілька планів лише для предикатів-рівностей, більше записів у кеші |
SQL Server 2025 на рівні сумісності 170 додав optional parameter plan optimization: для шаблону col = @p OR @p IS NULL — окремі плани для NULL і не-NULL.
Як перевірити в продакшені
- Query Store: запит із кількома планами й різним часом. Добрий план закріплюють, потім стежать за регресіями.
- Відтворюють той самий
sp_executesqlз тим самим оголошенням параметрів іSET ARITHABORT OFF. Запит із трасування з підставленими літералами — уже інший запит: літерал дає свій план, локальна змінна — план за щільністю.
Підсумок: sniffing лікують точково й вимірювано: довести за ParameterCompiledValue, вибрати між перекомпіляцією, фіксацією плану й PSP, перевірити інші значення.
DROP TABLE IF EXISTS dbo.Orders;CREATE TABLE dbo.Orders( OrderId int IDENTITY PRIMARY KEY, CustomerId int NOT NULL, Amount decimal(12,2) NOT NULL);-- Перекіс: у клієнта 1 — 100 000 замовлень, в інших — по одномуINSERT dbo.Orders (CustomerId, Amount)SELECT TOP (100000) 1, 10 FROM sys.all_objects AS a CROSS JOIN sys.all_objects AS b;INSERT dbo.Orders (CustomerId, Amount)SELECT TOP (1000) 1 + ROW_NUMBER() OVER (ORDER BY (SELECT NULL)), 10 FROM sys.all_objects;CREATE INDEX IX_Orders_CustomerId ON dbo.Orders (CustomerId);
-- Так запит надсилають ADO.NET і EF CoreDECLARE @sql nvarchar(200) = N'SELECT SUM(Amount) FROM dbo.Orders WHERE CustomerId = @c';EXEC sp_executesql @sql, N'@c int', @c = 2; -- план: Seek + Key Lookup, потрапляє в кешEXEC sp_executesql @sql, N'@c int', @c = 1; -- той самий план: 100 000 Key Lookup-- На рівні сумісності 160+ PSP optimization може побудувати другий план
-- Без зміни застосунку (SQL Server 2022+): знайти query_id у Query Store і додати підказку-- EXEC sys.sp_query_store_set_hints @query_id = 42, @query_hints = N'OPTION (RECOMPILE)';- Чому запит швидкий у SSMS і повільний із застосунку? — Різні SET-параметри (
ARITHABORT) дають різні записи в кеші, і в SSMS план компілюється під ваші значення. - Чим
OPTIMIZE FOR UNKNOWNвідрізняється відRECOMPILE? — Перший будує один середній план за щільністю, другий — свіжий план під кожне значення ціною компіляції. - Як прибрати поганий план одного запиту? —
DBCC FREEPROCCACHE (plan_handle)абоsp_recompileдля процедури, а не очищення всього кешу. - Що робить PSP optimization? — Для рівності за перекошеним стовпцем тримає кілька варіантів плану за діапазонами кардинальності.
- Як виправити план запиту EF Core, не змінюючи код? — Query Store: закріпити добрий план або додати підказку через
sys.sp_query_store_set_hints.
Статистика, фрагментація й обслуговування індексів: що важливо і коли потрібні filtered і columnstore індекси?
Section titled “Статистика, фрагментація й обслуговування індексів: що важливо і коли потрібні filtered і columnstore індекси?”Що це
- Статистика — об’єкт із гістограмою значень стовпця: скільки рядків припадає на кожен діапазон. За нею оптимізатор вирішує, seek чи scan.
- Фрагментація — логічний порядок сторінок індексу розходиться з фізичним, а сторінки заповнені не до кінця.
- Фільтрований індекс —
CREATE INDEX ... WHERE Status = 0: у ньому лише відповідні рядки. - Columnstore — зберігає кожен стовпець окремо й стиснуто, тому агрегати по мільйонах рядків читають мало даних.
Як користуватися
- Автооновлення статистики (
AUTO_UPDATE_STATISTICS) не вимикають. - Після масового завантаження статистику оновлюють вручну:
UPDATE STATISTICS dbo.Orders.
Типова помилка
Нічний REBUILD усіх індексів «для профілактики». Він навантажує диск і журнал, а користь часто дає лише оновлена статистика.
Як влаштовано
- Гістограма — до 200 кроків за першим стовпцем статистики, плюс щільність для префіксів стовпців.
- На рівні сумісності 130+ поріг автооновлення —
MIN(500 + 0.2 * n, SQRT(1000 * n))змін. Для 2 млн рядків це близько 45 000, а не 400 000. - Із
AUTO_UPDATE_STATISTICS_ASYNCзапит не чекає оновлення: він компілюється зі старою статистикою, а нова рахується у фоні.
Обслуговування
| Операція | Що робить |
|---|---|
UPDATE STATISTICS |
перераховує статистику, дешево |
REORGANIZE |
ущільнює листки, завжди онлайн, статистику не чіпає |
REBUILD |
перестворює індекс і статистику з повним скануванням |
Коли що обирати
- Filtered — мала гаряча частина таблиці (черга,
IsDeleted = 0) та унікальність серед не-NULLзначень. - Nonclustered columnstore — аналітика просто по OLTP-таблиці, clustered columnstore — таблиці фактів сховища.
Пастки
- Індекс
WHERE Status = 0не підійде запитуWHERE Status = @s: закешований план мусить працювати за будь-якого значення. Потрібен літерал абоOPTION (RECOMPILE). - Зміна таблиці з фільтрованим індексом падає за
QUOTED_IDENTIFIER OFF— так за замовчуванням у sqlcmd і в процедурах, створених із цим налаштуванням.
Під капотом
REBUILDоновлює статистику індексу якFULLSCAN, і залежні від неї плани перекомпілюються. Microsoft прямо пише, що ефект «після перебудови» часто дає саме статистика.- Фрагментація заважає, коли послідовне читання помітно швидше за випадкове, — на HDD. На SSD і для даних у пам’яті важливіша щільність сторінок:
avg_page_space_used_in_percentуsys.dm_db_index_physical_stats. - Columnstore ділить таблицю на групи до 1 048 576 рядків (rowgroup) і стискає кожен стовпець у сегмент із min і max. Запит пропускає сегменти поза умовою, а пакетний режим обробляє рядки пачками.
- Дрібні вставки потрапляють у delta store — звичайне B-дерево; видалення в стиснених групах лише позначає рядки.
Ціна
REBUILDпише в журнал і потребує місця під копію індексу.ONLINE = ONє лише в Enterprise, відновлювана перебудова (RESUMABLE = ON) — із SQL Server 2017.UPDATEу columnstore — це позначка видалення плюс вставка в delta store. Для пошуку окремих рядків поверх clustered columnstore додають B-tree індекс.
Компроміси
- Ordered columnstore (
ORDER (...)): clustered — із SQL Server 2022, nonclustered — із 2025. Пропуск сегментів кращий, завантаження повільніше. - Пакетний режим без columnstore — batch mode on rowstore, SQL Server 2019 на рівні сумісності 150.
Як перевірити в продакшені
sys.dm_db_stats_properties:last_updated,rows_sampled,modification_counter.- Ефект обслуговування вимірюють у Query Store до і після, а не за відсотком фрагментації.
Підсумок: статистику обслуговують регулярно, індекси — за вимірюваннями, а filtered і columnstore виграють лише на своєму профілі даних.
DROP TABLE IF EXISTS dbo.Tasks;CREATE TABLE dbo.Tasks( TaskId int IDENTITY PRIMARY KEY, Status tinyint NOT NULL, -- 0 — нове, 1 — оброблене CreatedAt datetime2 NOT NULL DEFAULT SYSUTCDATETIME());INSERT dbo.Tasks (Status)SELECT TOP (100000) IIF(ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 1000 = 0, 0, 1)FROM sys.all_objects AS a CROSS JOIN sys.all_objects AS b;
-- Фільтрований індекс: 100 нових завдань зі 100 000CREATE INDEX IX_Tasks_New ON dbo.Tasks (CreatedAt) WHERE Status = 0;SELECT TOP (10) TaskId FROM dbo.Tasks WHERE Status = 0 ORDER BY CreatedAt; -- Seek по IX_Tasks_New
-- Коли оновлювалася статистика і скільки змін накопичилосяSELECT s.name, sp.last_updated, sp.rows, sp.modification_counterFROM sys.stats AS sCROSS APPLY sys.dm_db_stats_properties(s.object_id, s.stats_id) AS spWHERE s.object_id = OBJECT_ID('dbo.Tasks');
-- Щільність сторінок і фрагментаціяSELECT index_id, avg_page_space_used_in_percent, avg_fragmentation_in_percentFROM sys.dm_db_index_physical_stats(DB_ID(), OBJECT_ID('dbo.Tasks'), NULL, NULL, 'SAMPLED');- Чому після
REBUILDзапити прискорилися? — Частіше через статистику, оновлену зFULLSCAN, і перекомпіляцію планів, а не через дефрагментацію. - Чим
REORGANIZEвідрізняється відREBUILD? — Перший ущільнює листки онлайн і не чіпає статистику, другий перестворює індекс і статистику. - Чому фільтрований індекс не використовується з параметром? — Закешований план має працювати для будь-якого значення, а значення може вийти за фільтр.
- Коли columnstore — погана ідея? — Часті точкові зміни й пошук окремих рядків в OLTP.
- Як зрозуміти, що статистика застаріла? —
modification_counterіlast_updatedуsys.dm_db_stats_propertiesта розбіжність оцінки й факту в плані.