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

SQL

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

Ви описуєте
Результат, а не шлях
Швидкість дає
Індекси й план
Правильність
Забезпечує рушій

Ви описуєте результат, рушій обирає шлях

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

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

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

Індекси не безкоштовні й не автоматичні, і чотири їхні властивості пояснюють більшість несподіванок. Вони коштують часу на запис, бо кожне вставляння й оновлення має підтримати кожен індекс таблиці. У складеному індексі важить порядок: індекс на (customer_id, created_at) допомагає запиту, що фільтрує лише за customer_id, і не робить нічого для того, що фільтрує лише за created_at, — він упорядкований спершу за першою колонкою, точно як телефонна книга за прізвищем, а тоді за іменем. Обгортання колонки у функцію зазвичай вимикає індекс, бо індекс зберігає колонку, а не функцію від неї. А індекс, що містить усі потрібні запиту колонки, дозволяє рушієві відповісти з самого індексу, взагалі не торкаючись таблиці.

СимптомЗвичайна причинаЩо робити
Швидко в розробці, повільно у продіПослідовне сканування дешеве на тисячі рядків і згубне на десяти мільйонах.Тестуйте на даних розміру проду й читайте план, а не час на годиннику.
Індекс є, але не використовуєтьсяКолонку обгорнуто у функцію, типи не збігаються, або вона не є провідною.Індексуйте сам вираз, полагодьте розбіжність типів або перевпорядкуйте складений індекс.
Записи сповільнилися після налаштуванняКожен індекс, доданий заради читання, підтримується на кожному записі — назавжди.Приберіть індекси, якими ніщо не користується: рушій може сказати, які саме.
Один запит нормальний, а сторінка повільнаСторінка виконує запит на кожен рядок списку, тож швидкий запит виконується двісті разів.Дістаньте повʼязані рядки одним виразом — приєднанням або одним запитом IN.

Що насправді обіцяє транзакція

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

Уся корисна конкретика — в ізольованості, бо повна ізоляція дорога, і кожен рушій типово постачається зі слабшою. Рівні визначені тим, які аномалії вони дозволяють, а не тим, як їх реалізовано, — і саме знання аномалії дозволяє впізнати ваду у власному продукті. Брудне читання бачить незакомічену роботу іншої транзакції. Неповторюване читання дістає дві різні відповіді на той самий запит усередині однієї транзакції, бо хтось закомітив між ними. Фантомне читання дістає інший набір рядків за тією самою умовою, бо рядки вставили. А перекіс запису — це те, що ловить досвідчені команди: дві транзакції читають коректний стан, кожна робить зміну, яка сама собою нормальна, і разом вони ламають правило, якого поодинці не порушила жодна.

Рівень ізоляціїДосі дозволяєКоли брати
Read uncommittedБрудні читання: ви бачите роботу, яку потім відкотять.Фактично ніколи. Postgres навіть не реалізує його окремо від наступного рівня.
Read committedНеповторювані читання й фантоми: два читання в одній транзакції можуть розійтися.Типовий і правильний для більшості роботи — за умови, що кожне «прочитав і записав» атомарне.
Repeatable readПерекіс запису: кожна транзакція бачить стабільний знімок, але конфліктувати вони все одно можуть.Звіти й багатовиразні читання, які всі мають бачити ту саму мить у часі.
SerializableНічого: результат гарантовано збігається з якимось послідовним порядком транзакцій.Інваріанти між рядками: бронювання, баланси, квоти. Чекайте повторів і пишіть код під них.

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

Обмеження — це не документація

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

Перевірено в застосунку

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

Забезпечено базою

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

Використовуйте обидва, а базі дайте тримати правила, які мають бути істинними щодо самих даних: NOT NULL там, де відсутнє значення не має сенсу; зовнішній ключ там, де рядок має посилатися на щось наявне; унікальне обмеження там, де дублікат — це дефект; CHECK там, де в значення є припустимий діапазон. Колонка, що допускає null, — це обіцянка, що ваш код оброблятиме цей null скрізь і завжди, а більшість таких колонок існує тому, що ніхто не ухвалив рішення, а не тому, що значення справді необовʼязкове.

Зміна схеми на живій системі — це та частина, якій потрібна процедура, а не сміливість. Два правила покривають більшість. Перше: розширити й звузити — додати нову колонку, писати в обидві, дозаповнити, перемкнути читання, а тоді прибрати стару. Це кілька розгортань, кожне безпечне саме собою, замість однієї зміни, яка вимагає, щоб код і схема перемкнулися рівно в ту саму мить. Друге: знайте, які операції беруть блокування, що спиняє записи, бо на великій таблиці це блокування є аварією. Додати колонку з null і без значення за замовчуванням зазвичай миттєво; переписати тип колонки — ні; побудова індексу бере блокування на запис, якщо ви не попросили рушій будувати його паралельно.

Як це виявляється в реальній поставці

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

Зʼєднання — це ресурс із жорсткою стелею, і вона нижча, ніж очікують: кожне коштує памʼяті, а на деяких рушіях — процесу. Пул на двадцять зʼєднань на примірник застосунку виглядає скромно, доки оркестратор не запустить пʼятдесят примірників, а база не відмовить тисяча першому зʼєднанню. Визначайте розмір пулу з того, скільки може обслужити база, а не з того, скільки хотів би один примірник, і ставте перед нею пулер зʼєднань, коли кількість примірників еластична. Пул, менший за очікуваний, пояснює й цілий клас загадкових затримок: час іде на очікування зʼєднання, а не на виконання запиту.

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

Де це вироджується

  • Оцінка запиту за відчуттям на базі розробника, де тисяча рядків.
  • Один запит на рядок списку: рендер сторінки перетворюється на двісті звернень.
  • Схема «прочитав, змінив, записав» у коді: вона губить оновлення тієї миті, коли два запити перетнулися.
  • Мережевий виклик усередині відкритої транзакції: блокування тримаються стільки, скільки думає інша система.
  • Індекс, доданий під кожен повільний запит і ніколи не прибраний: кожен запис платить за всі.
  • Обмеження, лишені застосунку, у базі, куди пише ще кілька інших речей.
  • Руйнівна міграція, розгорнута одним кроком: відкат коду вже не поверне колонки.
  • Читання, надіслані на репліку одразу після запису: користувачі не бачать власних змін.

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

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

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

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

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

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

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