Собираем таблицу: строки, индекс, уникальность

база≈ 16 минут

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

Каркас, собранный в прошлых главах, умеет всё, кроме одного: он ничего не помнит. Перезапуск — и заметок нет. Настоящий Postgres в главе тоже будет, целой статьёй, но начинать с него нельзя: в браузере его не запустить, а разбирать по документации то, чего ни разу не видел, — худший способ учить базу. Поэтому сначала таблицу соберём сами: строки, поиск, индекс и уникальность. Сотня строк кода отвечает на три вопроса — почему поиск по индексу не читает лишнего, почему индекс приходится чинить при каждой правке и почему «занята ли почта» обязана решать база, а не ваш запрос перед вставкой. А в конце ровно то же самое покажут два настоящих движка.

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

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

Найти что-нибудь в такой таблице можно единственным способом — просмотреть строки подряд. У этого способа есть цена, и на двенадцати строках она не видна. Чтобы стала видна, в примере стоит счётчик scanned: сколько строк движок прочитал, чтобы ответить.

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

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

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

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

Полный перебор — не ошибка проектирования. Он плох ровно одним: его цена растёт вместе с таблицей, а таблицы всегда растут.

Чтобы не читать лишнего, нужна вторая структура — та, что сразу называет номера нужных строк. Это и есть индекс: словарь, где ключ — значение колонки, а значение — номера строк с этим значением. Словарь с произвольными ключами вы знаете, это Map; ничего другого внутри индекса нет.

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

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

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

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

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

Индекс — не справка о данных, а вторая их копия. Где живут две копии, там всегда один вопрос: кто их сводит.

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

Индекс вынесен в свой класс: у него есть имя, колонка и признак уникальности. Уникальный умеет то, чего не умеет обычный, — отказывать. Отказ у него не абы какой: это DbError с кодом 23505 и именем нарушенного ограничения. Названы они так же, как у драйвера pg: подробностей он кладёт в ошибку больше, но ветвится боевой код именно по этим двум.

Две подробности стоят отдельного взгляда. Первая: проверка уникального индекса пропускает саму правящуюся строку (id !== row.id) — иначе смена города у человека спорила бы с его же почтой. Вторая: вставка сначала спрашивает все индексы и только потом пишет. Порядок наоборот означал бы, что отказ второго индекса оставил строку в первом, и таблица разъехалась бы со своими индексами прямо на отказе.

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

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

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

Дубль почты отказан кодом 23505 и именем users_email_key. Это не выдумка примера: так отвечает Postgres, и его настоящий ответ вы увидите в конце статьи. Имя здесь важнее кода — код один на все уникальности базы, а имя называет ту самую.

Переезд прошёл, и оба индекса починились сами: Казань потеряла жителя, Томск получил. Это и отличает update от правки объекта в карте, с которой начинался прошлый раздел.

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

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

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

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

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

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

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

Значит ли это, что проверку надо убрать? Нет, но держат её не ради надёжности, а ради вежливости: человеку приятнее увидеть «почта занята» прямо в форме, чем после отправки. Гарантия при этом всё равно одна, и это индекс. Убрать придётся другое — уверенность, что проверка что-то гарантирует.

Всякая проверка перед записью — это снимок прошлого. Гарантию даёт только то, что проверяется в сам момент записи.

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

Различает их имя индекса, и проверка, которая смотрит и код, и имя, у вас уже написана. Она смотрит два уровня, потому что библиотека доступа к данным заворачивает ошибку драйвера в свою, а исходную кладёт в cause. Ниже она ловит отказ нашей таблицы — не переписанная, ровно та же.

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

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

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

Последняя строка — про правку. Уникальность спрашивают не только при вставке: попытка поменять почту на чужую отказывает тем же кодом. А вот поправить что угодно в своей строке можно — проверка не считает строку конфликтом с самой собой.

Код отказа общий на всю базу, имя ограничения — своё у каждого. Поэтому ветка сервиса смотрит на имя, а не только на код.

Модель собрана, и то же самое стоит увидеть у настоящих движков — их будет два. Сначала тот, что уже лежит внутри Node и не требует ни сервера, ни установки: node:sqlite. В браузере такого примера не запустить, поэтому дальше идут записанные прогоны — файлы примера, команда и снятый вывод. Сюжет тот же, что в модели: таблица, уникальный индекс, вторая вставка того же названия. Прогноз: по каким полям ошибки здесь придётся ветвиться?

