Всем привет! На связи Михаил Поливаха, технический лидер проекта Axelix.
Я уже выпускал статью по мотивам вопросов в рамках Spring Айо Академии. Так вот, когда мы разгребали отзывы с первого потока, мы также получили вопрос (это не прямая цитата, я лишь передаю посыл в том виде, как я его понял):
А что насчет натуральных ключей в БД? Если, допустим, у меня есть поле, по которому я могу явно идентифицировать запись, стоит ли его использовать как Primary Key?
И я за время дизайна enterprise систем, и за время дизайна Axelix пришёл к выводу: Просто не используйте натуральные ключи вообще никогда. Когда у вас появляется такое желание – выйдите на свежий воздух, пройдитесь, прогуляйтесь, и вас отпустит.
Я понимаю, что ответ категоричный, я дам к нему пару пояснений, что делать, если всё же есть уникальная колонка-дискриминант, по которой вы, как вам кажется, можете уникально идентифицировать запись в табличке в бд. Полный и развернутый ответ понятное дело сложнее, но если давать прямо TL;DR:
В дизайне новых систем, на мой взгляд, стоит всегда использовать суррогатные первичные ключи.
Теперь давайте к тому, почему я так считаю (опять же, это во многом мой опыт).
Правила, написанные кровью
Подобного рода правила выше, как правило, рождаются от того, что ты несколько раз обжигаешься на реальных проектах. У меня есть прямо идеальная история из Open Source Axelix (опять же, source code на GitHub, при желании, можете поизучать).
Я сильно прямо вдаваться в подробности не буду, но с целью того, чтобы вы понимали глубину проблемы, я дам немного вводных. Я также буду местами где-то сознательно упрощать в тех местах, которые я считаю не принципиальными для понимания проблемы.
Axelix по сути состоит из двух компонентов – Master, это независимое приложение, которое представляет собой “мозг” системы. Оно разворачивается либо в K8S кластере, либо запускается как отдельный docker контейнер, либо вообще как просто JAR запускается.
Master агрегирует информацию из ваших Spring Boot сервисов и кладет к себе в БД. Эта информация нужна чтобы потом на основании нее понимать некоторую техническую зрелость вашей экосистемы, распределение версий каких-то ключевых компонентов (например, версий Spring Boot или Java), контроля известных проблем тех. долга и т.д.
Если мы представим себе типичную компанию, то у них, как правило, есть кластер K8S/OpenShift, в котором крутится их production. Практически всегда то или иное приложение на production задеплоено не в одном экземпляре, а в качестве некоторого набора Instance-ов (K8S Deployment + настроенный HPA и т.д.). То есть, фактически у нас есть логически одно приложение, физически представляющее собой набор разных контейнеров.
Теперь, я думаю, нам достаточно контекста для обсуждения проблемы.
Начало. Natural Keys – вполне себе!
Как я уже сказал, Axelix хранит данные в своей БД для понимания общего состояния вашего приложения. Назовём эту табличку “Application” (В реальности в Axelix эта абстракция называется по-другому, но опять же, я сильно упрощаю). И вот в ней хранятся данные на уровне приложения.
Master умеет получать данные из Spring Boot микросервисов посредством как push так и pull модели, но независимо от модели – он получает их на уровне Instance, а не на уровне Application, то есть всего приложения. То есть Master опрашивает каждый Instance, и это уже задача Master-а каким-то образом понять, что все те Instance-ы относятся к одному приложению.
Как Master-у это сделать? Как ему понять, что вот эти Instance-ы относятся к одному приложению? (не забывайте, Axelix не всегда разворачивается в K8S, и у нас есть пилотные команды, кто работает без K8S вообще. Надеяться на ClusterIP сервисы и т.п нельзя)
На самом деле, если немного подумать, то решение на поверхности – можно просто агрегировать информацию на уровне пары GroupID/ArtifactID из GAV координат (стандартный формат Maven дистрибутива). Все же instance-ы обязаны иметь один и тот же GroupID/ArtifactID, ведь так?
В целом конечно, это так. Возможно, кому-то может показаться, что можно ориентироваться на другие вещи, например, на spring.application.name или т.п – на самом деле, к сожалению, так не получится по ряду причин, но это другая история. Она нам сейчас не важна.
И вот представь себе, мы с тобой дизайним такое отношение в БД. У меня к тебе вопрос – какой первичный ключ ты бы хотел сделать для такой таблички? Когда мы дизайнили сущность “Application”, казалось правильным сделать первичным ключом как раз пару {groupId/artifactId}, т.е. Natural Composite Key.
И это же очень удобно! Когда в Axelix Master приходит информация по тому или иному Instance (неважно, посредством push или pull модели):
-
Мы же можем при обновлении данных делать простой ANSI SQL
MERGEилиINSERT ... ON CONFLICT DO…, ведь artifactId/groupId это первичный ключ! Spring Data JDBC в 4.1 наконец-то научилось в UPSERT-ы на первичном ключе, и теперь мы можем просто же сделать вот так!
@Transactional
public void reloadCurrentState(BasicRegistrationMetadata metadata) {
Application application = converter.currentSnapshot(metadata);
jdbcAggregateTemplate.upsert(application);
}
А для front-end-а как замечательно получается! И тут мы приходим к тому, что сам по себе натуральный ключ имеет бизнес значение, и иногда нам этого значения достаточно, чтобы принять решение в логике!. Это кстати одно из действительно хороших свойств натуральных ключей.
Что я имею в виду? Например, в ситуации, когда нам нужно просто отобразить имя нашего “Application”, и нам достаточно только имени, то можно же использовать artifactId, т.е часть составного натурального ключа. Не надо ничего “дозапрашивать” и т.д.
Ну где же, в чём же тогда проблема? Неужели с учетом всего того, что я сказал ранее, эта проблема настолько критичная, что я утверждаю, что вообще не стоит использовать натуральные ключи? Да, настолько серьезная. И вот почему.
Так в чём же дело? Немного Философии
Чем старше человек становится, тем более ему свойственно сомневаться в тех или иных вещах (например, в моём утверждении в этой статье, кстати! И это нормально). Это связано с тем, что у людей появляется опыт.
Я буквально знал системы, где в специальной битовой int-овой маске хранился 1 бит, который представлял собой информацию о поле человека – мужчина или женщина. Не сложно догадаться, что случилось потом. И вот люди, кто уже довольно долго занимаются инженерией, накапливают опыт и понимают, насколько всё меняется, и насколько много они ещё не знают (опытные инженеры меня 100% сейчас понимают). Вендор приходит и уходит. Сотрудник тоже. Уникальность натурального ключа…
Рей Далио (потрясающий человек и макро-инвестор, очень рекомендую его почитать) в своей книжке “Principles” говорил:
Sincerely believe that you might not know the best possible path and recognize that your ability to deal well with “not knowing” is more important than whatever it is you do know
Это невероятная мудрость. Идея в том, чтобы принять тот факт, что Ваши знания о внешнем ограничены и они всегда будут кратно меньше чем множество тех вещей, которые вы не знаете, но которые влияют на вашу жизнь/систему и т.д. И самое важное в такой ситуации это уметь работать СО СВОИМ НЕЗНАНИЕМ чего-то, хеджировать риски.
Как это относится к натуральным ключам?
А очень просто: если тот или иной дискриминант тебе в моменте кажется очевидным ключом, просто вспомни о том, что область твоих знаний она несопоставимо мала с тем, чего ты не знаешь. И этот “инвариант”, на который ты возлагаешь надежды, который ты думаешь, что будет уникален – он очень просто через полгода уже может таким не быть.
Более того, область твоих знаний она ещё и будет расти. Со временем, ты (да именно ты, дружище) растёшь как инженер. Спустя время ты посмотришь на этот код, или на дизайн этой системы и скажешь:
Ну кааак!? Как я мог это сделать? Такое дерьмо ну просто атас, это же очевидно было, что такой ключ сломает уникальность в кейсе Х!
И это тебе будет очевидно. Но потом. Когда ты станешь мудрее. Кстати, если у тебя вот такие моменты “прозрения” в карьере не происходят, когда ты журишь себя за свои же решения в прошлом – это очень сильный звоночек о том, что ты остановился в своём развитии как специалист.
Обратно в Инженерию
Давайте вернемся чуть ближе к технической части.
Основная мысль прошлой секции в том, что кажущаяся сегодня уникальность натурального ключа, на деле, спустя время, легко может перестать ей быть. Теперь мыслим как инженеры – насколько это вообще плохо? Насколько плохо то, что мы ошибемся в том, что наш natural key (составной или нет – сейчас не так важно) – что вот он будет не уникален?
На самом деле у первичного ключа записи всегда (!) должны быть (помимо прочих) следующие две отличительные особенности:
1. Он должен быть immutable
Когда мы записи даем тот или иной ключ, по которому мы её идентифицируем – мы потом не имеем права его менять. Почему? Потому что внешний мир, который от нашей системы зависит, он хранит именно этот самый ID, первичный ключ, чтобы идентифицировать запись. Он хранит ссылку, а не саму запись.
Например, представь себе, что у тебя есть сторонний сервис, который хранит профили пользователей: user-service. И вот там решили использовать email в качестве natural key. Вы пишите сервис, который оркестрирует подписки пользователя на те или иные сервисы в рамках экосистемы. И вот вам надо для своих операций получить профиль пользователя из вот этой смежной вам системы. Каким образом вы его получать будете? Конечно же, по email! Это же “уникальный ключ”.
А теперь представим себе, что бизнес такой приходит и говорит: мы в нашем сервисе хотим дать возможность пользователю менять email, привязанный к аккаунту. То есть, по сути, у уже существующей записи в БД будет меняться identity. Поменяв ID у записи в этом user-service, любая другая система, в том числе ваша, не может теперь найти нужный ей профиль. Это будет массовый инцидент, причём тестами это отловить будет ой как непросто. Поэтому, ID должен быть immutable всегда.
2. Он должен уникально идентифицировать запись в любой момент времени t
А теперь представь себе, что вдруг парни, из вашей смежной команды, которая заведует user-service, получают требование. Им говорят:
Ребят, у нас иногда возникает такая ситуация, что пользователь когда-то создал какой-то аккаунт, привязал к нему свой email. И вот сейчас он хочет как-то старый аккаунт (который он создавал лет 10 назад) удалить, и создать новый, и привязать к нему ту же самую почту. Старый аккаунт мы удалять бы не хотели (В Enterprise по разного рода причинам Hard delete делают нечасто). Ну что, сделаем?
Тут проблема ещё более очевидная. Мало того, что теперь ваша система, зависящая от user-service не найдёт нужный профиль пользователя (их же может быть несколько!), все существующие контракты сломаются, и чтобы их “починить”, придётся дорабатывать ID (он ведь больше не уникален, теперь нельзя только на ID смотреть). А если нам придётся менять ID, то смотрите на пункт выше.
Причина смерти: Natural Id
Ошибки бывают разной степени тяжести. Бывают такие ошибки, которые имеют локальный эффект, и исправить которые можно относительно быстро и просто.
Но ключи, которые идентифицируют данные в распределенны системах – это то, что распространяется по всей распределённой системе в самые разные её уголки. Поэтому в момент, когда вы вдруг с ужасом осознаете, что natural id больше не уникален, так называемый blast radius будет фантастическим. Особенно в современной микросервисной архитектуре.
Поэтому, друзья, причины смерти у людей бывают разные. Кто-то умер от рака, кто-то умер от сердечной недостаточности. А кто-то просто выбрал Natural Id в качестве первичного ключа, а потом получил на почту email, или вдруг на daily услышал о том, что предположение уникальности этого ключа может пошатнуться.
Я предлагаю сделать минуту молчания перед чтением дальше, в память о тех инженерах, кто поплатился, выбрав Natural ID в качестве первичного ключа…
Спасибо.
Кейс Axelix
Вернемся к реальному кейсу, который был у нас в Axelix.
У нас хоть пока и не вышел GA (выйдет этим летом, мы активно над этим работаем), но у нас уже есть несколько Milestone релизов. Мы встаем к различным компаниям в контур, чтобы пособирать фидбек, возможные баги, проблемы и т.д.
И вот одна комания нам говорит:
А у нас знаете, у нас так получилось, что есть по сути два сервиса: сервис А и сервис B. Они по большому счёту одинаковые, просто задеплоены в разных сегментах сети. Там один и тот же groupId и artifactId. Тем не менее, сервис A сопровождает вот эта команда, а сервис B – вот эта.
Я все детали упростил, но заметьте, как много деталей выясняется уже потом, после того, как релиз встал в контур. Мы уже не можем идентифицировать приложение как мы хотели через пару artifactId/groupId. Вспоминаем Рея Далио!
… What exists within the area of “not knowing” is so much greater and more exciting than anything any one of us knows
Именно в такой ситуации нужно просить пользователей самим предоставить Axelix информацию о том, какой уникальный ID у того или иного приложения (например, в application.yaml, что в общем-то Axelix и делает).
Но ведь у Natural ID есть преимущества…
На моем опыте тот факт, что у Natural ID есть бизнес значение, которое можно где-то использовать (например, на UI отображать имя application в качестве artifactId, как я уже приводил пример из Axelix) – эта проблема решается просто проектированием API.
Иными словами, даже с суррогатными ключами, можно проектировать API таким образом, чтобы не пришлось дозапрашивать данные с backend-а, это не большая проблема (например, получить некоторую метаинформацию, положить в state manager на фронте и т.д. Там способов много).
Из того, что действительно важно в натуральных ключах – они заставляют вас думать об инвариантах ваших данных. То есть, например, по логике, если ваш email уникальный, то логично создать индекс как раз по нему (что, например, и сделает Postgres, когда вы попросите создать Primary Key). И чтобы не иметь два разных индекса, почему бы не сделать email primary key, ведь в таком случае, будет всего один индекс – лишь на email?
Это в целом валидный аргумент, но я скажу так – он того не стоит. Если вы не будете использовать email в качестве Natural ID, создавать ли вам уникальный индекс на email или нет – это уже решать надо в каждом конкретном случае. Я бы сказал, что для 95%+ кейсов ответ точно стоит и никаких проблем с этим не будет. Тем не менее, для больших write heavy систем, с большим количеством данных, это может создать определённый оверхед – но опять же, как правило, незначительный в масштабах системы.
Ну и финально, по поводу операций MERGE/INSERT ON CONFLICT. Их можно спокойно делать и не на основе первичного ключа, а на основе любого constraint, например, на основе UNIQUE constraint, который вы явно определите в миграции. Другой вопрос, что не все ORM в это умеют, но технически это возможно.
Выводы
На основе своего опыта могу вам сказать одно – помните, что область вашего незнания гораздо больше по природе, чем область вашего “знания”. Поэтому очень опасно строить предположение о том, что Natural ID, который вам в моменте кажется уникальным для той или иной записи, будет хорошим Primary Key.
Тем не менее, стоит признать, что основное преимущество Natural ID в том, что оно заставляет тебя думать о том, какие инварианты у твоих данных в целом есть. И эти инварианты должны давать тебе инсайты на тему того, как моделировать паттерны доступа и хранения данных, например – определять уникальные b+tree индексы для колонки email.
Помни – индексы и подобные вещи можно будет потом убрать без последствий для всей системы. Изменять же первичные ключи это dead end.
Автор: mipo256


