При создании любой системы, распределенной или нет, вы в конечном итоге имеете дело с множеством идентификаторов для ваших данных. От строк базы данных до идентификаторов версии вашей системы в производстве. Неудивительно, что собственные системы Энкор ничем не отличаются. Нам необходимо однозначно идентифицировать и отслеживать все: от отдельных конечных точек API в ваших приложениях, следов от вызовов во время выполнения, до различных
инфраструктурных ресурсов, предоставляемых в одном из поддерживаемых нами облаков, таких как группа безопасности AWS.
Решение о том, как генерировать идентификаторы, иногда может быть очень простым. Например, вы можете просто поместить автоинкрементирующееся число в качестве первичного ключа в вашей базе данных. Бум, у вас есть тип идентификатора и способ его генерации, просто как по волшебству.
Однако в распределенной системе гораздо сложнее просто поставить число, начинающееся с 1 и медленно увеличивающееся. Вы можете построить систему, которая выбирает лидера, и этот лидер отвечает за увеличение числа — но это добавляет много сложностей в дизайн вашей системы, она не может масштабироваться бесконечно, поскольку вы все равно будете ограничены пропускной способностью лидера. Вы все еще можете столкнуться с проблемой раздвоения мозга, когда одно и то же число генерируется дважды двумя разными «лидерами».
Принятие правильного решения на ранней стадии проекта всегда важно, так как это одна из самых сложных вещей, которую трудно изменить в системе после запуска в производство.
Начиная с наших требований
Когда мы начали думать о том, как мы хотим представлять идентификаторы в платформе Энкор, мы записали, что мы хотим видеть в идентификаторе в качестве основных требований:
- Сортируемый
Мы хотим иметь возможность упорядочивать идентификаторы так, чтобы A
< B
, когда A
был создан первым. Однако нам не нужна полная сортировка, допустимо быть k-сортируемым.
Сортируемость позволяет нам улучшить производительность индексирования в базе данных, позволит нам легко перебирать записи по порядку и улучшит наши возможности отладки, поскольку порядок событий можно определить по идентификаторам событий.
- Масштабируемый
Нам нужна система, которая будет масштабироваться вместе с нами без узких мест. Мы будем использовать эту систему для генерации идентификаторов для трасс и пролетов, в которых мы будем создавать огромное их количество.
- Отсутствие риска столкновений
Поскольку мы используем распределенную систему, нам не нужен риск того, что два наших процесса создадут один и тот же идентификатор.
- Нулевая конфигурация
Мы не хотим, чтобы при использовании этой системы нам приходилось выполнять какие-либо настройки на уровне машины или процесса.
- Безопасность типов
Нам нужна система, в которой идентификатор для одного ресурса не может быть случайно передан или возвращен как идентификатор для другого типа ресурса.
В качестве бонуса мы также хотим, чтобы идентификаторы были:
- разумно малы
Как в памяти, так и на проводе.
- Читаемыми для человека
Это связано с безопасностью типов. Однако мы хотим, чтобы строковое представление позволяло человеку (нам 🤖) понять, для чего был создан идентификатор.
Кажется, что это довольно большой список требований, но он вполне выполним, если разбить его на части.
Существующие варианты
Если мы временно проигнорируем требования безопасности типов и читабельности, то то, что мы просим, уже было решено тысячу раз, и нам не нужно изобретать колесо. Давайте рассмотрим некоторые существующие проверенные в боях варианты.
Автоинкрементные ключи с питанием от базы данных ✘
Первым делом большинство людей используют автоинкрементный первичный ключ. Это решает проблему сортируемости и не имеет риска столкновения. Однако он может масштабироваться только настолько, насколько ваш сервер базы данных сможет обрабатывать записи. Это также добавляет новое требование: каждый создаваемый вами идентификатор должен иметь соответствующую строку в базе данных. Учитывая, что мы хотели использовать эту систему для вещей, которые мы не храним в нашей базе данных, мы довольно быстро исключили эту возможность.
UUIDs ✘
Существуют различные версии UUID, и все они состоят из 128 бит. На первый взгляд, версии 1, 2, 4 кажутся идеальными с точки зрения масштабируемости, нулевой конфигурации и риска столкновений. Однако, если копнуть глубже, различные версии UUID начинают показывать недостатки:
- Версии 1 и 2
Несмотря на то, что они могут генерировать около 160 миллиардов идентификаторов в секунду, они используют MAC-адрес машины, что означает, что два процесса на одной машине могут генерировать один и тот же идентификатор при одновременном вызове.
- Версия 4
Генерирует полностью случайные идентификаторы с 2122 битами случайности. Это означает, что вероятность столкновения очень мала, и мы можем бесконечно расширяться без узких мест. Однако мы теряем возможность сортировать идентификаторы в хронологическом порядке.
Снежинка ✘
Идентификаторы типа «снежинка» были впервые разработаны компанией Twitter и обычно имеют размер
64 бита (хотя некоторые варианты используют 128 бит). Эта схема кодирует время
, когда идентификатор был создан, в качестве первых 41 бита, затем кодирует идентификатор инстанции
для следующих 10 бит, и, наконец, последовательность
номер для последних 12 бит.
Это дает нам неплохую масштабируемость, поскольку набор битов instance
позволяет нам запускать 1024
различных процессов одновременно, зная, что они не могут генерировать один и тот же идентификатор, поскольку собственный идентификатор процесса закодирован внутри! Это дает нам k-сортировку, так как верхние биты — это время, когда был сгенерирован идентификатор. Наконец, у нас есть последовательность
номеров внутри процесса, что позволяет нам генерировать 4096
идентификаторов в секунду на процесс.
Однако Snowflake не выполняет наше требование нулевой конфигурации, поскольку идентификатор instance
должен быть настроен для каждого процесса.
KSUID ✘
KSUID — это нечто среднее между UUID версии 4 и Snowflake. Они состоят из 160 бит, где первые 32 бита — это время
создания идентификатора (с точностью до секунды), а затем 128 бит случайных
данных. Это делает их почти идеальными для нас. Они k-сортируемы, не требуют конфигурации и не имеют риска столкновений из-за большого количества энтропии в случайной части идентификатора.
Однако во время исследования KSUID мы обнаружили кое-что интересное. Строковая кодировка KSUID использует кодировку Base-62 и поэтому содержит как прописные, так и строчные буквы. Это означает, что в зависимости от вашей сортировки строк, вы можете сортировать идентификаторы по-разному — т.е. мы теряем наше требование о сортируемости в зависимости от системы. Например, Postgres сортирует строчные буквы перед прописными, тогда как большинство алгоритмов сортируют прописные буквы перед строчными, что может привести к очень неприятным & трудноидентифицируемым ошибкам. (Стоит отметить, что это влияет на любую схему кодирования, в которой используются как прописные, так и строчные буквы, так что это не ограничивается только KSUID)
XID ✓
XID состоит из 96 бит. Первые 32 бита — это time
, что означает, что мы сразу получаем k-сортировку. Следующие 40 бит — это идентификатор машины
и идентификатор процесса
. Однако, в отличие от других систем, они вычисляются автоматически с помощью библиотеки и не требуют от нас самостоятельной настройки. Последние 24 бита — это последовательность
, что позволяет одному процессу генерировать 16 777 216 идентификаторов в секунду!
XID предоставляет нам все наши основные требования, а его строковая кодировка использует основание 32 (никаких заглавных букв, чтобы нарушить нашу сортировку!). Эта строковая кодировка всегда состоит из 20 символов, что означает, что мы можем использовать этот факт для проверки в любом коде маршалинга (например, ограничение Postgres CHECK
на тип базы данных).
на тип базы данных).
Наши идентификаторы
Как только мы остановились на использовании XID в качестве основы для наших типов идентификаторов, мы переключили внимание на два последних требования: безопасность типов и удобочитаемость.
Что касается первого требования, мы могли бы решить эту проблему просто:
import "github.com/rs/xid"
type AppID xid.ID
type TraceID xid.ID
func NewAppID() AppID { return AppID(xid.New()) }
func NewTraceID() TraceID { return TraceID(xid.New()) }
И благодаря системе типов Go, и AppID
, и TraceID
были бы конкретными разными типами, так что следующий вариант
станет ошибкой компиляции:
var app AppID = NewTraceID() // this won't compile
Это могло бы сработать. Однако нам пришлось бы реализовать все функции маршалинга (encoding.TextMarshaler
,
Примечание: Мы могли бы написать функции маршалинга один раз, используя псевдонимы типов (type AppID = xid.ID
), но компилятор будет считать типы ID взаимозаменяемыми.
Еще один минус здесь в том, что наша система — это не только Go. У нас также есть фронтенд Typescript и база данных Postgres. Это означает, что как только мы кодируем ID в проводной формат, мы теряем все гарантии безопасности типов, и теперь в другой системе можно ошибочно использовать одну форму ID для другой.
До Go 1.18 мы могли решить эту проблему, добавив структуру-обертку, содержащую информацию о типе:
import (
"fmt"
"github.com/rs/xid"
)
type EncoreID struct {
ResourceType string
ID xid.ID
}
type AppID *EncoreID
type TraceID *EncoreID
func NewAppID() AppID { return &EncoreID{ "app", xid.New() } }
func NewTraceID() TraceID { return &EncoreID{ "trace", xid.New() } }
Теперь мы можем обновить наши функции маршалинга, чтобы префикс EncoreID.ResourceType
находился в начале строки. Один
небольшой недостаток здесь в том, что помимо передачи прекрасного 20-байтового массива (именно так xid
кодируется в памяти),
мы также будем передавать структуру с экземплярами строк. Не очень неэффективно, но не так приятно!
Вступаем в Go Generics
В Go 1.18 мы можем создать один абстрактный тип ID, а затем создавать различные конкретные типы на основе ResourceType
,
но на самом деле это просто xid
. Итак, концептуально мы начали с этого:
import (
"fmt"
"github.com/rs/xid"
)
type ResourceType struct{}
type App ResourceType
type Trace ResourceType
type ID[T ResourceType] xid.ID
func New[T ResourceType]() ID[T] { return ID[T](xid.New()) }
Это дало нам отличную отправную точку, поскольку формат памяти этих идентификаторов не изменился по сравнению с базовым форматом XID [12]byte
.
Однако компилятор будет рассматривать ID[App]
и ID[Trace]
как два разных типа. Нам также не нужно использовать генерацию кода для всех функций маршалинга, так как мы можем написать их один раз для общего типа.
Единственное требование, которое мы не решили в этой общей версии, — это безопасность типов как для форматов проводов, так и для человеческой читаемости.
читабельности. (т.е. мы все еще можем json.Marshal
идентификатор приложения и json.Unmarshal
превратить его в идентификатор трассы). Чтобы решить эту проблему, мы можем использовать возможность generics в Go для создания экземпляра типа по умолчанию. Затем мы можем вызвать метод на нем. Например, наш метод string выглядит следующим образом:
func (id ID[T]) String() string {
var resourceType T // create the default value for the resource type
return fmt.Sprintf(
"%s_%s",
resourceType.Prefix(), // Extract the "prefix" we want from the resource type
xid.ID(id).String(), // Use XID's string marshalling
)
}
Однако мы пропустили небольшой момент: как сделать так, чтобы наши типы ресурсов App
и Trace
имели метод Prefix, который можно вызвать?
Ну, нам нужно изменить ResourceType
в интерфейс:
type ResourceType interface { Prefix() string }
type App struct{}
func (u App) Prefix() { return "app" }
type Trace struct{}
func (a Trace) Prefix() { return "trace" }
Теперь весь наш код маршалинга и размаршалинга может включать и проверять префикс типа идентификаторов, что означает, что мы
можем легко проверить, если из-за опечатки мы передаем trace_0ncid27rirfkbch0hld0
в качестве аргумента API, ожидающему идентификатор приложения.
PS. Postgres находится здесь
Как я уже упоминал ранее, эти сильно типизированные форматы проводов означают, что мы можем создавать соответствующие типы в других системах без потери гарантий безопасности типов, которые мы хотели получить. Например, мы используем генерацию кода для автоматического создания новых
Postgres для каждого из наших ResourceTypes
в нашей базе данных, чтобы мы могли жестко типизировать столбцы базы данных.
CREATE DOMAIN application_id AS TEXT CHECK (VALUE ~ '^app_[a-z0-9]{20}$');
Затем мы используем SQLC для генерации безопасного для типов кода Go для чтения и записи в нашу базу данных, принимая наши пользовательские типы ID и передавая их через все уровни к базе данных без потери безопасности типов. Мы поговорим о том, как мы используем SQLC и другие способы генерации кода в одной из следующих статей блога, чтобы обеспечить безопасность типов в базе данных и вне ее. Я оставлю вас с небольшим фрагментом сгенерированного помощника:
func GetMembersForApp(q db.Querier, id eid.ID[eid.App]) ([]*db.AppMembers, error)
Заключительные слова
В то время как дженерики долгое время не появлялись в Go, для восполнения этого пробела были созданы отличные инструменты генерации кода. Эти два понятия не являются взаимоисключающими и могут использоваться одновременно для снижения когнитивной нагрузки и уменьшения объема кода, который вам приходится писать. Легко злоупотреблять дженериками, но если вы не торопитесь и используете их осторожно, вы будете думать с помощью дженериков!
Вы можете использовать все возможности Go 1.18, включая Generics, с помощью Encore. Мы будем рады, если вы попробуете Encore и присоединитесь к Slack, чтобы оставить отзыв!
Эта статья была первоначально опубликована в блоге Encore 25-03-2022.