Как делать11 октября 2026 г.7 мин чтения
SQL и PostgreSQLШаг 7 из 9

Транзакции и изоляция: ACID без мистики

Коротко

Транзакция объединяет несколько изменений в одно действие «всё или ничего»: после COMMIT видны все изменения, после ROLLBACK или ошибки — ни одного. Уровень изоляции решает, что транзакция видит из параллельных: в PostgreSQL по умолчанию Read Committed, где каждый запрос видит свежие данные, поэтому схема «прочитал — посчитал — записал» теряет обновления. Защищаются атомарным UPDATE, блокировкой SELECT … FOR UPDATE, ограничением UNIQUE или EXCLUDE либо уровнем Serializable с повтором транзакции при ошибке 40001.

Глубокий разборУровень: СреднийТранзакции

После материала вы сможете

  • обернуть операцию в транзакцию с BEGIN, COMMIT и ROLLBACK и объяснить, что происходит после ошибки внутри неё
  • объяснить разницу между Read Committed, Repeatable Read и Serializable в PostgreSQL на примере двух сессий
  • устранить потерянное обновление атомарным UPDATE, SELECT … FOR UPDATE или версией строки
  • предотвратить двойное бронирование ограничением EXCLUDE или транзакцией SERIALIZABLE с повтором при ошибке 40001

Проверено на

PostgreSQL 17 (17.11, postgres:17-alpine)Node.js 24 LTS (24.18)node-postgres (pg) 8.23TypeScript 7.0

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01ACID на примере перевода денег
  2. 02BEGIN, COMMIT, ROLLBACK
  3. 03Уровни изоляции и аномалии
  4. 04Потерянное обновление и как его не допустить
  5. 05Двойная запись на одно время: три способа защиты
  6. 06Повтор транзакции в приложении
  7. 07Что выбрать и упражнения

ACID на примере перевода денег

Перевод 300 тенге со счёта Айгерим на счёт Данияра — это два изменения: списать и зачислить. Если сервер упадёт между ними, деньги исчезнут; если два перевода выполнятся одновременно, баланс может «забыть» один из них. Документация PostgreSQL описывает транзакцию как объединение нескольких шагов в одну операцию «всё или ничего», промежуточные состояния которой не видны другим.

Atomicity — атомарность

Что гарантирует
Применяются все изменения транзакции или ни одного
Чем обеспечивается в PostgreSQL
BEGIN … COMMIT / ROLLBACK; при ошибке транзакция прерывается целиком

Consistency — согласованность

Что гарантирует
Данные не нарушают правил схемы
Чем обеспечивается в PostgreSQL
Ограничения: CHECK, UNIQUE, FOREIGN KEY, EXCLUDE

Isolation — изоляция

Что гарантирует
Параллельные транзакции не мешают друг другу сверх выбранного уровня
Чем обеспечивается в PostgreSQL
MVCC (снимки данных) и уровни изоляции; блокировки строк

Durability — долговечность

Что гарантирует
Подтверждённая транзакция переживает сбой
Чем обеспечивается в PostgreSQL
Журнал предзаписи: изменения записываются в постоянное хранилище до ответа об успешном COMMIT

Важно разделять: атомарность и долговечность PostgreSQL даёт всегда, согласованность — ровно настолько, насколько правила записаны в схеме ограничениями, а изоляция настраивается и именно в ней прячется большинство ошибок. Дальше все примеры — на PostgreSQL 17 в Docker, с реальным выводом.

bash
docker run -d --rm --name kb-pg-tranzakcii -e POSTGRES_PASSWORD=test postgres:17-alpine
docker exec -i kb-pg-tranzakcii psql -U postgres -f - < 00-setup.sql

# два терминала — две сессии A и B
docker exec -it kb-pg-tranzakcii psql -U postgres
Стенд: контейнер PostgreSQL 17 и две сессии psql. В транскриптах ниже A=# и B=# — команды первой и второй сессии. Пароль test — только для локального одноразового контейнера
00-setup.sql
-- Время показываем по Алматы (для новых сессий)
ALTER DATABASE postgres SET timezone TO 'Asia/Almaty';

-- Счета для примеров с переводом денег
DROP TABLE IF EXISTS accounts;
CREATE TABLE accounts (
  id      integer PRIMARY KEY,
  owner   text          NOT NULL,
  balance numeric(12,2) NOT NULL CHECK (balance >= 0)
);
INSERT INTO accounts VALUES (1, 'Айгерим', 1000.00), (2, 'Данияр', 500.00);

