РозробкаБудь-який рівень

SQL

SQL — це мова, якою ви просите в реляційної бази дані, і спосіб покластися на гарантії, яких ваш застосунок сам не забезпечить. Ви описуєте результат, планувальник обирає шлях, а транзакції вирішують, що бачать паралельні запити. Сторінка починається з шістнадцяти несподіванок — хибних рядків, повільних запитів, загублених оновлень — із правилом за кожною і способом виправити; далі — як обчислюється запит, планувальник та індекси, транзакції й ізоляція, обмеження й зміни схеми, рушій у продакшні, п’ять задокументованих кейсів і де все це вироджується.

Ви описуєте
Результат, а не шлях
Поточні версії
PostgreSQL 18 (19 — у жовтні 2026); MySQL 9.7 LTS
Типова ізоляція
Read committed (PostgreSQL); repeatable read (MySQL)

Шістнадцять несподіванок — що ви бачите, яке правило за цим стоїть і як виправити

Ніхто не шукає SQL заради визначення. Шукають, бо запит повернув не ті рядки, виконувався повільно або загубив оновлення, яке двоє користувачів зробили одночасно. Майже кожна така несподіванка походить з одного з шістнадцяти правил, які пропускають підручники, — бо підручник виконує один запит за раз на тисячі рядків. Ось вони: що ви бачите, яке правило це спричиняє і як виправити. Наступні розділи посилаються на рядки за номерами.

#Що ви бачитеПравило, що за цим стоїтьЯк виправити
1WHERE manager_id = NULL нічого не повертає; NOT IN (підзапит) нічого не повертає, щойно в підзапиті з’являється один NULLNULL означає «невідомо»: порівняння з ним ні істинне, ні хибне, а WHERE лишає тільки рядки, де умова істиннаIS NULL; NOT EXISTS замість NOT IN; IS DISTINCT FROM для порівняння, що вважає NULL рівними
2COUNT(email) менший за COUNT(*); SUM по нулю рядків дає NULL, а не 0Агрегати пропускають NULL, і кожен агрегат, крім COUNT, повертає NULL для порожньої множиниCOUNT(*), щоб рахувати рядки; COALESCE(SUM(amount), 0)
3LEFT 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 може. Порядок нижче — логічний, а не фізичний. Планувальник вільний фільтрувати раніше, використати індекс, щоб не сортувати, або взагалі пропустити кроки. Чого він робити не має права — так це повернути інший результат, ніж дав би цей порядок.

КрокЧастина запитуЩо відбуваєтьсяТому…
1FROM, JOIN … ONРядки створюються й поєднуютьсяУмова в ON формує саме з’єднання (рядок 3)
2WHEREРядки фільтруються, по одномуАгрегатів і псевдонімів із SELECT ще немає
3GROUP BYРядки згортаються в групиВідтепер — один рядок на групу
4HAVINGГрупи фільтруютьсяСаме тут місце для COUNT(*) > 5
5SELECTОбчислюються вихідні колонки, псевдоніми й віконні функціїВіконні функції бачать рядки, що пережили кроки 1–4
6DISTINCTПрибираються однакові вихідні рядкиDISTINCT, що ховає розмноження рядків, однаково виконав усю його роботу (рядок 4)
7ORDER BYРезультат сортуєтьсяТут псевдоніми працюють; без цього кроку порядку немає взагалі (рядок 5)
8LIMIT, 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 декларативний, і це найважливіше, що про нього треба знати. Ви кажете, які рядки вам потрібні; ви не кажете, як їх знайти. Компонент під назвою планувальник запитів читає ваш вираз і статистику, яку рушій тримає про кожну таблицю, — скільки рядків, скільки різних значень у колонці, як вони розподілені. Потім він оцінює вартість кількох можливих стратегій і обирає найдешевшу. Тому той самий вираз може виконатися сьогодні одним способом, а наступного місяця — іншим: змінилися дані під ним.

