Настоящий Postgres: типы, ограничения, EXPLAIN и 23505
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Три статьи подряд таблица жила внутри вашей программы: номера строк раздавали вы, индексы чинили вы, 23505 бросали тоже вы. Теперь на её место встаёт настоящий Postgres — и первое, что меняется, это расстояние. База больше не объект в том же процессе, а отдельная программа на другом конце сокета: у её колонок есть типы, у таблицы — собственные правила, у запроса — план, который выбирает не ваш код, а она сама, и даже строка внутри неё не одна, а стопка версий. Разберём эти четыре отличия по очереди: сначала модель, которую можно запустить прямо на странице, а следом записанный прогон — тот же сюжет на настоящей базе, снятый настоящим node.
Наша таблица хранит строки обычными объектами, а объект в JavaScript не спрашивает, что кладут в поле. Пока значения приходили из одного места, это сходило с рук. Стоит появиться второму — форме, из которой всё приезжает строками, — и в одной колонке заводятся два вида одного и того же значения.
Прогноз: на колонке views стоит уникальный индекс. Первая заметка кладёт туда число 9, вторая — строку "9" из формы, третья просмотров не прислала вовсе. Сколько строк окажется в таблице, что напечатает сумма просмотров и сколько строк найдёт индекс по 9 и по "9"?
Уникальный индекс пропустил обе девятки: в его карте 9 и "9" — разные ключи, и по каждому лежит своя строка. Третья заметка записалась без колонки вовсе. Сумма просмотров сложилась по правилам JavaScript: 0 + 9 дало число, число плюс строка склеилось в "99", а undefined дописал к нему своё имя. Ни одна из трёх бед не пожаловалась — ровно тот случай, ради которого мы всю главу заводим правила в таблице, а не в обработчике.
В Postgres колонка объявляется типом: views integer. Тип — не подпись о том, что здесь лежит, а правило приведения, которое выполняется до записи: значение либо приводится к типу колонки, либо не записывается вовсе, и отказ приходит кодом. Добавим это правило в модель и посмотрим, что изменится.
Прогноз: те же три попытки и четвёртая — вставка в колонку author, которой в схеме нет. Какие из четырёх пройдут и сколько строк останется в таблице?
Строка "9" привелась к числу — и тут же столкнулась с уникальным индексом, потому что девятка в таблице уже была: 23505. Слово «много» к целому не приводится никак — 22P02, ошибка разбора значения. Колонки author в схеме нет, и это 42703: писать некуда. Из четырёх попыток прошла одна, и строк в таблице осталось столько же.
Тип колонки — самое дешёвое правило из всех: оно не знает ни про предметную область, ни про пользователя, и всё равно не пускает в неё то, что колонкой не является.
Приведение работает в одну сторону — на запись. Обратно значения едут по соединению текстом, и во что их превратить, решает драйвер: пакет pg, через который с базой разговаривает Node. Часть типов он разбирает, часть оставляет как приехало — и выбор этот не произвольный.
Прогноз: в таблице колонки bigint, integer, numeric(10, 2), boolean, timestamptz и jsonb, а запрос ещё и считает строки через count(*). Какие значения приедут в JavaScript числами, а какие — нет?
import pg from 'pg'; // адрес базы — первой строкой и без пароля: видно, куда ушли запросы const url = new URL(process.env.DATABASE_URL); url.password = ''; console.log('база:', url.href); const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); await client.connect(); // колонки разных типов: целое, длинное целое, деньги, флаг, время и объект await client.query(`CREATE TABLE notes ( id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, seq bigint NOT NULL, views integer NOT NULL, price numeric(10, 2) NOT NULL, published boolean NOT NULL, created_at timestamptz NOT NULL, fields jsonb NOT NULL )`); const insert = (seq, views) => client.query( `INSERT INTO notes (seq, views, price, published, created_at, fields) VALUES ($1, $2, $3, $4, $5, $6)`, [seq, views, '19.90', true, '2026-09-23T09:00:00Z', { title: 'план' }], ); await insert(9, 3); await insert(10, 40); const { rows, fields } = await client.query('SELECT seq, views, price, published, created_at, fields FROM notes WHERE seq = 10'); const row = rows[0]; for (const колонка of fields) { const значение = row[колонка.name]; const вид = значение instanceof Date ? 'Date' : typeof значение; console.log(`${колонка.name}: ${вид} ${JSON.stringify(значение)}`); } const счёт = await client.query('SELECT count(*) AS всего FROM notes'); console.log('count(*):', typeof счёт.rows[0].всего, JSON.stringify(счёт.rows[0].всего)); const порядок = await client.query('SELECT seq FROM notes ORDER BY seq'); console.log('порядок базы:', порядок.rows.map((r) => r.seq).join(', ')); const свой = [...порядок.rows].sort((a, b) => (a.seq > b.seq ? -1 : 1)); console.log('свой порядок по убыванию:', свой.map((r) => r.seq).join(', ')); const числом = порядок.rows.map((r) => ({ seq: Number(r.seq) })).sort((a, b) => b.seq - a.seq); console.log('он же после разбора в number:', числом.map((r) => r.seq).join(', ')); await client.end();
база: postgres://postgres@127.0.0.1:<порт-базы>/app
seq: string "10"
views: number 40
price: string "19.90"
published: boolean true
created_at: Date "2026-09-23T09:00:00.000Z"
fields: object {"title":"план"}
count(*): string "2"
порядок базы: 9, 10
свой порядок по убыванию: 9, 10
он же после разбора в number: 10, 9Четыре типа из шести драйвер разобрал: integer стал числом, boolean — булевым значением, timestamptz — настоящим Date, jsonb — обычным объектом. А bigint, numeric и результат count(*) остались строками, и это не недоделка драйвера, а единственный честный выбор. bigint вмещает числа до 9 223 372 036 854 775 807, а число JavaScript считает целые точно лишь до 9 007 199 254 740 991: разбери драйвер такое значение сам — и часть значений молча испортилась бы, а какая именно, он узнать не может. С numeric то же самое, но про деньги: 19.90 в двоичной дроби не представимо точно.
Пока такое значение печатают, разницы не видно: "10" в выводе выглядит как 10. Она всплывает в сравнении — и в самом тихом его виде, в сортировке: последние три строки прогона именно про это. Строки сравниваются посимвольно, и "9" оказывается больше "10" на первом же знаке. Поэтому своя сортировка по убыванию вернула то же 9, 10, что база отдаёт по возрастанию, и настоящее убывание получилось только после разбора в число — 10, 9.
Само по себе это выглядит мелочью: подумаешь, две строки местами. Посмотрим, во что мелочь обходится там, где по номеру не просто сортируют, — в курсоре синхронизации. Сервер выдаёт клиенту страницу правок и номер, с которого продолжать в следующий раз.
Прогноз: на странице три изменения с номерами 8, 9 и 10, курсор — самый большой номер страницы. Какой курсор уедет на телефон и что придёт ему следующей выдачей?
Курсором стало 9 — тем же посимвольным сравнением. Клиент запомнил его как «всё до девятки получено», сервер считает номера числами и честно отдаёт всё, что больше девяти, и правка c приезжает второй раз — телефон применяет уже применённое. Потерять строку такой курсор не может (он всегда меньше настоящего максимума), а вот возить лишнее и путать порядок — сколько угодно. Стоит разобрать номер в число на границе чтения — и курсор, и порядок, и выдача становятся такими, какими задумывались.
Это же место ломает и правило конфликтов: сравнение правок по отметке at написано как a.at > b.at, и строки разной длины дают в нём неверный ответ. Отсюда правило: значения, приехавшие из базы, разбираются один раз, в одном месте — там, где строка таблицы превращается в объект предметной области. В схеме на Drizzle такое место объявляется прямо у колонки, режимом её типа.
Число, приехавшее строкой, не ошибается в равенстве и ошибается в порядке. Поэтому обнаруживается оно не там, где его сравнили, а там, где по нему отсортировали.
Правило «заголовок непустой, состояние — одно из двух» обычно пишут там, где его впервые понадобилось проверить: в обработчике редактора. Живёт оно там ровно до того дня, когда в таблицу понадобится записать что-то помимо редактора.
Прогноз: заметки пишет ещё и перенос из старого приложения — код, написанный на полгода позже и про проверки редактора не знающий. Что окажется в таблице после трёх попыток редактора и одного переноса?
Редактор отказал дважды и был прав. Перенос записал ровно то, что редактор не пустил: заметку с заголовком из пробелов и состоянием «публикация», которого приложение не знает. Дальше эта строка будет вылезать в самых неожиданных местах — в фильтре по состоянию, в списке, в выгрузке, — и каждый раз виноватым будет выглядеть код, который её читает.
Дверей к таблице всегда больше, чем кажется: перенос, разовый скрипт, ручной UPDATE в консоли базы, вторая версия API рядом с первой. Правило, которое обязано выполняться всегда, переезжает в саму таблицу: NOT NULL у колонки и CHECK с условием у таблицы. Тогда дверь только одна, и проверка стоит именно в ней.
Второе правило сложнее: адрес заметки уникален у автора, но только пока заметка жива. Выброшенная заметка адрес освобождает. Обычный уникальный индекс так не умеет — ему нужно условие, по которому часть строк под его надзор не попадает.
Прогноз: шесть действий подряд — две заметки с одним адресом у одного автора, такая же у другого, удаление первой, новая заметка на освободившийся адрес и попытка вернуть удалённую. Какие из них пройдут?
Адрес выброшенной заметки достался новой — условие индекса на удалённые строки не распространяется. А вот вернуть выброшенную уже нельзя: её адрес занят, и «отмена удаления» упирается в ту же уникальность, что и обычная вставка. Это не недосмотр модели, а настоящее поведение: восстановление придётся либо предлагать с новым адресом, либо запрещать честной ошибкой.
В Postgres обе эти вещи пишутся в самой таблице — и отвечают кодами, а не текстом.
import pg from 'pg'; // адрес базы — первой строкой и без пароля: видно, куда ушли запросы const url = new URL(process.env.DATABASE_URL); url.password = ''; console.log('база:', url.href); const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); await client.connect(); await client.query(`CREATE TABLE notes ( id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, author_id integer NOT NULL, slug text NOT NULL, title text NOT NULL, status text NOT NULL DEFAULT 'draft', deleted_at timestamptz, CONSTRAINT notes_title_filled CHECK (length(btrim(title)) > 0), CONSTRAINT notes_status_known CHECK (status IN ('draft', 'published')) )`); // уникальность с условием WHERE: под её надзором не вся таблица, а часть строк await client.query('CREATE UNIQUE INDEX notes_live_slug_key ON notes (author_id, slug) WHERE deleted_at IS NULL'); async function попытка(подпись, sql, значения) { try { const { rows } = await client.query(sql, значения); console.log(`${подпись}: записано #${rows[0].id}`); } catch (err) { // ветвимся по коду SQLSTATE, а не по тексту: текст зависит от языка сервера console.log(`${подпись}: ${err.code} ${err.constraint ?? err.column ?? ''}`); console.log(' текст:', err.message); if (err.detail) console.log(' подробности:', err.detail); } } const вставить = 'INSERT INTO notes (author_id, slug, title, status) VALUES ($1, $2, $3, $4) RETURNING id'; await попытка('обычная заметка', вставить, [7, 'plan', 'План на неделю', 'draft']); await попытка('заголовок из пробелов', вставить, [7, 'blank', ' ', 'draft']); await попытка('неизвестное состояние', вставить, [7, 'state', 'Ещё одна', 'публикация']); await попытка('без заголовка', вставить, [7, 'none', null, 'draft']); await попытка('номер автора словом', вставить, ['седьмой', 'who', 'Чужая', 'draft']); await попытка('тот же адрес у того же автора', вставить, [7, 'plan', 'Другой план', 'draft']); await попытка('тот же адрес у другого автора', вставить, [8, 'plan', 'План Бори', 'draft']); await client.query("UPDATE notes SET deleted_at = now() WHERE author_id = 7 AND slug = 'plan'"); await попытка('адрес выброшенной заметки', вставить, [7, 'plan', 'План заново', 'draft']); await попытка('вернуть выброшенную', 'UPDATE notes SET deleted_at = NULL WHERE id = 1 RETURNING id'); const { rows } = await client.query('SELECT id, author_id, slug, title, deleted_at IS NULL AS live FROM notes ORDER BY id'); console.log('в таблице:', rows.map((r) => `#${r.id} ${r.author_id}/${r.slug} ${r.live ? 'живая' : 'выброшена'}`).join(' | ')); await client.end();
база: postgres://postgres@127.0.0.1:<порт-базы>/app обычная заметка: записано #1 заголовок из пробелов: 23514 notes_title_filled текст: new row for relation "notes" violates check constraint "notes_title_filled" подробности: Failing row contains (2, 7, blank, , draft, null). неизвестное состояние: 23514 notes_status_known текст: new row for relation "notes" violates check constraint "notes_status_known" подробности: Failing row contains (3, 7, state, Ещё одна, публикация, null). без заголовка: 23502 title текст: null value in column "title" of relation "notes" violates not-null constraint подробности: Failing row contains (4, 7, none, null, draft, null). номер автора словом: 22P02 текст: invalid input syntax for type integer: "седьмой" тот же адрес у того же автора: 23505 notes_live_slug_key текст: duplicate key value violates unique constraint "notes_live_slug_key" подробности: Key (author_id, slug)=(7, plan) already exists. тот же адрес у другого автора: записано #6 адрес выброшенной заметки: записано #7 вернуть выброшенную: 23505 notes_live_slug_key текст: duplicate key value violates unique constraint "notes_live_slug_key" подробности: Key (author_id, slug)=(7, plan) already exists. в таблице: #1 7/plan выброшена | #6 8/plan живая | #7 7/plan живая
Каждому отказу — свой код SQLSTATE и своё имя. 23514 называет нарушенное CHECK-ограничение, 23502 — колонку, у которой не должно быть NULL, 23505 — индекс уникальности; у 22P02 имени нет вовсе, потому что до строки дело не дошло: сорвалось приведение значения. Рядом с кодом приезжает detail: у нарушенной проверки — та самая строка, которая не прошла, целиком, у дубля — ключ, который уже занят.
Дырки в номерах вы уже видели у первой настоящей таблицы: первая заметка получила #1, а следующая удачная — сразу #6, четыре номера сгорели на неудачных попытках. Новое здесь то, что сгорают не все: номер уцелел у той неудачи, где сорвалось приведение значения. До построения строки дело там не дошло, а значит, номера никто и не просил.
Поля code и constraint, по которым ветвится пример, — те же самые, что собирались руками в статье про доменные ошибки. Теперь они настоящие: драйвер раскладывает ответ сервера по семнадцати полям — от message и code до file и routine в исходниках самого сервера, — а боевому коду из них нужны ровно два.
Обработчик проверяет то, что видит. Таблица проверяет всё, что в неё пишут, — включая то, о чём обработчик никогда не узнает.
К базе ходит не только приложение. В комплекте с Postgres идёт psql — консольный клиент, которым смотрят, что в базе на самом деле лежит, и которым выполняют разовые вещи вроде переноса или починки. Тот же файл с таблицей можно скормить ему целиком.
Прогноз: файл создаёт таблицу с двумя CHECK и уникальным индексом с условием, печатает её командой \d и делает три вставки, из которых две нарушают ограничения. Что покажет \d, как psql расскажет об отказах и с каким кодом он завершится?
CREATE TABLE notes (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
author_id integer NOT NULL,
slug text NOT NULL,
title text NOT NULL,
status text NOT NULL DEFAULT 'draft',
deleted_at timestamptz,
CONSTRAINT notes_title_filled CHECK (length(btrim(title)) > 0),
CONSTRAINT notes_status_known CHECK (status IN ('draft', 'published'))
);
CREATE UNIQUE INDEX notes_live_slug_key ON notes (author_id, slug) WHERE deleted_at IS NULL;
\d notes
INSERT INTO notes (author_id, slug, title) VALUES (7, 'plan', 'План на неделю');
INSERT INTO notes (author_id, slug, title) VALUES (7, 'plan', 'Другой план');
INSERT INTO notes (author_id, slug, title, status) VALUES (7, 'state', 'Ещё одна', 'публикация');
SELECT id, slug, title FROM notes ORDER BY id;CREATE TABLE
CREATE INDEX
Таблица "public.notes"
Столбец | Тип | Правило сортировки | Допустимость NULL | По умолчанию
------------+--------------------------+--------------------+-------------------+------------------------------
id | integer | | not null | generated always as identity
author_id | integer | | not null |
slug | text | | not null |
title | text | | not null |
status | text | | not null | 'draft'::text
deleted_at | timestamp with time zone | | |
Индексы:
"notes_pkey" PRIMARY KEY, btree (id)
"notes_live_slug_key" UNIQUE, btree (author_id, slug) WHERE deleted_at IS NULL
Ограничения-проверки:
"notes_status_known" CHECK (status = ANY (ARRAY['draft'::text, 'published'::text]))
"notes_title_filled" CHECK (length(btrim(title)) > 0)
INSERT 0 1
psql:session.sql:17: ОШИБКА: повторяющееся значение ключа нарушает ограничение уникальности "notes_live_slug_key"
ПОДРОБНОСТИ: Ключ "(author_id, slug)=(7, plan)" уже существует.
psql:session.sql:18: ОШИБКА: новая строка в отношении "notes" нарушает ограничение-проверку "notes_status_known"
ПОДРОБНОСТИ: Ошибочная строка содержит (3, 7, state, Ещё одна, публикация, null).
id | slug | title
----+------+----------------
1 | plan | План на неделю
(1 строка)Команда \d печатает не то, что вы написали, а то, что получилось. Объявленный timestamptz показан полным именем timestamp with time zone, условие status IN ('draft', 'published') переписано в status = ANY (ARRAY[…]), а уникальный индекс с условием стоит в списке индексов вместе со своим WHERE. Это лучший способ проверить, что в базе оказалось ровно то, что вы задумывали: описание печатает сама база.
Сообщения об отказах пришли по-русски — и это не особенность psql, а язык сервера: он берёт его из настройки lc_messages, а та по умолчанию наследует локаль, с которой базу заводили. Тот же отказ на сервере с другой локалью будет написан другими словами. Вот почему в примерах этой статьи код ветвится по err.code: код 23505 одинаков на любом сервере, а текст — нет.
И последнее, ради чего этот прогон здесь: psql завершился кодом 0, хотя две команды из трёх отказали. Файл выполняется до конца, каждая ошибка печатается и забывается — скрипт переноса, запущенный так, отрапортует успехом, залив половину данных. Чтобы этого не случилось, psql запускают с -v ON_ERROR_STOP=1: тогда он останавливается на первой ошибке и выходит с кодом 3.
Postgres — отдельная программа, и ставится она отдельно от проекта: пакетным менеджером системы (у автора это Homebrew, формула postgresql@18) или официальным образом postgres в контейнере. При установке или первом запуске выполняется initdb — он заводит кластер, каталог со всеми базами этого сервера. Пустая база создаётся отдельной командой, createdb notes, и уже к ней ходит psql -d notes из прогона выше.
Приложение получает базу не именем, а адресом — postgres://пользователь@127.0.0.1:5432/notes: кто подключается, к какой машине, в какой порт и в какую базу. Держат его в переменной окружения: у разработчика, на тестовом сервере и на боевом он разный, а пароль в репозитории не нужен никому. Такой адрес и печатают первой строкой прогоны этой страницы, только порт в нём закрыт маской: кластер для них поднимается временный, на свободном порту. Как база встаёт рядом с приложением на боевом сервере и почему «контейнер запущен» и «база отвечает» — разные события, разбирает статья про порядок запуска.
Сообщение об ошибке написано для человека и переводится вместе с ним. Код ошибки написан для программы и не переводится никогда.
В модели индекс выбирали вы: notes.find("notes_author_idx", 7) называет его по имени. В SQL имени индекса в запросе нет вовсе — вы описываете, что хотите получить, а как это достать, решает планировщик. Решает он по оценке: сколько строк, по его мнению, отвечает условию.
Прогноз: в таблице 200 строк, индекс по автору есть, у автора 7 — двадцать заметок, черновиков — почти все. Про колонку, о которой ничего не известно, планировщик берёт долю по умолчанию. Что он выберет для автора и для черновиков до пересчёта статистики и после него?
До пересчёта обе колонки выглядят одинаково избирательными — доля по умолчанию мала, и индекс кажется выгодным в обоих случаях. После пересчёта картина расходится: по автору индекс оставляет двадцать строк из двухсот и остаётся полезным, а по состоянию он вернул бы почти всю таблицу, и перебор дешевле. Индекс проигрывает не потому, что плох, а потому, что строк под условие подходит слишком много: читать их через индекс — это читать и индекс, и таблицу.
Настоящий план печатает EXPLAIN. Флаг COSTS OFF убирает из него оценки стоимости — те самые числа, которые меняются от статистики и от машины; остаётся дерево узлов, а оно про суть. Пересчёт статистики в Postgres называется ANALYZE; обычно его делает сам сервер, но на только что залитой таблице его стоит позвать руками.
import pg from 'pg'; const url = new URL(process.env.DATABASE_URL); url.password = ''; console.log('база:', url.href); const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); await client.connect(); await client.query(`CREATE TABLE notes ( id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, author_id integer NOT NULL, status text NOT NULL, created_at timestamptz NOT NULL, title text NOT NULL )`); // двадцать тысяч заметок сотни авторов; опубликованных — один процент await client.query(`INSERT INTO notes (author_id, status, created_at, title) SELECT n % 100, CASE WHEN n % 100 = 0 THEN 'published' ELSE 'draft' END, timestamptz '2026-01-01 00:00:00Z' + (n || ' minutes')::interval, 'заметка ' || n FROM generate_series(1, 20000) AS n`); const ЗАПРОС = `SELECT id, title FROM notes WHERE author_id = 7 AND status = 'draft' ORDER BY created_at DESC LIMIT 10`; async function план(подпись, запрос = ЗАПРОС) { const { rows } = await client.query(`EXPLAIN (COSTS OFF) ${запрос}`); console.log(подпись); for (const r of rows) console.log(' ' + r['QUERY PLAN']); } await план('без индекса:'); await client.query('CREATE INDEX notes_author_idx ON notes (author_id)'); await план('с индексом по автору:'); await client.query('CREATE INDEX notes_author_time_idx ON notes (author_id, created_at DESC)'); await план('с индексом по автору и времени:'); await client.query('ANALYZE notes'); await план('после ANALYZE:'); const публикации = `SELECT id, title FROM notes WHERE status = 'published' ORDER BY created_at DESC LIMIT 10`; await план('лента публикаций:', публикации); await client.query("CREATE INDEX notes_published_idx ON notes (created_at DESC) WHERE status = 'published'"); await план('с частичным индексом:', публикации); // поиск по заголовку: обычный индекс на колонке есть, а шаблоны у запросов разные await client.query('CREATE INDEX notes_title_idx ON notes (title)'); await client.query('ANALYZE notes'); await план('шаблон с процентом впереди:', "SELECT count(*) FROM notes WHERE title LIKE '%17%'"); await план('шаблон с процентом сзади:', "SELECT count(*) FROM notes WHERE title LIKE 'заметка 17%'"); await client.end();
база: postgres://postgres@127.0.0.1:<порт-базы>/app
без индекса:
Limit
-> Sort
Sort Key: created_at DESC
-> Seq Scan on notes
Filter: ((author_id = 7) AND (status = 'draft'::text))
с индексом по автору:
Limit
-> Sort
Sort Key: created_at DESC
-> Bitmap Heap Scan on notes
Recheck Cond: (author_id = 7)
Filter: (status = 'draft'::text)
-> Bitmap Index Scan on notes_author_idx
Index Cond: (author_id = 7)
с индексом по автору и времени:
Limit
-> Sort
Sort Key: created_at DESC
-> Bitmap Heap Scan on notes
Recheck Cond: (author_id = 7)
Filter: (status = 'draft'::text)
-> Bitmap Index Scan on notes_author_time_idx
Index Cond: (author_id = 7)
после ANALYZE:
Limit
-> Index Scan using notes_author_time_idx on notes
Index Cond: (author_id = 7)
Filter: (status = 'draft'::text)
лента публикаций:
Limit
-> Sort
Sort Key: created_at DESC
-> Seq Scan on notes
Filter: (status = 'published'::text)
с частичным индексом:
Limit
-> Index Scan using notes_published_idx on notes
шаблон с процентом впереди:
Aggregate
-> Seq Scan on notes
Filter: (title ~~ '%17%'::text)
шаблон с процентом сзади:
Aggregate
-> Bitmap Heap Scan on notes
Filter: (title ~~ 'заметка 17%'::text)
-> Bitmap Index Scan on notes_title_idx
Index Cond: ((title >= 'заметка 17'::text) AND (title < 'заметка 18'::text))Без индексов — Seq Scan, полный проход, и поверх него Sort: порядок строить не из чего. Индекс по автору добавил Bitmap Index Scan, но сортировка осталась. Индекс по автору и времени формы плана не изменил — только подменил имя в том же узле, — а вот после ANALYZE план перевернулся: Index Scan по нему идёт сразу в нужном порядке, и узел Sort из плана исчез. Это и есть та самая оценка из модели: пока статистики нет, планировщик не знает, что десяти строк хватит, и предпочитает взять всё и отсортировать.
Дальше — лента публикаций, которых в таблице один процент. Обычный индекс по состоянию здесь хранил бы 20 000 записей ради двухсот; вместо него стоит индекс с условием — в нём только опубликованные, и упорядочены они по времени. От чтения в плане остался один узел — Index Scan без всякого Filter: фильтровать уже нечего, в индексе лежат ровно нужные строки.
Последняя пара планов — про поиск из статьи про видимость и запросы. Индекс по заголовку есть, а шаблоны разные: LIKE '%17%' даёт Seq Scan по всей таблице, LIKE 'заметка 17%' — Bitmap Index Scan. Разницу видно прямо в плане: во втором случае условие превратилось в диапазон title >= 'заметка 17' AND title < 'заметка 18', а такой диапазон индекс умеет отдать одним куском. У шаблона, который начинается с процента, никакого диапазона нет — искать придётся везде.
Диапазон из шаблона получается только тогда, когда строки в индексе лежат в том же порядке, в каком их сравнивает LIKE, — то есть посимвольно. Кластер, на котором снят прогон, заведён с локалью C (так делает прогонщик, чтобы тексты ошибок были одинаковы на любой машине), и там это так.
В базе с обычной локалью порядок сортировки другой, и обычный индекс шаблону не помогает: тот же запрос уходит в полный проход. Проверено 23.09.2026 на PostgreSQL 18.4 с локалью ru_RU.UTF-8: Seq Scan. Лечится это индексом, объявленным с text_pattern_ops, — он хранит значения в посимвольном порядке специально для шаблонов, и план снова становится Bitmap Index Scan.
План — не оценка индекса, а ответ на вопрос «сколько строк я ожидаю». Поэтому первое, что делают с медленным запросом, — смотрят не на индексы, а на то, во сколько строк база верит.
Когда мы собирали таблицу, в списке «чего у модели нет» стояли видимость чужих правок до фиксации и уровни изоляции. Дошли до них. Наш откат отменял уже записанное, и соседний читатель успевал увидеть полуфабрикат. Настоящая база устроена иначе: она не отменяет, а не показывает.
Приём называется многоверсионностью. Строка в ней — не ячейка, которую переписывают, а список версий: у каждой номер транзакции, которая её создала, и номер той, которая её заменила. Транзакция, начинаясь, запоминает номер — свой снимок — и читает только те версии, которые в него попадают.
Прогноз: читатель открыл снимок и прочитал строку. Пока он не закрылся, писатель заменил значение. Что увидит читатель во второй раз, что увидит тот, кто пришёл после писателя, и сколько версий строки будет лежать в таблице?
Читатель видит своё и после чужой записи: его снимок старее новой версии. Тот, кто пришёл позже, видит новую. Версий в таблице стало две, и выбросить старую нельзя, пока на неё смотрит хоть один снимок, — только когда последний такой читатель уйдёт, она превращается в мусор. Именно его и убирает VACUUM, а «раздувшаяся таблица» на боевом сервере — это почти всегда долгая транзакция, которая держит старые версии живыми.
Теперь то же на настоящей базе, двумя соединениями: одно правит, другое читает. Третья сцена интереснее первых двух — там читатель не просто читает, а пробует записать.
Прогноз: сначала A правит строку и не фиксирует — что видит B? Потом B читает строку дважды в одной транзакции, а между чтениями A фиксирует правку — совпадут ли чтения? И то же самое на уровне repeatable read, где B после двух чтений пробует записать своё.
import pg from 'pg'; const url = new URL(process.env.DATABASE_URL); url.password = ''; console.log('база:', url.href); // два клиента — два соединения: внутри одного клиента одновременности не бывает const a = new pg.Client({ connectionString: process.env.DATABASE_URL }); const b = new pg.Client({ connectionString: process.env.DATABASE_URL }); await a.connect(); await b.connect(); await a.query(`CREATE TABLE notes ( id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, title text NOT NULL )`); await a.query("INSERT INTO notes (title) VALUES ('черновик')"); const title = async (client) => (await client.query('SELECT title FROM notes WHERE id = 1')).rows[0].title; const reset = () => a.query("UPDATE notes SET title = 'черновик' WHERE id = 1"); // Сцена 1: A правит строку и не фиксирует await a.query('BEGIN'); await a.query("UPDATE notes SET title = 'план на неделю' WHERE id = 1"); console.log('внутри транзакции A:', await title(a)); console.log('снаружи, у B:', await title(b)); await a.query('COMMIT'); console.log('после COMMIT у B:', await title(b)); // Сцена 2: B читает дважды в одной транзакции, между чтениями пишет A await reset(); await b.query('BEGIN'); console.log('B, read committed, первое чтение:', await title(b)); await a.query("UPDATE notes SET title = 'список покупок' WHERE id = 1"); console.log('B, read committed, второе чтение:', await title(b)); await b.query('COMMIT'); // Сцена 3: то же самое на repeatable read await reset(); await b.query('BEGIN ISOLATION LEVEL REPEATABLE READ'); console.log('B, repeatable read, первое чтение:', await title(b)); await a.query("UPDATE notes SET title = 'список дел' WHERE id = 1"); console.log('B, repeatable read, второе чтение:', await title(b)); try { const { rowCount } = await b.query("UPDATE notes SET title = 'мой вариант' WHERE id = 1"); console.log('B записал строк:', rowCount); } catch (err) { console.log('B при записи:', err.code); console.log(' текст:', err.message); } await b.query('ROLLBACK'); console.log('в строке осталось:', await title(a)); await a.end(); await b.end();
база: postgres://postgres@127.0.0.1:<порт-базы>/app внутри транзакции A: план на неделю снаружи, у B: черновик после COMMIT у B: план на неделю B, read committed, первое чтение: черновик B, read committed, второе чтение: список покупок B, repeatable read, первое чтение: черновик B, repeatable read, второе чтение: черновик B при записи: 40001 текст: could not serialize access due to concurrent update в строке осталось: список дел
Незафиксированное не видно никому, кроме самой транзакции, — обещание статьи про откат закрыто. Дальше начинается выбор. Уровень по умолчанию, read committed, обновляет снимок на каждой команде: два одинаковых чтения в одной транзакции честно вернули разное, потому что между ними чужая правка зафиксировалась. На repeatable read снимок берётся один раз на всю транзакцию, и оба чтения совпали.
А вот запись на repeatable read отказала кодом 40001: база не может применить правку к строке, которую транзакция видит устаревшей. Это не поломка, а договор: транзакция повторяется целиком, с новым снимком. Тем и отличается этот уровень — он не избавляет от конфликтов, он переносит их из тихих потерянных обновлений в явный отказ, который обязан уметь обработать ваш код.
Сравните с обновлением по версии: там условие писали вы, и ноль изменённых строк приходилось замечать самому. Здесь условие ставит база и сама же отказывает — но ценой того, что повторять придётся всю транзакцию, а не один запрос.
Уровень изоляции — не мера надёжности базы, а мера того, что вам разрешено увидеть. Чем меньше видно чужого, тем чаще придётся переделывать своё.
- У колонки есть тип, и это правило приведения, которое выполняется до записи: значение либо приводится, либо не записывается вовсе. Модель без типов принимает
9и"9"как разные значения, и уникальный индекс не возражает. - Обратно
bigint,numericиcount(*)приезжают строками: они вмещают больше, чем число JavaScript считает точно. Равенство на этом не ломается, ломается порядок — курсор"9"оказывается больше"10". Разбор — один раз, на границе чтения. - Правило, которое обязано выполняться всегда, живёт в таблице:
NOT NULLиCHECKпроверяют любую запись, включая перенос и ручнойUPDATE. Отказ приходит кодомSQLSTATEи именем ограничения:23514,23502,23505,22P02. - Уникальный индекс с условием
WHEREследит не за всей таблицей: адрес выброшенной заметки освобождается, но вернуть её уже нельзя — адрес занят. Текст сообщения зависит от языка сервера, код — нет, поэтому ветвятся по коду. - Индекс выбирает база, и выбирает по оценке числа строк. Пока статистики нет, план может быть любым;
ANALYZEеё пересчитывает,EXPLAIN (COSTS OFF)показывает дерево узлов. Условие, из которого не получается диапазон, индексу не по зубам. - Строка в Postgres — список версий, и транзакция читает те, что попадают в её снимок. На
read committedснимок обновляется каждой командой, наrepeatable readон один на транзакцию, а конфликт записи приходит кодом40001— повторять придётся всю транзакцию.
1/4Запрос SELECT count(*) FROM notes через драйвер pg вернул значение. Какого оно типа в JavaScript?
2/4На колонке title есть обычный индекс. Что будет в плане запроса с условием title LIKE '%17%'?
3/4Транзакция на уровне repeatable read получила при записи код 40001. Что должен сделать код?
4/4Правило «состояние заметки — только draft или published» проверяется в обработчике. В базу приходит перенос данных мимо приложения. Что удержит правило?
Дверь есть, проверок нет
Ограничения таблицы объявлены списком: у каждого имя и условие, которому обязана отвечать строка. Записывают через единственную функцию write — через неё ходят и редактор, и перенос. Вот только спросить ограничения она забыла, и в таблицу попадает всё подряд.
Допишите write так, чтобы нарушение отвечало как настоящая база: ошибкой с кодом 23514 и именем нарушенного ограничения. Проверять надо до вставки, а не после неё.
Ограничения проверяются по очереди и до записи: строка, не прошедшая хотя бы одно, в таблицу не попадает вовсе. Имя нарушенного ограничения едет в ошибку — именно по нему сервис потом различает, какое из правил не сошлось.
Строку для проверки собираем до вставки и с заведомо неверным id: номер ещё не выдан, а условиям он и не нужен. После правки редактор запишет только первую заметку, а перенос получит 23514 notes_status_known — тот самый код, который вы видели у настоящего Postgres.
Курсор приехал строкой
Страница изменений приехала из базы так, как её отдал драйвер: номер seq объявлен в колонке как bigint и приехал строкой. Контракт синхронизации обещает клиенту number, но pullPage отдаёт строки как есть, а приведением занимается только утверждение типа.
Почините границу чтения: строки базы должны превращаться в объекты контракта в одном месте. Курсор после этого обязан стать числом 10, порядок — 8, 9, 10, а следующая выдача с этим курсором — пустой.
Утверждение типа as ничего не приводит — оно только заставляет компилятор замолчать. Разбор пишется руками, по одному полю, и там же, где строка базы становится объектом:
Одной этой функции хватает на все три беды сразу: курсор считается по числам, сортировка сравнивает числа, и выдача с курсором 10 оказывается пустой. Обратите внимание, что делать это надо именно на границе: разбор в каждом месте, где номер сравнивают, — это тот же as, только многословнее.
Индекс, который не подходит запросу
Частичный индекс хранит только опубликованные заметки. Планировщик в примере берёт его для любого запроса, не спрашивая, укладывается ли условие запроса в условие индекса, — и два запроса из трёх получают неполный ответ.
Научите run проверять пригодность: индекс годится, только если каждая строка, подходящая запросу, попадает в индекс. Непригодный индекс — повод читать таблицу целиком. Печатайте рядом с числом строк, чем запрос обслужен.
Проверка у пригодности простая: не найдётся ли в таблице строка, которая запросу подходит, а в индекс не попала. Нашлась — индекс отвечает не на тот вопрос:
Теперь status = published обслуживается индексом, а status <> draft и запрос без условия уходят в перебор и дают 3 и 4 строки. Настоящий планировщик делает ровно это, только вместо перебора строк доказывает вложенность условий по их тексту — и именно поэтому условие частичного индекса пишут так, чтобы оно дословно совпадало с условием запросов, ради которых индекс заводили.