SQL і T-SQL
Мова запитів очима розробника торговельної системи: з’єднання, групування, підзапити й CTE, віконні функції, процедури й функції, обробка множин замість циклів. Основний діалект — T-SQL; де PostgreSQL поводиться інакше, це сказано одним рядком, а подробиці — в темі «PostgreSQL проти SQL Server».
Які бувають JOIN і чим вони відрізняються?
Section titled “Які бувають JOIN і чим вони відрізняються?”Що це
JOIN з’єднує рядки двох таблиць за умовою в ON. Види відрізняються тим, що робити з рядками без пари.
| Вид | Що поверне |
|---|---|
INNER JOIN |
лише рядки з парою |
LEFT JOIN |
усі рядки зліва; немає пари — NULL справа |
RIGHT JOIN |
те саме для правої таблиці |
FULL JOIN |
усі рядки обох таблиць |
CROSS JOIN |
усі комбінації, без умови |
Як користуватися
- Чеки з назвою магазину —
INNER JOIN. - Усі магазини, навіть без продажів, —
LEFT JOINвід магазинів до продажів. - Сітка «магазин × день» для звіту з нулями —
CROSS JOINз календарем.
Типова помилка
Умова на праву таблицю LEFT JOIN у WHERE: WHERE s.SoldOn = '2026-10-01'. У магазинів без продажів там NULL, порівняння не істинне, і вони зникнуть. Такий фільтр ставлять в ON.
Як влаштовано
- Сервер логічно будує всі пари рядків і залишає ті, де умова
ONістинна. - Зовнішнє з’єднання додає рядки збережуваної сторони без пари, з
NULLна місці іншої сторони. - Лише після цього
WHEREфільтрує результат.
Тому для INNER JOIN умова в ON і в WHERE рівнозначна, а для LEFT JOIN — ні.
Коли що обирати
| Задача | Конструкція |
|---|---|
| Чи є пара, стовпці справа не потрібні | EXISTS |
| Що є лише в одному з двох джерел | FULL JOIN і перевірка на NULL |
| Top-N або функція від стовпців кожного рядка | CROSS APPLY або OUTER APPLY |
EXISTS — це напівз’єднання: рядок зліва повертається один раз, скільки б пар не знайшлося. CROSS APPLY поводиться як INNER JOIN, OUTER APPLY — як LEFT. RIGHT JOIN зазвичай переписують на LEFT — так легше читати.
Пастки
- Розмноження рядків. З’єднання «один до багатьох» повторює рядок шапки для кожної деталі, і
SUM(h.Total)за шапкою чека порахує суму кілька разів. NULLу ключі.NULL = NULLне істинне, тому рядки зNULLу стовпці з’єднання пари не знаходять.- LEFT, потім INNER.
A LEFT JOIN B ... JOIN C ON C.Id = B.CIdвідкине рядки A без B: внутрішнє з’єднання знову вимагає пари.
Правило
Фільтр за збережуваною стороною — у WHERE, за приєднуваною — в ON. Перевірка існування — EXISTS, а не JOIN із DISTINCT.
Під капотом
- Логічний JOIN не задає алгоритм. Оптимізатор обирає Nested Loops (мало рядків ззовні, індекс усередині), Merge Join (обидва входи відсортовані за ключем) або Hash Match (великі невідсортовані входи). Докладно — в темі «Індекси й плани запитів».
APPLYлогічно викликає праву частину для кожного рядка зліва. Підзапит або вбудовувана функція при цьому розкриваються в план, і оптимізатор може переписати їх на звичайне з’єднання.- Усунення з’єднань. LEFT JOIN до таблиці, чиї стовпці не використовуються, оптимізатор прибирає, якщо ключ справа унікальний. INNER JOIN — якщо є довірений (
WITH CHECK) зовнішній ключ на стовпціNOT NULL.
Ціна
- Розмноження рядків — це не лише неправильна сума, а й зайві читання та пам’ять на сортування й хеш-таблиці.
JOINізDISTINCTспершу розмножує рядки, потім згортає їх сортуванням або хешем.EXISTSзупиняється на першій знайденій парі.
Компроміси
CROSS APPLY (SELECT TOP (1) ... ORDER BY ...)швидкий за індексу за ключем і датою та невеликої кількості зовнішніх рядків. На мільйонах рядків вигідніша нумерація одним проходом.- У PostgreSQL те саме пишуть як
CROSS JOIN LATERALіLEFT JOIN LATERAL ... ON true.
Як перевірити в продакшені
- У фактичному плані порівнюють оцінку й реальну кількість рядків після з’єднання: зростання на порядок — ознака розмноження.
- Якщо з’єднання усунуто, таблиці в плані просто немає; якщо вона там є — перевірте унікальність і довіреність ключів.
Підсумок: JOIN — логічна операція над множинами; алгоритм обирає оптимізатор, а кількість рядків і місце умови лишаються на вас.
CREATE TABLE #Stores (StoreId int PRIMARY KEY, Name varchar(20));CREATE TABLE #Sales (SaleId int PRIMARY KEY, StoreId int, Amount int, SoldOn date);INSERT #Stores VALUES (1, 'Center'), (2, 'Station'), (3, 'Outskirts');INSERT #Sales VALUES (101, 1, 500, '2026-10-01'), (102, 2, 300, '2026-09-30'), (103, NULL, 70, '2026-10-01');
-- Усі магазини та їхні продажі за 1 жовтня: фільтр за правою таблицею — в ONSELECT st.Name, sa.AmountFROM #Stores AS stLEFT JOIN #Sales AS sa ON sa.StoreId = st.StoreId AND sa.SoldOn = '2026-10-01';-- Center 500, Station NULL, Outskirts NULL
-- Той самий фільтр у WHERE відкидає рядки з NULL: LEFT JOIN став внутрішнімSELECT st.Name, sa.AmountFROM #Stores AS stLEFT JOIN #Sales AS sa ON sa.StoreId = st.StoreIdWHERE sa.SoldOn = '2026-10-01';-- Center 500
-- CROSS APPLY: останній продаж кожного магазину; магазини без продажів випадутьSELECT st.Name, lastSale.AmountFROM #Stores AS stCROSS APPLY (SELECT TOP (1) sa.Amount FROM #Sales AS sa WHERE sa.StoreId = st.StoreId ORDER BY sa.SoldOn DESC) AS lastSale;-- Center 500, Station 300- Чим умова в
ONвідрізняється від умови уWHEREдля LEFT JOIN? — УWHEREрядки без пари відкидаються, і LEFT JOIN стає внутрішнім. - Коли потрібен FULL JOIN? — Звірка двох джерел: знайти рядки, які є лише в одному з них.
- Чим CROSS APPLY відрізняється від INNER JOIN? — Права частина може посилатися на стовпці поточного рядка зліва: функція,
TOP (N)на рядок. - Чому сума зросла після JOIN? — З’єднання «один до багатьох» повторило рядки; агрегуйте до з’єднання.
- Як знайти магазини без продажів? —
NOT EXISTSабо LEFT JOIN ізWHERE sa.SaleId IS NULL.
Як працюють GROUP BY і HAVING і в якому порядку логічно виконується SELECT?
Section titled “Як працюють GROUP BY і HAVING і в якому порядку логічно виконується SELECT?”Що це
GROUP BYрозбиває рядки на групи з однаковими значеннями вказаних стовпців.- Агрегатна функція згортає групу в одне значення:
COUNT,SUM,AVG,MIN,MAX. HAVING— фільтр за групами; у ньому можна використовувати агрегати.
Логічний порядок
FROMіJOIN— джерело рядків.WHERE— фільтр рядків.GROUP BY— групи.HAVING— фільтр груп.SELECT— вирази й псевдоніми.DISTINCT, потімORDER BY, потімTOPабоOFFSET.
Як користуватися
Магазини з виторгом понад 100: GROUP BY StoreId, потім HAVING SUM(Amount) > 100 і ORDER BY Revenue DESC за псевдонімом.
Типова помилка
Написати WHERE SUM(Amount) > 100. На кроці WHERE груп ще немає — потрібен HAVING.
Як влаштовано
- Після
GROUP BYкожен рядок результату — група. УSELECTдопустимі стовпці групування, агрегати й вирази над ними; будь-який інший стовпець — помилка. - Агрегати пропускають
NULL:COUNT(*)рахує рядки,COUNT(Amount)— лише заповнені значення,AVGділить на кількість не-NULL. AVGвідintу SQL Server повертаєint: середнє 400,5 стане 400. Потрібен дріб —AVG(Amount * 1.0)або приведення доdecimal.- Усі
NULLу стовпці групування потрапляють в одну групу, хочаNULL = NULLне істинне.
Коли що обирати
| Умова | Куди ставити |
|---|---|
| На вихідні стовпці | WHERE: менше рядків потрапить у групування |
| На агрегат | HAVING |
На псевдонім із SELECT |
обгорнути запит у підзапит і фільтрувати ззовні |
Пастки
- Умовна агрегація. Виторг за категоріями в одному рядку рахують через
SUM(CASE WHEN Category = 'Milk' THEN Amount END). У PostgreSQL те саме коротше:SUM(Amount) FILTER (WHERE ...). - Порядок рядків. Його гарантує лише зовнішній
ORDER BY. Без нього порядок будь-який, навіть якщо «завжди був за ключем».
Правило
Фільтруйте якомога раніше: WHERE до HAVING. Вираз, що повторюється, виносьте в підзапит або CROSS APPLY (VALUES (...)), а не копіюйте в SELECT і GROUP BY.
Під капотом
- Логічний порядок — порядок зв’язування імен. Документація Microsoft прямо пише, що фізичний порядок обирає обробник запитів і він може відрізнятися.
- Оптимізатор переносить умови
HAVINGбез агрегатів нижче групування, а фільтри — ближче до читання таблиці. - Групування виконує Stream Aggregate (вхід відсортовано за ключами групування, наприклад індексом) або Hash Aggregate (хеш-таблиця в пам’яті).
Ціна
- Hash Aggregate і сортування отримують виділення пам’яті до старту. За заниженої оцінки рядків дані скидаються в tempdb, і в плані з’являється попередження про spill.
- Індекс, ключ якого починається зі стовпців
GROUP BY, дає Stream Aggregate без сортування.
Компроміси
- Порядок обчислення предикатів не гарантований.
WHERE ISNUMERIC(Code) = 1 AND CAST(Code AS int) > 100може впасти, надійно —TRY_CAST(Code AS int) > 100. GROUPING SETS,ROLLUPіCUBEрахують підсумки й проміжні підсумки за один прохід. ФункціяGROUPING()відрізняє рядок підсумку від групи зі справжнімNULL.- PostgreSQL дозволяє псевдонім із
SELECTуGROUP BYі стовпці, функціонально залежні від первинного ключа групи; SQL Server — ні.
Як перевірити в продакшені
- У фактичному плані дивляться оператор агрегації й попередження про spill у Sort і Hash.
- Велика розбіжність оцінки й факту перед агрегацією — привід перевірити статистику, докладно — в темі «Індекси й плани запитів».
Підсумок: логічний порядок пояснює видимість імен, а фізичний обирає оптимізатор, тому покладатися на порядок обчислення умов не можна.
-- Чеки: магазин і сума (NULL — чек ще не закритий)SELECT StoreId, COUNT(*) AS Receipts, -- усі рядки групи COUNT(Amount) AS Closed, -- лише не NULL SUM(Amount) AS Revenue, AVG(Amount) AS AvgCheck -- int: дробова частина відкидаєтьсяFROM (VALUES (1, 500), (1, 301), (2, 70), (2, NULL), (3, 900), (4, 1000)) AS r(StoreId, Amount)WHERE StoreId <> 4 -- фільтр рядків до групуванняGROUP BY StoreIdHAVING SUM(Amount) > 100 -- фільтр груп після агрегаціїORDER BY Revenue DESC; -- псевдонім із SELECT видно лише тут-- StoreId Receipts Closed Revenue AvgCheck-- 3 1 1 900 900-- 1 2 2 801 400 (у PostgreSQL 400.5)-- Магазин 2 (виторг 70) відсіяв HAVING, магазин 4 — WHERE.-- WHERE Revenue > 100 дав би помилку: Invalid column name 'Revenue'- Чому псевдонім із
SELECTне можна використати уWHERE? —WHEREлогічно виконується раніше заSELECT, імені ще немає. - Чим
COUNT(*)відрізняється відCOUNT(col)? — Перший рахує рядки, другий — значення, відмінні відNULL. - Чи можна в
HAVINGнаписати умову без агрегату? — Можна, але місце їй уWHERE; оптимізатор зазвичай перенесе її сам. - Що поверне
AVGза стовпцемint? — У SQL Serverintз відкинутою дробовою частиною, у PostgreSQL —numeric. - Як отримати підсумки за магазинами й загальний підсумок одним запитом? —
GROUP BY ROLLUP (StoreId)абоGROUPING SETS.
Підзапити й CTE: чим відрізняються, як працює рекурсія і чому NOT IN ламається на NULL?
Section titled “Підзапити й CTE: чим відрізняються, як працює рекурсія і чому NOT IN ламається на NULL?”Що це
- Підзапит — запит усередині запиту: скалярний (одне значення в
SELECT), табличний (уFROM) або умова (IN,EXISTSуWHERE). - Корельований підзапит посилається на стовпці зовнішнього запиту: «продажі цього магазину».
- CTE (common table expression, узагальнений табличний вираз) — підзапит з ім’ям:
WITH Recent AS (SELECT ...) SELECT ... FROM Recent. - Рекурсивний CTE посилається сам на себе. Ним обходять дерева: категорії, оргструктуру, комплекти.
Навіщо
CTE розбиває складний запит на кроки зі зрозумілими іменами, і він читається згори донизу, як код.
Типова помилка
WHERE ProductId NOT IN (SELECT ProductId FROM Stops) за NULL у Stops повертає порожній результат: порівняння з NULL не дає ні «так», ні «ні». Використовуйте NOT EXISTS.
Як влаштовано
- Тризначна логіка. Умова буває TRUE, FALSE або UNKNOWN. Будь-яке порівняння з
NULL, навітьNULL = NULL, дає UNKNOWN, аWHEREпропускає лише TRUE. NOT IN.x NOT IN (2, NULL)розкривається вx <> 2 AND x <> NULL. Друга частина — UNKNOWN, тому вся умова не TRUE для жодного рядка.- Рекурсія. Спершу виконується якір. Потім рекурсивна частина з’єднується з результатом попереднього кроку, поки не поверне порожній набір.
UNION ALLсклеює всі кроки. - Матеріалізація. CTE в SQL Server не зберігається: два посилання на нього — два виконання.
Коли що обирати
| Задача | Вибір |
|---|---|
| Чи є пов’язаний рядок | EXISTS або NOT EXISTS |
| Короткий список констант | IN (1, 2, 3) |
| Кроки складного запиту | CTE |
| Проміжний результат потрібен багато разів | тимчасова таблиця #t |
Пастки
- Інструкція перед
WITHмає закінчуватися;, інакше синтаксична помилка. MAXRECURSIONза замовчуванням 100: глибше — помилка.OPTION (MAXRECURSION 0)знімає ліміт, але разом із ним і захист від зациклення.Status <> 'Closed'не поверне рядки зNULLуStatus. Потрібно додатиOR Status IS NULLабо, із SQL Server 2022, писатиStatus IS DISTINCT FROM 'Closed'.
Правило
Для «немає у списку» — NOT EXISTS. Стовпці, де NULL не має сенсу, оголошуйте NOT NULL.
Під капотом
- CTE в SQL Server розгортається в запит, як представлення. За документацією результат не матеріалізується: кожне зовнішнє посилання виконує визначення заново.
- У PostgreSQL 12+ CTE без побічних ефектів вбудовується, якщо на нього одне посилання, а за кількох обчислюється один раз.
MATERIALIZEDіNOT MATERIALIZEDзмінюють це рішення. - Рекурсивний CTE у плані — spool зі стеком (
WITH STACK) і вкладені цикли: рекурсивна частина обробляє рядки по одному. NOT INза nullable-стовпцем додає в план перевірки наNULL;NOT EXISTSдає один anti semi join.
Ціна
- CTE з трьома посиланнями тричі читає таблиці. Тимчасова таблиця обчислюється один раз і має статистику, а в табличної змінної статистики за стовпцями немає.
- Рекурсія без індексу за
ParentIdчитає таблицю на кожній ітерації.
Компроміси
| Зберігання ієрархії | Плюс | Мінус |
|---|---|---|
ParentId і рекурсивний CTE |
просто, легко змінювати | обхід дорогий |
hierarchyid |
піддерево — діапазон за індексом | перенесення гілки дороге |
| Closure table | предки й нащадки — звичайний JOIN | окрема таблиця, підтримка під час змін |
Тризначна логіка діє й в обмеженнях: CHECK відхиляє лише FALSE, тому NULL його проходить. Унікальний індекс SQL Server допускає один NULL, PostgreSQL — скільки завгодно, а з 15-ї версії можна NULLS NOT DISTINCT.
Як перевірити в продакшені
- У плані шукають повторне читання однієї таблиці від різних посилань на CTE.
- Підозрілі
NOT IN (SELECTзнаходить пошук заsys.sql_modules.definition.
Підсумок: CTE покращує читабельність, але не кешує результат, а NULL у запереченнях закривають NOT EXISTS або обмеженням NOT NULL.
DECLARE @Products TABLE (ProductId int NOT NULL);DECLARE @Stops TABLE (ProductId int NULL); -- стоп-лист із «порожнім» рядкомINSERT @Products VALUES (1), (2), (3);INSERT @Stops VALUES (2), (NULL);
SELECT ProductId FROM @ProductsWHERE ProductId NOT IN (SELECT ProductId FROM @Stops);-- порожньо: 1 <> NULL дає UNKNOWN, і рядок відкидається
SELECT p.ProductId FROM @Products AS pWHERE NOT EXISTS (SELECT 1 FROM @Stops AS s WHERE s.ProductId = p.ProductId);-- 1, 3-- Усі підкатегорії «Food» з рівнем вкладеностіWITH Categories AS ( SELECT * FROM (VALUES (1, NULL, 'Food'), (2, 1, 'Dairy'), (3, 2, 'Cheese'), (4, 1, 'Bakery'), (5, NULL, 'Household')) AS c(Id, ParentId, Name)), Tree AS ( SELECT Id, Name, 0 AS Lvl FROM Categories WHERE Name = 'Food' -- якір UNION ALL SELECT c.Id, c.Name, t.Lvl + 1 -- рекурсивна частина FROM Categories AS c JOIN Tree AS t ON c.ParentId = t.Id)SELECT Id, Name, Lvl FROM TreeOPTION (MAXRECURSION 100); -- 100 і так за замовчуванням; 0 — без обмеження-- Food 0, Dairy 1, Bakery 1, Cheese 2 (порядок рядків не гарантований)- Чи матеріалізується CTE в SQL Server? — Ні, кожне звернення виконує його заново; для повторного використання — тимчасова таблиця.
- Чому
NOT INповернув порожній результат? — У підзапиті єNULL, і умова стала UNKNOWN для всіх рядків. - Чим обмежена глибина рекурсії? —
MAXRECURSION: за замовчуванням 100, допустимо від 0 до 32767, 0 — без ліміту. - Чи є різниця між
INіEXISTSдля позитивної перевірки? — Зазвичай ні: оптимізатор будує те саме напівз’єднання. - Як написати рекурсію в PostgreSQL? —
WITH RECURSIVE; з 14-ї версії єCYCLEдля захисту від циклів.
Що таке віконні функції і які задачі вони розв’язують?
Section titled “Що таке віконні функції і які задачі вони розв’язують?”Що це
Віконна функція — функція з OVER (...). Вона бачить «вікно» рядків довкола поточного й повертає значення для кожного рядка.
| Функція | Що робить |
|---|---|
ROW_NUMBER() |
номер рядка: 1, 2, 3 |
RANK(), DENSE_RANK() |
місце з урахуванням рівних значень |
LAG(), LEAD() |
значення з попереднього або наступного рядка |
SUM() OVER (...) |
сума за вікном, наприклад накопичувальна |
Як користуватися
PARTITION BY StoreId— рахувати окремо для кожного магазину.ORDER BY SoldOn— у якому порядку йдуть рядки всередині магазину.LAG(Amount) OVER (PARTITION BY StoreId ORDER BY SoldOn)— виторг минулого дня поруч із поточним.
Типова помилка
Написати WHERE ROW_NUMBER() OVER (...) = 1. Віконні функції дозволені лише в SELECT і ORDER BY, тому нумерують у CTE або підзапиті, а фільтрують ззовні.
Як влаштовано
PARTITION BYділить рядки на секції; у кожній розрахунок починається заново.ORDER BYзадає порядок усередині секції.- Рамка (
ROWSабоRANGE) обирає рядки відносно поточного. ЯкщоORDER BYє, а рамки немає, дієRANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW.
Рівні значення
| Функція | Для виторгу 900, 700, 700, 300 |
|---|---|
ROW_NUMBER |
1, 2, 3, 4 — порядок рівних не визначений |
RANK |
1, 2, 2, 4 — із пропуском |
DENSE_RANK |
1, 2, 2, 3 — без пропуску |
Коли що обирати
- Top-N у групі та видалення дублікатів —
ROW_NUMBERу CTE й фільтрrnу зовнішньому запиті. - «Усі, хто ділить призові місця», —
RANKабоDENSE_RANK. - Зміна до минулого періоду —
LAG; накопичувальний підсумок —SUM(...) OVER (... ROWS UNBOUNDED PRECEDING).
Пастки
RANGEза замовчуванням. Рядки з однаковим ключемORDER BYпотрапляють у рамку разом: два чеки за один день отримають однаковий «накопичувальний» підсумок.LAST_VALUE. З рамкою за замовчуванням повертає поточний рядок. Потрібна рамкаROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.- Недетермінованість.
ROW_NUMBERза неунікальним ключем нумерує рівні рядки довільно — додавайте вORDER BYунікальний стовпець.
Правило
Для накопичувальних підсумків завжди пишіть рамку явно: ROWS UNBOUNDED PRECEDING.
Під капотом
- Віконні функції обчислюються на кроці
SELECT, післяGROUP BYіHAVING, тому працюють поверх агрегатів:SUM(SUM(Amount)) OVER ()— загальний виторг поруч із виторгом магазину. - Плану потрібен вхід, відсортований за
PARTITION BYіORDER BY: оператор Sort або індекс у потрібному порядку. - У row mode рамку тримає Window Spool: для
ROWSз рамкою менше 10 000 рядків — у пам’яті, дляRANGEі великих рамок — у робочій таблиці tempdb. - У batch mode агрегати рахує Window Aggregate без spool. Batch mode вмикається з columnstore, а із SQL Server 2019 і на звичайних таблицях за рівня сумісності 150.
Ціна
- Сортування великого входу — виділення пам’яті й ризик spill у tempdb.
RANGEзамістьROWSу накопичувальному підсумку на унікальному ключі дає той самий результат, але в рази більше читань.
Компроміси
- POC-індекс (partition, order, covering: ключ зі стовпців
PARTITION BYіORDER BY, решта вINCLUDE) прибирає сортування, але здорожує запис. - Top-1 у групі через
CROSS APPLY (SELECT TOP (1) ...)виграє, коли груп мало і є індекс;ROW_NUMBER— коли груп багато. - SQL Server 2022 додав
WINDOW(рівень сумісності 160) таIGNORE NULLSдляFIRST_VALUE,LAST_VALUE,LAG,LEAD.RANGEіз числовим зсувом є в PostgreSQL 11+, але не в SQL Server.
Як перевірити в продакшені
- У фактичному плані: Sort перед Window Spool, режим виконання (
BatchабоRow), попередження про spill. SET STATISTICS IO ON: великі читанняWorktable— ознакаRANGEабо широкої рамки.
Підсумок: віконна функція коштує сортування й spool; явна рамка ROWS, POC-індекс і batch mode роблять її дешевшою за самоз’єднання й курсори.
CREATE TABLE #Receipts (ReceiptId int PRIMARY KEY, StoreId int, SoldOn date, Amount int);INSERT #Receipts VALUES (1, 1, '2026-10-01', 100), (2, 1, '2026-10-02', 150), (3, 1, '2026-10-02', 50), (4, 1, '2026-10-03', 120), (5, 2, '2026-10-01', 80), (6, 2, '2026-10-02', 90);
SELECT ReceiptId, StoreId, Amount, SUM(Amount) OVER (PARTITION BY StoreId ORDER BY SoldOn, ReceiptId ROWS UNBOUNDED PRECEDING) AS RunRows, SUM(Amount) OVER (PARTITION BY StoreId ORDER BY SoldOn) AS RunRange, -- рамка RANGE Amount - LAG(Amount) OVER (PARTITION BY StoreId ORDER BY SoldOn, ReceiptId) AS DeltaFROM #Receipts;-- ReceiptId StoreId Amount RunRows RunRange Delta-- 1 1 100 100 100 NULL-- 2 1 150 250 300 50 -- RANGE: обидва чеки за 02.10-- 3 1 50 300 300 -100 -- отримують суму на кінець дня-- 4 1 120 420 420 70-- 5 2 80 80 80 NULL -- нова секція: рахунок заново-- 6 2 90 170 170 10-- Топ-2 товари за виторгом у кожному магазиніWITH Ranked AS ( SELECT StoreId, Product, Revenue, ROW_NUMBER() OVER (PARTITION BY StoreId ORDER BY Revenue DESC, Product) AS Rn, RANK() OVER (PARTITION BY StoreId ORDER BY Revenue DESC) AS Rnk, DENSE_RANK() OVER (PARTITION BY StoreId ORDER BY Revenue DESC) AS DenseRnk FROM (VALUES (1, 'Milk', 900), (1, 'Bread', 700), (1, 'Eggs', 700), (1, 'Tea', 300), (2, 'Milk', 400), (2, 'Coffee', 650)) AS t(StoreId, Product, Revenue))SELECT StoreId, Product, Revenue, Rn, Rnk, DenseRnkFROM RankedWHERE Rn <= 2; -- у WHERE вихідного запиту віконну функцію використати не можна-- 1 Milk 900 1 1 1-- 1 Bread 700 2 2 2 -- Eggs відсічено: Rn = 3, хоча Rnk = 2; у Tea Rnk = 4, DenseRnk = 3-- 2 Coffee 650 1 1 1-- 2 Milk 400 2 2 2- Чим
RANKвідрізняється відDENSE_RANK? —RANKпропускає місця після рівних значень,DENSE_RANK— ні. - Чому накопичувальний підсумок однаковий у двох рядків? — Рамка за замовчуванням
RANGEоб’єднує рядки з рівним ключем сортування. - Як видалити дублікати, залишивши по одному рядку? —
ROW_NUMBER() OVER (PARTITION BY ключ ...)у CTE іDELETEіз CTE, де номер більший за 1. - Як знайти безперервні періоди продажів (gaps and islands)? — Різниця дати й
ROW_NUMBERстала всередині періоду, за нею й групують. - Чи можна фільтрувати за віконною функцією у
WHERE? — Ні, лише в зовнішньому запиті: вікна рахуються на кроціSELECT.
Збережені процедури, функції, представлення й тригери: що коли використовувати?
Section titled “Збережені процедури, функції, представлення й тригери: що коли використовувати?”Що це
| Об’єкт | Що робить | Як викликати |
|---|---|---|
| Процедура | будь-які інструкції, транзакції | EXEC dbo.PostInvoice @id = 1 |
| Скалярна функція | повертає одне значення | SELECT dbo.PriceWithVat(Price) |
| Таблична функція | повертає таблицю | FROM dbo.StoreSales(@storeId) |
| Представлення | збережений SELECT |
FROM dbo.ActiveProducts |
| Тригер | реагує на зміну таблиці | викликається сам |
Як користуватися
- Бізнес-операція зі зміною даних — процедура.
- Повторюваний відбір з параметром — таблична функція, без параметра — представлення.
- Аудит і перевірки, які не виразити обмеженням, — тригер.
Типова помилка
Вважати, що тригер спрацьовує на кожен рядок. У SQL Server він спрацьовує один раз на інструкцію, а таблиці inserted і deleted містять усі змінені рядки. SELECT @id = Id FROM inserted мовчки обробить лише один.
Як влаштовано
- Inline TVF — один вираз
RETURN (SELECT ...). Оптимізатор підставляє його в запит, як представлення, і бачить індекси та статистику. - Багатоінструкційна TVF заповнює табличну змінну кількома інструкціями. Оптимізатор оцінює її в 100 рядків, а до SQL Server 2014 — в один.
- Скалярна UDF до SQL Server 2019 викликається на кожен рядок і робить увесь план однопотоковим.
- Процедура компілюється під час першого виклику; план кешується й перевикористовується.
Обробка помилок
- На початку процедури —
SET XACT_ABORT, NOCOUNT ON. BEGIN TRY,BEGIN TRANSACTION, зміни,COMMIT.- У
BEGIN CATCH—IF @@TRANCOUNT > 0 ROLLBACK;іTHROW;.
Без XACT_ABORT частина помилок відкочує лише одну інструкцію, транзакція триває, і можна зафіксувати половину операції.
Пастки
THROWвраховуєXACT_ABORT,RAISERROR— ні. У новому коді пишутьTHROW, а інструкцію перед ним завершують;.- Помилки компіляції й розв’язання імен на тому самому рівні
TRY/CATCHне ловить. - Тригер
AFTERвиконується в тій самій транзакції:ROLLBACKу ньому скасовує й вихідну інструкцію.
Правило
Таблицю з функції повертайте лише через inline TVF. Багатоінструкційну переписуйте на inline або на процедуру з тимчасовою таблицею.
Під капотом
- Скалярна UDF до SQL Server 2019 — інтерпретоване порядкове виконання. Оптимізатор майже не враховує її вартість, паралелізм заборонено.
- SQL Server 2019 на рівні сумісності 150 перетворює придатну UDF на підзапит усередині плану. Не вбудовуються функції з
GETDATE(), табличними змінними, CTE, кількомаRETURNі низкою інших конструкцій; список обмежень зростав у накопичувальних оновленнях. - Із SQL Server 2017 (рівень 140) чергування виконання (interleaved execution) спершу матеріалізує багатоінструкційну TVF, а потім оптимізує решту запиту за реальною кількістю рядків. Працює лише в запитах на читання.
Ціна
SET STATISTICS IOне показує читань усередині скалярної UDF: запит виглядає дешевим, а витрачає хвилини.- Тригер подовжує транзакцію кожного запису в таблицю й ховає логіку від того, хто читає процедуру.
Компроміси
- Inline TVF дає перевикористання без втрати прозорості, але всередині — лише один
SELECT. - Індексоване представлення зберігає результат і прискорює агрегати, але здорожує кожен запис у базові таблиці.
- Історію змін замість тригерів ведуть темпоральні таблиці (SQL Server 2016+), вивантаження змін — Change Tracking або CDC.
- У PostgreSQL основна одиниця — функція; процедури з
COMMITусередині з’явилися в 11-й версії, а тригери буваютьFOR EACH ROWіFOR EACH STATEMENT.
Як перевірити в продакшені
sys.dm_exec_function_stats(SQL Server 2016+) показує кількість викликів і CPU кожної невбудованої скалярної функції.- Відкриті після тайм-аутів транзакції видно в
sys.dm_tran_session_transactionsіDBCC OPENTRAN.
Підсумок: об’єкт обирають за тим, чи бачить оптимізатор його вміст, а XACT_ABORT ON закриває діру з тайм-аутами клієнта.
CREATE TABLE dbo.Stock (ProductId int PRIMARY KEY, Qty int NOT NULL CHECK (Qty >= 0));INSERT dbo.Stock VALUES (1, 10), (2, 0);GOCREATE OR ALTER PROCEDURE dbo.MoveStock @from int, @to int, @qty intASSET XACT_ABORT, NOCOUNT ON; -- помилка виконання відкочує всю транзакціюBEGIN TRY BEGIN TRANSACTION; UPDATE dbo.Stock SET Qty = Qty + @qty WHERE ProductId = @to; UPDATE dbo.Stock SET Qty = Qty - @qty WHERE ProductId = @from; -- порушить CHECK COMMIT;END TRYBEGIN CATCH IF @@TRANCOUNT > 0 ROLLBACK; -- відкотити, якщо транзакція ще відкрита THROW; -- прокинути вихідну помилку клієнтуEND CATCH;GOEXEC dbo.MoveStock @from = 2, @to = 1, @qty = 5; -- помилка 547: конфлікт із CHECKGOSELECT ProductId, Qty FROM dbo.Stock; -- 1: 10, 2: 0 — перший UPDATE теж відкотився-- Які скалярні функції бази можна вбудувати (SQL Server 2019+)SELECT o.name, m.is_inlineableFROM sys.sql_modules AS mJOIN sys.objects AS o ON o.object_id = m.object_idWHERE o.type = 'FN'; -- FN — скалярні, IF — inline TVF, TF — багатоінструкційні- Чому скалярна UDF у
SELECTгальмує? — До SQL Server 2019 викликається на кожен рядок, забороняє паралелізм і не видна вSTATISTICS IO. - Чим inline TVF відрізняється від багатоінструкційної? — Inline розкривається в план, багатоінструкційна заповнює табличну змінну з фіксованою оцінкою рядків.
- Скільки разів спрацює тригер на
UPDATEтисячі рядків? — Один раз; усі рядки — вinsertedіdeleted. - Навіщо
SET XACT_ABORT ON, якщо єTRY/CATCH? —CATCHне ловить тайм-аут клієнта, аXACT_ABORTвідкотить транзакцію. - Чим
THROWкращий заRAISERROR? — Без параметрів прокидає вихідну помилку й враховуєXACT_ABORT.
Чим set-based підхід кращий за порядковий і як масово змінювати дані з .NET?
Section titled “Чим set-based підхід кращий за порядковий і як масово змінювати дані з .NET?”Що це
- Порядково — курсор або
WHILEпо рядках, а в C# — цикл, який на кожен рядок викликає окремийUPDATE. - Set-based — одна інструкція над набором:
UPDATE ... FROM ... JOIN,INSERT ... SELECT,MERGE.
Навіщо
Кожна інструкція має сталу ціну: план, блокування, фіксація. Порядковий код платить її на кожному рядку, а із застосунку додає мережевий виклик: 100 000 викликів по 1 мс — 100 секунд самої лише мережі.
Як користуватися
- Перерахувати ціни за прайсом —
UPDATE p SET Price = n.Price FROM Products AS p JOIN NewPrices AS n ON .... - Передати список рядків із C# — табличний параметр, одним викликом.
Типова помилка
Курсор за звичкою процедурної мови: «для кожного товару знайти ціну й оновити». Майже будь-який такий цикл — одне з’єднання й один UPDATE.
Як влаштовано
- Курсор тримає позицію в результаті й виконує тіло циклу на кожному рядку. Якщо він усе-таки потрібен, оголошуйте
LOCAL FAST_FORWARD— однопрохідний і лише для читання. MERGEза один прохід зіставляє джерело з цільовою таблицею й виконуєUPDATE,INSERTабоDELETEза гілкамиWHEN.OUTPUT $actionпоказує, що зроблено з кожним рядком.- TVP — параметр табличного типу (
CREATE TYPE ... AS TABLE); у процедурі він оголошуєтьсяREADONLY.
Пастки MERGE
- Два рядки джерела на один рядок цілі — помилка: рядок не можна оновити двічі. Джерело має бути унікальним за ключем.
- В
ON— лише ключ зіставлення. Фільтр цілі вONдає неправильні результати; фільтрують у гілкахWHEN ... AND. - Без
WITH (HOLDLOCK)паралельні upsert одного ключа ловлять порушення первинного ключа. MERGEмусить закінчуватися;(помилка 10713) і не проходить просту параметризацію, тому літерали замінюють параметрами.
Коли що обирати
| Обсяг із .NET | Спосіб |
|---|---|
| Кілька значень | звичайні параметри |
| До тисяч рядків | TVP або JSON з OPENJSON (SQL Server 2016+) |
| Сотні тисяч рядків | SqlBulkCopy у проміжну таблицю, потім set-based |
Правило
Курсор — лише там, де на кожен рядок потрібен окремий виклик: процедура, динамічний SQL по базах. Решта — однією інструкцією або порціями.
Під капотом
- Кожна фіксація чекає запису журналу на диск. Порядковий
INSERTв автофіксації — N скидань журналу й очікуваньWRITELOG, одна інструкція — одне. - Set-based дає оптимізатору вибір: hash join замість N пошуків за індексом, паралелізм, batch mode.
- Якщо одна інструкція бере 5000 і більше блокувань на одній таблиці чи індексі, SQL Server намагається замінити їх блокуванням усієї таблиці.
Компроміси
| Підхід | Плюс | Мінус |
|---|---|---|
| Одна інструкція | мінімум накладних витрат | довга транзакція, зростання журналу, ескалація |
| Порції за ключем | короткі блокування, журнал звільняється | більше коду, немає загальної атомарності |
| Порядково | контроль кожного рядка | накладні витрати й round trip на рядок |
- Порцію обирають за діапазоном ключа або через
TOP (N)з умовою за індексом. Інакше кожна порція заново сканує таблицю. - У моделі відновлення FULL журнал між порціями звільняють лише резервні копії журналу.
- Upsert без
MERGE:UPDATE ... WITH (UPDLOCK, SERIALIZABLE), за@@ROWCOUNT = 0—INSERT, усе в одній транзакції. У PostgreSQL для цього є атомарнийINSERT ... ON CONFLICT, аMERGE— з 15-ї версії. - TVP не має статистики за стовпцями: за великих обсягів дані перекладають у тимчасову таблицю.
Як перевірити в продакшені
- Query Store або
sys.dm_exec_query_stats: запит із мільйонами виконань по частці мілісекунди — порядкова обробка із застосунку. - Часті очікування
WRITELOG— дрібні фіксації;sys.dm_exec_cursorsпоказує відкриті курсори.
Підсумок: set-based прибирає накладні витрати на рядок, а порції не дають одній інструкції стати проблемою для всіх інших.
CREATE TABLE #SalesLog (Id int IDENTITY PRIMARY KEY, SoldAt datetime2 NOT NULL);INSERT #SalesLog (SoldAt)SELECT TOP (50000) DATEADD(DAY, -ABS(CHECKSUM(NEWID()) % 730), SYSDATETIME())FROM sys.all_objects AS a CROSS JOIN sys.all_objects AS b;
DECLARE @batch int = 4000, @deleted int = 1; -- порція нижче порогу ескалаціїWHILE @deleted > 0BEGIN DELETE TOP (@batch) FROM #SalesLog WHERE SoldAt < DATEADD(YEAR, -1, SYSDATETIME()); SET @deleted = @@ROWCOUNT; -- кожна порція — окрема транзакція -- WAITFOR DELAY '00:00:01'; -- пауза для OLTP-навантаженняEND;SELECT COUNT(*) AS LeftRows FROM #SalesLog; -- лишилися продажі за останній рікCREATE TABLE dbo.Prices (ProductId int PRIMARY KEY, Price decimal(10, 2) NOT NULL);GO-- Табличний тип: PRIMARY KEY не пустить дублікати в джерело MERGECREATE TYPE dbo.PriceList AS TABLE (ProductId int PRIMARY KEY, Price decimal(10, 2) NOT NULL);GOCREATE OR ALTER PROCEDURE dbo.UpsertPrices @Prices dbo.PriceList READONLYASSET XACT_ABORT, NOCOUNT ON;MERGE dbo.Prices WITH (HOLDLOCK) AS t -- HOLDLOCK: без нього паралельні викликиUSING @Prices AS s -- ловлять порушення PRIMARY KEY ON t.ProductId = s.ProductId -- в ON лише ключ зіставленняWHEN MATCHED AND t.Price <> s.Price THEN UPDATE SET Price = s.PriceWHEN NOT MATCHED BY TARGET THEN INSERT (ProductId, Price) VALUES (s.ProductId, s.Price); -- ';' обов'язковаGODECLARE @p dbo.PriceList;INSERT @p VALUES (1, 49.90), (2, 15.00);EXEC dbo.UpsertPrices @Prices = @p;SELECT ProductId, Price FROM dbo.Prices; -- 1: 49.90, 2: 15.00using System.Data;using Microsoft.Data.SqlClient;
// Стовпці та їхній порядок збігаються з типом dbo.PriceList на серверіvar prices = new DataTable();prices.Columns.Add("ProductId", typeof(int));prices.Columns.Add("Price", typeof(decimal));prices.Rows.Add(1, 49.90m);prices.Rows.Add(2, 15.00m);
await using var connection = new SqlConnection( "Server=.;Database=Shop;Integrated Security=true;TrustServerCertificate=true");await connection.OpenAsync();
await using var command = new SqlCommand("dbo.UpsertPrices", connection){ CommandType = CommandType.StoredProcedure};command.Parameters.Add(new SqlParameter("@Prices", SqlDbType.Structured){ TypeName = "dbo.PriceList", // ім'я табличного типу на сервері Value = prices // DataTable, DbDataReader або IEnumerable<SqlDataRecord>});
// Один виклик і один MERGE на сервері замість N окремих UPDATE та INSERTawait command.ExecuteNonQueryAsync();- Коли курсор виправданий? — Коли на кожен рядок потрібен окремий виклик: процедура, динамічний SQL по об’єктах.
- Чому
MERGEпадає з порушенням первинного ключа під навантаженням? — Два сеанси одночасно не знайшли ключ і обидва вставили рядок; потрібенHOLDLOCK. - Як видалити 50 млн рядків, не зупинивши роботу бази? — Порціями по кілька тисяч за ключем, кожна у своїй транзакції, з паузами.
- Чим TVP відрізняється від
SqlBulkCopy? — TVP — параметр виклику із серверною логікою,SqlBulkCopy— потокове завантаження в таблицю для великих обсягів. - Як передати список id у PostgreSQL? — Масивом:
WHERE id = ANY(@ids), Npgsql передаєint[]як масив.