-- Мастера салона и записи к ним
DROP TABLE IF EXISTS bookings;
DROP TABLE IF EXISTS masters;
CREATE TABLE masters (
  id   integer PRIMARY KEY,
  name text NOT NULL
);
CREATE TABLE bookings (
  id        bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  master_id integer   NOT NULL REFERENCES masters (id),
  during    tstzrange NOT NULL,
  guest     text      NOT NULL
);
INSERT INTO masters VALUES (1, 'Мастер Алия');
Счета с CHECK (balance >= 0), мастера салона и записи к ним

BEGIN, COMMIT, ROLLBACK

Без явного BEGIN каждая команда PostgreSQL выполняется в своей неявной транзакции и фиксируется сама — это режим автокоммита. Чтобы объединить несколько команд, их окружают BEGIN и COMMIT. Если что-то пошло не так, ROLLBACK отменяет всё, что сделано с момента BEGIN.

01-atomicity.sql
-- Перевод 1500 со счёта 1 на счёт 2: на счёте 1 только 1000
BEGIN;
UPDATE accounts SET balance = balance + 1500 WHERE id = 2;   -- зачисление прошло
UPDATE accounts SET balance = balance - 1500 WHERE id = 1;   -- списание нарушает CHECK
SELECT balance FROM accounts WHERE id = 2;                   -- транзакция уже прервана
ROLLBACK;

SELECT id, owner, balance FROM accounts ORDER BY id;          -- ничего не изменилось

-- Успешный перевод 300: оба изменения видны только после COMMIT
BEGIN;
UPDATE accounts SET balance = balance - 300 WHERE id = 1;
UPDATE accounts SET balance = balance + 300 WHERE id = 2;
COMMIT;

SELECT id, owner, balance FROM accounts ORDER BY id;
Перевод, который нарушает CHECK, и успешный перевод
terminal
$ docker exec -i kb-pg-tranzakcii sh -c 'psql -U postgres -a -f - 2>&1' < 01-atomicity.sql
-- Перевод 1500 со счёта 1 на счёт 2: на счёте 1 только 1000
BEGIN;
BEGIN
UPDATE accounts SET balance = balance + 1500 WHERE id = 2;   -- зачисление прошло
UPDATE 1
UPDATE accounts SET balance = balance - 1500 WHERE id = 1;   -- списание нарушает CHECK
psql:<stdin>:4: ERROR:  new row for relation "accounts" violates check constraint "accounts_balance_check"
DETAIL:  Failing row contains (1, Айгерим, -500.00).
SELECT balance FROM accounts WHERE id = 2;                   -- транзакция уже прервана
psql:<stdin>:5: ERROR:  current transaction is aborted, commands ignored until end of transaction block
ROLLBACK;
ROLLBACK
SELECT id, owner, balance FROM accounts ORDER BY id;          -- ничего не изменилось
 id |  owner  | balance
----+---------+---------
  1 | Айгерим | 1000.00
  2 | Данияр  |  500.00
(2 rows)

-- Успешный перевод 300: оба изменения видны только после COMMIT
BEGIN;
BEGIN
UPDATE accounts SET balance = balance - 300 WHERE id = 1;
UPDATE 1
UPDATE accounts SET balance = balance + 300 WHERE id = 2;
UPDATE 1
COMMIT;
COMMIT
SELECT id, owner, balance FROM accounts ORDER BY id;
 id |  owner  | balance
----+---------+---------
  1 | Айгерим |  700.00
  2 | Данияр  |  800.00
(2 rows)
Реальный вывод: -a печатает каждую команду перед результатом

Зачисление 1500 прошло, но списание нарушило CHECK (balance >= 0). После ошибки транзакция переходит в прерванное состояние: любая следующая команда отвечает current transaction is aborted, пока не придёт ROLLBACK. В итоге балансы не изменились — зачисление тоже отменено. Именно это и есть атомарность.

  • SAVEPOINT имя и ROLLBACK TO имя откатывают часть транзакции; по документации это единственный способ продолжить транзакцию после ошибки, не отменяя её целиком.
  • Многие драйверы и ORM сами отправляют BEGIN и COMMIT — проверьте, как это устроено в вашей библиотеке, прежде чем оборачивать код в транзакцию повторно.
  • Значения последовательностей (serial, identity) не откатываются: после ROLLBACK в нумерации останется «дырка». Это нормальное поведение, а не ошибка.