SQL декларативний: вираз каже, які рядки вам потрібні, а не як їх знайти. Компонент під назвою планувальник читає статистику, яку база тримає про кожну таблицю — кількість рядків, різні значення, розподіл, — і оцінює вартість кожного можливого шляху. Послідовне сканування читає кожен рядок і фільтрує; сканування за індексом спускається впорядкованою структурою просто до потрібних рядків, і це коштує близько двадцяти порівнянь замість мільйона. Оскільки вибір робиться зі статистики, а не з ваших намірів, той самий вираз може дістати інший план, коли даних більшає, а EXPLAIN ANALYZE показує план поруч із тим, що сталося насправді.

Те, що робить один шлях набагато дешевшим за інший, — це майже завжди індекс. Індекс — це друга структура, яку тримають упорядкованою: вона зіставляє значення однієї чи кількох колонок із рядками, що їх містять. Зазвичай це B-дерево — неглибоке дерево, яким рушій спускається за кілька кроків незалежно від розміру таблиці. Знайти один рядок серед мільйона через індекс коштує близько двадцяти порівнянь, без нього — мільйон. У цьому вся різниця між запитом, що відповідає за мілісекунду, і тим самим запитом, що відповідає за секунду.

Індекс допомагає лише умові, яку він може використати, і рядки 7–9 — три способи, якими умова стає непридатною. Рядок 7: індекс зберігає email, а не lower(email), тож для умови на функцію немає впорядкованої структури, якою можна спуститися. Проіндексуйте сам вираз або зберігайте значення вже в нижньому регістрі. Рядок 8: порівняйте текстову колонку з числом — і рушій мусить перетворити кожне збережене значення, перш ніж порівнювати. Посібник MySQL каже це прямо: для такого порівняння він не може використати індекс на колонці. Виправлення — у застосунку, часто в тому, що ORM передає як параметр. Рядок 9 — про порядок колонок, і він вартий малюнка.

Складений індекс на customer_id і created_at упорядкований як телефонна книга: спершу за клієнтом, а за датою — лише всередині кожного клієнта. Запит, що фіксує клієнта й просить діапазон дат, читає одну коротку суцільну ділянку індексу. Запит, що просить лише діапазон дат, знаходить збіги, розкидані по розділах усіх клієнтів. PostgreSQL 18 і MySQL з версії 8.0.13 усе одно можуть використати індекс через skip scan — перестрибуючи до кожного клієнта по черзі й шукаючи там, — що дешево, коли в першій колонці кілька значень, і безнадійно, коли їх мільйони. Колонки рівності — спершу, колонка діапазону — останньою.

Складений індекс на (customer_id, created_at) упорядкований як телефонна книга: спершу за клієнтом, потім за датою всередині кожного клієнта. Запит про нещодавні замовлення одного клієнта читає один короткий суцільний діапазон. Запит про всі нещодавні замовлення, хоч би хто їх зробив, знаходить потрібні записи розкиданими по розділах усіх клієнтів. Стара порада казала, що такий індекс йому нічим не допоможе. Це вже не зовсім так. PostgreSQL 18, випущений 25 вересня 2025 року, додав skip scan для багатоколонкових індексів B-дерева, а MySQL має його з версії 8.0.13. Skip scan перестрибує від одного клієнта до наступного й шукає в кожному розділі окремо. Це швидко, коли клієнтів двадцять, і безнадійно, коли їх два мільйони. Тож правило лишається, але із зірочкою: колонки рівності — спершу, колонка діапазону — останньою, а skip scan вважайте порятунком, а не дизайном.

Тип індексуДеДобре дляНе для
B-деревоКожен рушій; типовийРівність, діапазони, сортування, префікси на кшталт LIKE 'abc%'Шаблон із символом підстановки на початку, LIKE '%abc'
GINPostgreSQLМасиви, вкладення в JSON, повнотекстовий пошук; триграми для LIKE '%abc%'Таблиці з інтенсивним записом — оновлювати його дорого
GiSTPostgreSQLДіапазони, геометрія, обмеження «жодні два бронювання не перетинаються»Прості пошуки за рівністю
BRINPostgreSQLВеличезні таблиці лише для дописування, впорядковані за часом; індекс лишається крихітнимДані, фізичний порядок яких не відповідає колонці
Кластеризований первинний ключMySQL InnoDB, SQL ServerЧитання діапазонів за первинним ключем: таблиця зберігається в порядку ключаВипадкові ключі на кшталт UUIDv4, що розкидають кожне вставляння по всій таблиці

