Перевірити правило в коді здається достатнім.
Наприклад: знайти активний запис, переконатися, що іншого немає, а потім створити новий.
У послідовному виконанні це працює.
Але два конкурентні запити можуть одночасно пройти одну й ту саму перевірку і обидва побачити, що запису ще немає.
Саме тому для важливих правил я розділяю дві задачі: код пояснює правило і керує поведінкою, а база даних гарантує, що неправильний стан фізично не буде збережений.
Перевірка в коді все одно потрібна
$existing = $repository->findActiveByOwner( $ownerId,); if ($existing !== null) { throw ActiveRecordAlreadyExists::forOwner( $ownerId, );} $repository->add( Record::create($ownerId),);Я не прибираю таку перевірку лише тому, що правило також є в базі.
Вона корисна з кількох причин:
- дозволяє швидко завершити неправильний сценарій;
- повертає зрозумілу прикладну помилку;
- не змушує використовувати виняток бази як звичайний спосіб керування потоком;
- робить правило видимим у коді.
Проблема не в самій перевірці. Проблема в припущенні, що після неї конкурентна зміна вже неможлива.
Два запити можуть пройти одну перевірку одночасно
Запит A Запит B find existing find existing | |нічого немає нічого немає | |перевірка OK перевірка OK | |INSERT INSERTОбидва запити тут поводяться правильно.
Кожен чесно виконує перевірку і в момент читання бачить правильний результат.
Але між SELECT і INSERT є проміжок часу.
Якщо правило існує лише в PHP, обидва записи можуть бути створені.
Тому правило "такий активний запис може бути лише один" повинно мати гарантію ближче до місця, де дані реально зберігаються.
UNIQUE робить правило фізично неможливим порушити
Schema::create('example_records', function (Blueprint $table): void { $table->uuid('id')->primary(); $table->uuid('owner_id'); $table->string('environment'); $table->unique([ 'owner_id', 'environment', ]);});З унікальним індексом ситуація змінюється.
Два запити все ще можуть одночасно пройти початкову перевірку.
Але два однакові записи вже не можуть обидва успішно потрапити в базу.
Один запис буде збережений. Другий отримає конфлікт унікальності.
Тобто конкурентність нікуди не зникає. Змінюється інше: неправильний остаточний стан стає неможливим.
Я не використовую UNIQUE, щоб уникнути конкурентного запиту. Я використовую його, щоб конкурентний запит не зміг зламати правило.
Помилку бази я перетворюю на зрозумілий конфлікт
try { ExampleRecord::query()->create([ 'owner_id' => $record->ownerId(), 'environment' => $record->environment(), ]);} catch (QueryException $exception) { if (! $this->isUniqueViolation($exception)) { throw $exception; } throw WriteConflict::detected( previous: $exception, );}Я не хочу, щоб SQL-помилка випадково піднялася до контролера як публічний текст.
Сховище знає, що конкретне порушення обмеження означає конкурентний конфлікт запису, і перетворює технічний виняток на зрозумілу помилку для вищого рівня.
При цьому невідомі помилки бази не маскуються. Якщо це не очікуване порушення конкретного правила, початковий виняток продовжує підніматися.
Це не переносить бізнес-логіку в базу даних
Для мене тут важлива межа.
Те, що база захищає правило, не означає, що саме вона повинна пояснювати його зміст застосунку.
Наприклад, прикладний або доменний код все одно може знати, що одночасно дозволений лише один певний стан і повернути нормальну помилку ще до запису.
Унікальний індекс лише додає технічну гарантію, що правило не буде порушене через конкурентність або інший шлях запису.
UNIQUE - лише один тип гарантії
Той самий принцип стосується не лише унікальних індексів.
База може захищати різні види цілісності:
UNIQUEне дозволяє створити два записи, які повинні бути унікальними;FOREIGN KEYне дозволяє посилатися на неіснуючий батьківський запис;NOT NULLне дозволяє залишити обов’язкове значення порожнім;CHECK, де він доречний, може обмежити допустимі значення;- складені індекси можуть захищати унікальність комбінації кількох полів.
Я використовую такі обмеження там, де правило справді можна надійно виразити на рівні даних.
Foreign key захищає правило навіть поза Laravel-кодом
$table->foreignUuid('parent_id') ->constrained('parents') ->restrictOnDelete();Ще одна причина для обмежень бази: не кожен запис обов’язково проходитиме через той самий PHP-метод назавжди.
Може з’явитися інший сценарій, імпорт, консольна команда, фоновий процес або майбутній код.
Якщо зв’язок справді обов’язковий, зовнішній ключ захищає його незалежно від того, який саме код виконав INSERT або DELETE.
Обмеження не замінюють транзакції та блокування
UNIQUE не є універсальною відповіддю на будь-яку конкурентність.
Якщо операція змінює кілька пов’язаних рядків або рішення залежить від актуального стану, все одно можуть знадобитися транзакція, умовний UPDATE або lockForUpdate().
Обмеження бази вирішує інше питання: воно захищає конкретний інваріант незалежно від того, який конкурентний шлях привів до спроби неправильного запису.
Найнебезпечніше припущення: "ми вже перевірили це в коді"
Для мене фраза "це вже перевіряється перед save()" не є достатньою відповіддю для критичного правила.
Я додатково питаю:
- чи можуть два запити пройти цю перевірку одночасно;
- чи існують інші шляхи запису;
- чи буде база залишатися правильною після помилки посеред операції;
- чи можна саме правило виразити обмеженням схеми.
Якщо база здатна гарантувати правило без двозначності, я зазвичай не залишаю цю гарантію лише на рівні PHP.
Я використовую кілька рівнів захисту для різних задач
Прикладний код | +--> зрозуміле правило +--> рання перевірка +--> нормальна помилка Транзакція / блокування | +--> захист критичної зміни +--> актуальний стан Обмеження бази | +--> остаточна гарантія цілісностіЦі рівні не дублюють одне одного.
Перевірка в коді потрібна для зрозумілої поведінки.
Транзакція або блокування потрібні, коли сама операція має бути захищена від конкурентної зміни.
Обмеження схеми гарантує, що база не прийме стан, який суперечить фундаментальному правилу даних.
Які правила я намагаюся захистити на рівні бази
Я особливо дивлюся на обмеження бази, якщо правило звучить приблизно так:
- "це значення має бути унікальним";
- "така комбінація полів може існувати лише один раз";
- "дочірній запис не може існувати без батьківського";
- "це поле обов’язкове";
- "значення повинно належати до чітко обмеженого набору";
- "два конкурентні запити не повинні мати можливість створити два однаково допустимі записи".
Не кожне бізнес-правило можна або потрібно переносити в SQL. Але базові правила цілісності даних я намагаюся не залишати лише на довірі до application code.
Правило має бути захищене там, де його ще можна порушити
Для мене обмеження бази даних - не заміна хорошому коду.
Навпаки, хороший код пояснює правило, перевіряє його раніше і повертає зрозумілу помилку.
А база даних робить останню важливу річ: не дозволяє неправильному стану стати фактом лише тому, що два запити прийшли одночасно або з’явився новий шлях запису.
Якщо цілісність даних залежить від правила, яке база здатна гарантувати сама, я не покладаюся лише на те, що всі майбутні рядки PHP-коду завжди виконаються в правильному порядку.
Код допомагає не зробити неправильний запис. Обмеження бази гарантує, що неправильний запис не стане частиною даних.