Уровни изоляции и аномалии

Стандарт SQL описывает уровни изоляции через аномалии — эффекты, которые возникают, когда транзакции идут одновременно. Грязное чтение — видны незафиксированные изменения чужой транзакции. Неповторяющееся чтение — повторный запрос той же строки возвращает другое значение. Фантомное чтение — повторный запрос с тем же условием возвращает другой набор строк. Аномалия сериализации — результат не совпадает ни с одним порядком последовательного выполнения транзакций.

Read Uncommitted

Грязное чтение
в PostgreSQL нет
Неповторяющееся
возможно
Фантомы
возможно
Аномалия сериализации
возможна

Read Committed (по умолчанию)

Грязное чтение
нет
Неповторяющееся
возможно
Фантомы
возможно
Аномалия сериализации
возможна

Repeatable Read

Грязное чтение
нет
Неповторяющееся
нет
Фантомы
в PostgreSQL нет
Аномалия сериализации
возможна

Serializable

Грязное чтение
нет
Неповторяющееся
нет
Фантомы
нет
Аномалия сериализации
нет
По таблице 13.1 документации PostgreSQL 17

В PostgreSQL реально работают три уровня: Read Uncommitted ведёт себя как Read Committed. В Read Committed каждый запрос видит снимок данных на момент своего начала, в Repeatable Read — снимок на момент первого запроса транзакции. Разницу видно на двух сессиях: B меняет баланс, A читает его дважды.

terminal
A=# BEGIN;
BEGIN
A=# SELECT balance FROM accounts WHERE id = 1;
 balance
---------
 1000.00
(1 row)
B=# UPDATE accounts SET balance = balance + 50 WHERE id = 1;  -- автокоммит
UPDATE 1
A=# SELECT balance FROM accounts WHERE id = 1;  -- то же чтение, другой ответ
 balance
---------
 1050.00
(1 row)
A=# COMMIT;
COMMIT
A=# BEGIN ISOLATION LEVEL REPEATABLE READ;
BEGIN
A=# SELECT balance FROM accounts WHERE id = 1;
 balance
---------
 1050.00
(1 row)
B=# UPDATE accounts SET balance = balance + 50 WHERE id = 1;
UPDATE 1
A=# SELECT balance FROM accounts WHERE id = 1;  -- снимок не меняется
 balance
---------
 1050.00
(1 row)
A=# COMMIT;
COMMIT
A=# SELECT balance FROM accounts WHERE id = 1;
 balance
---------
 1100.00
(1 row)
Реальный вывод двух сессий psql: команды отправлялись по очереди, A и B — разные подключения

В Read Committed второе чтение внутри одной транзакции вернуло 1050 вместо 1000 — неповторяющееся чтение. В Repeatable Read транзакция A до конца видит 1050, хотя B уже записал 1100; новое значение A увидит только в следующей транзакции. Для отчётов, которые читают много связанных таблиц, это полезно: все цифры взяты из одного момента времени.

Потерянное обновление и как его не допустить

Самая частая ошибка в приложениях — схема «прочитал — посчитал в коде — записал число». Две транзакции читают баланс 1000, первая записывает 900, вторая 800. Обе успешны, но одно списание пропало.

terminal
A=# BEGIN;
BEGIN
A=# SELECT balance FROM accounts WHERE id = 1;
 balance
---------
 1000.00
(1 row)
B=# BEGIN;
BEGIN
B=# SELECT balance FROM accounts WHERE id = 1;
 balance
---------
 1000.00
(1 row)
A=# UPDATE accounts SET balance = 900.00 WHERE id = 1;  -- 1000 − 100, посчитано в приложении
UPDATE 1
B=# UPDATE accounts SET balance = 800.00 WHERE id = 1;  -- 1000 − 200
-- B ждёт: мешает незавершённая транзакция
A=# COMMIT;
COMMIT
-- B дождалась ответа:
UPDATE 1
B=# COMMIT;
COMMIT
A=# SELECT balance FROM accounts WHERE id = 1;  -- списали 300, а баланс уменьшился на 200
 balance
---------
  800.00
(1 row)
Реальный вывод: Read Committed не защищает от потерянного обновления