Останній рядок пояснює поширену загадку продуктивності. З кластеризованим первинним ключем рядки фізично лежать у порядку ключа. Випадковий ключ UUIDv4 кладе кожен новий рядок у випадкове місце, розщеплюючи сторінки й тримаючи всю таблицю гарячою в пам’яті. Послідовний ключ дописує в кінець. UUIDv7 зберігає переваги — генерується будь-де, не вгадується за сусіднім, — але сортується за часом створення, а PostgreSQL 18 додав вбудовану функцію uuidv7(). Рядок 4 сторінки про дизайн вимагає 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, і це аномалія, найменше схожа на баг. Ніхто нічого не перезаписав. Кожна транзакція перевірила правило, і кожна мала рацію щодо того, що бачила.

Перекіс запису — аномалія, що переживає кожен рівень ізоляції нижче serializable. У прикладі Мартіна Клеппмана лікарня вимагає, щоб чергував щонайменше один лікар. Аліса й Боб, обидва на чергуванні, одночасно просять його зняти. Кожна транзакція перевіряє правило на тому самому знімку, бачить двох лікарів і проходить. Далі кожна оновлює інший рядок — свій, — тож жоден рядок не записано двічі, і база не бачить конфлікту. Обидві комітять, і не чергує ніхто. Кожна транзакція мала рацію щодо того, що бачила. Ліки: виконувати на рівні serializable й повторювати після помилки серіалізації; блокувати рядки, які читає правило, через SELECT … FOR UPDATE, щоб друга транзакція дочекалася й побачила одного лікаря; або, де можливо, сформулювати правило для бази як обмеження.

Приклад на діаграмі — з книжки Мартіна Клеппмана «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).

Зміна схеми, що триває дві мілісекунди, усе одно може покласти таблицю на хвилини. Блокування видають у черзі. Довгий звіт тримає спільне блокування; ALTER TABLE потрібне ексклюзивне, тож він чекає. Звичайні запити, що прийшли після ALTER, не конфліктують зі звітом, але конфліктують з ексклюзивним блокуванням, на яке чекає ALTER, тож стають у чергу за ним — і таблиця недоступна, доки триває звіт. PostgreSQL і MySQL поводяться однаково; MySQL називає це очікуванням блокування метаданих. lock_timeout змушує міграцію натомість здатися, тож черга розходиться, а зміну повторюють трохи згодом.

Захист короткий і механічний. Ставте lock_timeout у кожній міграції на паузу, яку ваш застосунок витримає, — секунду-дві, — щоб міграція, яка не може отримати блокування, здавалася, і повторюйте її трохи згодом. Розбивайте зміни так, щоб кожен вираз брав найслабше можливе блокування. І знайте, які операції переписують або сканують усю таблицю, бо ті тримають блокування хвилинами, а не мілісекундами:

ОпераціяPostgreSQLMySQL (InnoDB)Безпечний спосіб
Додати колонку з NULL або зі сталим значенням за замовчуваннямЛише каталог (стале значення — з версії 11); коротке ексклюзивне блокуванняALGORITHM=INSTANT з 8.0.12, у будь-якій позиції з 8.0.29lock_timeout і повтор
Створити індексБлокує записи на весь час побудовиБудується онлайн; записи триваютьPostgreSQL: CREATE INDEX CONCURRENTLY, поза транзакцією
Додати зовнішній ключБлокує обидві таблиці від запису на час скануванняКопіює таблицю, якщо не вимкнути перевірку зовнішніх ключівPostgreSQL: NOT VALID, а тоді VALIDATE CONSTRAINT під слабшим блокуванням
Зробити колонку NOT NULLСканує таблицю під ексклюзивним блокуваннямПеребудовує таблицю на місціPostgreSQL 18: додати як NOT VALID, а тоді перевірити
Змінити тип колонкиЗазвичай переписує таблицю під ексклюзивним блокуваннямКопіює таблицю; записи заблокованоРозширити й звузити: нова колонка, дозаповнення, перемикання, видалення старої
Перейменувати чи прибрати колонкуМиттєвоМиттєвоМиттєво для бази, ламко для коду, що працює: розширити й звузити

