Собираем таблицу: строки, индекс, уникальность
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Каркас, собранный в прошлых главах, умеет всё, кроме одного: он ничего не помнит. Перезапуск — и заметок нет. Настоящий Postgres в главе тоже будет, целой статьёй, но начинать с него нельзя: в браузере его не запустить, а разбирать по документации то, чего ни разу не видел, — худший способ учить базу. Поэтому сначала таблицу соберём сами: строки, поиск, индекс и уникальность. Сотня строк кода отвечает на три вопроса — почему поиск по индексу не читает лишнего, почему индекс приходится чинить при каждой правке и почему «занята ли почта» обязана решать база, а не ваш запрос перед вставкой. А в конце ровно то же самое покажут два настоящих движка.
База данных внутри — не магия, а две структуры: строки и способ их найти. Начнём с первой. Таблица — это карта: ключ — номер строки, значение — сама строка. Номер раздаёт счётчик таблицы, а не тот, кто вставляет: два клиента, придумавших номер одновременно, придумали бы один и тот же.
Найти что-нибудь в такой таблице можно единственным способом — просмотреть строки подряд. У этого способа есть цена, и на двенадцати строках она не видна. Чтобы стала видна, в примере стоит счётчик scanned: сколько строк движок прочитал, чтобы ответить.
Прогноз: три запроса — по почте, которая в таблице есть, по городу, где живут четверо, и по почте, которой нет вовсе. Сколько строк прочитает каждый?
Двенадцать, двенадцать и двенадцать. Перебор платит полным проходом всегда. Даже когда нужная строка нашлась первой: движок не знает, что второй такой почты дальше нет, — это ему никто не обещал. Даже когда не нашлось ничего: сказать «нет» можно, только досмотрев до конца.
В настоящей базе это называется последовательным просмотром, и на двенадцати строках он как раз самый быстрый способ: читать подряд дёшево, а любая хитрость стоит дороже самой работы. Беда начинается на миллионе строк — и особенно на миллионе, умноженном на сотню запросов в секунду.
Полный перебор — не ошибка проектирования. Он плох ровно одним: его цена растёт вместе с таблицей, а таблицы всегда растут.
Чтобы не читать лишнего, нужна вторая структура — та, что сразу называет номера нужных строк. Это и есть индекс: словарь, где ключ — значение колонки, а значение — номера строк с этим значением. Словарь с произвольными ключами вы знаете, это Map; ничего другого внутри индекса нет.
У второй карты есть цена, и платится она не памятью. Индекс — копия части данных, а две копии расходятся от любой правки мимо одной из них. Ниже строка переезжает в другой город прямо в карте строк, и индекс об этом не узнаёт.
Прогноз: после переезда жителей Томска и Казани считают дважды — по индексу и перебором. Сойдутся ли ответы, и если нет, то в какую сторону разойдутся?
Индекс недосчитал томичей и приписал лишнего Казани: строка уехала, а её номер остался лежать в прежней связке. Ошибку при этом видно только рядом с перебором — само по себе число из индекса выглядит как обычный ответ.
Вывод жёсткий: писать в таблицу мимо индексов нельзя. Значит, писать должна сама таблица — единственное место, которое знает про все свои индексы сразу. Так устроена и настоящая база, и отсюда же её нелюбовь к лишним индексам: одна правка колонки — это правка каждого индекса, который эту колонку включает.
Индекс — не справка о данных, а вторая их копия. Где живут две копии, там всегда один вопрос: кто их сводит.
Соберём таблицу целиком. Внутри у неё три вещи: карта строк, счётчик номеров и список индексов. Снаружи — вставка, правка, удаление, поиск по индексу и полный перебор. Сто с небольшим строк, и дальше вся глава пишет поверх них.
Индекс вынесен в свой класс: у него есть имя, колонка и признак уникальности. Уникальный умеет то, чего не умеет обычный, — отказывать. Отказ у него не абы какой: это DbError с кодом 23505 и именем нарушенного ограничения. Названы они так же, как у драйвера pg: подробностей он кладёт в ошибку больше, но ветвится боевой код именно по этим двум.
Две подробности стоят отдельного взгляда. Первая: проверка уникального индекса пропускает саму правящуюся строку (id !== row.id) — иначе смена города у человека спорила бы с его же почтой. Вторая: вставка сначала спрашивает все индексы и только потом пишет. Порядок наоборот означал бы, что отказ второго индекса оставил строку в первом, и таблица разъехалась бы со своими индексами прямо на отказе.
Прогноз: тринадцать строк, поиск по индексу города и тот же поиск перебором, попытка вставить вторую строку с занятой почтой и переезд одного человека. Сколько строк прочитает каждый из двух поисков и чем ответит вставка дубля?
Ответ у двух поисков один, а прочитано — ноль строк против тринадцати. Ноль здесь не подтасовка: счётчик считает просмотренные строки таблицы, а индекс сразу называет номера нужных, и заглядывать в чужие ему незачем.
Дубль почты отказан кодом 23505 и именем users_email_key. Это не выдумка примера: так отвечает Postgres, и его настоящий ответ вы увидите в конце статьи. Имя здесь важнее кода — код один на все уникальности базы, а имя называет ту самую.
Переезд прошёл, и оба индекса починились сами: Казань потеряла жителя, Томск получил. Это и отличает update от правки объекта в карте, с которой начинался прошлый раздел.
Отказ базы — не сбой программы, а ответ на заданный вопрос. Вопрос был «можно ли вставить», ответ — «нет, и вот по какому ограничению».
Теперь главный вопрос статьи: зачем вообще уникальный индекс, если занятость можно спросить перед вставкой? Проверка выглядит честной — поискать почту, и если строка нашлась, отказать, не доводя дело до ошибки.
Что так делать не надо, вы уже читали: занятость определяет база, а не отдельный запрос. Там речь шла о том, во что превращается отказ по дороге к клиенту. Здесь видно другое — что при этом происходит с данными.
Прогноз: две одинаковые регистрации по очереди, две одновременно в таблице без уникального индекса и две одновременно в таблице с ним. Сколько строк останется в каждой таблице?
По очереди проверка справилась. Разом — нет: обе регистрации увидели пустую таблицу, потому что обе успели спросить раньше, чем первая вставила. В таблице две строки с одной почтой, и ни одна из них не «ошибка»: каждая прошла проверку честно, и никакой записи в журнале об этом не останется.
С уникальным индексом тот же сценарий кончается отказом, а строка остаётся одна. Разница не в качестве кода, а в том, кто отвечает на вопрос. Проверка отвечает на вопрос «было ли занято в момент запроса», а нужен ответ на вопрос «занято ли в момент вставки» — и дать его может только тот, кто вставляет.
Значит ли это, что проверку надо убрать? Нет, но держат её не ради надёжности, а ради вежливости: человеку приятнее увидеть «почта занята» прямо в форме, чем после отправки. Гарантия при этом всё равно одна, и это индекс. Убрать придётся другое — уверенность, что проверка что-то гарантирует.
Всякая проверка перед записью — это снимок прошлого. Гарантию даёт только то, что проверяется в сам момент записи.
Отказ приезжает в сервис одним и тем же кодом, сколько бы уникальных индексов в таблице ни было. У пользователя их обычно два — почта и телефон, — и путать их нельзя: «почта занята» и «номер занят» это разные подписи и разные действия человека дальше.
Различает их имя индекса, и проверка, которая смотрит и код, и имя, у вас уже написана. Она смотрит два уровня, потому что библиотека доступа к данным заворачивает ошибку драйвера в свою, а исходную кладёт в cause. Ниже она ловит отказ нашей таблицы — не переписанная, ровно та же.
Прогноз: три регистрации и одна правка. Что вернёт каждая — и узнает ли проверка ошибку, завёрнутую в чужую?
Один и тот же 23505 превратился в два разных доменных кода: различило их имя индекса. Завёрнутая ошибка узналась тоже — снаружи у неё кода нет вовсе, он лежит уровнем ниже.
Последняя строка — про правку. Уникальность спрашивают не только при вставке: попытка поменять почту на чужую отказывает тем же кодом. А вот поправить что угодно в своей строке можно — проверка не считает строку конфликтом с самой собой.
Код отказа общий на всю базу, имя ограничения — своё у каждого. Поэтому ветка сервиса смотрит на имя, а не только на код.
Модель собрана, и то же самое стоит увидеть у настоящих движков — их будет два. Сначала тот, что уже лежит внутри Node и не требует ни сервера, ни установки: 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();
вставлено: { changes: 1, lastInsertRowid: 1 }
дубль: ERR_SQLITE_ERROR 2067 constraint failed
имя ограничения: undefined
UNIQUE constraint failed: notes.title
в таблице: [ [Object: null prototype] { id: 1, title: 'купить хлеб' } ]Сюжет знакомый, а поля ошибки — нет. Код отказа у SQLite свой, 2067, и лежит он в errcode; общий code говорит только «ошибка SQLite». Имени ограничения нет вовсе: вместо него в тексте таблица и колонка, notes.title. Значит, ветка «эта почта занята» пишется не один раз на все базы — она пишется под конкретный драйвер.
Две мелочи из того же вывода. Строки приезжают объектами без прототипа — console.log честно это подписывает, и от обычного объекта такая строка отличается тем, что у неё нет даже hasOwnProperty. И сам модуль помечен как кандидат в релиз: флага он не требует и предупреждений не печатает, но обещание неизменности у него слабее, чем у остального ядра Node.
Теперь 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();
база: postgres://postgres@127.0.0.1:<порт-базы>/app
вставлено: 1 ann@example.com +79990000001
отказ: 23505 users_email_key
cause: undefined
текст: duplicate key value violates unique constraint "users_email_key"
отказ: 23505 users_phone_key
cause: undefined
текст: duplicate key value violates unique constraint "users_phone_key"
вставлено: 4 bob@example.com +79990000003
в таблице: [
{
id: 1,
email: 'ann@example.com',
phone: '+79990000001',
nickname: null
},
{
id: 4,
email: 'bob@example.com',
phone: '+79990000003',
nickname: null
}
]Вот он, ответ, который повторяет наша модель: 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 к поведению настоящей базы. Список строк не трогайте.
Пустое значение сравнивать не с чем — значит, и спрашивать индекс о нём незачем.
Третья строка после правки принимается, четвёртая отказывает по-прежнему: прозвище «ann» занято. В add пустые значения можно оставить как есть — check до них больше не доходит, а лишняя связка в карте никому не мешает.
Это умолчание Postgres, а не единственный возможный выбор: у индекса есть и обратный режим, NULLS NOT DISTINCT, когда пустоты обязаны быть одной на таблицу. Умолчание удивляет ровно до того момента, когда вспоминаешь, что NULL значит «неизвестно»: два неизвестных значения равными не объявишь.
Одно название у двух человек
У заметки есть владелец, и уникальность названия должна действовать внутри одного человека: Аня и Боря вправе завести по заметке «план на неделю», а сама Аня второй такой — нет. Сейчас индекс по названию отказывает Боре. Почините это, не трогая класс Table.
Уникальность нужна по паре значений, а индекс у таблицы умеет только одну колонку. Значит, пару нужно положить в колонку — вычисляемую, которую пишет сама вставка.
Теперь Боря заметку заводит, Аня вторую такую же — нет, и в таблице две строки. Склейка через двоеточие здесь безопасна ровно потому, что номер владельца — число: двоеточию в нём взяться неоткуда, и разные пары одинаковым ключом не станут. Со склейкой двух текстовых колонок так уже не повезёт.
Настоящей базе вычисляемая колонка не нужна: там индекс заводится сразу по нескольким колонкам, CREATE UNIQUE INDEX notes_user_title_key ON notes (user_id, title), и ключом индекса становится пара значений, а не строка. Наша склейка — это та же пара, записанная единственным способом, который понимает модель.
Почта, набранная по-разному
Один и тот же человек зарегистрировался трижды: ann@example.com, Ann@Example.com и капсом. Уникальный индекс не возразил ни разу, а войти он потом сможет не всегда. Сделайте так, чтобы вторая и третья попытки отказали.
Индекс сравнивает ровно то, что лежит в колонке, и текст для него — последовательность символов, а не почтовый адрес. Значит, приводить адрес к одному виду нужно до того, как он попадёт в колонку.
После правки в таблице одна строка, а две попытки получают 23505. Последняя строка вывода не меняется — «1», — но значит теперь другое: до правки поиск по ann@example.com доставал только первую из трёх записей одного человека, а две другие лежали в таблице под своими написаниями и в этот ответ не попадали.
Нормализация на границе — общее правило, а не приём для почты: значение приводится к одному виду там, где входит в систему, и дальше везде лежит одинаковым. Альтернатива у настоящей базы есть — индекс не по колонке, а по выражению lower(email), — но она лечит только сравнение, а в таблице по-прежнему будут три написания одного адреса, и любой отчёт это увидит.