B подождала, пока A зафиксируется, — PostgreSQL не даёт двум транзакциям менять одну строку одновременно, — но затем записала своё число, посчитанное по устаревшему чтению. Есть четыре рабочих исправления:

  1. Атомарный UPDATE

    Считать в базе: SET balance = balance - 200. Вторая транзакция дождётся первой и применит вычитание к уже обновлённой строке — проверьте это в упражнении.

  2. SELECT … FOR UPDATE

    Заблокировать строку при чтении: вторая транзакция ждёт на своём SELECT и получает уже новое значение.

  3. Оптимистическая блокировка

    Хранить номер версии и обновлять с условием WHERE version = …; ноль обновлённых строк означает конфликт.

  4. Repeatable Read или Serializable

    База сама обнаружит конфликт и прервёт вторую транзакцию с кодом 40001; приложение повторяет транзакцию.

terminal
A=# BEGIN;
BEGIN
A=# SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
 balance
---------
 1000.00
(1 row)
B=# BEGIN;
BEGIN
B=# SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- B ждёт: мешает незавершённая транзакция
A=# UPDATE accounts SET balance = 900.00 WHERE id = 1;
UPDATE 1
A=# COMMIT;
COMMIT
-- B дождалась ответа:
 balance
---------
  900.00
(1 row)
B=# UPDATE accounts SET balance = 700.00 WHERE id = 1;  -- 900 − 200
UPDATE 1
B=# COMMIT;
COMMIT
A=# SELECT balance FROM accounts WHERE id = 1;
 balance
---------
  700.00
(1 row)
Реальный вывод: FOR UPDATE заставляет вторую транзакцию ждать и читать актуальный баланс

FOR UPDATE блокирует выбранные строки до конца транзакции: другие транзакции не могут их изменить, удалить или заблокировать. Если ждать нельзя, есть NOWAIT — ошибка вместо ожидания — и SKIP LOCKED, который пропускает занятые строки. Документация оговаривает, что SKIP LOCKED даёт несогласованный взгляд на данные и подходит для очередей задач, а не для обычной работы.

03-optimistic.sql
-- Оптимистическая блокировка: номер версии вместо удержания блокировки
ALTER TABLE accounts ADD COLUMN version integer NOT NULL DEFAULT 0;

-- Оба клиента прочитали строку: balance = 1000.00, version = 0
SELECT balance, version FROM accounts WHERE id = 1;

-- Первый сохраняет изменения: строка совпала по версии
UPDATE accounts SET balance = 900.00, version = version + 1
WHERE id = 1 AND version = 0;

-- Второй пишет с устаревшей версией: 0 строк — конфликт, перечитать и решить заново
UPDATE accounts SET balance = 800.00, version = version + 1
WHERE id = 1 AND version = 0;

SELECT balance, version FROM accounts WHERE id = 1;
Оптимистическая блокировка: проверяется результат UPDATE, а не удерживается блокировка
terminal
$ docker exec -i kb-pg-tranzakcii sh -c 'psql -U postgres -a -f - 2>&1' < 03-optimistic.sql
 balance | version
---------+---------
 1000.00 |       0
(1 row)

UPDATE 1
UPDATE 0
 balance | version
---------+---------
  900.00 |       1
(1 row)
Вывод сокращён до результатов: второй UPDATE затронул 0 строк — приложение должно сообщить о конфликте
terminal
A=# BEGIN ISOLATION LEVEL REPEATABLE READ;
BEGIN
A=# SELECT balance FROM accounts WHERE id = 1;
 balance
---------
 1000.00
(1 row)
B=# BEGIN ISOLATION LEVEL REPEATABLE READ;
BEGIN
B=# SELECT balance FROM accounts WHERE id = 1;
 balance
---------
 1000.00
(1 row)
A=# UPDATE accounts SET balance = 900.00 WHERE id = 1;
UPDATE 1
B=# UPDATE accounts SET balance = 800.00 WHERE id = 1;
-- B ждёт: мешает незавершённая транзакция
A=# COMMIT;
COMMIT
-- B дождалась ответа:
ERROR:  could not serialize access due to concurrent update
B=# \echo :LAST_ERROR_SQLSTATE
40001
B=# ROLLBACK;  -- приложение повторит транзакцию целиком
ROLLBACK
A=# SELECT balance FROM accounts WHERE id = 1;
 balance
---------
  900.00
