<? phpukraine PHP-ПЛАТФОРМА
⌕Пошук по платформі
БАЗИ ДАНИХ · ТРАНЗАКЦІЇ

ACID

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

16 хв читання Junior → Senior
01 · ЧОТИРИ ОБІЦЯНКИ

Усе або нічого, і після COMMIT назавжди

обʼєднує кілька змін в одну. Відгук кандидата це рядок у applications і запис в історії application_events. Якщо другий INSERT упав, першого теж не має бути, інакше роботодавець побачить відгук без жодної події в історії. ACID це чотири властивості, які база гарантує такій групі.

СЛОВНИКТранзакція. Послідовність команд між BEGIN (START TRANSACTION) і COMMIT або ROLLBACK, яку база виконує як одне ціле. Без явного BEGIN кожна окрема команда це своя маленька транзакція.

: або всі зміни, або жодної. : після транзакції виконуються всі обмеження схеми, від NOT NULL до FOREIGN KEY і CHECK. : паралельні транзакції не бачать незакомічених змін одна одної, а наскільки сильно вони одна від одної відгороджені, задає рівень ізоляції. : COMMIT повертається лише тоді, коли зміни лежать у на диску, і після падіння сервера база їх відновить.

СЛОВНИКAtomicity. Атомарність. Транзакція застосовується цілком або не застосовується зовсім, навіть якщо сервер упав посередині. Рятує від відгуку без історії й від списання грошей без зарахування.
СЛОВНИКConsistency. Узгодженість. Транзакція переводить базу з одного стану, що задовольняє всі обмеження, в інший такий самий. Порушила обмеження, отримала помилку. Рятує від вакансії неіснуючої компанії, але лише якщо обмеження оголошено.
СЛОВНИКIsolation. Ізоляція. Паралельні транзакції не заважають одна одній так, наче виконуються по черзі, з точністю, яку задає рівень ізоляції. Рятує від читання чужих незакомічених змін.
СЛОВНИКDurability. Довговічність. Після COMMIT зміни не зникнуть після падіння процесу бази чи вимкнення живлення. Рятує від «оплату прийнято», якої після перезапуску немає.
СЛОВНИКЖурнал. Write-ahead log: перед зміною сторінок даних база дописує опис змін у послідовний журнал і скидає його на диск. У PostgreSQL це WAL, в InnoDB redo log. Після збою база програє журнал і відновлює закомічене.

Чого ACID не обіцяє. Consistency охоплює лише ті правила, що записані в схемі: правило «зарплата від не більша за зарплату до» без CHECK база не перевірить. Ізоляція на рівнях за замовчуванням не рятує від втраченого оновлення, коли дві сесії читають 10 і обидві пишуть 11. І жодна транзакція не відкличе лист, запит до API чи job у черзі, які ви відправили до COMMIT.

02 · ЩО ОБІЦЯЄ ТРАНЗАКЦІЯ

Сім сценаріїв і що буде після 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 на обох базах.

03 · DDL У ТРАНЗАКЦІЇ

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 міграції в транзакцію не загортає, хоча рушій це вміє.

MySQL 8.0 PostgreSQL 16 SQLite 3.45
1START TRANSACTION;
2INSERT INTO audit VALUES (1);
3CREATE TABLE audit_archive (id INT PRIMARY KEY);
4INSERT INTO audit VALUES (2);
5ROLLBACK;
6
7SELECT * FROM audit;
8SHOW TABLES LIKE 'audit%';
+----+
| id |
+----+
|  1 |
|  2 |
+----+
Tables_in_lab (audit%): audit, audit_archive

Залишились обидва рядки і нова таблиця. Рядок 1 закомітив CREATE TABLE, рядок 2 пішов уже в autocommit.

1BEGIN;
2INSERT INTO audit VALUES (1);
3CREATE TABLE audit_archive (id INT PRIMARY KEY);
4INSERT INTO audit VALUES (2);
5ROLLBACK;
6
7SELECT * FROM audit;
8\dt audit*
 id
----
(0 rows)

 Schema | Name  | Type  | Owner
--------+-------+-------+-------
 public | audit | table | root

