Настоящий Postgres: типы, ограничения, EXPLAIN и 23505

база≈ 19 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Три статьи подряд таблица жила внутри вашей программы: номера строк раздавали вы, индексы чинили вы, 23505 бросали тоже вы. Теперь на её место встаёт настоящий Postgres — и первое, что меняется, это расстояние. База больше не объект в том же процессе, а отдельная программа на другом конце сокета: у её колонок есть типы, у таблицы — собственные правила, у запроса — план, который выбирает не ваш код, а она сама, и даже строка внутри неё не одна, а стопка версий. Разберём эти четыре отличия по очереди: сначала модель, которую можно запустить прямо на странице, а следом записанный прогон — тот же сюжет на настоящей базе, снятый настоящим node.

Разделы темы:·

Наша таблица хранит строки обычными объектами, а объект в JavaScript не спрашивает, что кладут в поле. Пока значения приходили из одного места, это сходило с рук. Стоит появиться второму — форме, из которой всё приезжает строками, — и в одной колонке заводятся два вида одного и того же значения.

Прогноз: на колонке views стоит уникальный индекс. Первая заметка кладёт туда число 9, вторая — строку "9" из формы, третья просмотров не прислала вовсе. Сколько строк окажется в таблице, что напечатает сумма просмотров и сколько строк найдёт индекс по 9 и по "9"?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Уникальный индекс пропустил обе девятки: в его карте 9 и "9" — разные ключи, и по каждому лежит своя строка. Третья заметка записалась без колонки вовсе. Сумма просмотров сложилась по правилам JavaScript: 0 + 9 дало число, число плюс строка склеилось в "99", а undefined дописал к нему своё имя. Ни одна из трёх бед не пожаловалась — ровно тот случай, ради которого мы всю главу заводим правила в таблице, а не в обработчике.

В Postgres колонка объявляется типом: views integer. Тип — не подпись о том, что здесь лежит, а правило приведения, которое выполняется до записи: значение либо приводится к типу колонки, либо не записывается вовсе, и отказ приходит кодом. Добавим это правило в модель и посмотрим, что изменится.

Прогноз: те же три попытки и четвёртая — вставка в колонку author, которой в схеме нет. Какие из четырёх пройдут и сколько строк останется в таблице?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Строка "9" привелась к числу — и тут же столкнулась с уникальным индексом, потому что девятка в таблице уже была: 23505. Слово «много» к целому не приводится никак — 22P02, ошибка разбора значения. Колонки author в схеме нет, и это 42703: писать некуда. Из четырёх попыток прошла одна, и строк в таблице осталось столько же.

Тип колонки — самое дешёвое правило из всех: оно не знает ни про предметную область, ни про пользователя, и всё равно не пускает в неё то, что колонкой не является.

Приведение работает в одну сторону — на запись. Обратно значения едут по соединению текстом, и во что их превратить, решает драйвер: пакет pg, через который с базой разговаривает Node. Часть типов он разбирает, часть оставляет как приехало — и выбор этот не произвольный.

Прогноз: в таблице колонки bigint, integer, numeric(10, 2), boolean, timestamptz и jsonb, а запрос ещё и считает строки через count(*). Какие значения приедут в JavaScript числами, а какие — нет?

прогон под базой PostgreSQL 18.4 · node 26.5.0 · 23 сентября 2026
Одна строка настоящего 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,
  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();
node types.mjs

Четыре типа из шести драйвер разобрал: 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, курсор — самый большой номер страницы. Какой курсор уедет на телефон и что придёт ему следующей выдачей?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Курсором стало 9 — тем же посимвольным сравнением. Клиент запомнил его как «всё до девятки получено», сервер считает номера числами и честно отдаёт всё, что больше девяти, и правка c приезжает второй раз — телефон применяет уже применённое. Потерять строку такой курсор не может (он всегда меньше настоящего максимума), а вот возить лишнее и путать порядок — сколько угодно. Стоит разобрать номер в число на границе чтения — и курсор, и порядок, и выдача становятся такими, какими задумывались.

Это же место ломает и правило конфликтов: сравнение правок по отметке at написано как a.at > b.at, и строки разной длины дают в нём неверный ответ. Отсюда правило: значения, приехавшие из базы, разбираются один раз, в одном месте — там, где строка таблицы превращается в объект предметной области. В схеме на Drizzle такое место объявляется прямо у колонки, режимом её типа.

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

Правило «заголовок непустой, состояние — одно из двух» обычно пишут там, где его впервые понадобилось проверить: в обработчике редактора. Живёт оно там ровно до того дня, когда в таблицу понадобится записать что-то помимо редактора.

