JOIN, Materialized Views и границы ORM
Изучите реальный JOIN plan, воспроизведите N+1 и увидьте, как Materialized View остаётся старым до REFRESH.
Временная шкала
Разбираем: JOIN, Materialized Views и границы ORM
Этот раздел можно читать до запуска опыта. После теории вернитесь к live trace и сопоставьте каждый шаг с реальным событием.
JOIN собирает связанные данные, но для этого СУБД должна прочитать два набора строк и найти пары. Materialized View заранее сохраняет результат тяжёлого запроса: чтение становится дешевле, зато сохранённые данные устаревают до следующего REFRESH.
INNER JOIN оставляет совпавшие пары, LEFT JOIN сохраняет все строки слева и дополняет отсутствующие правые значения NULL. Planner выбирает nested loop, hash join или merge join по размерам, порядку и индексам. Агрегация и сортировка требуют CPU и памяти, а иногда временных файлов. Materialized View физически хранит результат SELECT и обновляется явно. ORM может ускорять CRUD и mapping, но не отменяет SQL, plans, транзакции и стоимость round trips.
Термины этого эксперимента
Сначала поймите слова — затем порядок выполнения.
INNER JOIN
Возвращает только комбинации строк, удовлетворяющие условию ON.
LEFT JOIN
Сохраняет каждую строку слева; при отсутствии пары правые столбцы становятся NULL.
Nested Loop
Для каждой строки outer input ищет строки inner input. Особенно хорош для маленького outer и индексного lookup.
Hash Join
Строит hash table по одному входу и проверяет второй. Подходит большим неотсортированным наборам и равенству.
Merge Join
Идёт по двум отсортированным входам. Может использовать порядок индексов и поддерживает некоторые неравенства.
N+1 query
Один запрос получает N сущностей, затем ещё N запросов загружают связанные данные — много лишних round trips.
Materialized View
Физически сохранённый результат запроса, который остаётся устаревшим до REFRESH.
Что происходит по шагам
Каждый шаг соответствует наблюдаемому состоянию runtime.
- 01Создаём связанную модель
customers и orders соединяются через FOREIGN KEY; индекс на orders.customer_id поддерживает lookup.
- 02Выполняем JOIN
Planner выбирает scan и join algorithms, затем aggregation и top-N sort.
- 03Читаем план
Runtime показывает дерево, actual rows и время: JOIN — это оператор над двумя входами, а не бесплатное склеивание.
- 04Воспроизводим N+1
Двадцать связанных выборок создают 21 round trip; один grouped JOIN выполняет ту же форму загрузки одним запросом.
- 05Сохраняем агрегацию
Materialized View записывает totals и получает собственный индекс для быстрого поиска.
- 06Наблюдаем staleness
Новый заказ не меняет сохранённый total до REFRESH MATERIALIZED VIEW.
Где результат требует оговорки
Эти детали объясняют, почему похожий код иногда даёт другой trace.
JOIN — не обязательно плохо
Один хорошо спланированный JOIN часто дешевле N+1. Проблема определяется объёмом, селективностью, indexes, spills и количеством возвращаемых строк.
WHERE может сломать LEFT JOIN
Условие WHERE по правой таблице отбрасывает NULL-строки и фактически может превратить outer join в inner. Иногда условие должно находиться в ON.
Rows multiply
Связь one-to-many размножает строку родителя. JOIN нескольких коллекций может создать декартово произведение до aggregation.
Materialized View — не автоматический cache
PostgreSQL не обновляет его при каждой записи. Нужна стратегия refresh, допустимая задержка и мониторинг неуспешного обновления.
REFRESH CONCURRENTLY имеет условия
Нужен подходящий UNIQUE index; concurrent refresh обычно дольше, но позволяет продолжать чтение старой версии.
ORM — trade-off, не религия
ORM полезен для mapping, migrations и простого CRUD. Опасность начинается, когда команда не видит generated SQL, N+1 и transaction boundaries.
Сначала разберитесь, какие части Node участвуют в выполнении.
Затем уберите служебные детали и рассмотрите только главную идею.
После этого сопоставьте модель с кодом, который создаёт live trace.
Минимальная модель без служебного кода
const result = await pool.query(
`SELECT c.id, c.name, sum(o.amount) AS total
FROM customers AS c
JOIN orders AS o ON o.customer_id = c.id
WHERE c.active
GROUP BY c.id, c.name
ORDER BY total DESC
LIMIT $1`,
[20],
);Полный код, который выполняет сценарий
Это не альтернативный пример: ниже показаны функции и файлы, используемые кнопкой запуска.
Код сформирован из реальной серверной функции. Для сценариев с отдельным процессом или Worker показаны все участвующие файлы.
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();
}
});
}
Именно вызовы emit(...) превращаются в строки live trace. await и Promise удерживают HTTP-поток открытым до завершения сценария.
Практические шаблоны, которые можно подсмотреть
Сравнивайте цель, код и оговорки — не запоминайте синтаксис без модели.
LEFT JOIN с условием в ON
Сохранить клиентов без оплаченных заказов.
SELECT c.id, count(o.id)
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.id
AND o.status = 'paid'
GROUP BY c.id;- Если перенести o.status в WHERE, клиенты без заказов исчезнут.
DataLoader-style batching
Убрать N+1, не создавая огромный JOIN.
SELECT customer_id, id, amount
FROM orders
WHERE customer_id = ANY($1::bigint[]);- Результат группируется в приложении по customer_id.
Materialized View
Предварительно вычислять дневную аналитику.
CREATE MATERIALIZED VIEW daily_sales AS
SELECT date_trunc('day', created_at) AS day,
sum(amount) AS total
FROM orders
GROUP BY 1;
REFRESH MATERIALIZED VIEW daily_sales;- Нужно явно определить допустимую задержку данных.
Repository с видимым SQL
Сохранить Nest DI без потери контроля над запросом.
@Injectable()
export class OrdersRepository {
constructor(@Inject(PG_POOL) private readonly db: Pool) {}
findRecent(customerId: number) {
return this.db.query(
`SELECT id, amount
FROM orders
WHERE customer_id = $1
ORDER BY created_at DESC
LIMIT 20`,
[customerId],
);
}
}- Репозиторий — граница инфраструктуры, а не место для скрытия неизвестного SQL.
Популярные заблуждения
Миф слева, корректная модель справа.
JOIN всегда медленнее нескольких простых запросов.
Один set-based запрос часто уменьшает round trips; решение подтверждается планом и измерением.
Индекс нужен только на PRIMARY KEY.
Внешний ключ не создаёт автоматический индекс на referencing column; join/delete parent могут нуждаться в нём.
Materialized View всегда содержит свежие данные.
Он показывает результат последнего успешного REFRESH.
Отказ от ORM автоматически делает SQL быстрым.
Плохой raw SQL остаётся плохим. Важны модель, параметры, plans, indexes и observability.
Ответьте своими словами
Если ответ получается объяснить без терминов из документации, ментальная модель уже начала складываться.
- Когда nested loop лучше hash join?
- Как WHERE по правой таблице меняет LEFT JOIN?
- Почему N+1 остаётся проблемой при быстрых индексных lookup?
- Как определить допустимую staleness Materialized View?
- Что требуется для REFRESH MATERIALIZED VIEW CONCURRENTLY?
- Какие гарантии должен предоставлять repository независимо от ORM?