(1 row)
Реальный вывод: в Repeatable Read тот же сценарий заканчивается ошибкой сериализации 40001

Двойная запись на одно время: три способа защиты

Запись к мастеру салона: перед вставкой приложение проверяет, свободен ли интервал. Два клиента нажимают «Записаться» одновременно, обе проверки видят пустой слот, обе вставки проходят. Строки разные, поэтому блокировка строк не срабатывает — это аномалия «асимметричная запись» (write skew).

terminal
A=# BEGIN;
BEGIN
A=# SELECT count(*) FROM bookings WHERE master_id = 1 AND during && '[2026-10-12 10:00+05, 2026-10-12 11:00+05)';
 count
-------
     0
(1 row)
B=# BEGIN;
BEGIN
B=# SELECT count(*) FROM bookings WHERE master_id = 1 AND during && '[2026-10-12 10:00+05, 2026-10-12 11:00+05)';
 count
-------
     0
(1 row)
A=# INSERT INTO bookings (master_id, during, guest) VALUES (1, '[2026-10-12 10:00+05, 2026-10-12 11:00+05)', 'Гость A');
INSERT 0 1
B=# INSERT INTO bookings (master_id, during, guest) VALUES (1, '[2026-10-12 10:00+05, 2026-10-12 11:00+05)', 'Гость B');
INSERT 0 1
A=# COMMIT;
COMMIT
B=# COMMIT;
COMMIT
A=# SELECT guest, during FROM bookings ORDER BY id;
  guest  |                       during
---------+-----------------------------------------------------
 Гость A | ["2026-10-12 10:00:00+05","2026-10-12 11:00:00+05")
 Гость B | ["2026-10-12 10:00:00+05","2026-10-12 11:00:00+05")
(2 rows)
Реальный вывод: в Read Committed оба клиента записаны на одно время

Мы прогнали тот же сценарий на Repeatable Read — результат такой же, обе записи сохраняются: снимки не спасают, ведь каждая транзакция пишет свою строку. Документация прямо говорит, что бизнес-правила на этом уровне без явных блокировок, скорее всего, не будут работать правильно. Защиту дают три подхода.

Первый — Serializable. PostgreSQL отслеживает зависимости «прочитал — записал» между транзакциями и прерывает одну, если результат не совпадает ни с одним последовательным порядком:

terminal
A=# BEGIN ISOLATION LEVEL SERIALIZABLE;
BEGIN
A=# SELECT count(*) FROM bookings WHERE master_id = 1 AND during && '[2026-10-12 10:00+05, 2026-10-12 11:00+05)';
 count
-------
     0
(1 row)
B=# BEGIN ISOLATION LEVEL SERIALIZABLE;
BEGIN
B=# SELECT count(*) FROM bookings WHERE master_id = 1 AND during && '[2026-10-12 10:00+05, 2026-10-12 11:00+05)';
 count
-------
     0
(1 row)
A=# INSERT INTO bookings (master_id, during, guest) VALUES (1, '[2026-10-12 10:00+05, 2026-10-12 11:00+05)', 'Гость A');
INSERT 0 1
B=# INSERT INTO bookings (master_id, during, guest) VALUES (1, '[2026-10-12 10:00+05, 2026-10-12 11:00+05)', 'Гость B');
INSERT 0 1
A=# COMMIT;
COMMIT
B=# COMMIT;
ERROR:  could not serialize access due to read/write dependencies among transactions
DETAIL:  Reason code: Canceled on identification as a pivot, during commit attempt.
HINT:  The transaction might succeed if retried.
A=# SELECT guest, during FROM bookings ORDER BY id;
  guest  |                       during
---------+-----------------------------------------------------
 Гость A | ["2026-10-12 10:00:00+05","2026-10-12 11:00:00+05")
(1 row)
Реальный вывод: вторая транзакция прервана при COMMIT с ошибкой сериализации; код SQLSTATE — 40001

Второй — блокировка общей «родительской» строки. Все записи к мастеру сначала блокируют его строку в masters, поэтому проверки идут строго по очереди:

terminal
A=# BEGIN;
BEGIN
A=# SELECT id FROM masters WHERE id = 1 FOR UPDATE;
 id
----
  1
(1 row)
B=# BEGIN;
BEGIN
B=# SELECT id FROM masters WHERE id = 1 FOR UPDATE;
-- B ждёт: мешает незавершённая транзакция
A=# SELECT count(*) FROM bookings WHERE master_id = 1 AND during && '[2026-10-12 10:00+05, 2026-10-12 11:00+05)';
 count