Останній рядок — причина, з якої існує «розширити й звузити» (). Код і схема ніколи не можуть перемкнутися в ту саму мить, бо деплой розгортається хвилинами, а старі екземпляри працюють далі. Тож зміна стає кількома деплоями, кожен безпечний сам по собі. Додати нову колонку. Писати в обидві. Дозаповнити старі рядки порціями. Перемкнути читання. Перестати писати в стару. Прибрати її. Кожен крок можна відкотити, крім останнього, а на той час старою колонкою вже ніщо не користується. Сторінка про перехід від моноліту до мікросервісів застосовує ту саму послідовність до перенесення цілої таблиці між сервісами.

Експлуатація — з’єднання, репліки й прибирання, якого ніхто не бачить

З’єднання — ресурс із жорсткою стелею, і вона нижча, ніж очікують. У 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
Довжина історії скасуваньВідповідник першого рядка в InnoDBMySQL: 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, 201843-секундний розрив мережі призвів до перемикання через увесь континент і 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, запущені в пік, бо на вони були «миттєві» (рядок 15).
  • Жодного алерту на найстарішу відкриту транзакцію чи вік ідентифікаторів транзакцій — доки база не зупиниться (рядок 16).
  • Читання, надіслані на репліку одразу після запису, тож користувачі не бачать власних змін.

Чесна межа в тому, що база гарантує лише те, що їй сказали. Вона не може захистити правило, яке живе лише в коді застосунку, транзакцію, проведену навколо не тих виразів, чи рішення, ухвалене на даних, прочитаних в іншій транзакції. Сімнадцять атак ACIDRain, пов’язаних із межами транзакцій, — доказ: рушій зробив рівно те, що обіцяв, а обіцянка не покривала бізнесу. Рівні ізоляції, обмеження й блокування — інструменти, щоб сформулювати правила для рушія. Команда, що не формулює жодного, отримує швидке й надійне сховище, яке нічим не захищає її інваріантів. Робота в тому, щоб вирішити, які правила достатньо важливі, щоб сформулювати їх там.

Практична перевірка займає пів дня. Проженіть десять найповільніших запитів із pg_stat_statements або журналу повільних запитів через EXPLAIN ANALYZE і пошукайте оцінки, що помиляються вдесятеро. Пошукайте в коді SELECT, за яким іде UPDATE значення, обчисленого між ними. Випишіть бізнес-правила, що охоплюють кілька рядків, — баланси, залишки, бронювання, квоти — і запишіть, як захищено кожне. Перевірте, що кожна міграція задає lock_timeout. І знайдіть алерт на найстарішу відкриту транзакцію; якщо його немає, це перше, що варто додати.

Коли це застосовувати

Застосовуйте, коли

  • Дані зі зв’язками, які мають лишатися узгодженими: замовлення та їхні позиції, рахунки та їхні записи.
  • Усе, де частково застосована зміна неприпустима і транзакція є самим сенсом.
  • Запити, не всі з яких відомі наперед: планувальник і довільний SQL кращі за фіксований шлях доступу.
  • Типовий вибір для нового продукту — доки виміряна вимога справді не скаже інакше.

Уникайте, коли

  • Обсяги запису, яких не витримає одна основна база, коли форма даних натомість дозволяє поділ за ключем.
  • Документи, чиї поля справді різняться від запису до запису: там зі схемою борються, а не користуються нею.
  • Глибоко рекурсивні запити про зв’язки — друзі друзів друзів, — на які графовий рушій відповідає напряму.
  • Кеш, черга чи сховище сесій: там спеціалізована система простіша й на порядок швидша.

Джерела й метод

На чому стоїть ця стаття

  • 16 джерел
  • 5 задокументованих збоїв
  • Перевірено 13 жовтня 2026 р.

Дослідження з Claude. Пошук і звірку першоджерел зроблено з Claude (Anthropic). Відбір, текст і перевірку фактів — редакція Anatomy of IT Product.

Було корисно?

Поділіться з тим, хто працює над тією ж задачею.