UUID vs ULID vs serial IDs: узнайте, как выбор ID влияет на индексацию, сортировку, шардирование и безопасный экспорт/импорт данных в реальных проектах.

Выбор ID кажется скучным на первой неделе. Потом вы релизите, данные растут, и это «простое» решение проявляется повсюду: индексы, URL, логи, экспорты и интеграции.
Настоящий вопрос не «что лучше?», а «какую боль я хочу избежать позже?» ID сложно менять, потому что они копируются в другие таблицы, кэшируются клиентами и зависят в других системах.
Когда ID не соответствует развитию продукта, это обычно видно в нескольких местах:
Всегда есть компромисс между удобством сейчас и гибкостью позже. Последовательные целые числа легко читать и они часто быстры, но раскрывают количество записей и усложняют слияния наборов данных. Случайные UUID хороши для уникальности между системами, но они тяжёлее для индексов и неудобны для человека. ULID стремится дать глобальную уникальность с приблизительным упорядочиванием по времени, но у него тоже есть компромиссы по хранению и инструментам.
Полезный способ подумать: для кого в основном этот ID?
Если ID в основном для людей (саппорт, отладка, опс), выигрывают короче и проще для чтения варианты. Если для машин (распределённые записи, оффлайн-клиенты, мульти-региональные системы), важнее глобальная уникальность и избежание коллизий.
Когда люди спорят "UUID vs ULID vs serial IDs", на самом деле они выбирают, как каждой строке присвоить уникальную метку. Эта метка влияет на то, насколько легко вставлять, сортировать, сливать и перемещать данные позже.
Serial ID — это счётчик. База даёт 1, затем 2, затем 3 и так далее (обычно как integer или bigint). Его легко читать, дешево хранить и обычно быстро, потому что новые строки попадают в конец индекса.
UUID — 128-битный идентификатор, который выглядит случайным, вроде 3f8a.... В большинстве настроек его можно генерировать без обращения к базе, поэтому разные системы могут создавать ID независимо. Компромисс в том, что вставки с "случайным" видом могут сильнее нагружать индексы и занимать больше места, чем простой bigint.
ULID тоже 128-битный, но спроектирован так, чтобы быть приблизительно упорядоченным по времени. Новые ULID обычно сортируются позже старых, оставаясь глобально уникальными. Вы часто получаете часть преимуществ "генерации в любом месте" как у UUID, но с более дружелюбным поведением при сортировке.
Короткое резюме:
Serial ID распространены в приложениях с одной базой и внутренних инструментах. UUID появляются, когда данные создаются в нескольких сервисах, на устройствах или в регионах. ULID популярен, когда команда хочет распределённую генерацию ID, но заботится о порядке сортировки, пагинации или "последние сверху" запросах.
Первичный ключ обычно поддерживается индексом (часто B-tree). Думайте об этом индексе как о сортированной телефонной книге: каждой новой строке нужно место в нужном порядке, чтобы выдача оставалась быстрой.
С случайными ID (классический UUIDv4) новые записи попадают по всему индексу. Это значит, что база трогает много страниц индекса, чаще делает split-ы страниц и выполняет дополнительные записи. Со временем вы получаете больший "хаос" в индексе: больше работы на вставку, больше пропусков кэша и большие индексы.
С преимущественно возрастающими ID (serial/bigint или ID, упорядоченные по времени, как многие ULID) база обычно может дописывать новые записи ближе к концу индекса. Это более кэш-дружелюбно: недавние страницы остаются горячими, и вставки идут ровнее при высоких нагрузках записи.
Размер ключа важен, потому что записи индекса не бесплатны:
Больше ключи означают меньше записей на страницу индекса. Часто это приводит к более глубокой структуре индекса, большему числу прочитанных страниц на запрос и большему объёму RAM, необходимому для высокой скорости.
Если у вас есть таблица событий с постоянными вставками, первичный ключ в виде случайного UUID может начать ощущаться медленнее, чем bigint, даже если одиночные выборки по строке всё ещё быстрые. При ожидаемой высокой записи стоимость индексации — обычно первое заметное отличие.
Если вы делали "Загрузить ещё" или бесконечную прокрутку, вы уже сталкивались с проблемой ID, которые плохо сортируются. ID "хорошо сортируется", когда порядок по нему даёт стабильную, осмысленную последовательность (часто по времени создания), чтобы пагинация была предсказуемой.
Со случайными ID (как UUIDv4) новые строки разбросаны. Сортировка по id не будет соответствовать времени, и курсорная пагинация вроде "дай элементы после этого id" становится ненадёжной. Обычно вы переходите на created_at, что нормально, но нужно делать аккуратно.
ULID спроектирован так, чтобы быть приблизительно упорядоченным по времени. Если вы сортируете по ULID (как по строке или в бинарном виде), новые элементы, как правило, идут позже старых. Это упрощает курсорную пагинацию, потому что курсором может быть последний увиденный ULID.
ULID помогает с естественным временным порядком для лент, упрощает курсоры и уменьшает "случайную" нагрузку вставок по сравнению с UUIDv4.
Но ULID не гарантирует идеального временного порядка, когда много ID генерируется в одну и ту же миллисекунду на разных машинах. Если вам нужен точный порядок, всё равно лучше полагаться на реальную метку времени.
created_at всё ещё лучшеСортировка по created_at часто безопаснее при бэфиллах, импортах исторических записей или когда нужно ясное разрешение совпадений.
Практический паттерн — сортировать по (created_at, id), где id выступает только тай-брейкером.
Шардирование — это разделение одной базы на несколько меньших, чтобы каждая шарда хранила часть данных. Команды обычно делают это позже, когда одна база уже тяжело масштабируется или представляет риск единой точки отказа.
Выбор ID может сделать шардирование управляемым или болезненным.
С последовательными ID (автоинкремент serial или bigint) каждая шард будет счастливо генерировать 1, 2, 3.... Один и тот же ID может существовать на разных шардах. Когда придёт время сливать данные, перемещать строки или строить кросс-шардовые фичи, вы получите коллизии.
Избежать коллизий можно с координацией (центральный сервис ID или выделенные диапазоны на каждую шард), но это добавляет составные части и может стать узким местом.
UUID и ULID уменьшают необходимость координации, потому что каждая шард может генерировать ID независимо с крайне низким риском дубликатов. Если вы думаете о разделении данных между базами, это один из сильнейших аргументов против чистых последовательностей.
Популярный компромисс — добавить префикс шарда и использовать локальную последовательность на каждой шарде. Можно хранить как две колонки или упаковать в одно значение.
Это работает, но создаёт кастомный формат ID. Каждая интеграция должна его понимать, сортировка перестаёт отражать глобальный временной порядок без дополнительной логики, и перемещение данных между шардами может требовать переписывания ID (что ломает ссылки, если ID используются в других местах).
Задайте один вопрос рано: нужно ли вам когда-нибудь объединять данные из нескольких баз и сохранять стабильные ссылки? Если да, планируйте глобально уникальные ID с самого начала или выделите бюджет на миграцию позже.
Экспорт/импорт — это то место, где выбор ID перестаёт быть теорией. Как только вы клонируете прод в стейдж, восстанавливаете бэкап или сливаете данные из двух систем, вы узнаете, стабильны ли ваши ID и пригодны ли для переноса.
С serial (автоинкремент) вы обычно не сможете безопасно воспроизвести вставки в другую базу и сохранить ссылки, если не сохранить оригинальные номера. Если вы импортируете только подмножество строк (скажем, 200 клиентов и их заказы), нужно загружать таблицы в правильном порядке и сохранять первичные ключи. Любая перенумерация ломает внешние ключи.
UUID и ULID генерируются вне последовательности базы, поэтому их проще переносить между средами. Вы можете копировать строки, сохранять ID, и отношения останутся корректными. Это удобно при восстановлении бэкапов, частичных экспортов или слияниях.
Пример: экспортируете 50 аккаунтов из продакшена для отладки в стейдже. С UUID/ULID первичными ключами вы можете импортировать аккаунты и связанные строки (проекты, инвойсы, логи) и всё будет ссылаться на правильных родителей. С serial ID обычно придётся строить таблицу соответствий (old_id -> new_id) и переписывать внешние ключи при импорте.
Для массовых импортов базовые практики важнее типа ID:
Можно принять разумное решение быстро, если сосредоточиться на том, что будет болеть позже.
Запишите основные риски на будущее. Конкретные события помогают: разделение на несколько баз, слияние данных из другой системы, оффлайн-записи, частые копии между окружениями.
Решите, должен ли порядок ID соответствовать времени. Если вы хотите "последние сверху" без дополнительных колонок, ULID (или другой time-sortable ID) хорош. Если устраивает сортировка по created_at, то подойдут UUID и serial.
Оцените объём записей и чувствительность индекса. При интенсивных вставках и сильно нагруженном первичном индексе BIGINT обычно легче для B-tree. Случайные UUID повышают churn.
Выберите дефолт и задокументируйте исключения. Держите всё просто: один дефолт для большинства таблиц и чёткое правило, когда отклоняться (часто: публичные ID vs внутренние ID).
Оставьте возможность поменять. Не кодируйте смысл в ID, решите где генерируются ID (БД или приложение) и держите ограничения явными.
Самая большая ловушка — выбрать ID, потому что он популярен, и обнаружить, что он конфликтует с тем, как вы делаете запросы, масштабируетесь или делитесь данными. Большинство проблем проявляются через месяцы.
Типичные провалы:
123, 124, 125, люди могут угадывать соседние записи и сканировать систему.Сигналы тревоги, которые стоит решать рано:
Выберите один тип первичного ключа и придерживайтесь его для большинства таблиц. Смешение типов (bigint в одном месте, UUID в другом) усложняет джоины, API и миграции.
Оцените размер индекса на ожидаемой шкале. Шире ключи — больший первичный индекс и больше IO/RAM.
Решите, как будете пагинировать. Если пагинация по ID — убедитесь, что ID имеет предсказуемый порядок (или примите, что не имеет). Если пагинация по таймстемпу — индексируйте created_at и используйте его последовательно.
Протестируйте план импорта на данных, похожих на прод. Убедитесь, что можно воссоздать записи без ломки внешних ключей и что повторы импортов не генерируют новые ID незаметно.
Запишите стратегию на случай коллизий. Кто генерирует ID (БД или приложение) и что происходит, если две системы создают записи оффлайн и позже синхронизируются?
Убедитесь, что публичные URL и логи не выдают паттерны, которые вам важны (количество записей, скорость создания, внутренние подсказки шардов). Если используете serial ID, предполагается, что люди могут угадывать соседние ID.
Один основатель запускает простой CRM: контакты, сделки, заметки. Одна Postgres-база, одно веб-приложение, цель — быстро запустить.
Сначала serial bigint первичный ключ кажется идеальным. Вставки быстры, индексы аккуратны, и в логах легко читать.
Через год клиент просит квартальные экспорты для аудита, и основатель начинает импорт лидов из маркетингового инструмента. ID, которые были внутренними, теперь появляются в CSV, письмах и тикетах поддержки. Если две системы используют 1, 2, 3..., слияния становятся запутанными. Приходится добавлять колонки с источником, таблицы мэппинга или переписывать ID при импорте.
К второму году появляется мобильное приложение. Оно должно создавать записи оффлайн и потом синкать. Теперь нужны ID, которые клиент может генерировать без связи с базой, с низким риском коллизий при синке в разных окружениях.
Компромисс, который часто стареет лучше:
Если вы колеблетесь между UUID, ULID и serial ID — решайте, исходя из того, как данные будут перемещаться и расти.
Короткие рекомендации для типичных случаев:
bigint serial первичный ключ.Часто смешение — лучшее решение. Используйте serial bigint для внутренних таблиц, которые никогда не покидают базу (таблицы джоинтов, фоновые задачи), и UUID/ULID для публичных сущностей: пользователей, организаций, счетов и всего, что может экспортироваться или ссылаться из другой системы.
Если вы строите на Koder.ai (koder.ai), стоит решить шаблон ID до того, как сгенерируете много таблиц и API. Режим планирования платформы и снимки/откат облегчают применение и проверку изменений схемы на раннем этапе, пока систему ещё можно безопасно изменить.
Начните с боли, которую хотите избежать в будущем: медленные вставки из-за случайной записи в индекс, неудобная пагинация, рискованные миграции или коллизии ID при импортах и слияниях. Если данные будут перемещаться между системами или создаваться в нескольких местах, выбирайте глобально уникальные ID (UUID/ULID) и отделяйте вопросы порядка по времени.
Serial bigint — хороший выбор, когда у вас одна база данных, большой поток записей и ID остаются внутренними. Он компактный, эффективный для B-tree индексов и удобочитаемый в логах. Минус — сложнее слияния данных и риск утечки количества записей при публичном использовании.
Используйте UUID, когда записи могут создаваться в нескольких сервисах, регионах, устройствах или оффлайн-клиентах и нужен крайне низкий риск коллизий без координации. UUID также удобен как публичный ID, потому что его трудно угадать. Компромисс — большие индексы и более случайный порядок вставок по сравнению с последовательными ключами.
ULID оправдан, когда нужен ID, который можно генерировать где угодно и который в целом сортируется по времени создания. Это упрощает курсорную пагинацию и уменьшает случайность вставок по сравнению с UUIDv4. Но ULID не заменяет точную метку времени: для строгого порядка и бэфиллов всё ещё нужен created_at.
Да, особенно в таблицах с высоким числом записей и UUIDv4-стилем случайности. Случайные вставки распределяются по индексу, вызывают больше разбиений страниц, кэш-турбулентность и рост индекса со временем. Это обычно проявляется сначала в снижении устойчивой скорости вставок и увеличении потребления памяти/IO, а не в медленных одиночных выборах.
Потому что сортировка по случайному ID (например, UUIDv4) не соответствует времени создания — курсоры вида «после этого id» дают непредсказуемую последовательность. Надёжный фикс — пагинация по created_at с добавлением ID как тай-брейкера, например (created_at, id). Если вы хотите пагинацию только по ID, проще использовать ID, сортируемый по времени, вроде ULID.
Последовательные ID коллидят при шардинге, потому что каждая шард генерирует 1, 2, 3... независимо. Коллизии можно избежать с координацией (диапазоны для шардов или сервис ID), но это добавляет операционную сложность и может стать узким местом. UUID/ULID уменьшают необходимость координации — каждая шард может безопасно генерировать ID сама по себе.
UUID/ULID проще для экспорта/импорта и слияний: вы можете экспортировать строки, импортировать их в другую среду и сохранить связи без перенумерации. С serial ID частичные импорты часто требуют таблицы соответствий (old_id -> new_id) и аккуратной переписи внешних ключей, что легко сделать неправильно. Если вы часто клонируете окружения или сливаете наборы данных, глобально уникальные ID экономят время.
Популярный подход — два ID: компактный внутренний первичный ключ (serial bigint) для внутренних джоин-вставок и эффективности хранения, плюс неизменяемый публичный ID (ULID или UUID) для URL, API, экспортов и межсистемных ссылок. Это сохраняет скорость базы и облегчает интеграции. Главное — считать публичный ID стабильным и не переиспользовать его.
Раннее планирование и единообразие по таблицам и API. В Koder.ai решите стратегию ID в режиме планирования до генерации множества схем и эндпоинтов, затем используйте снимки/откат для проверки изменений, пока проект ещё небольшой. Самая большая проблема — не создание новых ID, а обновление внешних ключей, кэшей, логов и внешних интеграций, которые продолжают ссылаться на старые значения.