-------
     0
(1 row)
A=# INSERT INTO bookings (master_id, during, guest) VALUES (1, '[2026-10-12 10:00+05, 2026-10-12 11:00+05)', 'Гость A');
INSERT 0 1
A=# COMMIT;
COMMIT
-- B дождалась ответа:
 id
----
  1
(1 row)
B=# SELECT count(*) FROM bookings WHERE master_id = 1 AND during && '[2026-10-12 10:00+05, 2026-10-12 11:00+05)';  -- слот уже занят
 count
-------
     1
(1 row)
B=# ROLLBACK;  -- приложение отвечает «время занято»
ROLLBACK
Реальный вывод: B ждёт на FOR UPDATE и после разблокировки видит уже занятый слот

Третий — ограничение EXCLUDE. Оно обобщает UNIQUE: запрещает две строки, для которых все перечисленные операторы вернут true. Здесь — тот же мастер (=) и пересекающиеся интервалы (&&). Проверка выполняется самой базой, поэтому её нельзя забыть в одном из мест кода.

02-exclude.sql
-- Запрет пересечения записей одного мастера на уровне базы
CREATE EXTENSION IF NOT EXISTS btree_gist;   -- нужен для «=» по integer внутри GiST
ALTER TABLE bookings
  ADD CONSTRAINT bookings_no_overlap
  EXCLUDE USING gist (master_id WITH =, during WITH &&);
Ограничение исключения: расширение btree_gist входит в стандартную поставку (contrib)
terminal
A=# BEGIN;
BEGIN
A=# INSERT INTO bookings (master_id, during, guest) VALUES (1, '[2026-10-12 10:00+05, 2026-10-12 11:00+05)', 'Гость A');
INSERT 0 1
B=# BEGIN;
BEGIN
B=# INSERT INTO bookings (master_id, during, guest) VALUES (1, '[2026-10-12 10:30+05, 2026-10-12 11:30+05)', 'Гость B');
-- B ждёт: мешает незавершённая транзакция
A=# COMMIT;
COMMIT
-- B дождалась ответа:
ERROR:  conflicting key value violates exclusion constraint "bookings_no_overlap"
DETAIL:  Key (master_id, during)=(1, ["2026-10-12 10:30:00+05","2026-10-12 11:30:00+05")) conflicts with existing key (master_id, during)=(1, ["2026-10-12 10:00:00+05","2026-10-12 11:00:00+05")).
B=# \echo :LAST_ERROR_SQLSTATE
23P01
B=# ROLLBACK;
ROLLBACK
Реальный вывод: пересекающаяся запись отклонена с кодом 23P01, даже без проверки в приложении

Повтор транзакции в приложении

Ошибки сериализации — штатный сигнал, а не авария. Документация требует, чтобы приложение на уровнях Repeatable Read и Serializable было готово повторить транзакцию целиком — вместе с логикой, которая решала, какие запросы отправить. Ошибки сериализации всегда имеют код 40001; повторять также стоит взаимоблокировки (40P01). Автоматического повтора в PostgreSQL нет: база не знает, какую логику приложения нужно выполнить заново.

bash
npm init -y
npm pkg set type=module
npm install pg@8
npm install -D typescript @types/pg @types/node@24
npx tsc -p .
DATABASE_URL=postgres://postgres:YOUR_PASSWORD@127.0.0.1:5432/postgres node --test test/booking.test.ts
Временный проект: Node.js 24 запускает .ts напрямую, tsc только проверяет типы. YOUR_PASSWORD — пароль вашей тестовой базы
tsconfig.json
{
  "compilerOptions": {
    "noEmit": true,
    "target": "esnext",
    "module": "nodenext",
    "rewriteRelativeImportExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "strict": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src", "test"]
}
Настройки TypeScript для запуска .ts без сборки
src/tx.ts
import type { Pool, PoolClient } from "pg";
import { setTimeout as sleep } from "node:timers/promises";

// 40001 — serialization_failure, 40P01 — deadlock_detected: их безопасно повторять
const RETRYABLE = new Set(["40001", "40P01"]);

type Options = { maxAttempts?: number; onRetry?: (attempt: number, code: string) => void };