Прогноз: заметки пишет ещё и перенос из старого приложения — код, написанный на полгода позже и про проверки редактора не знающий. Что окажется в таблице после трёх попыток редактора и одного переноса?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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

Дверей к таблице всегда больше, чем кажется: перенос, разовый скрипт, ручной UPDATE в консоли базы, вторая версия API рядом с первой. Правило, которое обязано выполняться всегда, переезжает в саму таблицу: NOT NULL у колонки и CHECK с условием у таблицы. Тогда дверь только одна, и проверка стоит именно в ней.

Второе правило сложнее: адрес заметки уникален у автора, но только пока заметка жива. Выброшенная заметка адрес освобождает. Обычный уникальный индекс так не умеет — ему нужно условие, по которому часть строк под его надзор не попадает.

Прогноз: шесть действий подряд — две заметки с одним адресом у одного автора, такая же у другого, удаление первой, новая заметка на освободившийся адрес и попытка вернуть удалённую. Какие из них пройдут?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Адрес выброшенной заметки достался новой — условие индекса на удалённые строки не распространяется. А вот вернуть выброшенную уже нельзя: её адрес занят, и «отмена удаления» упирается в ту же уникальность, что и обычная вставка. Это не недосмотр модели, а настоящее поведение: восстановление придётся либо предлагать с новым адресом, либо запрещать честной ошибкой.

В Postgres обе эти вещи пишутся в самой таблице — и отвечают кодами, а не текстом.

прогон под базой PostgreSQL 18.4 · node 26.5.0 · 23 сентября 2026
Девять попыток записать заметку в настоящую таблицу: CHECK, NOT NULL, неверный тип и уникальность с условием
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();
node constraints.mjs

Каждому отказу — свой код SQLSTATE и своё имя. 23514 называет нарушенное CHECK-ограничение, 23502 — колонку, у которой не должно быть NULL, 23505 — индекс уникальности; у 22P02 имени нет вовсе, потому что до строки дело не дошло: сорвалось приведение значения. Рядом с кодом приезжает detail: у нарушенной проверки — та самая строка, которая не прошла, целиком, у дубля — ключ, который уже занят.

Дырки в номерах вы уже видели у первой настоящей таблицы: первая заметка получила #1, а следующая удачная — сразу #6, четыре номера сгорели на неудачных попытках. Новое здесь то, что сгорают не все: номер уцелел у той неудачи, где сорвалось приведение значения. До построения строки дело там не дошло, а значит, номера никто и не просил.

Поля code и constraint, по которым ветвится пример, — те же самые, что собирались руками в статье про доменные ошибки. Теперь они настоящие: драйвер раскладывает ответ сервера по семнадцати полям — от message и code до file и routine в исходниках самого сервера, — а боевому коду из них нужны ровно два.

Обработчик проверяет то, что видит. Таблица проверяет всё, что в неё пишут, — включая то, о чём обработчик никогда не узнает.

К базе ходит не только приложение. В комплекте с Postgres идёт psql — консольный клиент, которым смотрят, что в базе на самом деле лежит, и которым выполняют разовые вещи вроде переноса или починки. Тот же файл с таблицей можно скормить ему целиком.

Прогноз: файл создаёт таблицу с двумя CHECK и уникальным индексом с условием, печатает её командой \d и делает три вставки, из которых две нарушают ограничения. Что покажет \d, как psql расскажет об отказах и с каким кодом он завершится?

запись с машины: psql 18.4 к PostgreSQL 18.4 (Homebrew) с локалью ru_RU.UTF-8, 23.09.2026 · 23 сентября 2026
Файл с DDL, командой \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;
psql -d notes -f session.sql

Команда \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 — двадцать заметок, черновиков — почти все. Про колонку, о которой ничего не известно, планировщик берёт долю по умолчанию. Что он выберет для автора и для черновиков до пересчёта статистики и после него?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

До пересчёта обе колонки выглядят одинаково избирательными — доля по умолчанию мала, и индекс кажется выгодным в обоих случаях. После пересчёта картина расходится: по автору индекс оставляет двадцать строк из двухсот и остаётся полезным, а по состоянию он вернул бы почти всю таблицу, и перебор дешевле. Индекс проигрывает не потому, что плох, а потому, что строк под условие подходит слишком много: читать их через индекс — это читать и индекс, и таблицу.

Настоящий план печатает EXPLAIN. Флаг COSTS OFF убирает из него оценки стоимости — те самые числа, которые меняются от статистики и от машины; остаётся дерево узлов, а оно про суть. Пересчёт статистики в Postgres называется ANALYZE; обычно его делает сам сервер, но на только что залитой таблице его стоит позвать руками.