Відкочено все, включно з CREATE TABLE. Винятки є: CREATE INDEX CONCURRENTLY, VACUUM і CREATE DATABASE у транзакції не працюють зовсім.

1BEGIN;
2INSERT INTO audit VALUES (1);
3CREATE TABLE audit_archive (id INT PRIMARY KEY);
4INSERT INTO audit VALUES (2);
5ROLLBACK;
6
7SELECT * FROM audit;
8SELECT name FROM sqlite_master WHERE name LIKE 'audit%';
┌───────┐
│ name  │
├───────┤
│ audit │
└───────┘

Як у PostgreSQL: SELECT * FROM audit повернув порожньо, а таблиці audit_archive немає.

04 · НЕ КОЖНА ТАБЛИЦЯ

Таблиця, яка не вміє ROLLBACK

Рушій у MySQL обирають для кожної таблиці. InnoDB транзакційний, MyISAM і MEMORY ні: зміни в них застосовуються одразу, і ROLLBACK їх не повертає. Якщо в одній транзакції є таблиці обох видів, атомарність зберігається лише для InnoDB, а MySQL відповідає попередженням, яке легко не помітити.

MySQL 8.0
1CREATE TABLE hits (id INT PRIMARY KEY) ENGINE=MyISAM;
2
3START TRANSACTION;
4INSERT INTO hits VALUES (1);
5ROLLBACK;
6SHOW WARNINGS;
7SELECT * FROM hits;
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().

05 · ПОМИЛКА ПОСЕРЕД ТРАНЗАКЦІЇ

Одна команда впала. Що з рештою?

Тут рушії розходяться. MySQL і SQLite відкочують лише команду, що впала, і транзакція триває: COMMIT збереже все інше. PostgreSQL позначає всю транзакцію як зламану, кожна наступна команда отримує помилку, а COMMIT насправді виконує ROLLBACK.

[[Savepoint|savepoint]] дає продовжити після помилки свідомо: ROLLBACK TO SAVEPOINT скасовує лише те, що було після позначки. Laravel ставить SAVEPOINT trans2, trans3 і так далі на кожен вкладений DB::transaction(), тож виняток у вкладеному замиканні відкочує лише його.

PostgreSQL 16 MySQL 8.0 SQLite 3.45
1-- PRIMARY KEY (vacancy_id, email)
2BEGIN;
3INSERT INTO ap VALUES (10, 'olena@kvitka.ua');
4INSERT INTO ap VALUES (10, 'olena@kvitka.ua');
5INSERT INTO ap VALUES (11, 'olena@kvitka.ua');
6COMMIT;
7
8BEGIN;
9INSERT INTO ap VALUES (10, 'olena@kvitka.ua');
10SAVEPOINT second;
11INSERT INTO ap VALUES (10, 'olena@kvitka.ua');
12ROLLBACK TO SAVEPOINT second;
13INSERT INTO ap VALUES (11, 'olena@kvitka.ua');
14COMMIT;
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 повернув транзакцію до робочого стану, і вона закомітилась.

1START TRANSACTION;
2INSERT INTO ap VALUES (10, 'olena@kvitka.ua');
3INSERT INTO ap VALUES (10, 'olena@kvitka.ua');
4INSERT INTO ap VALUES (11, 'olena@kvitka.ua');
5COMMIT;
6
7SELECT * FROM ap;
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 транзакція збереже часткову роботу: атомарність тут за вами, а не за базою.

1BEGIN;
2INSERT INTO ap VALUES (10, 'olena@kvitka.ua');
3INSERT INTO ap VALUES (10, 'olena@kvitka.ua');
4INSERT INTO ap VALUES (11, 'olena@kvitka.ua');
5COMMIT;
6
7SELECT * FROM ap;
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.

06 · ДОВГОВІЧНІСТЬ

Коли 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 рідше, і після вимкнення живлення останні транзакції можуть відкотитися.

