Транзакция объединяет несколько изменений в одно действие «всё или ничего»: после 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 г.
Содержание
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, с реальным выводом.
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-- Время показываем по Алматы (для новых сессий)
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, 'Мастер Алия');BEGIN, COMMIT, ROLLBACK
Без явного BEGIN каждая команда PostgreSQL выполняется в своей неявной транзакции и фиксируется сама — это режим автокоммита. Чтобы объединить несколько команд, их окружают BEGIN и COMMIT. Если что-то пошло не так, ROLLBACK отменяет всё, что сделано с момента BEGIN.
-- Перевод 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;$ 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)Зачисление 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
- Грязное чтение
- нет
- Неповторяющееся
- нет
- Фантомы
- нет
- Аномалия сериализации
- нет
В PostgreSQL реально работают три уровня: Read Uncommitted ведёт себя как Read Committed. В Read Committed каждый запрос видит снимок данных на момент своего начала, в Repeatable Read — снимок на момент первого запроса транзакции. Разницу видно на двух сессиях: B меняет баланс, A читает его дважды.
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)В Read Committed второе чтение внутри одной транзакции вернуло 1050 вместо 1000 — неповторяющееся чтение. В Repeatable Read транзакция A до конца видит 1050, хотя B уже записал 1100; новое значение A увидит только в следующей транзакции. Для отчётов, которые читают много связанных таблиц, это полезно: все цифры взяты из одного момента времени.
Потерянное обновление и как его не допустить
Самая частая ошибка в приложениях — схема «прочитал — посчитал в коде — записал число». Две транзакции читают баланс 1000, первая записывает 900, вторая 800. Обе успешны, но одно списание пропало.
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)B подождала, пока A зафиксируется, — PostgreSQL не даёт двум транзакциям менять одну строку одновременно, — но затем записала своё число, посчитанное по устаревшему чтению. Есть четыре рабочих исправления:
Атомарный UPDATE
Считать в базе:
SET balance = balance - 200. Вторая транзакция дождётся первой и применит вычитание к уже обновлённой строке — проверьте это в упражнении.SELECT … FOR UPDATE
Заблокировать строку при чтении: вторая транзакция ждёт на своём
SELECTи получает уже новое значение.Оптимистическая блокировка
Хранить номер версии и обновлять с условием
WHERE version = …; ноль обновлённых строк означает конфликт.Repeatable Read или Serializable
База сама обнаружит конфликт и прервёт вторую транзакцию с кодом
40001; приложение повторяет транзакцию.
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 блокирует выбранные строки до конца транзакции: другие транзакции не могут их изменить, удалить или заблокировать. Если ждать нельзя, есть NOWAIT — ошибка вместо ожидания — и SKIP LOCKED, который пропускает занятые строки. Документация оговаривает, что SKIP LOCKED даёт несогласованный взгляд на данные и подходит для очередей задач, а не для обычной работы.
-- Оптимистическая блокировка: номер версии вместо удержания блокировки
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;$ 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)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)Двойная запись на одно время: три способа защиты
Запись к мастеру салона: перед вставкой приложение проверяет, свободен ли интервал. Два клиента нажимают «Записаться» одновременно, обе проверки видят пустой слот, обе вставки проходят. Строки разные, поэтому блокировка строк не срабатывает — это аномалия «асимметричная запись» (write skew).
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)Мы прогнали тот же сценарий на Repeatable Read — результат такой же, обе записи сохраняются: снимки не спасают, ведь каждая транзакция пишет свою строку. Документация прямо говорит, что бизнес-правила на этом уровне без явных блокировок, скорее всего, не будут работать правильно. Защиту дают три подхода.
Первый — Serializable. PostgreSQL отслеживает зависимости «прочитал — записал» между транзакциями и прерывает одну, если результат не совпадает ни с одним последовательным порядком:
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)Второй — блокировка общей «родительской» строки. Все записи к мастеру сначала блокируют его строку в masters, поэтому проверки идут строго по очереди:
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Третий — ограничение EXCLUDE. Оно обобщает UNIQUE: запрещает две строки, для которых все перечисленные операторы вернут true. Здесь — тот же мастер (=) и пересекающиеся интервалы (&&). Проверка выполняется самой базой, поэтому её нельзя забыть в одном из мест кода.
-- Запрет пересечения записей одного мастера на уровне базы
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 &&);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Повтор транзакции в приложении
Ошибки сериализации — штатный сигнал, а не авария. Документация требует, чтобы приложение на уровнях Repeatable Read и Serializable было готово повторить транзакцию целиком — вместе с логикой, которая решала, какие запросы отправить. Ошибки сериализации всегда имеют код 40001; повторять также стоит взаимоблокировки (40P01). Автоматического повтора в PostgreSQL нет: база не знает, какую логику приложения нужно выполнить заново.
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{
"compilerOptions": {
"noEmit": true,
"target": "esnext",
"module": "nodenext",
"rewriteRelativeImportExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"strict": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src", "test"]
}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();
}
}
}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 });
}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 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- Транзакция должна быть короткой: никаких 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)- Цена
- Снимок держится до конца транзакции
- 01BEGIN
- 02Чтение и проверки
- 03Изменения
- 04COMMIT
- 05Побочные эффекты
Следующие шаги — оптимизация запросов с 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.