Усе або нічого, і після COMMIT назавжди
обʼєднує кілька змін в одну. Відгук кандидата це рядок у applications і запис в історії application_events. Якщо другий INSERT упав, першого теж не має бути, інакше роботодавець побачить відгук без жодної події в історії. ACID це чотири властивості, які база гарантує такій групі.
: або всі зміни, або жодної. : після транзакції виконуються всі обмеження схеми, від NOT NULL до FOREIGN KEY і CHECK. : паралельні транзакції не бачать незакомічених змін одна одної, а наскільки сильно вони одна від одної відгороджені, задає рівень ізоляції. : COMMIT повертається лише тоді, коли зміни лежать у на диску, і після падіння сервера база їх відновить.
Чого ACID не обіцяє. Consistency охоплює лише ті правила, що записані в схемі: правило «зарплата від не більша за зарплату до» без CHECK база не перевірить. Ізоляція на рівнях за замовчуванням не рятує від втраченого оновлення, коли дві сесії читають 10 і обидві пишуть 11. І жодна транзакція не відкличе лист, запит до API чи job у черзі, які ви відправили до COMMIT.
Сім сценаріїв і що буде після COMMIT
Кожен рядок це ситуація всередині транзакції на налаштуваннях за замовчуванням: InnoDB з REPEATABLE READ у MySQL, READ COMMITTED у PostgreSQL.
INSERT відгуку, INSERT історії, другий падає
A · відкочено все
ROLLBACK скасовує і перший INSERT. Laravel робить це сам, якщо замикання DB::transaction() кинуло виняток.
COMMIT, і за мить вимкнули живлення
D · дані на місці
Якщо innodb_flush_log_at_trx_commit = 1 і synchronous_commit = on, як за замовчуванням. Послаблені налаштування міняють останні транзакції на швидкість.
Інша сесія читає посеред транзакції
I · бачить старе
Незакомічених змін не видно на жодному рівні, крім READ UNCOMMITTED у MySQL. Що саме побачить друге читання, вирішує рівень ізоляції.
salary_from > salary_to
C · лише з CHECK
Транзакція гарантує лише ті правила, що є в схемі. З CONSTRAINT … CHECK (salary_from <= salary_to) вставка впаде, без нього пройде.
Дві сесії читають views = 10 і пишуть 11
втрачене оновлення
Обидві транзакції успішні, у базі 11 замість 12. Рятує UPDATE … SET views = views + 1, SELECT … FOR UPDATE або перевірка версії рядка.
Mail::send() усередині транзакції, потім ROLLBACK
лист уже пішов
Транзакція охоплює лише базу. Лист, HTTP-запит і запис у Redis не відкочуються. Їх виносять після COMMIT: afterCommit або подія після коміту.
CREATE TABLE посеред транзакції MySQL
неявний COMMIT
DDL у MySQL комітить усе, що було до нього, і ROLLBACK після цього вже нічого не скасує. У PostgreSQL і SQLite DDL відкочується разом з транзакцією.
Сценарії перевірено на MySQL 8.0.46 і PostgreSQL 16; втрачене оновлення відтворено двома зʼєднаннями PDO на обох базах.
CREATE TABLE, який комітить усе
У MySQL транзакції є лише в InnoDB, а autocommit увімкнений: кожна команда поза START TRANSACTION комітиться сама. DDL (CREATE, ALTER, DROP, RENAME, TRUNCATE) спричиняє неявний COMMIT. Транзакція закінчується просто посередині, наступні команди знову йдуть в autocommit, і ROLLBACK наприкінці нічого не робить.
PostgreSQL і SQLite виконують DDL усередині транзакції і відкочують його разом з усім іншим. Laravel цим користується: міграцію на PostgreSQL він загортає в транзакцію, тож упала посередині, і схема лишилась як була. На MySQL половина міграції лишиться застосованою, і повторний запуск упаде на першому ж CREATE TABLE. Для SQLite Laravel міграції в транзакцію не загортає, хоча рушій це вміє.
+----+ | id | +----+ | 1 | | 2 | +----+ Tables_in_lab (audit%): audit, audit_archive
Залишились обидва рядки і нова таблиця. Рядок 1 закомітив CREATE TABLE, рядок 2 пішов уже в autocommit.
id ---- (0 rows) Schema | Name | Type | Owner --------+-------+-------+------- public | audit | table | root
Відкочено все, включно з CREATE TABLE. Винятки є: CREATE INDEX CONCURRENTLY, VACUUM і CREATE DATABASE у транзакції не працюють зовсім.
┌───────┐ │ name │ ├───────┤ │ audit │ └───────┘
Як у PostgreSQL: SELECT * FROM audit повернув порожньо, а таблиці audit_archive немає.
Таблиця, яка не вміє ROLLBACK
Рушій у MySQL обирають для кожної таблиці. InnoDB транзакційний, MyISAM і MEMORY ні: зміни в них застосовуються одразу, і ROLLBACK їх не повертає. Якщо в одній транзакції є таблиці обох видів, атомарність зберігається лише для InnoDB, а MySQL відповідає попередженням, яке легко не помітити.
Warning 1196: Some non-transactional changed tables couldn't be rolled back +----+ | id | +----+ | 1 | +----+
Рядок лишився. Перевірити рушій усіх таблиць: SELECT table_name, engine FROM information_schema.tables WHERE table_schema = DATABASE().
Одна команда впала. Що з рештою?
Тут рушії розходяться. MySQL і SQLite відкочують лише команду, що впала, і транзакція триває: COMMIT збереже все інше. PostgreSQL позначає всю транзакцію як зламану, кожна наступна команда отримує помилку, а COMMIT насправді виконує ROLLBACK.
[[Savepoint|savepoint]] дає продовжити після помилки свідомо: ROLLBACK TO SAVEPOINT скасовує лише те, що було після позначки. Laravel ставить SAVEPOINT trans2, trans3 і так далі на кожен вкладений DB::transaction(), тож виняток у вкладеному замиканні відкочує лише його.
ERROR: duplicate key value violates unique constraint "ap_pkey" ERROR: current transaction is aborted, commands ignored until end of transaction block ROLLBACK -- у таблиці 0 рядків ERROR: duplicate key value violates unique constraint "ap_pkey" COMMIT -- у таблиці (10, olena@…) і (11, olena@…)
Перша транзакція: третій INSERT проігноровано, а COMMIT відповів ROLLBACK. Друга: savepoint повернув транзакцію до робочого стану, і вона закомітилась.
ERROR 1062 (23000): Duplicate entry '10-olena@kvitka.ua' for key 'ap.PRIMARY' +------------+-----------------+ | vacancy_id | email | +------------+-----------------+ | 10 | olena@kvitka.ua | | 11 | olena@kvitka.ua | +------------+-----------------+
Відкочено лише дубль, решта закомічена. Якщо код ловить виняток і йде далі, у MySQL транзакція збереже часткову роботу: атомарність тут за вами, а не за базою.
Runtime error: UNIQUE constraint failed: ap.vacancy_id, ap.email (19) ┌────────────┬─────────────────┐ │ vacancy_id │ email │ ├────────────┼─────────────────┤ │ 10 │ olena@kvitka.ua │ │ 11 │ olena@kvitka.ua │ └────────────┴─────────────────┘
Як MySQL: відкочено одну команду. Тести на SQLite не покажуть, що на PostgreSQL та сама послідовність закінчиться ROLLBACK.
Коли COMMIT справді на диску
innodb_flush_log_at_trx_commit у MySQL: 1 (за замовчуванням) скидає redo log на диск на кожному COMMIT; 2 пише його в кеш ОС на COMMIT і скидає раз на секунду, тож падіння mysqld нічого не губить, а вимкнення живлення до секунди транзакцій; 0 пише й скидає раз на секунду, і секунду можна втратити навіть при падінні самого процесу.
synchronous_commit у PostgreSQL: on (за замовчуванням) чекає, поки WAL ляже на диск; off повертає COMMIT одразу, і при збої зникнуть транзакції за останні до трьох wal_writer_delay, тобто до 600 мс за замовчуванням, але база лишиться цілою. Його можна вимкнути для однієї транзакції: SET LOCAL synchronous_commit = off для запису аналітики. fsync = off на відміну від нього може зіпсувати базу.
PRAGMA synchronous у SQLite: FULL скидає журнал на кожному COMMIT, NORMAL у режимі WAL рідше, і після вимкнення живлення останні транзакції можуть відкотитися.
+--------------+----------------------------------+---------------+-------------------------+ | @@autocommit | @@innodb_flush_log_at_trx_commit | @@sync_binlog | @@transaction_isolation | +--------------+----------------------------------+---------------+-------------------------+ | 1 | 1 | 1 | REPEATABLE-READ | +--------------+----------------------------------+---------------+-------------------------+
sync_binlog = 1 так само скидає на диск бінарний лог, з якого живуть репліки. Обидва 1 це найнадійніше і найповільніше поєднання, і воно стоїть за замовчуванням.
synchronous_commit -------------------- on fsync ------- on
synchronous_commit має ще значення remote_write, remote_apply і local, які стосуються синхронних реплік.
delete 2
2 означає FULL. Laravel для SQLite дає налаштувати journal_mode і synchronous у config/database.php, а режим WAL з NORMAL часто вмикають заради швидкості.
DB::transaction() і що навколо нього
Поведінку нижче звірено з трейтом ManagesTransactions з laravel/framework 13: саме він дає DB::transaction(), savepoint і afterCommit.
Відгук, історія і версія разом
EloquentApplicationRepository::save() пише рядок відгуку і нові записи його історії в одній транзакції: роботодавець ніколи не побачить відгук на новому етапі без події, яка його туди перевела. Друга половина методу закриває те, чого ACID не дає. Оновлення йде з умовою WHERE version = прочитана версія, і якщо хтось встиг змінити відгук раніше (кандидат відкликав, роботодавець виставив підсумок), UPDATE зачепить нуль рядків. Тоді метод кидає StaleApplication, DB::transaction() відкочує все, включно з подіями, і застаріла зміна не затре свіжу. Це оптимістичне блокування: на READ COMMITTED у PostgreSQL сама ізоляція від втраченого оновлення не рятує.
database/migrations/2026_09_10_000008_add_version_to_applications.phpКолонка version з коментарем про оптимістичне блокування: застаріле записування відкидається, а не перетирає новіше відкликання чи підсумок.src/Hiring/Infrastructure/Persistence/EloquentSurveyAnswerRepository.phpreplacePeriod() видаляє відповіді опитування за період і вставляє нові пачками по 500 в одній транзакції. Інші сесії до COMMIT бачать старі відповіді, а не порожній період.src/ContentOps/Infrastructure/Persistence/EloquentContentTaskRepository.phpleaseBatch() бере задачі через lockForUpdate() усередині транзакції: два воркери не візьмуть ту саму задачу, бо другий чекає на блокування рядків до COMMIT першого.app/Livewire/Employer/Register.phpКомпанія й користувач-власник створюються в одному DB::transaction(): не буде компанії без власника. Лист із підтвердженням і сповіщення редакції йдуть уже після транзакції.config/queue.phpafter_commit стоїть false у всіх зʼєднаннях черги, тож job, відправлений з транзакції, не чекає на COMMIT; там, де це важливо, потрібен ->afterCommit().Де транзакції підводять
Зовнішній виклик усередині транзакції
Лист, HTTP-запит до платіжного шлюзу чи job у черзі йдуть до COMMIT. Транзакція відкотилась, а лист «відгук отримано» вже в кандидата. Ще й транзакція тримає блокування, поки чекає на мережу.
Натомість: Усе зовнішнє після COMMIT: DB::afterCommit(), ->afterCommit() для job, ShouldDispatchAfterCommit для подій.
Транзакція, якої немає
Два save() поспіль без DB::transaction(): в autocommit кожен комітиться окремо, і падіння між ними лишає половину. Або таблиця на MyISAM, і ROLLBACK лише пише попередження.
Натомість: Пов’язані записи в одному DB::transaction(); усі таблиці MySQL на InnoDB.
Міграція на MySQL з кількома кроками
CREATE TABLE, потім заповнення, потім ALTER, і на третьому кроці помилка. На MySQL кожен DDL уже закомічений, міграція не записана як виконана, і повторний запуск падає на першому CREATE.
Натомість: Маленькі міграції по одному DDL і down(), що справді працює. Для перевірки запускати їх на копії продакшн-бази.
Перехоплений виняток посеред транзакції PostgreSQL
try/catch навколо INSERT, який падає на дублі, а далі ще запити. На PostgreSQL усі вони отримують current transaction is aborted, а на MySQL і SQLite у тестах усе працює.
Натомість: Вкладений DB::transaction() навколо ризикованої команди (Laravel поставить savepoint) або INSERT … ON CONFLICT DO NOTHING замість ловлі помилки.
Довга транзакція
Транзакція відкрита, поки йде імпорт на хвилини чи поки користувач думає. Вона тримає блокування, а в PostgreSQL ще й не дає VACUUM прибрати старі версії рядків у всій базі.
Натомість: Транзакція лише навколо записів, без мережі й очікування. Великий імпорт пачками, кожна в своїй транзакції. У PostgreSQL ліміт idle_in_transaction_session_timeout.
- Транзакція
- Послідовність команд між BEGIN (START TRANSACTION) і COMMIT або ROLLBACK, яку база виконує як одне ціле. Без явного BEGIN кожна окрема команда це своя маленька транзакція.
- Atomicity
- Атомарність. Транзакція застосовується цілком або не застосовується зовсім, навіть якщо сервер упав посередині. Рятує від відгуку без історії й від списання грошей без зарахування.
- Consistency
- Узгодженість. Транзакція переводить базу з одного стану, що задовольняє всі обмеження, в інший такий самий. Порушила обмеження, отримала помилку. Рятує від вакансії неіснуючої компанії, але лише якщо обмеження оголошено.
- Isolation
- Ізоляція. Паралельні транзакції не заважають одна одній так, наче виконуються по черзі, з точністю, яку задає рівень ізоляції. Рятує від читання чужих незакомічених змін.
- Durability
- Довговічність. Після COMMIT зміни не зникнуть після падіння процесу бази чи вимкнення живлення. Рятує від «оплату прийнято», якої після перезапуску немає.
- Журнал
- Write-ahead log: перед зміною сторінок даних база дописує опис змін у послідовний журнал і скидає його на диск. У PostgreSQL це WAL, в InnoDB redo log. Після збою база програє журнал і відновлює закомічене.
- Savepoint
- Позначка всередині транзакції. ROLLBACK TO SAVEPOINT скасовує лише зміни після неї, а транзакція триває. Laravel ставить savepoint на кожен вкладений DB::transaction().
Питання зі співбесід
Atomicity: усі зміни транзакції застосовуються разом або жодна. Consistency: після транзакції виконуються всі обмеження схеми. Isolation: паралельні транзакції не бачать незакомічених змін одна одної. Durability: закомічене переживе падіння сервера, бо лежить у журналі на диску.
PostgreSQL виконує DDL усередині транзакції, і Laravel загортає в неї кожну міграцію на цьому драйвері, тож помилка відкочує все. У MySQL кожен CREATE чи ALTER робить неявний COMMIT, тому вже виконані кроки лишаються, а запис у таблиці migrations не зʼявляється. Повторний запуск падає на першому ж кроці.
Лист, бо пошта не частина транзакції бази. Відправку переносять на після COMMIT: DB::afterCommit(), job з ->afterCommit() або подія з ShouldDispatchAfterCommit. Тоді при ROLLBACK колбек просто викидається.
Ні на READ COMMITTED у PostgreSQL, ні на REPEATABLE READ у MySQL: обидві транзакції коректно прочитали 10 і записали 11, це втрачене оновлення. Рішення: атомарний вираз UPDATE … SET n = n + 1, SELECT … FOR UPDATE перед читанням, оптимістична перевірка версії в WHERE або REPEATABLE READ у PostgreSQL, де друга транзакція отримає could not serialize access і її можна повторити.