SQL
SQL — це мова, якою ви просите в реляційної бази дані, і спосіб покластися на гарантії, яких ваш застосунок сам не забезпечить. Ви описуєте результат, планувальник обирає шлях, а транзакції вирішують, що бачать паралельні запити. Сторінка починається з шістнадцяти несподіванок — хибних рядків, повільних запитів, загублених оновлень — із правилом за кожною і способом виправити; далі — як обчислюється запит, планувальник та індекси, транзакції й ізоляція, обмеження й зміни схеми, рушій у продакшні, п’ять задокументованих кейсів і де все це вироджується.
- Ви описуєте
- Результат, а не шлях
- Поточні версії
- PostgreSQL 18 (19 — у жовтні 2026); MySQL 9.7 LTS
- Типова ізоляція
- Read committed (PostgreSQL); repeatable read (MySQL)
Шістнадцять несподіванок — що ви бачите, яке правило за цим стоїть і як виправити
Ніхто не шукає SQL заради визначення. Шукають, бо запит повернув не ті рядки, виконувався повільно або загубив оновлення, яке двоє користувачів зробили одночасно. Майже кожна така несподіванка походить з одного з шістнадцяти правил, які пропускають підручники, — бо підручник виконує один запит за раз на тисячі рядків. Ось вони: що ви бачите, яке правило це спричиняє і як виправити. Наступні розділи посилаються на рядки за номерами.
| # | Що ви бачите | Правило, що за цим стоїть | Як виправити |
|---|---|---|---|
| 1 | WHERE manager_id = NULL нічого не повертає; NOT IN (підзапит) нічого не повертає, щойно в підзапиті з’являється один NULL | NULL означає «невідомо»: порівняння з ним ні істинне, ні хибне, а WHERE лишає тільки рядки, де умова істинна | IS NULL; NOT EXISTS замість NOT IN; IS DISTINCT FROM для порівняння, що вважає NULL рівними |
| 2 | COUNT(email) менший за COUNT(*); SUM по нулю рядків дає NULL, а не 0 | Агрегати пропускають NULL, і кожен агрегат, крім COUNT, повертає NULL для порожньої множини | COUNT(*), щоб рахувати рядки; COALESCE(SUM(amount), 0) |
| 3 | LEFT JOIN загубив клієнтів, у яких немає замовлень | WHERE виконується після з’єднання; з боку, де пари немає, кожна колонка — NULL, і умова не виконується | Перенести умови на приєднану таблицю в ON |
| 4 | Суми подвоїлися після того, як додали друге з’єднання | З’єднання двох таблиць «один до багатьох» множить рядки: 3 позиції замовлення × 2 платежі = 6 рядків | Агрегувати кожну дочірню таблицю окремо, а тоді з’єднувати результати |
| 5 | Той самий запит повертає рядки в іншому порядку; сторінки повторюють або пропускають рядки | Без ORDER BY порядку немає, і серед однакових значень його теж немає | ORDER BY, що закінчується унікальною колонкою, наприклад id |
| 6 | Звіт за жовтень губить останній день | BETWEEN включає обидва кінці, а '2026-10-31' означає північ на початку цього дня | Напіввідкриті діапазони: >= '2026-10-01' AND < '2026-11-01'; моменти часу зберігати з часовим поясом |
| 7 | Індекс є, але WHERE lower(email) = … читає всю таблицю | Індекс зберігає колонку, а не функцію від неї | Індекс на сам вираз або умова, переписана на чисту колонку |
| 8 | Індекс на текстовій колонці ігнорується, коли значення — число | Рушій перетворює колонку на тип іншої сторони, рядок за рядком | Передавати параметри типу самої колонки й перевіряти, що насправді надсилає ORM |
| 9 | Індекс на (customer_id, created_at) не допомагає WHERE created_at > … | Складений індекс упорядкований спершу за першою колонкою, як телефонна книга за прізвищем | Впорядкувати колонки так, як фільтруєте: рівність спершу, діапазон останнім; skip scan допомагає, лише коли в першій колонці мало значень |
| 10 | Сторінка 1 — 2 мс; сторінка 5000 — 2 секунди | OFFSET 100000 читає й викидає 100 000 рядків | Пагінація за ключем: WHERE (created_at, id) < ($1, $2) з тим самим ORDER BY |
| 11 | Кожен запит швидкий, а сторінка повільна | Один запит на кожен рядок списку — патерн N+1 — це двісті звернень | Діставати пов’язані рядки одним виразом: з’єднанням або одним IN; жадібне завантаження в ORM |
| 12 | Той самий запит учора був швидким | План будують зі статистики; застаріла чи оманлива статистика дає план для таблиці, якої не існує | EXPLAIN ANALYZE і порівняти очікувані рядки з фактичними; ANALYZE; розширена статистика для пов’язаних колонок |
| 13 | Два запити виконуються одночасно, і одне оновлення зникає | Прочитати, змінити в коді, записати назад: обидва запити читають те саме старе значення | Робити арифметику в самому UPDATE; або SELECT … FOR UPDATE; або колонка версії |
| 14 | Дві транзакції перевіряють правило, обидві проходять — і разом його порушують | Кожна читає знімок, у якому немає запису іншої, — перекіс запису (write skew) | SERIALIZABLE з повторами; блокувати рядки, від яких залежить правило; або обмеження, що формулює правило |
| 15 | Міграція, що виконується за мілісекунди, кладе сайт на секунди | Зміни схеми чекають на сильне блокування за довгими запитами, а кожен наступний запит стає в чергу за зміною | lock_timeout і повтор; CREATE INDEX CONCURRENTLY; NOT VALID, а тоді VALIDATE |
| 16 | Таблиця росте, хоча рядки видаляють; у найгіршому разі база перестає приймати записи | Старі версії рядків лишаються до прибирання, яке не може пройти повз найстарішу відкриту транзакцію; у PostgreSQL незаморожені рядки зрештою зупиняють базу | Короткі транзакції; таймаут для бездіяльних; алерти на вакуум і вік ідентифікаторів транзакцій |
Як обчислюється запит — логічний порядок, NULL і з’єднання
SELECT пишуть в одному порядку, а обчислюють в іншому. Ви пишете SELECT першим, але обчислюється він майже останнім. Знання логічного порядку пояснює цілу родину незрозумілих помилок: чому WHERE не може використати псевдонім колонки, а ORDER BY може, чому WHERE не може фільтрувати за COUNT(*), а HAVING може. Порядок нижче — логічний, а не фізичний. Планувальник вільний фільтрувати раніше, використати індекс, щоб не сортувати, або взагалі пропустити кроки. Чого він робити не має права — так це повернути інший результат, ніж дав би цей порядок.
| Крок | Частина запиту | Що відбувається | Тому… |
|---|---|---|---|
| 1 | FROM, JOIN … ON | Рядки створюються й поєднуються | Умова в ON формує саме з’єднання (рядок 3) |
| 2 | WHERE | Рядки фільтруються, по одному | Агрегатів і псевдонімів із SELECT ще немає |
| 3 | GROUP BY | Рядки згортаються в групи | Відтепер — один рядок на групу |
| 4 | HAVING | Групи фільтруються | Саме тут місце для COUNT(*) > 5 |
| 5 | SELECT | Обчислюються вихідні колонки, псевдоніми й віконні функції | Віконні функції бачать рядки, що пережили кроки 1–4 |
| 6 | DISTINCT | Прибираються однакові вихідні рядки | DISTINCT, що ховає розмноження рядків, однаково виконав усю його роботу (рядок 4) |
| 7 | ORDER BY | Результат сортується | Тут псевдоніми працюють; без цього кроку порядку немає взагалі (рядок 5) |
| 8 | LIMIT, OFFSET | Повертається зріз | Пропущені рядки однаково створили й посортували (рядок 10) |
Рядки 1 і 2 походять від одного проєктного рішення, старшого за більшість людей, які ним користуються. NULL у SQL не означає «порожньо», «нуль» чи «відсутнє». Він означає «невідомо». Порівняння з невідомим значенням саме по собі невідоме — не хибне. Тож SQL працює з трьома значеннями істинності: істина, хиба й невідомо. WHERE і HAVING лишають рядок, лише коли умова істинна, тож невідома умова мовчки відкидає рядок. Саме це правило породжує кожну несподіванку з NULL у таблиці вище.
| Вираз | Результат | Чому це важливо |
|---|---|---|
| 1 = NULL | Невідомо | WHERE відкидає рядок |
| NULL = NULL | Невідомо | Дві невідомі величини не відомо, чи рівні; унікальна колонка приймає багато NULL |
| NULL IS NULL | Істина | Єдина правильна перевірка на відсутнє значення |
| FALSE AND NULL | Хиба | Хиба виграє AND, хай би яким було невідоме |
| TRUE OR NULL | Істина | Істина виграє OR, хай би яким було невідоме |
| NOT NULL | Невідомо | Заперечення невідомого не робить його відомим |
| 5 NOT IN (1, NULL) | Невідомо | Це означає 5 <> 1 AND 5 <> NULL — тож це ніколи не істина (рядок 1) |
Рядок про NOT IN коштує реальних грошей. Запит на кшталт «клієнти, чий id не входить до списку заблокованих» працює роками. А тоді один рядок у таблиці заблокованих отримує id NULL, і запит починає не повертати нікого — без помилки, лише порожній результат, схожий на добру новину. NOT EXISTS такої вади не має, бо питає, чи існує відповідний рядок, а не порівнює значення. Рушії розходяться в одній пов’язаній деталі. PostgreSQL і MySQL дозволяють унікальній колонці тримати скільки завгодно NULL, бо жодні два з них не відомо, чи рівні. SQL Server дозволяє лише один. PostgreSQL 15 додав NULLS NOT DISTINCT для команд, яким потрібна поведінка SQL Server.
| З’єднання | Повертає | Типове використання | Пастка |
|---|---|---|---|
| INNER JOIN | Лише рядки, що мають пару з обох боків | Замовлення разом із клієнтом | Рядки без пари зникають без попередження |
| LEFT JOIN | Кожен рядок ліворуч; NULL там, де праворуч немає пари | Клієнти — з їхніми замовленнями, якщо вони є | WHERE на правій таблиці знову перетворює його на внутрішнє з’єднання (рядок 3) |
| FULL JOIN | Кожен рядок з обох боків, зіставлений, де можливо | Звірка двох списків | MySQL його не підтримує; імітують через UNION |
| CROSS JOIN | Кожна комбінація | Генерація календаря «дні × кімнати» | Забутий ON створює його випадково |
| EXISTS (напівз’єднання) | Рядки ліворуч, що мають хоча б одну пару, — кожен один раз | Клієнти, що щось замовляли | Якщо замінити його з’єднанням із DISTINCT, спершу відбудеться розмноження рядків |
| NOT EXISTS (антиз’єднання) | Рядки ліворуч без пари | Клієнти, що ніколи не замовляли | Немає — це безпечна форма NOT IN (рядок 1) |
Планувальник та індекси — ви описуєте результат, рушій обирає шлях
SQL декларативний, і це найважливіше, що про нього треба знати. Ви кажете, які рядки вам потрібні; ви не кажете, як їх знайти. Компонент під назвою планувальник запитів читає ваш вираз і статистику, яку рушій тримає про кожну таблицю, — скільки рядків, скільки різних значень у колонці, як вони розподілені. Потім він оцінює вартість кількох можливих стратегій і обирає найдешевшу. Тому той самий вираз може виконатися сьогодні одним способом, а наступного місяця — іншим: змінилися дані під ним.
Те, що робить один шлях набагато дешевшим за інший, — це майже завжди індекс. Індекс — це друга структура, яку тримають упорядкованою: вона зіставляє значення однієї чи кількох колонок із рядками, що їх містять. Зазвичай це B-дерево — неглибоке дерево, яким рушій спускається за кілька кроків незалежно від розміру таблиці. Знайти один рядок серед мільйона через індекс коштує близько двадцяти порівнянь, без нього — мільйон. У цьому вся різниця між запитом, що відповідає за мілісекунду, і тим самим запитом, що відповідає за секунду.
Індекс допомагає лише умові, яку він може використати, і рядки 7–9 — три способи, якими умова стає непридатною. Рядок 7: індекс зберігає email, а не lower(email), тож для умови на функцію немає впорядкованої структури, якою можна спуститися. Проіндексуйте сам вираз або зберігайте значення вже в нижньому регістрі. Рядок 8: порівняйте текстову колонку з числом — і рушій мусить перетворити кожне збережене значення, перш ніж порівнювати. Посібник MySQL каже це прямо: для такого порівняння він не може використати індекс на колонці. Виправлення — у застосунку, часто в тому, що ORM передає як параметр. Рядок 9 — про порядок колонок, і він вартий малюнка.
Складений індекс на (customer_id, created_at) упорядкований як телефонна книга: спершу за клієнтом, потім за датою всередині кожного клієнта. Запит про нещодавні замовлення одного клієнта читає один короткий суцільний діапазон. Запит про всі нещодавні замовлення, хоч би хто їх зробив, знаходить потрібні записи розкиданими по розділах усіх клієнтів. Стара порада казала, що такий індекс йому нічим не допоможе. Це вже не зовсім так. PostgreSQL 18, випущений 25 вересня 2025 року, додав skip scan для багатоколонкових індексів B-дерева, а MySQL має його з версії 8.0.13. Skip scan перестрибує від одного клієнта до наступного й шукає в кожному розділі окремо. Це швидко, коли клієнтів двадцять, і безнадійно, коли їх два мільйони. Тож правило лишається, але із зірочкою: колонки рівності — спершу, колонка діапазону — останньою, а skip scan вважайте порятунком, а не дизайном.
| Тип індексу | Де | Добре для | Не для |
|---|---|---|---|
| B-дерево | Кожен рушій; типовий | Рівність, діапазони, сортування, префікси на кшталт LIKE 'abc%' | Шаблон із символом підстановки на початку, LIKE '%abc' |
| GIN | PostgreSQL | Масиви, вкладення в JSON, повнотекстовий пошук; триграми для LIKE '%abc%' | Таблиці з інтенсивним записом — оновлювати його дорого |
| GiST | PostgreSQL | Діапазони, геометрія, обмеження «жодні два бронювання не перетинаються» | Прості пошуки за рівністю |
| BRIN | PostgreSQL | Величезні таблиці лише для дописування, впорядковані за часом; індекс лишається крихітним | Дані, фізичний порядок яких не відповідає колонці |
| Кластеризований первинний ключ | MySQL InnoDB, SQL Server | Читання діапазонів за первинним ключем: таблиця зберігається в порядку ключа | Випадкові ключі на кшталт UUIDv4, що розкидають кожне вставляння по всій таблиці |
Останній рядок пояснює поширену загадку продуктивності. З кластеризованим первинним ключем рядки фізично лежать у порядку ключа. Випадковий ключ UUIDv4 кладе кожен новий рядок у випадкове місце, розщеплюючи сторінки й тримаючи всю таблицю гарячою в пам’яті. Послідовний ключ дописує в кінець. UUIDv7 зберігає переваги UUIDUniversally Unique Identifier — 128-бітний id, створений будь-де128-бітний ідентифікатор, який будь-яка машина може згенерувати, не питаючи центрального лічильника, і практично без шансу на збіг двох. Версія 4 випадкова — її не вгадати, але вставляння в базу розкидаються; версія 7 починається з мітки часу, тож нові id сортуються в порядку створення.Усі терміни — генерується будь-де, не вгадується за сусіднім, — але сортується за часом створення, а PostgreSQL 18 додав вбудовану функцію uuidv7(). Рядок 4 сторінки про дизайн APIApplication Programming Interface — контракт між програмамиПравила, за якими одна програма звертається до іншої: які запити можна надіслати, у якому вигляді й що прийде у відповідь. Для того, хто звертається, це контракт — усе, що за ним, лишається чорною скринькою, і саме тому другий бік можна переписати, не зламавши вас.Докладніше в статті →Усі терміни вимагає id, які неможливо перебрати; UUIDv7 — такий id, який база може згенерувати, не платячи за випадковість в індексі, хоча, на відміну від v4, він видає, коли рядок створено.
Знайшовши рядки кожної таблиці, планувальник мусить їх поєднати, і для цього в нього є три алгоритми. Вкладений цикл (nested loop) бере кожен рядок з одного боку й шукає йому пару з іншого — в ідеалі через індекс. Він неперевершений, коли один бік малий. Хеш-з’єднання будує хеш-таблицю з меншого боку й пропускає повз неї більший. Воно виграє для великих невпорядкованих входів, що з’єднуються за рівністю. З’єднання злиттям (merge join) іде двома входами, вже впорядкованими за ключем з’єднання. Що саме оберуть, залежить від оцінок кількості рядків. Тому погана оцінка така дорога: вкладений цикл, обраний для 50 рядків, що зустрів 500 000, робить 500 000 пошуків.
Рядки 10 і 11 — зовсім не про окремий запит. OFFSET 100000 просить рушій створити й посортувати сто тисяч рядків і викинути їх, тож що глибша сторінка, то вона повільніша. Пагінація за ключем пам’ятає, де скінчилася остання сторінка, — created_at та id її останнього рядка — і просить рядки після цього, на що індекс відповідає напряму на будь-якій глибині. Це половина курсорної пагінації зі сторінки про дизайн API, що живе в базі. Рядок 11, патерн N+1, — та проблема продуктивності, з якою ви справді зустрінетеся. Сторінка рендерить список із двохсот замовлень, а об’єктний мапер ліниво завантажує клієнта кожного замовлення всередині циклу. Результат — двісті швидких запитів і повільна сторінка, поки база виглядає незавантаженою. Кожен мапер уміє дістати пов’язані рядки одним виразом. Логування запитів у розробці — спосіб помітити, що це потрібно.
Транзакції та ізоляція — що насправді дозволяє кожен рівень
Транзакція — це група виразів, яку база сприймає як один. Чотири літери ACID — чотири окремі обіцянки, і порушуються вони по-різному. Атомарність: уся група стається або не стається зовсім, тож збій між двома оновленнями не може лишити пів переказу. Узгодженість: транзакція ніколи не завершується в стані, що порушує оголошене вами обмеження. Ізольованість: паралельні транзакції не бачать одна одну недоробленою — і саме вона має налаштування. Довговічність: щойно база підтвердила коміт, дані переживуть зникнення живлення.
Більшість рушіїв реалізують ізоляцію через багатоверсійне керування паралельністю, MVCC. Оновлення не перезаписує рядок; воно пише нову версію й лишає стару транзакціям, що почалися раніше. Кожна транзакція читає знімок — версії, закомічені на момент її початку або початку її поточного виразу. Читачі ніколи не чекають на тих, хто пише, — тому цей підхід і переміг. Його ціна в тому, що дві транзакції можуть кожна прочитати правильний знімок, ухвалити на ньому рішення — і так і не побачити рішення одна одної. Повна ізоляція, де результат завжди збігається з якимось виконанням по черзі, коштує додаткової роботи й час від часу невдач, тож кожен великий рушій типово постачається зі слабшою.
Рівні визначаються тим, які аномалії вони дозволяють, і знання аномалії — те, що дає впізнати ваду у власному продукті. 1995 року Гел Беренсон, Джим Грей і ще четверо співавторів показали, що власні визначення стандарту SQL неоднозначні й пропускають аномалії, які реальні рушії дозволяли. Словник нижче здебільшого походить із цієї статті:
| Аномалія | Що відбувається | У продукті |
|---|---|---|
| Брудне читання | Ви бачите роботу іншої транзакції до її коміту | Підтвердження замовлення, яке потім відкотили |
| Неповторюване читання | Той самий рядок, прочитаний двічі в одній транзакції, змінився | Звіт, у якому заголовок і деталі не сходяться |
| Фантомне читання | Та сама умова, прочитана двічі, повертає інший набір рядків | Кількість місць, що змінюється між перевіркою й бронюванням |
| Загублене оновлення | Дві транзакції читають те саме значення й обидві пишуть нове; перший запис зникає | Залишок 10, два продажі по 1, залишок тепер 9 (рядок 13) |
| Перекіс запису | Дві транзакції читають спільні дані, пишуть різні рядки й разом порушують правило | Обидва лікарі знімають чергування; кімнату заброньовано двічі (рядок 14) |
| Рівень | Досі дозволяє | Типовий у |
|---|---|---|
| Read uncommitted | Брудні читання — теоретично; PostgreSQL виконує його як read committed | Жодному з великих рушіїв |
| Read committed | Неповторювані читання, фантоми, загублені оновлення, перекіс запису | PostgreSQL, Oracle, SQL Server |
| Repeatable read | Перекіс запису; у MySQL ще й загублені оновлення, бо його записи діють на найновіший рядок, а не на знімок | MySQL (InnoDB) |
| Serializable | Нічого — але частина транзакцій падає, і їх треба повторювати | SQLite; CockroachDB |
Дві деталі цієї таблиці ловлять досвідчені команди. По-перше, та сама назва означає різне в різних рушіях. Repeatable read у PostgreSQL ще й запобігає фантомам і зупиняє загублене оновлення помилкою, тоді як repeatable read у MySQL загублене оновлення пропускає. А рівень Oracle, що зветься serializable, насправді є ізоляцією знімків, яка досі дозволяє перекіс запису, — у статті про ACIDRain зазначено, що справжньої серіалізованості Oracle не пропонує взагалі. По-друге, перекіс запису переживає кожен рівень нижче serializable, і це аномалія, найменше схожа на баг. Ніхто нічого не перезаписав. Кожна транзакція перевірила правило, і кожна мала рацію щодо того, що бачила.
Приклад на діаграмі — з книжки Мартіна Клеппмана «Designing Data-Intensive Applications». Лікарня вимагає, щоб чергував щонайменше один лікар. Аліса й Боб обидва чергують і обидва погано почуваються. Кожен водночас відкриває застосунок, той перевіряє, що чергують щонайменше двоє, бачить двох і дозволяє зняти чергування. Кожна транзакція оновлює інший рядок — Алісин і Бобів, — тож жоден рядок не записують двічі й жодного конфлікту немає. Обидві комітять, і не чергує ніхто. Справжніх ліків три. Виконувати транзакцію на рівні serializable й повторювати, коли вона падає. Блокувати рядки, які читає правило, через SELECT … FOR UPDATE на кожному черговому лікарі, щоб друга транзакція дочекалася першої й побачила одного лікаря. Або перенести правило в базу як обмеження, де це можливо, щоб рушій забезпечував його, хоч би хто писав.
Serializable дешевший за свою репутацію. PostgreSQL реалізує його з версії 9.1 2011 року через серіалізовану ізоляцію знімків — техніку, яку Майкл Кехілл, Уве Рьом і Алан Фекете опублікували 2008 року. Транзакції працюють на знімках, як завжди, а рушій стежить за небезпечним візерунком читань і записів між ними. Знайшовши його, він перериває одну транзакцію з помилкою серіалізації, SQLSTATE 40001. Ціна в тому, що ваш код мусить ловити цю помилку й виконувати всю транзакцію знову, з першого читання. Код, що робить це один раз, у спільному помічнику, отримує коректність для кожного правила ціною рідкісного повтору.
З усього цього випливає одне експлуатаційне правило: тримайте транзакції короткими. Відкрита транзакція тримає свої блокування. На рушіях, що зберігають версії рядків, вона ще й не дає прибрати жодну версію, створену від її початку, — це рядок 16. Конкретна звичка, яку варто мати, — ніколи не робити мережевого виклику всередині транзакції. Платіжний провайдер, що відповідає тридцять секунд, стає тридцятьма секундами утримуваних блокувань. Провайдер, який не відповідає ніколи, стає аварією в таблиці, про яку ви не думали.
Обмеження й зміни схеми — правила, які рушій тримає для кожного, хто пише
Звичайний аргумент проти обмежень у базі — що застосунок і так валідує. Так і є — доки він не єдиний, хто пише, а це стається з кожною системою, що живе досить довго. Міграційний скрипт, ручне виправлення даних, другий сервіс, задача імпорту, інженер підтримки з відкритим клієнтом бази: кожен пише, не проходячи крізь вашу валідацію. Обмеження в базі — єдине правило, що тримається проти всіх них, і тримається без винятків, бо рушій просто не приймає рядок.
| Обмеження | Чому запобігає | Варто знати |
|---|---|---|
| NOT NULL | Відсутнього значення там, де відсутність не має сенсу | Більшість колонок із NULL існують, бо ніхто не вирішив, а не тому, що значення необов’язкове |
| UNIQUE | Дублікатів — двох акаунтів на один email | Єдина надійна перевірка на дублікати: перевірка в застосунку змагається сама з собою (рядок 14) |
| PRIMARY KEY | Рядка, на який ніхто не може послатися | Його тип — довготривале рішення; див. таблицю індексів вище |
| FOREIGN KEY | Посилання на те, чого не існує | Індексуйте колонку, що посилається, інакше кожне видалення батька сканує дочірню таблицю |
| CHECK | Значення поза дозволеним діапазоном — від’ємної кількості | Дешеве, і документує правило там, де живуть дані |
| EXCLUDE / WITHOUT OVERLAPS | Двох рядків, що конфліктують, — двох бронювань однієї кімнати одночасно | PostgreSQL; версія 18 додала WITHOUT OVERLAPS для первинних та унікальних ключів — рядок 14, розв’язаний рушієм |
Перевірено в застосунку
- Дає добре повідомлення про помилку мовою користувача, чого база не може.
- Може виражати правила, яким потрібні інші системи, — перевірку через зовнішній сервіс.
- Повністю обходиться будь-чим, що пише, не проходячи крізь нього.
- Не може сказати, чи дотримуються його дані, які вже є в таблиці.
Забезпечено базою
- Тримається проти кожного, хто пише: сервісів, скриптів, міграцій і людей.
- Перевіряється на наявних рядках, коли ви його додаєте, тож порушення випливають одразу.
- Дає планувальнику підстави міркувати: унікальне обмеження каже йому, що пошук поверне щонайбільше рядок.
- Видає помилку, якої користувач бачити не має, тож лишайте й дружню перевірку.
Зміна схеми на живій системі
Рядок 15 дивує команди, які знають, що їхні міграції швидкі. Додавання колонки з NULL у PostgreSQL змінює лише каталог і завершується за мілісекунди. Але на ці мілісекунди йому потрібне найсильніше блокування таблиці, і чекати на нього доводиться за кожним запитом, що вже виконується. Блокування видають у черзі. Щойно міграція чекає на ексклюзивне блокування, кожен запит, що прийшов після неї, — навіть простий SELECT — стає в чергу за міграцією. Тож п’ятихвилинний звіт, який випадково виконується, здатен перетворити двомілісекундну міграцію на хвилини недоступності цієї таблиці. PostgreSQL і MySQL тут поводяться однаково. MySQL називає це очікуванням блокування метаданих (metadata lock).
Захист короткий і механічний. Ставте lock_timeout у кожній міграції на паузу, яку ваш застосунок витримає, — секунду-дві, — щоб міграція, яка не може отримати блокування, здавалася, і повторюйте її трохи згодом. Розбивайте зміни так, щоб кожен вираз брав найслабше можливе блокування. І знайте, які операції переписують або сканують усю таблицю, бо ті тримають блокування хвилинами, а не мілісекундами:
| Операція | PostgreSQL | MySQL (InnoDB) | Безпечний спосіб |
|---|---|---|---|
| Додати колонку з NULL або зі сталим значенням за замовчуванням | Лише каталог (стале значення — з версії 11); коротке ексклюзивне блокування | ALGORITHM=INSTANT з 8.0.12, у будь-якій позиції з 8.0.29 | lock_timeout і повтор |
| Створити індекс | Блокує записи на весь час побудови | Будується онлайн; записи тривають | PostgreSQL: CREATE INDEX CONCURRENTLY, поза транзакцією |
| Додати зовнішній ключ | Блокує обидві таблиці від запису на час сканування | Копіює таблицю, якщо не вимкнути перевірку зовнішніх ключів | PostgreSQL: NOT VALID, а тоді VALIDATE CONSTRAINT під слабшим блокуванням |
Зробити колонку NOT NULL | Сканує таблицю під ексклюзивним блокуванням | Перебудовує таблицю на місці | PostgreSQL 18: додати як NOT VALID, а тоді перевірити |
| Змінити тип колонки | Зазвичай переписує таблицю під ексклюзивним блокуванням | Копіює таблицю; записи заблоковано | Розширити й звузити: нова колонка, дозаповнення, перемикання, видалення старої |
| Перейменувати чи прибрати колонку | Миттєво | Миттєво | Миттєво для бази, ламко для коду, що працює: розширити й звузити |
Останній рядок — причина, з якої існує «розширити й звузити» (expand and contractзміна схеми оборотними крокамиЗміна форми даних за три релізи замість однієї міграції: додати нову структуру й писати в обидві, перевести читачів, а потім прибрати стару, коли з неї ніхто не читає. Кожен крок, крім останнього, можна скасувати, і в цьому суть: перейменування однією міграцією шляху назад не має.Докладніше в статті →Усі терміни). Код і схема ніколи не можуть перемкнутися в ту саму мить, бо деплой розгортається хвилинами, а старі екземпляри працюють далі. Тож зміна стає кількома деплоями, кожен безпечний сам по собі. Додати нову колонку. Писати в обидві. Дозаповнити старі рядки порціями. Перемкнути читання. Перестати писати в стару. Прибрати її. Кожен крок можна відкотити, крім останнього, а на той час старою колонкою вже ніщо не користується. Сторінка про перехід від моноліту до мікросервісів застосовує ту саму послідовність до перенесення цілої таблиці між сервісами.
Експлуатація — з’єднання, репліки й прибирання, якого ніхто не бачить
З’єднання — ресурс із жорсткою стелею, і вона нижча, ніж очікують. У PostgreSQL кожне з’єднання — окремий процес операційної системи, а типовий ліміт — сто. Пул на двадцять з’єднань на екземпляр застосунку виглядає скромно, доки оркестратор не запустить п’ятдесят екземплярів, а база не відмовить тисяча першому з’єднанню. Визначайте розмір пулу з того, скільки може обслужити база, а не з того, скільки хотів би один екземпляр. Ставте перед нею пулер з’єднань на кшталт PgBouncer, коли кількість екземплярів змінюється сама. Пул, менший за очікуваний, пояснює й цілий клас загадкових затримок: час іде на очікування з’єднання, а не на виконання запиту.
Репліки для читання — звичайна відповідь на навантаження читанням, і вони приносять одну поведінку, яку треба закладати в проєкт. Реплікація зазвичай асинхронна, тож репліка відстає на кілька мілісекунд, а часом і на кілька секунд. Користувач, що зберіг форму й потрапив на сторінку, яка читає з репліки, не бачить власної зміни. Це схоже на баг, а насправді це архітектура. Спрямовуйте на основну базу читання, що мають відображати власний запис користувача, а решту віддавайте реплікам. Те саме відставання має й другий, жорсткіший наслідок. Коли основна база падає й репліку підвищують, усе, що ще не дійшло до репліки, зникає — або, гірше, живе далі в копії, яку ніхто не читає. Кейс GitHub нижче — саме про це.
Рядок 16 — ціна MVCC, якої ніхто не бачить, доки вона не виставить рахунок. Кожне оновлення й видалення лишає по собі стару версію рядка. У PostgreSQL фоновий процес, autovacuum, прибирає версії, яких уже не може бачити жодна транзакція. Він не може прибрати нічого новішого за найстарішу відкриту транзакцію, тож одна транзакція, лишена відкритою на добу, зупиняє прибирання в усій базі, і таблиці роздуваються. Є й друга, рідша відмова. PostgreSQL нумерує транзакції 32-бітним лічильником, і щоб безпечно використовувати номери повторно, мусить «заморожувати» старі рядки, перш ніж лічильник обжене їх приблизно на два мільярди транзакцій. Якщо вакуум відстане досить сильно, база перестає приймати записи, щоб захистити ваші дані, а відновлення означає довгий ручний вакуум. InnoDB у MySQL має проблему тієї самої форми: його історія скасувань (undo) росте за довгими транзакціями.
| Що відстежувати | Чому | Де це прочитати |
|---|---|---|
| Вік найстарішої відкритої транзакції | Вона тримає блокування й зупиняє прибирання (рядок 16) | PostgreSQL: pg_stat_activity; задайте idle_in_transaction_session_timeout |
| Вік ідентифікаторів транзакцій | Відстань до зупинки записів | PostgreSQL: age(datfrozenxid) для кожної бази; алерт задовго до двох мільярдів |
| Мертві рядки й запуски вакууму | Роздування й відставання вакууму | PostgreSQL: pg_stat_user_tables |
| Довжина історії скасувань | Відповідник першого рядка в InnoDB | MySQL: SHOW ENGINE INNODB STATUS |
| Відставання реплікації | Застарілі читання зараз, загублені записи під час перемикання | PostgreSQL: pg_stat_replication; MySQL: статус репліки |
| Зайняті з’єднання відносно ліміту | Стеля приходить як аварія, а не як сповільнення | Списки процесів обох рушіїв; власна статистика пулера |
| Найповільніші й найчастіші запити | Рядки 7–12, перш ніж їх знайдуть користувачі | PostgreSQL: pg_stat_statements; MySQL: журнал повільних запитів |
Зрештою однієї основної бази може забракнути, і порядок ліків важить. Спершу — дешеві: індекси, виправлення N+1, пулер, репліки для читання, перенесення звітів на окрему копію. Потім партиціювання: поділ однієї величезної таблиці на частини за часом чи ключем у межах тієї самої бази, щоб старі дані можна було прибрати цілою партицією, а не видаляти рядок за рядком. Шардування між кількома базами — останнім. Це інша система в експлуатації, і кожен запит, що перетинає шарди, стає проблемою вашого коду. Кейс Notion нижче показує і чому команда зрештою на це йде, і чому вона шкодувала, що не почала раніше.
П’ять кейсів і те, чого в кожному бракувало
Жоден із цих кейсів не є екзотичним багом бази. Кожен — це один рядок таблиці вгорі, зустрінутий у масштабі, де його вже не можна було ігнорувати.
| Кейс | Що сталося | Що це показує |
|---|---|---|
| ACIDRain, 2017 | Дослідники знайшли 22 атаки на паралельність у 12 платформах електронної комерції, якими користуються понад два мільйони сайтів, — подарункові картки витрачали багато разів, залишки псувалися | Більшість — хибні межі транзакцій, яких не виправляє жоден рівень ізоляції (рядки 13–14) |
| GoCardless, 2015 | Міграція на двох порожніх таблицях поклала платіжний API приблизно на 15 секунд | Черга блокувань і lock_timeout як захист (рядок 15) |
| Mandrill, 2019 | Один із п’яти екземплярів Postgres перестав приймати записи через переповнення ідентифікаторів транзакцій; відновлення — близько 41 години | Попередження помітили за три місяці до того й поклали в беклог (рядок 16) |
| GitHub, 2018 | 43-секундний розрив мережі призвів до перемикання через увесь континент і 24 годин деградованої роботи | Асинхронна реплікація робить перемикання рішенням про те, які записи втратити |
| Notion, 2021 | Зупинки вакууму й загроза переповнення змусили поділити монолітний Postgres на 480 шардів | Рядок 16 — це ще й сигнал, що однієї основної бази забракне |
ACIDRain — кейс, який варто показати кожному, хто вважає рядки 13 і 14 теоретичними. 2017 року Тодд Варшавскі й Пітер Бейліс зі Стенфорда створили інструмент, що читає SQL, який надсилає застосунок, і знаходить, де паралельні виклики API можуть переплестися погано. Вони запустили його на дванадцяти популярних платформах електронної комерції, які розгортають на власних серверах; ці платформи написані чотирма мовами, і, за їхнім підрахунком, ними користуються понад два мільйони сайтів. Вони знайшли й підтвердили 22 атаки. У Magento, OpenCart і Oscar покупець міг купити одну подарункову картку й витрачати її необмежену кількість разів, надсилаючи запити на оформлення замовлення паралельно. Найважливіше — розподіл. Усі 22 працювали за типової ізоляції баз. Але 17 із них походили від транзакцій, проведених не в тому місці або не використаних зовсім, і вони виживають навіть на найсильнішому рівні ізоляції, бо жоден рівень не захистить роботу, що не всередині однієї транзакції. Стаття починається з прецеденту: у березні 2014 року біткоїн-біржу Flexcoin спустошили тисячі одночасних запитів на переказ, які перевищували залишки на рахунках раніше, ніж баланси оновлювалися, і біржа закрилася.
GoCardless показує, що міграція на порожній таблиці може покласти й завантажену. У січні 2015 року ця платіжна компанія перейменувала дві нові порожні таблиці й заново створила їхні зовнішні ключі. Додавання зовнішнього ключа потребує блокування й таблиці, на яку він посилається, а та була однією з найзавантаженіших у системі. Довгий запит на читання якраз тримав конфліктне блокування. Міграція стала в чергу за ним, а кожен запит API до цієї таблиці — в чергу за міграцією. Приблизно п’ятнадцять секунд API відвалювався за таймаутом. Запущена на резервній копії, та сама міграція тривала кілька сотень мілісекунд. Першою рекомендацією команди після цього був lock_timeout — щоб міграція здавалася, а не клала застосунок. Відтоді PostgreSQL послабив блокування, яке бере додавання зовнішнього ключа, але черга працює точно так само, і ця історія повторюється з будь-якою зміною схеми, що потребує ексклюзивного блокування. Це рядок 15.
Де це вироджується
Реляційна база рідко ламається сама. Її ламають звички, що працюють на ноутбуці й перестають працювати за першого справжнього навантаження. Майже кожен інцидент — одне з цього:
- Оцінка запиту за відчуттям на базі розробника, де тисяча рядків (рядки 7–12).
- Тести, у яких ніколи немає NULL, порожньої множини чи двох рядків з однаковою міткою часу (рядки 1–6).
- Один запит на рядок списку, що перетворює рендер сторінки на двісті звернень (рядок 11).
- «Прочитати, змінити, записати» в коді застосунку, що губить оновлення, щойно два запити перетнулися (рядок 13).
- Правило, перевірене в коді й ніколи не сформульоване для бази, яке два паралельні запити порушують разом (рядок 14).
- Мережевий виклик усередині відкритої транзакції, що тримає блокування стільки, скільки думає інша система.
- Індекс, доданий під кожен повільний запит і ніколи не прибраний, тож кожен запис платить за всі.
- Міграції без
lock_timeout, запущені в пік, бо на stagingсередовище-репетиціяКопія продакшену, на якій зміни пробують до того, як їх зустрінуть справжні користувачі. Скільки вона доводить, повністю залежить від того, наскільки вона схожа на продакшен: staging із десятою частиною даних, без реального трафіку й з іншою конфігурацією показує, що код запускається, і майже нічого більше.Усі терміни вони були «миттєві» (рядок 15). - Жодного алерту на найстарішу відкриту транзакцію чи вік ідентифікаторів транзакцій — доки база не зупиниться (рядок 16).
- Читання, надіслані на репліку одразу після запису, тож користувачі не бачать власних змін.
Чесна межа в тому, що база гарантує лише те, що їй сказали. Вона не може захистити правило, яке живе лише в коді застосунку, транзакцію, проведену навколо не тих виразів, чи рішення, ухвалене на даних, прочитаних в іншій транзакції. Сімнадцять атак ACIDRain, пов’язаних із межами транзакцій, — доказ: рушій зробив рівно те, що обіцяв, а обіцянка не покривала бізнесу. Рівні ізоляції, обмеження й блокування — інструменти, щоб сформулювати правила для рушія. Команда, що не формулює жодного, отримує швидке й надійне сховище, яке нічим не захищає її інваріантів. Робота в тому, щоб вирішити, які правила достатньо важливі, щоб сформулювати їх там.
Практична перевірка займає пів дня. Проженіть десять найповільніших запитів із pg_stat_statements або журналу повільних запитів через EXPLAIN ANALYZE і пошукайте оцінки, що помиляються вдесятеро. Пошукайте в коді SELECT, за яким іде UPDATE значення, обчисленого між ними. Випишіть бізнес-правила, що охоплюють кілька рядків, — баланси, залишки, бронювання, квоти — і запишіть, як захищено кожне. Перевірте, що кожна міграція задає lock_timeout. І знайдіть алерт на найстарішу відкриту транзакцію; якщо його немає, це перше, що варто додати.
Коли це застосовувати
Застосовуйте, коли
- Дані зі зв’язками, які мають лишатися узгодженими: замовлення та їхні позиції, рахунки та їхні записи.
- Усе, де частково застосована зміна неприпустима і транзакція є самим сенсом.
- Запити, не всі з яких відомі наперед: планувальник і довільний SQL кращі за фіксований шлях доступу.
- Типовий вибір для нового продукту — доки виміряна вимога справді не скаже інакше.
Уникайте, коли
- Обсяги запису, яких не витримає одна основна база, коли форма даних натомість дозволяє поділ за ключем.
- Документи, чиї поля справді різняться від запису до запису: там зі схемою борються, а не користуються нею.
- Глибоко рекурсивні запити про зв’язки — друзі друзів друзів, — на які графовий рушій відповідає напряму.
- Кеш, черга чи сховище сесій: там спеціалізована система простіша й на порядок швидша.
Джерела й метод
На чому стоїть ця стаття
- 16 джерел
- 5 задокументованих збоїв
- Перевірено 13 жовтня 2026 р.
Збої, на яких вчиться стаття
розділ «П’ять кейсів і те, чого в кожному бракувало»- ДослідженняTodd Warszawski, Peter Bailis — ACIDRain: Concurrency-Related Attacks on Database-Backed Web Applications (відкривається в новій вкладці)ACM SIGMOD 2017 · 2017
- ПостмортемChris Sinjakli — Zero-downtime Postgres migrations - the hard partsGoCardless · 05.2015
- ПостмортемWhat We Learned from the Recent Mandrill Outage (відкривається в новій вкладці)Mailchimp · 26.03.2019
- ПостмортемDavid Cramer — Transaction ID Wraparound in Postgres (відкривається в новій вкладці)Sentry Blog · 07.2015
- ПостмортемOctober 21 post-incident analysis (відкривається в новій вкладці)The GitHub Blog · 30.10.2018
Практики й походження термінів
шістнадцять сюрпризів: ізоляція, блокування, індекси й версії- ДокументаціяPostgreSQL 18 — 13.2. Transaction Isolation (відкривається в новій вкладці)PostgreSQL Documentation
- ДокументаціяPostgreSQL 18 — ALTER TABLE (відкривається в новій вкладці)PostgreSQL Documentation
- СтаттяPostgreSQL 18 Released! (відкривається в новій вкладці)PostgreSQL · 25.09.2025
- ДокументаціяPostgreSQL — Versioning Policy (відкривається в новій вкладці)postgresql.org
- ДокументаціяPostgreSQL — Roadmap (відкривається в новій вкладці)postgresql.org
- ДокументаціяMySQL 8.4 Reference Manual — Transaction Isolation Levels (відкривається в новій вкладці)Oracle · MySQL
- КнигаMartin Kleppmann — Designing Data-Intensive Applications (відкривається в новій вкладці)O’Reilly · 2017
Дослідження
що насправді гарантують рівні ізоляції- ДослідженняHal Berenson et al. — A Critique of ANSI SQL Isolation Levels (відкривається в новій вкладці)ACM SIGMOD 1995 · 1995
- ДослідженняMichael J. Cahill, Uwe Röhm, Alan D. Fekete — Serializable Isolation for Snapshot Databases (відкривається в новій вкладці)ACM SIGMOD 2008 · 2008
- ДослідженняDan R. K. Ports, Kevin Grittner — Serializable Snapshot Isolation in PostgreSQL (відкривається в новій вкладці)Proceedings of the VLDB Endowment · 2012
Шардування Notion
останній із п’яти кейсів: чого коштувало переростання однієї базиДослідження з Claude. Пошук і звірку першоджерел зроблено з Claude (Anthropic). Відбір, текст і перевірку фактів — редакція Anatomy of IT Product.
Було корисно?
Поділіться з тим, хто працює над тією ж задачею.