Houdrik
Engineering
· 12 серпня 2025 р.· 9 min read

Схеми, що переживають другу функцію

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

Cover · schemas-that-survive-the-second-feature

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

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

Зовнішні ключі, а не «зробимо join у коді застосунку»

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

У продакшні так не буває. У продакшні є воркер, який повторює спроби, фонове завдання, яке виконується не в тому порядку, розгортання, яке відкочується наполовину, інструмент підтримки, що робить UPDATE, про який ніхто не подумав. Без зовнішніх ключів база рано чи пізно міститиме рядок, який посилається на батьківський запис, що його вже немає або не було ніколи. Помилка виринає за три тижні як 500 на сторінці, за яку ніхто не відповідає, і вистежити її займає день, бо дані на диску брешуть про те, що взагалі можливо.

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

Справжні обмеження, а не відчуття

NOT NULL, CHECK, UNIQUE. Ці три обмеження несуть більшість роботи з підтримання продакшн-бази в чесному стані, і саме до них схеми, згенеровані ШІ, ставляться як до необов’язкової декорації.

Шаблон, який ми бачимо найчастіше: колонка, що «на практиці завжди заповнена», але в схемі — nullable, бо у seed-скрипті прототипу її випадково заповнювали. За шість місяців з’являються рядки, де вона NULL, бо якийсь шлях у коді, про який ніхто не пам’ятав, її не заповнив, і тепер кожен запит мусить захисно обробляти NULL. Помножте це на всю схему — і отримаєте кодову базу, де кожне читання загорнуте в умови, які існують лише тому, що базі не дали робити свою роботу.

Гірше за відсутність обмежень — обмеження, написані на відчуттях. CHECK, що дозволяє три значення, тоді як застосунок видає лише два, «раптом колись знадобиться». UNIQUE на хибній парі колонок. NOT NULL на колонці, яку застосунок заповнює порожнім рядком, коли реального значення немає. Вони виглядають як дисципліна — і не є нею. Вони дають наступному інженерові хибну впевненість щодо вмісту таблиці.

Ми пишемо обмеження, які відповідають тому, що застосунок насправді робить, і пишемо їх щільно. Якщо застосунок видає два значення — CHECK дозволяє два. Якщо колонка обов’язкова — вона NOT NULL, а змінюється застосунок, щоб надати значення, а не навпаки.

Індекси, спроєктовані під ті запити, які ви насправді виконуєте

Моделі вгадують індекси. Це не погані здогади — вони проіндексують зовнішні ключі, проіндексують очевидні колонки пошуку, інколи додадуть складений індекс, який виглядає правдоподібно. Це водночас, майже завжди, не ті індекси, які зрештою знадобляться застосункові.

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

Ми проєктуємо індекси під реальні шаблони запитів, коли вже маємо їх. Це означає дивитися в pg_stat_statements, читати EXPLAIN ANALYZE на запитах, що мають значення, і видаляти індекси, які мають нуль сканувань після тижня реального трафіку. Часткові індекси, коли навантаження перекошене, нерідко варті вдесятеро більше за повний індекс. Ніщо з цього не екзотика; усе вимагає просто посидіти з базою один вечір.

Міграції, що йдуть уперед і назад

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

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

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

JSONB — це інструмент, а не переможна пробіжка

Колонки JSONB — це місце, де схеми, згенеровані ШІ, ховають рішення, які не хотіли ухвалювати. «Розберемося зі структурою пізніше» перетворюється на колонку data jsonb, що накопичує поля, які ніхто не задокументував, типи, які ніхто не змусив, і шаблони доступу, які ніхто не проіндексував.

JSONB справді корисний. Це правильна відповідь для розріджених, мінливих, орендар-специфічних форм, де піднімати кожне поле до окремої колонки було б смішно. Це хибна відповідь для центральної сутності системи, де поля відомі, запити відомі, а єдина причина, чому це JSONB, — модель не хотіла фіксуватися.

Наше правило: кожна колонка JSONB отримує чесний опис того, що в ній лежить, задокументовану очікувану форму і план, які поля будуть винесені в окремі колонки, коли їхні шаблони доступу усталяться. Ми додаємо CHECK із jsonb_typeof там, де форма стабільна. Додаємо індекси за виразами там, де певний шлях часто потрапляє в запити. І переглядаємо колонку щоквартально, щоб побачити, що вже має стати окремою колонкою з реальним обмеженням.

Скільки це коштує і чого варте

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

Зробити її неправильно — це місяці поступового болю після того, як прийдуть перші сто клієнтів. Це виклик чергового інженера о 2-й ночі, бо NULL просочився в колонку, яка «не могла» його мати. Це міграція, що валить базу на сорок хвилин, бо ніхто не додав індекс із побудовою без блокування. Це звернення в підтримку, яке неможливо закрити, бо два рядки посилаються на орендаря, видаленого в березні, а зовнішнього ключа там не було, щоб це зупинити.

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

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

Маєте додаток, який має жити?

Виведіть його з прототипу в продакшн.

Відповідаємо протягом одного робочого дня. MVP, написаний на відчуттях, чернетка від AI, недороблений проєкт або робочий продукт, що починає тріщати — усе приймається.

Запустити проєкт