прогон node 26.5.0 · 22 сентября 2026
Таблица node:sqlite, уникальный индекс и вторая вставка того же названия
// Настоящий движок базы без сервера и без установки: node:sqlite лежит в самом Node
import { DatabaseSync } from 'node:sqlite';

const db = new DatabaseSync(':memory:');
db.exec('CREATE TABLE notes (id INTEGER PRIMARY KEY, title TEXT NOT NULL)');
db.exec('CREATE UNIQUE INDEX notes_title_key ON notes (title)');

const insert = db.prepare('INSERT INTO notes (title) VALUES (?)');
console.log('вставлено:', insert.run('купить хлеб'));

try {
  insert.run('купить хлеб');
} catch (err) {
  // печатаем всё, что движок положил в ошибку: поля у каждого свои
  console.log('дубль:', err.code, err.errcode, err.errstr);
  console.log('имя ограничения:', err.constraint);
  console.log(err.message);
}

console.log('в таблице:', db.prepare('SELECT id, title FROM notes ORDER BY id').all());
db.close();
node unique.mjs

Сюжет знакомый, а поля ошибки — нет. Код отказа у SQLite свой, 2067, и лежит он в errcode; общий code говорит только «ошибка SQLite». Имени ограничения нет вовсе: вместо него в тексте таблица и колонка, notes.title. Значит, ветка «эта почта занята» пишется не один раз на все базы — она пишется под конкретный драйвер.

Две мелочи из того же вывода. Строки приезжают объектами без прототипа — console.log честно это подписывает, и от обычного объекта такая строка отличается тем, что у неё нет даже hasOwnProperty. И сам модуль помечен как кандидат в релиз: флага он не требует и предупреждений не печатает, но обещание неизменности у него слабее, чем у остального ядра Node.

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