/** Выполняет fn в транзакции SERIALIZABLE и повторяет её целиком при ошибке сериализации. */
export async function serializable<T>(pool: Pool, fn: (client: PoolClient) => Promise<T>, opts: Options = {}): Promise<T> {
  const maxAttempts = opts.maxAttempts ?? 5;
  for (let attempt = 1; ; attempt++) {
    const client = await pool.connect();
    try {
      await client.query("BEGIN ISOLATION LEVEL SERIALIZABLE");
      const result = await fn(client);
      await client.query("COMMIT"); // ошибка 40001 может прийти и здесь
      return result;
    } catch (err) {
      await client.query("ROLLBACK").catch(() => {});
      const code = (err as { code?: string }).code ?? "";
      if (!RETRYABLE.has(code) || attempt >= maxAttempts) throw err;
      opts.onRetry?.(attempt, code);
      await sleep(Math.random() * 20 * attempt); // небольшой случайный разнос повторов
    } finally {
      client.release();
    }
  }
}
Обёртка: SERIALIZABLE, ROLLBACK и повтор при 40001/40P01 с ограничением числа попыток
src/booking.ts
import type { Pool } from "pg";
import { serializable } from "./tx.ts";

export type BookResult = "booked" | "taken";

/** Записывает гостя к мастеру, если интервал [from, to) свободен. */
export function bookSlot(pool: Pool, masterId: number, from: string, to: string, guest: string, onRetry?: (attempt: number, code: string) => void): Promise<BookResult> {
  return serializable(pool, async (client) => {
    const busy = await client.query(
      "SELECT 1 FROM bookings WHERE master_id = $1 AND during && tstzrange($2, $3)",
      [masterId, from, to],
    );
    if (busy.rowCount) return "taken";
    await client.query(
      "INSERT INTO bookings (master_id, during, guest) VALUES ($1, tstzrange($2, $3), $4)",
      [masterId, from, to, guest],
    );
    return "booked";
  }, { onRetry });
}
Запись к мастеру: проверка и вставка внутри одной сериализуемой транзакции
test/booking.test.ts
import { test, before, after, beforeEach } from "node:test";
import assert from "node:assert/strict";
import pg from "pg";
import { bookSlot } from "../src/booking.ts";
import { serializable } from "../src/tx.ts";

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 });
// прогрев: открываем все 10 соединений заранее, чтобы транзакции шли действительно параллельно
before(() => Promise.all(Array.from({ length: 10 }, () => pool.query("SELECT pg_sleep(0.05)"))));
after(() => pool.end());
beforeEach(() => pool.query("DELETE FROM bookings"));

const FROM = "2026-10-12 10:00+05";
const TO = "2026-10-12 11:00+05";

test("20 одновременных попыток занять один слот: ровно одна запись", async () => {
  let retries = 0;
  const results = await Promise.all(
    Array.from({ length: 20 }, (_, i) => bookSlot(pool, 1, FROM, TO, `guest-${i}`, () => retries++)),
  );
  const { rows } = await pool.query("SELECT count(*)::int AS n FROM bookings");
  console.log({ booked: results.filter((r) => r === "booked").length, taken: results.filter((r) => r === "taken").length, retries });
  assert.equal(results.filter((r) => r === "booked").length, 1);
  assert.equal(rows[0].n, 1);
});