прогон под базой PostgreSQL 18.4 · node 26.5.0 · 23 сентября 2026
Один запрос к 20 000 строк: без индекса, с двумя разными индексами, после 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();
node explain.mjs

Без индексов — 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.

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

Когда мы собирали таблицу, в списке «чего у модели нет» стояли видимость чужих правок до фиксации и уровни изоляции. Дошли до них. Наш откат отменял уже записанное, и соседний читатель успевал увидеть полуфабрикат. Настоящая база устроена иначе: она не отменяет, а не показывает.

Приём называется многоверсионностью. Строка в ней — не ячейка, которую переписывают, а список версий: у каждой номер транзакции, которая её создала, и номер той, которая её заменила. Транзакция, начинаясь, запоминает номер — свой снимок — и читает только те версии, которые в него попадают.

Прогноз: читатель открыл снимок и прочитал строку. Пока он не закрылся, писатель заменил значение. Что увидит читатель во второй раз, что увидит тот, кто пришёл после писателя, и сколько версий строки будет лежать в таблице?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Читатель видит своё и после чужой записи: его снимок старее новой версии. Тот, кто пришёл позже, видит новую. Версий в таблице стало две, и выбросить старую нельзя, пока на неё смотрит хоть один снимок, — только когда последний такой читатель уйдёт, она превращается в мусор. Именно его и убирает VACUUM, а «раздувшаяся таблица» на боевом сервере — это почти всегда долгая транзакция, которая держит старые версии живыми.

Теперь то же на настоящей базе, двумя соединениями: одно правит, другое читает. Третья сцена интереснее первых двух — там читатель не просто читает, а пробует записать.

Прогноз: сначала A правит строку и не фиксирует — что видит B? Потом B читает строку дважды в одной транзакции, а между чтениями A фиксирует правку — совпадут ли чтения? И то же самое на уровне repeatable read, где B после двух чтений пробует записать своё.

прогон под базой PostgreSQL 18.4 · node 26.5.0 · 23 сентября 2026
Два соединения к одной базе: незафиксированная правка, два чтения подряд и запись на repeatable read
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();
node isolation.mjs

Незафиксированное не видно никому, кроме самой транзакции, — обещание статьи про откат закрыто. Дальше начинается выбор. Уровень по умолчанию, 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 и именем нарушенного ограничения. Проверять надо до вставки, а не после неё.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ограничения проверяются по очереди и до записи: строка, не прошедшая хотя бы одно, в таблицу не попадает вовсе. Имя нарушенного ограничения едет в ошибку — именно по нему сервис потом различает, какое из правил не сошлось.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Строку для проверки собираем до вставки и с заведомо неверным id: номер ещё не выдан, а условиям он и не нужен. После правки редактор запишет только первую заметку, а перенос получит 23514 notes_status_known — тот самый код, который вы видели у настоящего Postgres.

Курсор приехал строкой

Страница изменений приехала из базы так, как её отдал драйвер: номер seq объявлен в колонке как bigint и приехал строкой. Контракт синхронизации обещает клиенту number, но pullPage отдаёт строки как есть, а приведением занимается только утверждение типа.

Почините границу чтения: строки базы должны превращаться в объекты контракта в одном месте. Курсор после этого обязан стать числом 10, порядок — 8, 9, 10, а следующая выдача с этим курсором — пустой.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Утверждение типа as ничего не приводит — оно только заставляет компилятор замолчать. Разбор пишется руками, по одному полю, и там же, где строка базы становится объектом:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Одной этой функции хватает на все три беды сразу: курсор считается по числам, сортировка сравнивает числа, и выдача с курсором 10 оказывается пустой. Обратите внимание, что делать это надо именно на границе: разбор в каждом месте, где номер сравнивают, — это тот же as, только многословнее.

Индекс, который не подходит запросу

Частичный индекс хранит только опубликованные заметки. Планировщик в примере берёт его для любого запроса, не спрашивая, укладывается ли условие запроса в условие индекса, — и два запроса из трёх получают неполный ответ.

Научите run проверять пригодность: индекс годится, только если каждая строка, подходящая запросу, попадает в индекс. Непригодный индекс — повод читать таблицу целиком. Печатайте рядом с числом строк, чем запрос обслужен.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Проверка у пригодности простая: не найдётся ли в таблице строка, которая запросу подходит, а в индекс не попала. Нашлась — индекс отвечает не на тот вопрос:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь status = published обслуживается индексом, а status <> draft и запрос без условия уходят в перебор и дают 3 и 4 строки. Настоящий планировщик делает ровно это, только вместо перебора строк доказывает вложенность условий по их тексту — и именно поэтому условие частичного индекса пишут так, чтобы оно дословно совпадало с условием запросов, ради которых индекс заводили.