прогон под базой PostgreSQL 18.4 · node 26.5.0 · 22 сентября 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 users (
  id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  email text NOT NULL,
  phone text NOT NULL,
  nickname text
)`);
// три уникальных индекса на одной таблице: по почте, по телефону и по прозвищу
await client.query('CREATE UNIQUE INDEX users_email_key ON users (email)');
await client.query('CREATE UNIQUE INDEX users_phone_key ON users (phone)');
await client.query('CREATE UNIQUE INDEX users_nickname_key ON users (nickname)');

const people = [
  ['ann@example.com', '+79990000001', null],
  ['ann@example.com', '+79990000002', null],
  ['bob@example.com', '+79990000001', null],
  ['bob@example.com', '+79990000003', null],
];

for (const [email, phone, nickname] of people) {
  try {
    const { rows } = await client.query('INSERT INTO users (email, phone, nickname) VALUES ($1, $2, $3) RETURNING id', [email, phone, nickname]);
    console.log('вставлено:', rows[0].id, email, phone);
  } catch (err) {
    // ветвимся по коду SQLSTATE и имени индекса, а не по тексту: текст зависит от языка сервера
    console.log('отказ:', err.code, err.constraint);
    console.log('  cause:', err.cause);
    console.log('  текст:', err.message);
  }
}

const { rows } = await client.query('SELECT id, email, phone, nickname FROM users ORDER BY id');
console.log('в таблице:', rows);
await client.end();
node unique.mjs

Вот он, ответ, который повторяет наша модель: 23505 и имя индекса в поле constraint. Два разных индекса дали один код и два разных имени — как в примере выше, и текст сообщения у настоящей базы совпал с текстом модели слово в слово.

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

И подробность, которой у модели нет. Строки в таблице — с номерами 1 и 4: номера 2 и 3 достались неудачным вставкам и сгорели. Счётчик IDENTITY живёт вне транзакции — иначе откат обязан был бы вернуть номер назад, а для этого все вставки в таблицу пришлось бы выстроить в очередь. Дырки в номерах — нормальное состояние базы, и тот, кто читает их как «пропавшие записи», ошибается.

Есть в выводе и вторая тихая подробность: у обеих строк nickname: null, а уникальный индекс по прозвищу их не рассорил. В Postgres два NULL в уникальном индексе дублем не считаются: NULL — это «неизвестно», а два неизвестных значения равными не объявишь. Наша модель здесь строже настоящей базы — это первая задача статьи.

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

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

Модель хороша ровно до той черты, где вы знаете, чего в ней нет. Список «чего нет» — часть модели, а не признание в её негодности.

  • Таблица — это карта строк с номерами, которые раздаёт она сама. Единственный способ что-то в ней найти без индекса — прочитать все строки, и полный проход случается даже тогда, когда нужная строка нашлась первой.
  • Индекс — вторая карта, значение колонки в номера строк. Он снимает перебор, но живёт отдельной копией данных: всякая правка мимо него делает его ответ неверным, и заметно это только рядом с перебором.
  • Поэтому пишет в таблицу сама таблица: вставка и правка спрашивают все индексы и чинят все индексы. Вставка сначала спрашивает и только потом пишет — иначе отказ второго индекса оставил бы строку в первом.
  • Проверка «занято?» перед вставкой отвечает на вопрос о прошлом. Два одновременных запроса проходят её оба, и в таблице появляются две строки; уникальный индекс в том же сценарии оставляет одну.
  • Отказ уникальности — это код 23505 и имя нарушенного индекса. Код общий на всю базу, поэтому ветка сервиса смотрит на имя, и смотрит его на двух уровнях: ошибка приезжает или голой, или завёрнутой в cause.
  • У настоящих движков тот же сюжет, но свои поля: у node:sqlite код 2067 в errcode и ни слова об имени ограничения. И своё поведение вокруг: номера, потраченные на неудачные вставки, не возвращаются, а два NULL в уникальном индексе Postgres дублем не считает.

1/4В таблице миллион строк, индекса по нужной колонке нет, и единственная подходящая строка оказалась первой же. Сколько строк прочитает движок?

2/4Сервис перед вставкой спрашивает у базы, занята ли почта; уникального индекса у таблицы нет. Что случится с двумя одновременными регистрациями на один адрес?

3/4У таблицы пользователей два уникальных индекса: по почте и по телефону. Вставка отказала кодом 23505. Как понять, что именно занято?

4/4Строку поправили напрямую в карте строк, мимо метода таблицы. Что станет с индексом по изменённой колонке?

Пустое прозвище — не дубль

Прозвище пользователь может не указывать, и тогда в колонке пусто. Уникальный индекс по прозвищу из-за этого отказывает второму человеку без прозвища — а настоящий Postgres обеих таких строк принимает: NULL не равен ничему, в том числе другому NULL. Приведите check к поведению настоящей базы. Список строк не трогайте.

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

Пустое значение сравнивать не с чем — значит, и спрашивать индекс о нём незачем.

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

Третья строка после правки принимается, четвёртая отказывает по-прежнему: прозвище «ann» занято. В add пустые значения можно оставить как есть — check до них больше не доходит, а лишняя связка в карте никому не мешает.

Это умолчание Postgres, а не единственный возможный выбор: у индекса есть и обратный режим, NULLS NOT DISTINCT, когда пустоты обязаны быть одной на таблицу. Умолчание удивляет ровно до того момента, когда вспоминаешь, что NULL значит «неизвестно»: два неизвестных значения равными не объявишь.

Одно название у двух человек

У заметки есть владелец, и уникальность названия должна действовать внутри одного человека: Аня и Боря вправе завести по заметке «план на неделю», а сама Аня второй такой — нет. Сейчас индекс по названию отказывает Боре. Почините это, не трогая класс Table.

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

Уникальность нужна по паре значений, а индекс у таблицы умеет только одну колонку. Значит, пару нужно положить в колонку — вычисляемую, которую пишет сама вставка.

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

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

Настоящей базе вычисляемая колонка не нужна: там индекс заводится сразу по нескольким колонкам, CREATE UNIQUE INDEX notes_user_title_key ON notes (user_id, title), и ключом индекса становится пара значений, а не строка. Наша склейка — это та же пара, записанная единственным способом, который понимает модель.

Почта, набранная по-разному

Один и тот же человек зарегистрировался трижды: ann@example.com, Ann@Example.com и капсом. Уникальный индекс не возразил ни разу, а войти он потом сможет не всегда. Сделайте так, чтобы вторая и третья попытки отказали.

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

Индекс сравнивает ровно то, что лежит в колонке, и текст для него — последовательность символов, а не почтовый адрес. Значит, приводить адрес к одному виду нужно до того, как он попадёт в колонку.

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

После правки в таблице одна строка, а две попытки получают 23505. Последняя строка вывода не меняется — «1», — но значит теперь другое: до правки поиск по ann@example.com доставал только первую из трёх записей одного человека, а две другие лежали в таблице под своими написаниями и в этот ответ не попадали.

Нормализация на границе — общее правило, а не приём для почты: значение приводится к одному виду там, где входит в систему, и дальше везде лежит одинаковым. Альтернатива у настоящей базы есть — индекс не по колонке, а по выражению lower(email), — но она лечит только сравнение, а в таблице по-прежнему будут три написания одного адреса, и любой отчёт это увидит.