MySQL 8.0 PostgreSQL 16 SQLite 3.45
1SELECT @@autocommit, @@innodb_flush_log_at_trx_commit, @@sync_binlog, @@transaction_isolation;
+--------------+----------------------------------+---------------+-------------------------+
| @@autocommit | @@innodb_flush_log_at_trx_commit | @@sync_binlog | @@transaction_isolation |
+--------------+----------------------------------+---------------+-------------------------+
|            1 |                                1 |             1 | REPEATABLE-READ         |
+--------------+----------------------------------+---------------+-------------------------+

sync_binlog = 1 так само скидає на диск бінарний лог, з якого живуть репліки. Обидва 1 це найнадійніше і найповільніше поєднання, і воно стоїть за замовчуванням.

1SHOW synchronous_commit;
2SHOW fsync;
 synchronous_commit
--------------------
 on

 fsync
-------
 on

synchronous_commit має ще значення remote_write, remote_apply і local, які стосуються синхронних реплік.

1PRAGMA journal_mode;
2PRAGMA synchronous;
delete
2

2 означає FULL. Laravel для SQLite дає налаштувати journal_mode і synchronous у config/database.php, а режим WAL з NORMAL часто вмикають заради швидкості.

07 · ТРАНЗАКЦІЇ В LARAVEL

DB::transaction() і що навколо нього

Поведінку нижче звірено з трейтом ManagesTransactions з laravel/framework 13: саме він дає DB::transaction(), savepoint і afterCommit.

php SubmitApplication.php Замикання
1$application = DB::transaction(function () use ($row, $events) {
2 $model = ApplicationModel::query()->create($row);
3 foreach ($events as $event) {
4 $model->events()->create($event);
5 }
6
7 return $model;
8});
ЩО ПЕРЕВІРЯЄ
BEGIN перед замиканням, COMMIT після, ROLLBACK і повторний виняток, якщо замикання кинуло будь-що. Повертає те, що повернуло замикання.
КОЛИ БРАТИ
Майже завжди. Ручні beginTransaction(), commit() і rollBack() потрібні лише тоді, коли транзакція охоплює кілька методів, які не вкладеш в одне замикання.
php SubmitApplication.php Повтор при дедлоку
1DB::transaction(function () {
2 // ...
3}, attempts: 3);
ЩО ПЕРЕВІРЯЄ
Повторює замикання, лише якщо помилка це конфлікт паралельних транзакцій: дедлок, SQLSTATE 40001 (serialization failure), Lock wait timeout exceeded, database is locked. Будь-який інший виняток кидається після першої спроби.
КОЛИ БРАТИ
Коли транзакція може стати жертвою дедлоку чи працює на SERIALIZABLE. Замикання має бути безпечним для повтору: без листів і HTTP-запитів усередині. Якщо дедлок стався у вкладеній транзакції, повтору немає: летить DeadlockException, бо база вже відкотила зовнішню.
php SubmitApplication.php Після коміту
1DB::transaction(function () use ($user) {
2 $user->save();
3
4 DB::afterCommit(fn () => Mail::to($user)->send(new Welcome($user)));
5 SendWelcomeSms::dispatch($user)->afterCommit();
6});
ЩО ПЕРЕВІРЯЄ
afterCommit() відкладає виклик до COMMIT найзовнішньої транзакції і викидає його при ROLLBACK. Поза транзакцією DB::afterCommit() виконує колбек одразу.
КОЛИ БРАТИ
Для всього, що виходить за межі бази: листи, job у черзі, події для інших систем. Для job це можна ввімкнути глобально: 'after_commit' => true у config/queue.php. Для подій є ShouldDispatchAfterCommit, для job і слухачів у черзі ShouldQueueAfterCommit.
php SubmitApplication.php Вкладені
1DB::transaction(function () { // BEGIN
2 $this->saveApplication();
3
4 try {
5 DB::transaction(function () { // SAVEPOINT trans2
6 $this->notifyRecruiter();
7 });
8 } catch (Throwable $e) {
9 // ROLLBACK TO SAVEPOINT trans2, зовнішня транзакція жива
10 }
11}); // COMMIT
ЩО ПЕРЕВІРЯЄ
Вкладений DB::transaction() не відкриває нову транзакцію, а ставить savepoint. Виняток всередині відкочує до нього, а якщо його не зловити, відкотиться й зовнішня.
КОЛИ БРАТИ
Коли частина роботи може не вдатися, а решта має лишитись. На PostgreSQL це ще й єдиний спосіб продовжити транзакцію після помилки SQL.
08 · ЯК У PHP UKRAINE

