Что первичный ключ обязан обеспечивать: невидимая арифметика хранилища
Первичный ключ — это не просто техническая формальность. Он определяет, как данные будут физически храниться, как быстро к ним можно будет обратиться и насколько эффективно система сможет масштабироваться. Вот что он обязан гарантировать:
- Уникальность без компромиссов: ни одна запись не должна совпадать с другой по ключу, иначе нарушается целостность данных.
- Стабильность на всём жизненном цикле: ключ не должен меняться после создания, иначе все ссылки на запись (внешние ключи, кэши, URL) сломаются.
- Эффективность индексации: ключ напрямую влияет на размер и скорость работы индексов — а значит, на производительность всей базы.
- Локальность хранения: как записи располагаются на диске. Если ключ выбран неудачно, даже простая вставка может превратиться в операцию с несколькими случайными обращениями к диску.
Выбор ключа — это не вопрос вкуса, а инженерное решение, от которого зависит, сколько ресурсов потребуется для хранения и обработки данных.
Автоинкремент: простота, которая работает — пока не перестаёт
Автоинкрементный ключ (INT или BIGINT) — первое, что приходит в голову. Он:
- Компактный: 4 или 8 байт на запись. Для таблицы с миллионом строк это разница между 4 МБ и 16 МБ на индекс.
- Упорядоченный: новые записи получают последовательные значения, что улучшает локальность хранения. Записи, созданные в одно время, физически лежат рядом на диске.
- Дружелюбен к индексам: B-деревья оптимизированы для монотонно возрастающих значений. Вставка в конец дерева требует минимальных операций.
Но у автоинкремента есть фундаментальные ограничения:
- Раскрывает порядок и объём данных: по значению ключа можно оценить, сколько записей уже создано и в каком порядке. Это может быть проблемой для публичных API или систем, где порядок создания не должен быть известен.
- Зависит от базы: ключ генерируется на стороне СУБД, что усложняет слияние данных из нескольких источников. Если у вас есть две базы, которые должны обмениваться данными, автоинкремент заставит вас либо синхронизировать генераторы ключей, либо придумывать обходные пути.
- Проблемы с распределёнными системами: если несколько баз или сервисов генерируют ключи независимо, коллизии неизбежны. Даже если вы используете разные диапазоны (например, первая база генерирует ключи от 1 до 1 000 000, вторая — от 1 000 001), это не масштабируется.
Автоинкремент — хороший выбор для монолитных приложений, где база одна, а данные не нужно объединять с другими источниками. Но как только появляется необходимость в распределённости, он становится источником проблем.
Случайный идентификатор: свобода ценой производительности
Случайные идентификаторы (например, UUID) решают проблемы автоинкремента:
- Генерируются где угодно: можно создать ключ на клиенте, в микросервисе или в очереди сообщений. Это упрощает архитектуру распределённых систем.
- Ничего не раскрывают: по значению UUID нельзя понять, сколько записей существует или в каком порядке они создавались. Это делает его безопасным для публичных API.
- Подходят для распределённых систем: коллизии крайне маловероятны (вероятность коллизии для UUIDv4 — порядка 1 к 2^122).
Но за эти преимущества приходится платить:
- Размер: UUID занимает 16 байт. Для таблицы с миллионом записей это 16 МБ на индекс — в 4 раза больше, чем для
INT, и в 2 раза больше, чем дляBIGINT. - Плохая локальность: случайные значения плохо ложатся в B-деревья. Индекс начинает фрагментироваться, что увеличивает количество операций ввода-вывода при вставках и выборках.
- Раздутые вторичные индексы: если внешний ключ ссылается на UUID, он тоже занимает 16 байт. Для таблицы с миллионами записей это может быть критично: каждый внешний ключ увеличивает размер индекса на 16 МБ.
UUID — хороший выбор для распределённых систем, где данные создаются в разных местах, но не подходит для высоконагруженных таблиц с частыми вставками.
Упорядоченные во времени идентификаторы: компромисс, который не всегда работает
Есть решения, которые пытаются объединить преимущества автоинкремента и UUID. Например:
- ULID (Universally Unique Lexicographically Sortable Identifier): первые 48 бит — временная метка, оставшиеся 80 бит — случайность. Упорядочен по времени, но генерируется где угодно.
- Snowflake ID: 64-битный идентификатор, где часть бит отвечает за время, часть — за идентификатор машины, часть — за порядковый номер.
Преимущества таких идентификаторов:
- Упорядоченность: записи можно сортировать по времени создания, что полезно для аналитики и кэширования.
- Генерация вне базы: подходит для распределённых систем.
- Компактнее UUID: обычно 8–16 байт.
Но у них есть свои недостатки:
- Сложнее в реализации: нужно следить за уникальностью генераторов. Если два сервиса используют один и тот же идентификатор машины, возможны коллизии.
- Раскрывает время создания: если это критично (например, для публичных API), может быть проблемой.
- Не решает проблему размера: 8–16 байт всё ещё больше, чем 4 байта автоинкремента.
Такие идентификаторы — хороший выбор для систем, где важна сортировка по времени, но нужна распределённость. Но если вам не нужна упорядоченность, они могут оказаться избыточными.
Где это заметно: индексы, вставки, внешние ключи
Выбор ключа влияет на несколько аспектов работы базы. Давайте разберём их подробнее.
Объём индексов
- Автоинкремент: индекс компактный, так как значения монотонно возрастают. B-дерево остаётся сбалансированным, и новые записи добавляются в конец.
- UUID: индекс раздувается из-за случайных значений. B-дерево начинает фрагментироваться, и каждая вставка может требовать перебалансировки. Для таблицы с миллионом записей это может означать в 2–3 раза больше операций ввода-вывода.
Скорость вставки пачками
- Автоинкремент: быстрая вставка, так как новые записи добавляются в конец индекса. Это минимизирует количество операций с диском.
- UUID: медленнее, так как случайные значения заставляют индекс часто перебалансироваться. В худшем случае вставка одной записи может потребовать нескольких случайных обращений к диску.
Размер внешних ключей
- Если внешний ключ ссылается на UUID, он тоже занимает 16 байт. Для таблицы с миллионами записей это может быть критично: каждый внешний ключ увеличивает размер индекса на 16 МБ. Для сравнения, если бы ключ был
INT, индекс занял бы всего 4 МБ.
Локальность хранения
- Автоинкремент: записи физически располагаются рядом, что ускоряет последовательные выборки. Например, если вы запрашиваете записи с ключами от 1 до 1000, они, скорее всего, будут лежать на одной странице диска.
- UUID: записи разбросаны по диску, что замедляет работу. Даже простая выборка может потребовать нескольких случайных обращений к диску.
Ключ в адресе страницы: почему это вопрос не только базы
Иногда ключ используется не только внутри базы, но и в URL. Например:
https://example.com/users/123(автоинкремент)https://example.com/users/550e8400-e29b-41d4-a716-446655440000(UUID)
Проблемы с автоинкрементом в URL:
- Раскрывает порядок создания записей. Например, если вы видите URL
users/42, вы можете предположить, что это 42-й зарегистрированный пользователь. - Позволяет перебирать идентификаторы. Например, можно попробовать
users/1,users/2и так далее, чтобы получить список всех пользователей.
Проблемы с UUID в URL:
- Длинные и нечитаемые адреса. Например,
550e8400-e29b-41d4-a716-446655440000сложно запомнить и передать. - Сложнее отлаживать. Если в логах появляется ошибка с UUID, его сложно воспроизвести вручную.
Решения:
- Использовать короткие хеши: например, base64-кодированный UUID. Это сокращает длину URL, но не решает проблему читаемости.
- Добавлять в URL не ключ, а другой уникальный атрибут: например, имя пользователя. Это делает URL более читаемым, но требует дополнительной логики для обработки коллизий (например, если два пользователя захотят зарегистрироваться с одним именем).
Тупик: выбрать по красоте адреса
Первое, что приходит в голову при выборе ключа, — это эстетика. Например:
- “UUID выглядит круче и современнее”.
- “Автоинкремент слишком простой и скучный”.
- “Хочу, чтобы URL были красивыми и короткими”.
Но красота адреса — это не критерий. Если UUID портит производительность, а автоинкремент не подходит для распределённой системы, нужно искать компромисс. Вот что обычно пробуют первым и почему это не работает:
- Использовать UUID везде: кажется, что это решит все проблемы с распределённостью. Но на практике UUID замедляет вставки и увеличивает размер индексов. Для высоконагруженных систем это может стать узким местом.
- Использовать автоинкремент и синхронизировать генераторы ключей: кажется, что это решит проблему слияния данных. Но на практике синхронизация генераторов ключей между несколькими базами — это сложная задача, которая требует дополнительной инфраструктуры и может стать источником ошибок.
- Использовать короткие хеши в URL: кажется, что это сделает адреса красивее. Но на практике хеши всё ещё нечитаемы, и их сложно отлаживать.
В итоге приходится возвращаться к инженерным соображениям: что важнее — производительность, распределённость или читаемость URL? И искать компромисс, который удовлетворит все требования.
Позиция редакции
Мы считаем, что выбор первичного ключа должен основываться на архитектуре системы, а не на личных предпочтениях. Вот как мы рекомендуем подходить к вопросу:
- Монолитное приложение с одной базой? Автоинкремент — лучший выбор. Он компактный, быстрый и простой в реализации. Если вам не нужно объединять данные с другими источниками, нет смысла усложнять систему.
- Распределённая система с несколькими источниками данных? UUID или упорядоченный идентификатор (например, ULID) — единственный вариант. Но будьте готовы к тому, что это увеличит размер индексов и замедлит вставки.
- Высокая нагрузка на вставки? Избегайте UUID. Он замедлит работу и увеличит нагрузку на диск. Если вам нужна распределённость, рассмотрите упорядоченные идентификаторы.
- Ключ используется в URL? Подумайте о коротком хеше или другом уникальном атрибуте (например, имени пользователя). Но не забывайте о проблемах с коллизиями и читаемостью.
Эта позиция неверна, если:
- Ваша система не подходит ни под один из описанных сценариев.
- У вас есть специфические требования (например, юридические ограничения на раскрытие порядка создания записей).
- Ваша нагрузка нетипична (например, вы вставляете данные пачками по миллиону записей, но редко их читаете).
Как проверить свой выбор
Если вы всё ещё не уверены, какой ключ выбрать, проведите эксперимент:
- Создайте тестовую таблицу с автоинкрементным ключом и заполните её миллионом записей.
- Измерьте скорость вставки и размер индексов.
- Повторите эксперимент с UUID.
- Сравните результаты.
Такой подход поможет вам принять взвешенное решение, основанное на данных, а не на предположениях.