test("без повторов часть запросов падает с 40001", async () => {
  const settled = await Promise.allSettled(
    Array.from({ length: 20 }, (_, i) =>
      serializable(pool, async (c) => {
        const busy = await c.query("SELECT 1 FROM bookings WHERE master_id = 1 AND during && tstzrange($1, $2)", [FROM, TO]);
        if (busy.rowCount) return "taken";
        await c.query("INSERT INTO bookings (master_id, during, guest) VALUES (1, tstzrange($1, $2), $3)", [FROM, TO, `guest-${i}`]);
        return "booked";
      }, { maxAttempts: 1 }),
    ),
  );
  const codes = settled.filter((s) => s.status === "rejected").map((s) => (s.reason as { code?: string }).code);
  const { rows } = await pool.query("SELECT count(*)::int AS n FROM bookings");
  console.log({ failed: codes.length, codes: [...new Set(codes)] });
  assert.ok(codes.length > 0);
  assert.ok(codes.every((c) => c === "40001"));
  assert.equal(rows[0].n, 1);
});
Тесты на встроенном node:test: 20 параллельных попыток с повтором и без
terminal
$ node --test test/booking.test.ts
{ booked: 1, taken: 19, retries: 9 }
✔ 20 одновременных попыток занять один слот: ровно одна запись (98.8715ms)
{ failed: 9, codes: [ '40001' ] }
✔ без повторов часть запросов падает с 40001 (12.4165ms)
ℹ tests 2
ℹ pass 2
ℹ fail 0
ℹ duration_ms 243.7152
Реальный вывод: с повтором 9 транзакций перезапустились и ответили «занято», без повтора 9 упали с 40001
  • Транзакция должна быть короткой: никаких HTTP-запросов, отправки писем и ожидания пользователя внутри. Документация советует не оставлять соединения в состоянии idle in transaction и упоминает параметр idle_in_transaction_session_timeout.
  • Внешние побочные эффекты — после успешного COMMIT, иначе при повторе письмо уйдёт дважды.
  • Ограничивайте число попыток и добавляйте случайную паузу: при сильной конкуренции успех не гарантирован с первого повтора.
  • Ошибки 23505 и 23P01 повторяют осторожно: они могут означать постоянный конфликт, а не временный.
  • Пример упрощён: в продакшне соединение, на котором оборвалась связь, освобождают с ошибкой (client.release(err)), чтобы пул его не переиспользовал.

Что выбрать и упражнения

Изменить число относительно текущего

Решение
Атомарный UPDATE … SET x = x - $1
Цена
Почти бесплатно

Прочитать, решить в коде и записать одну строку

Решение
SELECT … FOR UPDATE или версия строки
Цена
Ожидание или повтор на стороне клиента

Уникальность или непересечение

Решение
UNIQUE / EXCLUDE
Цена
Индекс на запись; обработка 23505/23P01

Сложное правило по нескольким строкам и таблицам

Решение
SERIALIZABLE + повтор
Цена
Повторы при конкуренции, небольшие накладные расходы на отслеживание

Согласованный отчёт из многих таблиц

Решение
REPEATABLE READ (или SERIALIZABLE READ ONLY DEFERRABLE)
Цена
Снимок держится до конца транзакции
  1. 01BEGIN
  2. 02Чтение и проверки
  3. 03Изменения
  4. 04COMMIT
  5. 05Побочные эффекты
Короткая транзакция: всё внешнее — после COMMIT

Следующие шаги — оптимизация запросов с EXPLAIN (долгие запросы удлиняют транзакции и увеличивают конфликты) и проектирование схемы с ограничениями, которые защищают данные без участия кода.

Как проверено3 · пройдено1 · не запускалосьсверено 11 октября 2026 г.
  • запущено, работает

    00-setup.sql, 01-atomicity.sql, 02-exclude.sql, 03-optimistic.sql (RU и EN)

    docker postgres:17-alpine (PostgreSQL 17.11), свежий контейнер на каждый язык, psql -a -f; вывод в статье — фактический (stdout и stderr слиты внутри контейнера, чтобы сохранить порядок)

  • запущено, работает

    две параллельные сессии psql: readCommitted, lostUpdate, forUpdate, repeatableRead (40001), doubleBookingRC, doubleBookingRR, doubleBookingSerializable (40001 при COMMIT), lockMaster, exclude (23P01), atomicUpdate, deadlock (40P01), deadlockFixed

    оркестратор duo.mjs: два процесса docker exec -i … psql, шаги отправляются по очереди с паузой 800 мс, вывод каждой сессии собирается после шага; строки «ждёт/дождалась» добавлены оркестратором по факту отсутствия/появления вывода. Все транскрипты в статье — без правки. doubleBookingRR в статью не вынесен, результат (две записи) описан текстом

  • запущено, работает

    src/tx.ts, src/booking.ts, test/booking.test.ts, tsconfig.json (RU и EN варианты комментариев)

    временный проект: tsc -p . (TypeScript 7.0.2) без ошибок; node --test (Node.js 24.18.0, pg 8.23.1) против контейнера — 2/2 в каждом прогоне (7 прогонов: всегда booked 1, taken 19, retries 9; без повторов 9 ошибок 40001). Мутация: пустой RETRYABLE — первый тест падает с 40001

  • не запускалось

    упражнение 3 (лимит 8 записей в день)

    решение не публикуется (только подсказка); подход опирается на проверенную обёртку serializable()

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.