Transaction isolation and locking
Open two real PostgreSQL sessions and compare Read Committed, Repeatable Read, SELECT FOR UPDATE, and optimistic versioning.
Timeline
Understanding: Transaction isolation and locking
You can read this chapter before running the experiment. Then return to the live trace and match each concept to a real event.
Two operations can each be correct alone and corrupt data together. Isolation determines what each transaction can see; locks or version checks determine who may change the same row.
PostgreSQL uses MVCC: updates create row versions and a transaction reads a suitable snapshot. READ COMMITTED obtains a snapshot per statement; REPEATABLE READ retains one and in PostgreSQL also prevents phantom reads; SERIALIZABLE detects dangerous dependency structures and can abort with SQLSTATE 40001. SELECT FOR UPDATE takes a row-level lock. Optimistic locking updates only when version still matches.
Terms used in this experiment
Understand the words first, then the execution order.
MVCC
Multiversion Concurrency Control: readers see appropriate row versions and usually do not block writers.
Snapshot
The visibility rules selecting which row versions a statement or transaction can see.
Isolation level
A visibility and anomaly contract: Read Committed, Repeatable Read, or Serializable.
Pessimistic lock
Acquiring a lock such as SELECT FOR UPDATE before changing a contested resource.
Optimistic lock
An UPDATE conditional on an old version; rowCount zero means another writer won.
Deadlock
A cycle where A waits for B while B waits for A; PostgreSQL aborts one participant.
Serialization failure
SQLSTATE 40001: the outcome cannot be represented safely as serial execution, so retry the whole transaction.
What happens step by step
Each step maps to an observable runtime state.
- 01Open two connections
Real concurrency uses different PostgreSQL sessions, not two Promises on one checked-out client.
- 02Compare snapshots
A Read Committed second SELECT sees another commit; Repeatable Read continues seeing its transaction snapshot.
- 03Acquire FOR UPDATE
The first transaction locks a row and the second waits for COMMIT or timeout.
- 04Modify under the lock
Both operations read the current balance sequentially, preserving both changes.
- 05Check a version
The first optimistic update increments version; an update using the old version affects zero rows.
- 06Bound and retry
lock_timeout and statement_timeout bound waiting; deadlock and serialization errors use bounded whole-transaction retries.
Where the result needs context
These details explain why similar code can sometimes produce a different trace.
Read Committed is the PostgreSQL default
Each command receives a new snapshot, so two SELECTs in one transaction can see different committed values.
PostgreSQL Repeatable Read is stronger than the SQL minimum
PostgreSQL prevents phantom reads at this level even though the SQL standard allows them.
Serializable requires retry
It is not a global queue. PostgreSQL allows concurrency, detects a dangerous dependency graph, and aborts one transaction.
A row lock lives until transaction end
Network calls or heavy computation in an open transaction increase contention, bloat, and timeout risk.
Correct statements can still deadlock
Consistent resource ordering reduces probability, but the application must handle SQLSTATE 40P01.
First understand which parts of Node participate in execution.
Then remove instrumentation and focus on the central mechanism.
Finally match the model to the code that produces the live trace.
A minimal model without instrumentation
await client.query('BEGIN');
const account = await client.query(
'SELECT balance FROM accounts WHERE id = $1 FOR UPDATE',
[accountId],
);
await client.query(
'UPDATE accounts SET balance = balance - $1 WHERE id = $2',
[amount, accountId],
);
await client.query('COMMIT');The complete code executed by the scenario
This is not an alternative example: these are the functions and files used by the Run button.
The source is generated from the real server function. Scenarios using a child process or Worker include every participating file.
import { randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import pg from 'pg';
const { Pool } = pg;
const STATEMENT_TIMEOUT_MS = 8_000;
const CONNECTION_TIMEOUT_MS = 1_500;
function databaseUrl() {
return process.env.DATABASE_URL?.trim() || null;
}
function safeIdentifier(prefix) {
return `${prefix}_${randomUUID().replaceAll('-', '').slice(0, 12)}`;
}
function planReport(result) {
const raw = result.rows[0]['QUERY PLAN'];
const report = Array.isArray(raw) ? raw[0] : JSON.parse(raw)[0];
const nodes = [];
function visit(node, depth = 0) {
nodes.push({
depth,
type: node['Node Type'],
relation: node['Relation Name'] ?? null,
index: node['Index Name'] ?? null,
estimatedRows: node['Plan Rows'],
actualRows: node['Actual Rows'],
loops: node['Actual Loops'],
});
for (const child of node.Plans ?? []) visit(child, depth + 1);
}
visit(report.Plan);
return {
nodes,
executionMs: Number(report['Execution Time'] ?? 0),
planningMs: Number(report['Planning Time'] ?? 0),
};
}
function planLine(plan) {
return plan.nodes
.map((node) => {
const target = node.index ?? node.relation;
return `${' '.repeat(node.depth)}${node.type}${target ? ` [${target}]` : ''}`;
})
.join(' → ');
}
async function configureClient(client) {
await client.query(`SET statement_timeout = '${STATEMENT_TIMEOUT_MS}ms'`);
await client.query(
`SET idle_in_transaction_session_timeout = '${STATEMENT_TIMEOUT_MS}ms'`,
);
await client.query("SET lock_timeout = '2500ms'");
}
async function rollbackQuietly(client) {
if (!client) return;
try {
await client.query('ROLLBACK');
} catch {
// The client may not currently be in a transaction.
}
}
async function withDatabaseLab(emit, run) {
const connectionString = databaseUrl();
if (!connectionString) {
emit(
'postgres',
'skip',
'PostgreSQL не подключён: задайте DATABASE_URL или запустите проект через Docker Compose',
);
return false;
}
const pool = new Pool({
connectionString,
max: 4,
connectionTimeoutMillis: CONNECTION_TIMEOUT_MS,
idleTimeoutMillis: 5_000,
allowExitOnIdle: true,
application_name: 'node-loop-lab',
});
const schema = safeIdentifier('node_loop_lab');
try {
const versionResult = await pool.query(
"SELECT current_setting('server_version') AS version",
);
emit(
'postgres',
'connect',
`Подключён PostgreSQL ${versionResult.rows[0].version}; создаём изолированную схему ${schema}`,
);
await pool.query(`CREATE SCHEMA ${schema}`);
await run({ pool, schema });
return true;
} catch (error) {
const safeMessage =
error?.code === 'ECONNREFUSED'
? 'соединение отклонено'
: error?.code
? `SQLSTATE ${error.code}`
: 'ошибка подключения';
emit('postgres', 'error', `Сценарий PostgreSQL остановлен: ${safeMessage}`);
return false;
} finally {
try {
await pool.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`);
emit('cleanup', 'drop', 'Учебная схема удалена; постоянные данные не создавались');
} catch {
// Connection failures can make cleanup impossible; the schema name is unique
// and contains no user data.
}
await pool.end().catch(() => {});
}
}
export async function databaseConstraintsAndAcid(emit) {
await withDatabaseLab(emit, async ({ pool, schema }) => {
const client = await pool.connect();
try {
await configureClient(client);
emit(
'ddl',
'schema',
'Создаём PRIMARY KEY, UNIQUE, CHECK и FOREIGN KEY как правила целостности внутри БД',
);
await client.query(`
CREATE TABLE ${schema}.customers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE,
name text NOT NULL CHECK (char_length(name) >= 2)
);
CREATE TABLE ${schema}.orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL
REFERENCES ${schema}.customers(id) ON DELETE RESTRICT,
amount numeric(12, 2) NOT NULL CHECK (amount > 0),
status text NOT NULL DEFAULT 'new'
CHECK (status IN ('new', 'paid', 'cancelled'))
);
`);
const customer = await client.query(
`INSERT INTO ${schema}.customers (email, name)
VALUES ($1, $2)
RETURNING id`,
['learner@example.com', 'Learner'],
);
emit(
'query',
'parameters',
'Параметры $1/$2 переданы отдельно от SQL: значения не становятся частью синтаксиса запроса',
);
try {
await client.query(
`INSERT INTO ${schema}.orders (customer_id, amount)
VALUES ($1, $2)`,
[customer.rows[0].id, -50],
);
} catch (error) {
emit(
'constraint',
'check',
`CHECK отклонил отрицательную сумму: SQLSTATE ${error.code}`,
);
}
await client.query('BEGIN');
await client.query(
`INSERT INTO ${schema}.orders (customer_id, amount, status)
VALUES ($1, $2, $3)`,
[customer.rows[0].id, 1250, 'paid'],
);
const inside = await client.query(
`SELECT count(*)::int AS count FROM ${schema}.orders`,
);
await client.query('ROLLBACK');
const after = await client.query(
`SELECT count(*)::int AS count FROM ${schema}.orders`,
);
emit(
'transaction',
'rollback',
`Внутри транзакции строк=${inside.rows[0].count}; после ROLLBACK строк=${after.rows[0].count}`,
);
emit(
'acid',
'model',
'ACID: constraints поддерживают consistency, транзакция даёт atomicity, WAL/disk — durability, а isolation управляет видимостью параллельных изменений',
);
} finally {
await rollbackQuietly(client);
client.release();
}
});
}
export async function databaseIndexesAndExplain(emit) {
await withDatabaseLab(emit, async ({ pool, schema }) => {
const client = await pool.connect();
try {
await configureClient(client);
emit(
'dataset',
'seed',
'Создаём 40 000 событий с коррелированным временем, tenant_id, status и массивом tags',
);
await client.query(`
CREATE TABLE ${schema}.events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id integer NOT NULL,
created_at timestamptz NOT NULL,
status text NOT NULL,
tags text[] NOT NULL
);
INSERT INTO ${schema}.events (tenant_id, created_at, status, tags)
SELECT
(g % 100) + 1,
now() - (g * interval '1 second'),
CASE WHEN g % 5 = 0 THEN 'failed' ELSE 'processed' END,
ARRAY[
CASE WHEN g % 3 = 0 THEN 'api' ELSE 'worker' END,
CASE WHEN g % 7 = 0 THEN 'priority' ELSE 'normal' END
]
FROM generate_series(1, 40000) AS g;
ANALYZE ${schema}.events;
`);
const query = `
SELECT id, created_at, status
FROM ${schema}.events
WHERE tenant_id = 37
AND created_at >= now() - interval '6 hours'
ORDER BY created_at DESC
`;
const before = planReport(
await client.query(`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${query}`),
);
emit(
'planner',
'before-index',
`До составного индекса: ${planLine(before)}; execution=${before.executionMs.toFixed(2)} ms`,
);
await client.query(`
CREATE INDEX events_tenant_created_btree
ON ${schema}.events USING btree (tenant_id, created_at DESC)
INCLUDE (status);
CREATE INDEX events_status_hash
ON ${schema}.events USING hash (status);
CREATE INDEX events_created_brin
ON ${schema}.events USING brin (created_at);
CREATE INDEX events_tags_gin
ON ${schema}.events USING gin (tags);
ANALYZE ${schema}.events;
`);
const after = planReport(
await client.query(`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${query}`),
);
emit(
'planner',
'after-index',
`После B-tree: ${planLine(after)}; execution=${after.executionMs.toFixed(2)} ms`,
);
const sizes = await client.query(
`
SELECT c.relname, pg_relation_size(c.oid)::bigint AS bytes
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
WHERE n.nspname = $1 AND c.relkind = 'i'
ORDER BY bytes DESC
`,
[schema],
);
emit(
'indexes',
'size',
`Размеры индексов: ${sizes.rows
.map((row) => `${row.relname}=${Math.round(Number(row.bytes) / 1024)} KiB`)
.join(', ')}`,
);
emit(
'optimizer',
'decision',
'Индекс не является приказом: planner выбирает Seq Scan, Index Scan или Bitmap Scan по статистике, селективности и стоимости',
);
} finally {
client.release();
}
});
}
export async function databaseTransactionsAndLocks(emit) {
await withDatabaseLab(emit, async ({ pool, schema }) => {
await pool.query(`
CREATE TABLE ${schema}.accounts (
id integer PRIMARY KEY,
balance integer NOT NULL CHECK (balance >= 0),
version integer NOT NULL DEFAULT 0
);
INSERT INTO ${schema}.accounts (id, balance) VALUES (1, 1000);
`);
const first = await pool.connect();
const second = await pool.connect();
try {
await Promise.all([configureClient(first), configureClient(second)]);
await first.query('BEGIN ISOLATION LEVEL READ COMMITTED');
const rcBefore = await first.query(
`SELECT balance FROM ${schema}.accounts WHERE id = 1`,
);
await second.query(
`UPDATE ${schema}.accounts SET balance = 1100 WHERE id = 1`,
);
const rcAfter = await first.query(
`SELECT balance FROM ${schema}.accounts WHERE id = 1`,
);
await first.query('ROLLBACK');
emit(
'isolation',
'read-committed',
`READ COMMITTED: первый SELECT=${rcBefore.rows[0].balance}, второй SELECT=${rcAfter.rows[0].balance}`,
);
await pool.query(
`UPDATE ${schema}.accounts SET balance = 1000, version = 0 WHERE id = 1`,
);
await first.query('BEGIN ISOLATION LEVEL REPEATABLE READ');
const rrBefore = await first.query(
`SELECT balance FROM ${schema}.accounts WHERE id = 1`,
);
await second.query(
`UPDATE ${schema}.accounts SET balance = 1100 WHERE id = 1`,
);
const rrAfter = await first.query(
`SELECT balance FROM ${schema}.accounts WHERE id = 1`,
);
await first.query('ROLLBACK');
emit(
'isolation',
'repeatable-read',
`REPEATABLE READ: первый SELECT=${rrBefore.rows[0].balance}, второй SELECT=${rrAfter.rows[0].balance}`,
);
await pool.query(
`UPDATE ${schema}.accounts SET balance = 1000, version = 0 WHERE id = 1`,
);
await first.query('BEGIN');
await first.query(
`SELECT balance FROM ${schema}.accounts WHERE id = 1 FOR UPDATE`,
);
await second.query('BEGIN');
let secondAcquired = false;
const waitStarted = performance.now();
const secondLock = second
.query(
`SELECT balance FROM ${schema}.accounts WHERE id = 1 FOR UPDATE`,
)
.then((result) => {
secondAcquired = true;
return result;
});
await new Promise((resolve) => setTimeout(resolve, 120));
emit(
'lock',
'wait',
`SELECT FOR UPDATE: вторая транзакция ждёт блокировку=${!secondAcquired}`,
);
await first.query(
`UPDATE ${schema}.accounts SET balance = balance - 100 WHERE id = 1`,
);
await first.query('COMMIT');
await secondLock;
const waitedMs = performance.now() - waitStarted;
await second.query(
`UPDATE ${schema}.accounts SET balance = balance - 200 WHERE id = 1`,
);
await second.query('COMMIT');
const pessimistic = await pool.query(
`SELECT balance FROM ${schema}.accounts WHERE id = 1`,
);
emit(
'lock',
'pessimistic',
`Пессимистичная блокировка ждала ${waitedMs.toFixed(0)} ms; итоговый balance=${pessimistic.rows[0].balance}`,
);
await pool.query(
`UPDATE ${schema}.accounts SET balance = 1000, version = 0 WHERE id = 1`,
);
const snapshotA = await first.query(
`SELECT balance, version FROM ${schema}.accounts WHERE id = 1`,
);
const snapshotB = await second.query(
`SELECT balance, version FROM ${schema}.accounts WHERE id = 1`,
);
const updateA = await first.query(
`UPDATE ${schema}.accounts
SET balance = $1, version = version + 1
WHERE id = 1 AND version = $2`,
[snapshotA.rows[0].balance - 100, snapshotA.rows[0].version],
);
const updateB = await second.query(
`UPDATE ${schema}.accounts
SET balance = $1, version = version + 1
WHERE id = 1 AND version = $2`,
[snapshotB.rows[0].balance - 200, snapshotB.rows[0].version],
);
emit(
'lock',
'optimistic',
`Оптимистичная версия: update A=${updateA.rowCount}, stale update B=${updateB.rowCount}; 0 означает конфликт`,
);
} finally {
await Promise.all([rollbackQuietly(first), rollbackQuietly(second)]);
first.release();
second.release();
}
});
}
export async function databaseJoinsAndMaterializedViews(emit) {
await withDatabaseLab(emit, async ({ pool, schema }) => {
const client = await pool.connect();
try {
await configureClient(client);
emit(
'dataset',
'seed',
'Создаём 2 000 клиентов и 30 000 заказов для JOIN и агрегирования',
);
await client.query(`
CREATE TABLE ${schema}.customers (
id integer PRIMARY KEY,
name text NOT NULL,
active boolean NOT NULL
);
CREATE TABLE ${schema}.orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id integer NOT NULL REFERENCES ${schema}.customers(id),
amount numeric(12, 2) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO ${schema}.customers (id, name, active)
SELECT g, 'customer-' || g, g % 5 <> 0
FROM generate_series(1, 2000) AS g;
INSERT INTO ${schema}.orders (customer_id, amount, created_at)
SELECT
(g % 2000) + 1,
((g % 5000) + 100)::numeric / 10,
now() - (g * interval '1 minute')
FROM generate_series(1, 30000) AS g;
CREATE INDEX orders_customer_id_idx
ON ${schema}.orders (customer_id);
ANALYZE ${schema}.customers;
ANALYZE ${schema}.orders;
`);
const joinPlan = planReport(
await client.query(`
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
SELECT c.id, c.name, sum(o.amount) AS total
FROM ${schema}.customers AS c
JOIN ${schema}.orders AS o ON o.customer_id = c.id
WHERE c.active
GROUP BY c.id, c.name
ORDER BY total DESC
LIMIT 20
`),
);
emit(
'join',
'plan',
`JOIN plan: ${planLine(joinPlan)}; execution=${joinPlan.executionMs.toFixed(2)} ms`,
);
const sampleCustomers = await client.query(
`SELECT id FROM ${schema}.customers ORDER BY id LIMIT 20`,
);
const nPlusOneStarted = performance.now();
for (const customer of sampleCustomers.rows) {
await client.query(
`SELECT count(*) FROM ${schema}.orders WHERE customer_id = $1`,
[customer.id],
);
}
const nPlusOneMs = performance.now() - nPlusOneStarted;
const oneQueryStarted = performance.now();
await client.query(`
SELECT c.id, count(o.id)
FROM ${schema}.customers AS c
LEFT JOIN ${schema}.orders AS o ON o.customer_id = c.id
WHERE c.id <= 20
GROUP BY c.id
`);
const oneQueryMs = performance.now() - oneQueryStarted;
emit(
'query-shape',
'n-plus-one',
`N+1: 21 round trips=${nPlusOneMs.toFixed(2)} ms; один JOIN=1 round trip=${oneQueryMs.toFixed(2)} ms`,
);
await client.query(`
CREATE MATERIALIZED VIEW ${schema}.customer_totals AS
SELECT customer_id, count(*)::int AS orders_count, sum(amount) AS total
FROM ${schema}.orders
GROUP BY customer_id;
CREATE UNIQUE INDEX customer_totals_customer_id_idx
ON ${schema}.customer_totals (customer_id);
`);
const before = await client.query(
`SELECT total FROM ${schema}.customer_totals WHERE customer_id = $1`,
[42],
);
await client.query(
`INSERT INTO ${schema}.orders (customer_id, amount) VALUES ($1, $2)`,
[42, 999],
);
const stale = await client.query(
`SELECT total FROM ${schema}.customer_totals WHERE customer_id = $1`,
[42],
);
await client.query(`REFRESH MATERIALIZED VIEW ${schema}.customer_totals`);
const refreshed = await client.query(
`SELECT total FROM ${schema}.customer_totals WHERE customer_id = $1`,
[42],
);
emit(
'materialized-view',
'refresh',
`Materialized View: было=${before.rows[0].total}, до REFRESH=${stale.rows[0].total}, после=${refreshed.rows[0].total}`,
);
emit(
'sql',
'control',
'Raw SQL здесь параметризован и видим; ORM полезен, пока команда проверяет сгенерированный SQL, планы, N+1 и границы транзакций',
);
} finally {
client.release();
}
});
}
The emit(...) calls become live-trace rows. await and Promise keep the HTTP stream open until the scenario completes.
Practical patterns worth keeping nearby
Compare the goal, code, and caveats instead of memorizing syntax without a model.
Pessimistic debit
Prevent simultaneous operations from spending one balance.
BEGIN;
SELECT balance FROM accounts
WHERE id = $1
FOR UPDATE;
UPDATE accounts
SET balance = balance - $2
WHERE id = $1;
COMMIT;- Keep the transaction short and always configure a timeout.
Optimistic version
Detect a conflict without acquiring a row lock first.
UPDATE documents
SET body = $1, version = version + 1
WHERE id = $2 AND version = $3;
// rowCount === 0 -> reload or report conflict- Works well when conflicts are rare and retry or rejection is cheap.
Serializable retry
Retry the entire unit of work after SQLSTATE 40001.
for (let attempt = 1; attempt <= 3; attempt++) {
try {
return await runSerializableTransaction();
} catch (error) {
if (error.code !== '40001' || attempt === 3) throw error;
await backoff(attempt);
}
}- Retry the whole transaction, not the last statement.
- Do not repeat an external side effect blindly.
Consistent lock order
Reduce transfer deadlocks.
const [firstId, secondId] = [fromId, toId].sort();
SELECT id FROM accounts
WHERE id IN ($1, $2)
ORDER BY id
FOR UPDATE;- Still handle SQLSTATE 40P01.
Common misconceptions
The myth is on the left; the accurate model is on the right.
A transaction automatically removes every race.
Results depend on isolation, statements, locks, and declared constraints.
Read Committed repeats the same read.
A new PostgreSQL statement receives a new snapshot and can see another commit.
SELECT FOR UPDATE makes concurrent code faster.
It serializes access to a row and can create a wait queue.
Serializable means no errors.
The database can abort a transaction to preserve serial behavior, so retry is required.
Explain it in your own words
If you can explain the answer without quoting documentation, your mental model is starting to take shape.
- Why do two Promises on one client not model two concurrent transactions?
- What can change between two Read Committed SELECTs?
- When is optimistic locking preferable to SELECT FOR UPDATE?
- Why should an HTTP call not run while holding a row lock?
- Which SQLSTATE codes require a whole-transaction retry?
- How does consistent resource ordering reduce deadlocks?