Привет! Меня зовут Женя, я бэкенд-разработчик, и в этом посте хотела бы кратко обсудить плюсы и минусы разных вариантов идентификаторов в базе данных.
Sequential INT
Думаю, все сталкивались с этим вариантом, когда id новых сущностей генерирует база при вставке, и они получают монотонно возрастающие номера:
id = 1, id = 2, id = 3…
Плюсы:
➕ удобно читать и воспринимать человеку
Например, это удобно, когда разбираем какие-то баги, смотрим логи.
➕ просто сделать
➕ можно видеть порядок добавления записей без поля createDate
Не очень частный кейс, но может быть актуально, например, для справочников.
➕ благодаря монотонному возрастанию можно использовать для keyset пагинации без offset.
Keyset — это способ реализации пагинации, при котором для получения данных отдельной страницы вместо добавления в запрос сдвига offset:
select name from users
order by id
limit 10 offset 50
используется условие:
select name from users
where id > x
order by id
limit 10
где x — это последнее значение с прошлой страницы.
➕ использует всего 64 бита (для bigint)
➕ при добавлении новых записей в БД индекс обновляется быстро благодаря последовательности ключей
➕ запросы выполняются быстрее, поскольку благодаря равномерному индексу активно используется кэш базы данных, в котором лежат стабильные части «горячих» данных — последний день, месяц
Минусы:
➖ основной минус проявляется при шардинге: если у нас много шардов базы, то при стандартном подходе сложно сохранить уникальность числовых идентификаторов.
Если каждый шард базы генерирует свои идентификаторы, то они будут пересекаться с другими шардами.
Решением может стать отдельный сервис для централизованной генерации айдишников, но у него тоже есть свои минусы (единая точка отказа, снижение скорости ответа в случае географически распределенных серверов).
➖ если числовой айдишник показывается на фронте, то из этой информации кто-то может сделать выводы, вредные для репутации или безопасности продукта.
Например, мы сделали свой новый продукт, написали на главной странице «Нам доверяют более 1000 пользователей!». Новый пользователь регистрируется на сайте, фронт отправляет запрос:
POST /users
{
"name": "Alex",
"age": 25
}
и получает ответ:
{
"id": 8,
"name": "Alex",
"age": 25
}
В некоторых источниках можно встретить упоминание, что последовательно возрастающие числовые идентификаторы небезопасны, так как злоумышленник может угадать/перебрать идентификаторы и получить доступ к чужим данным. Однако здесь дело не только в типе айдишника. Злоумышленник может подсмотреть его или получить от какого-то пользователя. Независимо от типа идентификатора, на бэке всегда нужно проверять права пользователя. Может ли этот пользователь взаимодействовать с этой сущностью, может ли он ее читать, редактировать, удалять.
История из личного опыта. После сдачи дома застройщик не торопился присылать данные обмеров квартир, и люди в чате ЖК забеспокоились. Один из участников чата нашел на сайте застройщика нужный файл PDF с id в адресе файла и рассказал в чате. Путём подбора id в URL все смогли скачать себе PDF своих (и чужих) квартир. Не то чтобы обмеры квартир — это секретная информация, но вот так мы добыли их до официальной рассылки застройщика.
UUIDv4
UUID (Universally Unique Identifiers) записываются в виде последовательности шестнадцатеричных цифр в нижнем регистре, разделённых знаками минуса на несколько групп. Например:
5b3201b6-b8e6-4544-96a7-ac046a734f17
UUID генерируется специальным алгоритмом, практически гарантирующим, что этим же алгоритмом оно не будет получено больше нигде в мире.
Плюсы и минусы идентификатора типа UUID во многом обратны таковым у числовых идентификаторов.
Плюсы:
➕ можно генерировать в коде приложения до записи в БД
➕ удобно в случае шардинга БД: у всех записей свои уникальные айдишники (можно перешардировать, можно смерджить две базы) — это основной плюс такого вида id
➕ при отображении на фронте не даёт пользователю информации о количестве записей (кейс с безопасностью остается, так как пользователь может переслать ссылку со своим uuid— всегда проверяйте права)
Минусы:
➖ неудобно для восприятия человеком
➖ генерация занимает дольше, если это делает БД (не играет роли, если генерирует приложение)
➖ занимает больше места — 128 бит (в большинстве случаев некритично)
➖ не виден порядок добавления записей без поля createDate
➖ не сделать keyset пагинацию по id
Индекс по UUIDv4 является неравномерным — это значит что соседние значения в индексе, например'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
и 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaab'
будут указывать на данные, которые расположены физически далеко, в разных блоках таблицы. В равномерном же индексе соседние значения указывают на один и тот же блок физических данных.
Из неравномерности индекса по UUIDv4 вытекают еще 2 минуса:
➖ при вставке в БД сильно перестраивается индекс (происходит чтение и запись в разные страницы индекса), что замедляет вставку
➖ запросы на чтение выполняются дольше, так как мало используется буферный кэш: нет "горячих частей", таких, как компактное хранение всех id текущего дня в ограниченном количестве блоков индекса.
UUIDv7
Для решения проблем, связанных с отсутствием последовательности в UUIDv4, были придуман новый формат UUIDv7. Например:
0193971d-b693-7853-bc89-7a13bda9a033
Он занимает те же 128 бит, но первые 36 бит занимает метка времени. На сайте uuid7.com можно проверить валидность UUIDv7 и определить дату генерации.
В UUIDv7 в отличие от предыдущих версий значения бинарно-сортируемые, то есть по двум значениям можно сразу без конвертации понять, какое больше. UUIDv7 является равномерным, а значит по нему можно построить эффективный индекс.
Про UUIDv7 на Хабре уже были подробные статьи, например: как устроен UUIDv7, спецификация UUIDv7 по RFC9562.
Кроме UUIDv7, который является частью стандарта, есть и другие UUID-подобные идентификаторы с метками времени: ULID, Snowflake ID и др.
В итоге, нельзя сказать, что какой-то тип идентификатора является «серебряной пулей». Как обычно, нужно отталкиваться от задачи и требований. При этом, если вы выбрали для нового проекта UUID, то очень рекомендую рассмотреть вариант v7 или другую реализацию с сортировкой. Надеюсь, что резюме по плюсам и минусам различных подходов вам пригодится!
У меня есть маленький ламповый блог в телеграм, где делаю разборы книг, пишу про разработку, архитектуру и немного про свой опыт. Буду рада, если вас он заинтересует.
Комментарии (33)
VVitaly
06.12.2024 06:40:-) В bigint ID (так же как и в символьных ID) при желании вы можете "впихнуть" что угодно... Номера шардов, дату время (с наносекундами) и все что необходимо базовой логике работы вашего приложения (зачастую "экономя" на дополнительных полях объекта)...
Но "по факту" выбор конечно за архитектором прикладного ПО в каждом конкретном случае... "Костыли" потом уже можно будет "подставить".... :-)miralasse Автор
06.12.2024 06:40Согласна, тут получается составной тип id. Например, известный SnowflakeID.
hard_sign
06.12.2024 06:40"впихнуть" что угодно... Номера шардов, дату время (с наносекундами) и все что необходимо базовой логике
Это нарушение первой требований первой нормальной формы. В будущем такое решение может привести к весьма забавным спецэффектам.
onyxmaster
06.12.2024 06:40Например к каким спецэффектам?
hard_sign
06.12.2024 06:40Например, строка переезжает из шарда в шард, и у неё меняется первичный ключ. Или какой-нибудь умник-программист начинает упорядочивать операции в соответствии со временем, содержащимся в первичном ключе, а по бизнесу это в нескольких трудноуловимых случаях оказывается неверно.
onyxmaster
06.12.2024 06:40Это что за шардирование где первичный ключ меняется?
Аргумент «а если разработчик тупит» я, простите, аргументом считать не буду, мне бы не пришло в голову сортировать по id как по дате, если контракта на содержание даты нет. Если есть как в MongoDB ObjectId — можно и использовать.
hard_sign
06.12.2024 06:40Ну там же комментатор пишет «впихнуть в ключ номер шарда». Так-то да, обычно шардирование двухуровневое: хеш-сумма — номер фрагмента, а потом консистентным хешированием или рандеву — номер шарда. Но иногда встречается и вот такое :))
hard_sign
06.12.2024 06:40➖ при вставке в БД сильно перестраивается индекс (происходит чтение и запись в разные страницы индекса), что замедляет вставку
Это полная ерунда. Как раз наоборот проблема возрастающего числового идентификатора в том, что при вставке куча транзакций конкурирует за единственный листовой блок. В PostgreSQL придумали какую-то специальную оптимизацию для этого случая, а Oracle позволяет создавать индексы с ключевым словом
REVERSE
, когда индексируется не число, а другое число, полученное перестановкой цифр в обратном порядке.➖ запросы на чтение выполняются дольше, так как мало используется буферный кэш: нет "горячих частей", таких, как компактное хранение всех id текущего дня в ограниченном количестве блоков индекса.
И это ерунда. id текущего дня ищется по другому индексу — по дате.
Есть такая характеристика индекса как фактор кластеризации. Индекс по дате хорошо кластеризован, а по id — плохо. Если сделать возрастающие числовые идентификаторы, то индекс по id тоже будет хорошо кластеризован, но от необходимости индекса по дате это не избавит.
onyxmaster
06.12.2024 06:40Полная ерунда это не различать две разные проблемы: замусоривание кэша БД/ФС неактуальными данными и запись в горячий участок индексного дерева.
Без анализа паттернов записи, схемы обновления индекса, устройства concurrency control и оценки необходимости кэша и влияния замусоривания на его эффективность сложно надёжно сказать что из этого хуже. А вот что «ерунда» видимо легко :)
Про дату сомнительный аргумент, дополнительный индекс это дополнительная нагрузка при вставке и дополнительный кэш. Если суррогатный ключ может обеспечить одновременно и уникальность и хронологическую локальность, то зачем делать ещё один индекс, если бизнес-логика не требует поиска по дате, мне непонятно.
hard_sign
06.12.2024 06:40Вы правы, это разные проблемы, и первая с алгоритмом формирования первичного ключа никак не связана. «Ерунда» — объединять две проблемы в одну.
Если бизнес-логика не требует поиска по дате, то второй индекс, конечно, не нужен. А вот если требует, то надо создать второй индекс, а не запихивать неявно дату в ключ. Ну элементарная же гигиена.
baldr
06.12.2024 06:40Какой тип id используется у вас на проекте?
У меня много "проектов" и везде разные подходы, как раз, по целесообразности. Поэтому опрос с единственным выбором не подходит.
antirek
06.12.2024 06:40uuid длинный и содержит дефисы, сложнее быстро копировать для разборов
nanoid - топ https://github.com/ai/nanoid/blob/HEAD/README.ru.md с переменной длиной, с кастомным алфавитом
плюс использовать в id префиксы, а-ля msg_, usr_ для разных сущностей, для относительно небольшого множества, например длину в 8 рандомных символов, а для ожидаемого большго множества - длину в 16, 24, 32 и т.д. рандомного символа
т.е. в данных, в логах будут id типа usr_ewndf32d отправил msg_835erjeiofdsf909 - читаемо и понимаемо
onyxmaster
06.12.2024 06:40Хранится в базе скорее всего как строка, следовательно для больших данных менее пригоден чем 64-битные целые или UUID. После того как я прочитал что размер кода генератора в 130 байт против 423 байт для UUID подаётся как значительное преимущество, я заплакал и закрыл страницу.
antirek
06.12.2024 06:40все только и говорят большие данные, большие данные... а многие 2 млрд записей ни разу и не видели в БД
SergeyProkhorenko
06.12.2024 06:40Это не так. RFC 9562 рекомендует хранить в бинарном формате, а в PostgreSQL хранится в специальном бинарном формате UUID - всего 128 бит.
Рекомендую пользоваться оптимизированными функциями-генераторами UUIDv7, встроенными в ядро СУБД. В PostgreSQL появится в 18 версии (сейчас на ревью), в MySQL уже есть, а в Clickhouse степень готовности непонятна. При отсутствии можно пользоваться реализациями в разных языках программирования или даже просто генерировать в SQL.onyxmaster
06.12.2024 06:40Что именно "это" не так?
Я не говорил что UUID не хранится в бинарном формате. Я говорил что nanoid скорее всего хранится как строка.
Nanoid это не UUID, это другой формат идентификатора. По количеству бит он помещается в 128 бит, но я в репозитории не вижу рекомендаций упаковке в UUID для хранения, то есть для пользователя это получается просто строка в 21 символ.
Да, используя знание о том, что алфавит этой строки -- URL-safe base64 можно сделать конвертер в массив байт (последние 2 бита видимо придётся добивать фиксированным значением, потому что 21*6=126), который можно хранить как UUID, но я не думаю что это стандартный паттерн использования. Я могу ошибаться, но документация мне не оставляет другого выбора =)
rznELVIS
06.12.2024 06:40Не очень частный кейс, но может быть актуально, например, для справочников.
использует всего 64 бита (для bigint)
Увидев две цитаты выше вспомнил кейс из практики. У нас в одном легаси-приложение, попавшем к нам на редизайн, Id/PK справочников делали bigint, но значений там было обычно не более 100, а обычно и нескольких десятков. Но проблема в том что они связывались с таблицей типа "item" или "entity" у которых были внешние ключи на десятки справочников. И эти внешние ключи были тоже bigint разумеется.
А таблицы "item" или "entity" содержали миллионы записей. Как следствие они все содержали десятки bigint колонок с внешними ключами на справочники. То есть если одна из таблиц "item" содержит хотя б 10 внешних ключей на такие справочники, то мы получим 10 * 8 байт * 1000000 (число записей) = 8 * 10 ^ 7 = 80 МБайт информации. У нас таких таблиц было штук 25. Но если предположить что их хотя бы 5, то мы получим 5 * 80 Мбайт = 400 Мбайт в одной базе подобных внешних ключей. С учетом что мы использовали полные бэкапы раз в сутки и они хранились иногда до полугода, то набегал приличный бесполезный объем данных. За месяц могло набежать 12 ГБ. Конечно если вы в облаке как SaaS то проблем почти нет, а если как IaaS или у вас свои сервера, то надо внимательно следить за свободным местом на диске. А об этом как обычно забывают)) честно скажу забывали и мы и наши опсы )
Хотя если б для Id/PK справочников использовать хотя бы int (4байта), то можно итоговые 12ГБ "перегруза" перевести в 6ГБ, а если smallint (2 байта) то в 3ГБ. Tinyint использовать наверное уже рискованно, но тоже можно рассмотреть.
Это я все к чему ?) К тому что bigint способ хранения PK наверное самый популярный но далеко не самый эффективный. По хорошему нужно проводить мини анализ при его использование от таблицы к таблице. И не слушать крики коллег из разряда "у нас все Id это bigint".
Давным давно хотел провести анализ с списком рекомендаций, когда использовать bigint когда int когда smallint и так далее. Но так и сделал. Если об этом подумаете вы, раз уж взялись за эту тему, то будет замечательно )
onyxmaster
06.12.2024 06:4064-битные идентификаторы проще генерировать без координации между генераторами чем 32-битные. А стоит ли задаваться вопросом «какой тип идентификатора выбрать» — это увеличение когнитивной нагрузки на разработчика. Некоторые предпочитают вообще везде UUID (чтобы не думать), но и на каждую коллекцию данных обдумывать размер может быть непродуктивно, особенно если предсказать рост объёма проблематично. Сменить тип идентификатора может быть трудно в работающей системе и выбор слишком короткого идентификатора может привести к неприятным последствиям.
arkady
06.12.2024 06:40Часто используется смешанный подход: автоинкерментные айди для плюсов (индексы и запросы) плюс генерится случайный айди (не обязательно uuid) для публичной части фронтенда. Таким образом мы скрываем айди на паблике и используем все преимущества целочисленных айди для бэкенда.
vagon333
06.12.2024 06:40Подтверждаю этот подход и жизненность решения:
- Для организации связей в базе использую автоинкрементный ID как первичный ключ,
- а для работы с записями из внешних клиентских приложений дополнительный uuid.
Sabirman
06.12.2024 06:40Главное никогда, не используйте натуральные ключи в качестве первичных ключей - только суррогатные. Натуральные ключи рано или поздно меняются и тогда наступает боль по переписыванию всего API.
Akina
06.12.2024 06:40можно видеть порядок добавления записей без поля createDateНе очень частный кейс, но может быть актуально, например, для справочников.
Только в монопольной среде. В конкурентной же запросто можно получить набор, в котором сортировки по id и по timestamp не совпадают.
Если каждый шард базы генерирует свои идентификаторы, то они будут пересекаться с другими шардами.
Только если чей-то гениальный ум велел всем шардам генерировать идентификаторы с единицы. Если поделить всё пространство 64-битного целого между всеми (ну сколько их там у вас?) шардами на равномерные блоки, то порядок вероятности пересечения сравним с порядком вероятности переполнения. Ну то есть - плюнуть и растереть.
inforus
06.12.2024 06:40В целом да, и там и там есть свои плюсы и минусы, по сути int64 это очень большое число, для идентификации сквозной эта куча цифр не сильно приминима, но можно украсть старшие биты и тем самым реализовать огромное кол-во групп, в которых тоже будет не мало так чисел для идентификации, да индексы по маске делать не так сложно.
Okunev_PY
06.12.2024 06:40Хорошая тема добавлю и я 5 копеек.
Во первых использовпание uuid в любом виде сильно усложняет чтение запросов, человек не машина и ему проще запомнить число, нежели буквенно чиловой набор.
Во вторых, не смотря на появившийся uuid7, далеко не во всех БД он поддерживаеться, а вставка uuid4 в качестве первичного ключа быстро приводит к высокой фрагментации индекса. Если кто не помнит, то я напомню, первичный ключ не только уникальный, но еще и кластерный, во всяком случае в большинстве систем. Кластерный индекс подразумевает следование записей в порядке возрастания или убывания индекса. Если вы каждый раз получаете уникальное значение, которое больше или меньше предидущего, то вы реально попадаете на лютую деградацию при чтении данных на больших объёмах.
Ну и в третьих, это операции сравнения.
Пока у нас 64 битные процессоры, поэтому на сравнение объекта 128 бит нужно как минимум две операции сравнения. Да казалось бы не существенно, но умножте это на количество этих операций в контексте одного запроса, а в контексте всех запросов к БД? А если БД весит не 100 Гб, а скажем 1 ПиБ и она постоянно находиться под нагрузкой?
Akina
06.12.2024 06:40На самом деле я вообще не понимаю тут изрядную часть плюсов и минусов в именно той форме, как они тут изложены. По весьма тривиальной причине - почему-то они сильно завязаны на визуальное отображение значения.
Вот есть BIGINT. 64 бита, и формально все случайны (правда, случайные я только в MS Access помню). И есть UUID. 128 бит, из которых только часть случайные, а часть - информационные (хотя и не совпадающие). Замените UUID с его 36-символьным представлением на соответствующее 40-циферное числовое представление. Пойдите дальше, поделите отображение на две части - истинно-случайную и информационную, представьте информационную (идентификатор узла, метка времени) в соответствующем формате. Шагните ещё дальше, и поделите поле на два - истинно случайное и алгоритмически заполняемое. Получите в итоге случайное число, от 92 до 122 битов длиной, и дополнительную нашлёпку, которой в принципе-то можно и пренебречь, и назначение которой в основном - обеспечение уникальности при последовательном, а не истинно случайном, алгоритме генерации первой части за счёт добавления некоей аппаратной характеристики узла-генератора и/или момента генерации.
В общем, с моей личной точки зрения основная разница состоит в количестве битов. А всё остальное - незначащие бантики.
40kTons
У меня всегда был глупый вопрос про uuid - точно уникальные? Что обеспечивает уникальность? Строка то конечная. Кажется, что коллизия вопрос времени. Проверяете ли вы существование uuid в базе после его генерации?
miralasse Автор
Спасибо за проявленный интерес!
Если говорить про UUIDv4, то в нем 122 бита отводится на случайную часть, что при хорошем генератора дает высокую вероятность уникальности. Согласно википедии:
for a total of 2^122, or 5.3×10^36 (5.3 undecillion) possible version-4 variant-1 UUIDs.
В статье про спецификацию UUIDv7, описаны несколько вещей, которые используется для достижения уникальности у него.
Когда UUID является ключом, то БД сама проверяет при вставке на коллизию. Ситуаций с коллизиями пока не было.
Naf2000
Если это PK база сама проверит. И да, это все равно займёт время.
Насчёт уникальности. Ну это как 640кБ хватит всем. Проблему возможно мы просто отложили для будущих поколений. Потом это всплывёт как проблема 2000 года. Но это же потом и возможно без нас?
miralasse Автор
Вы совершенно верно отметили, что нет универсального решения.
UUID не нужно использовать бездумно. Нужно смотреть на систему, которую вы проектируете, и принимать решения исходя из требований к ней. Где-то время на проверку будет критично, где-то нет. Где-то нужно шардирование, где-то нет. Где-то и размер самого идентификатора сыграет роль. Поэтому статья о сравнении плюсов и минусов разных подходов.
Pi-man
так то и int64 для обычного последовательного ID когда-то закончатся :)
Sabirman
Вероятность совпадения uuid-ов зависит от алгоритма генерации, но в худшем случае что-то порядка 1 раза в 30 лет, при условии, что генерируешь по 1 млн uuid-ов в секунду (пруфа не нашел). Т.е. специально проверять совпадение не нужно.