Відгук, історія і версія разом

EloquentApplicationRepository::save() пише рядок відгуку і нові записи його історії в одній транзакції: роботодавець ніколи не побачить відгук на новому етапі без події, яка його туди перевела. Друга половина методу закриває те, чого ACID не дає. Оновлення йде з умовою WHERE version = прочитана версія, і якщо хтось встиг змінити відгук раніше (кандидат відкликав, роботодавець виставив підсумок), UPDATE зачепить нуль рядків. Тоді метод кидає StaleApplication, DB::transaction() відкочує все, включно з подіями, і застаріла зміна не затре свіжу. Це оптимістичне блокування: на READ COMMITTED у PostgreSQL сама ізоляція від втраченого оновлення не рятує.

php EloquentApplicationRepository.php фрагмент
1// The row and its new history lines land together, and only over the version that was read.
2DB::transaction(function () use ($application, $row): void {← BEGIN
3 if ($application->id === null) {
4 $model = ApplicationModel::query()->create($row + ['created_at' => $application->createdAt, 'version' => 0]);
5 $application->assignId($model->id);
6 } else {
7 $next = $application->version + 1;
8 $written = ApplicationModel::query()->whereKey($application->id)->where('version', $application->version)->update($row + ['version' => $next]);← UPDATE … WHERE version = ?
9
10 if ($written === 0) {
11 throw new StaleApplication;← ROLLBACK разом з подіями
12 }
13
14 $application->assignVersion($next);
15 }
16
17 foreach ($application->newEvents() as $event) {← історія в тій самій транзакції
18 $created = ApplicationEventModel::query()->create([
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().
09 · ТИПОВІ ПОМИЛКИ

Де транзакції підводять

✕ 1

Зовнішній виклик усередині транзакції

Лист, HTTP-запит до платіжного шлюзу чи job у черзі йдуть до COMMIT. Транзакція відкотилась, а лист «відгук отримано» вже в кандидата. Ще й транзакція тримає блокування, поки чекає на мережу.

Натомість: Усе зовнішнє після COMMIT: DB::afterCommit(), ->afterCommit() для job, ShouldDispatchAfterCommit для подій.

✕ 2

Транзакція, якої немає

Два save() поспіль без DB::transaction(): в autocommit кожен комітиться окремо, і падіння між ними лишає половину. Або таблиця на MyISAM, і ROLLBACK лише пише попередження.

Натомість: Пов’язані записи в одному DB::transaction(); усі таблиці MySQL на InnoDB.

✕ 3

Міграція на MySQL з кількома кроками

CREATE TABLE, потім заповнення, потім ALTER, і на третьому кроці помилка. На MySQL кожен DDL уже закомічений, міграція не записана як виконана, і повторний запуск падає на першому CREATE.

Натомість: Маленькі міграції по одному DDL і down(), що справді працює. Для перевірки запускати їх на копії продакшн-бази.

✕ 4

Перехоплений виняток посеред транзакції PostgreSQL

try/catch навколо INSERT, який падає на дублі, а далі ще запити. На PostgreSQL усі вони отримують current transaction is aborted, а на MySQL і SQLite у тестах усе працює.

Натомість: Вкладений DB::transaction() навколо ризикованої команди (Laravel поставить savepoint) або INSERT … ON CONFLICT DO NOTHING замість ловлі помилки.

✕ 5

Довга транзакція

Транзакція відкрита, поки йде імпорт на хвилини чи поки користувач думає. Вона тримає блокування, а в PostgreSQL ще й не дає VACUUM прибрати старі версії рядків у всій базі.

Натомість: Транзакція лише навколо записів, без мережі й очікування. Великий імпорт пачками, кожна в своїй транзакції. У PostgreSQL ліміт idle_in_transaction_session_timeout.

10 · СЛОВНИК
Транзакція
Послідовність команд між 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().
11 · ПЕРЕВІР СЕБЕ

Питання зі співбесід

Усі питання з SQL →

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 і її можна повторити.