Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 20 из 25 ~18 мин +50 XP

Индексы и аннотации

Индексы — то, что отличает схему, которая работает, от схемы, которая работает в демо. Девятнадцатый урок научил схему отвечать «можно ли», сегодня — «быстро ли»: ent индексы описываются кодом рядом с полями и рёбрами, едут в миграции и превращаются в ограничения и деревья поиска базы. Разберём builder index с Fields и Edges, уникальность одного поля и уникальность пары — index.Fields(...).Unique() с её NULL-дырой, составной индекс и правило левого префикса на примере ленты author_id + created_at, entsql-аннотации: Desc для порядка сортировки, IndexType и IndexPrefix для MySQL, StorageKey для стабильных имён и обещанный четырнадцатым уроком частичный индекс IndexWhere("deleted_at IS NULL"), который вместе с интерсептором восемнадцатого наконец закрывает мягкое удаление до конца. Отдельно — entsql.Annotation{Table} на уровне схемы и жизнь индексов в миграциях Atlas из пятнадцатого урока. По канону курса: анна, борис, вера, группа go-ninja и счётчик posts_count.

Содержание урока · 13 разделов

Маршрут урока — от скорости к схеме. Сначала — мотив: полнотабличный скан против поиска по дереву на примере ленты постов анны и email-поиска, и заодно честная цена: индекс не бесплатен ни на диске, ни на записи. Затем — API: метод Indexes() в схеме, builder index с Fields и Edges, Unique и StorageKey; здесь же разница между уникальностью поля из третьего урока и уникальностью пары. Третья часть — составные индексы: порядок колонок, левый префикс и индекс ленты author_id + created_at с аннотацией Desc. Четвёртая — entsql-аннотации: тип и префикс для MySQL, направление сортировки, имя индекса. Пятая — частичный индекс IndexWhere: обещание четырнадцатого урока, которое вместе с интерсептором восемнадцатого закрывает мягкое удаление целиком. Финал — аннотация схемы entsql.Annotation{Table} и жизнь индексов в миграциях Atlas из пятнадцатого урока. Если вы искали «ent индексы» или «index ent» — это тот урок.

Зачем индексы: скан против поиска#

Лента анны — запрос WHERE author_id = ? ORDER BY created_at DESC на таблице постов, которых через полгода жизни группы go-ninja будут десятки тысяч. Без индекса база читает таблицу целиком — полнотабличный скан, сортирует строки в памяти, отбрасывает чужие; в плане запроса, который покажет EXPLAIN, это будет Seq Scan плюс Sort, и время растёт линейно с числом постов. С индексом по паре author_id + created_at она идёт по упорядоченному дереву: находит начало диапазона автора и читает строки уже в нужном порядке — точечный поиск вместо скана, сортировка не нужна вовсе. Та же история с поиском по email из девятого урока: селективность колонки решает — email почти уникален, и индекс на нём превращает запрос в одно спуск по дереву, тогда как индекс на deleted_at, где почти все значения одинаковые, почти бесполезен. Но индекс не бесплатен: каждый Create из восьмого урока и каждый Update из тринадцатого обслуживает все индексы таблицы — пишет не только строку, но и дерево каждого индекса; плюс место на диске и лишний шанс планировщика выбрать неудачный план. Отсюда рабочее правило: индекс появляется от запроса, который его ищет, а не «на всякий случай». Заметьте: счётчик posts_count из тринадцатого урока — колонка, а не индекс; он экономит Count, но ленту всё равно разворачивать деревом.

Проверка: уникальность поля

В схеме User поле email объявлено field.String("email").Unique(). Что случится при попытке создать второй аккаунт с тем же email?

Индексы в схеме: Fields, Edges и Unique#

Индексы объявляются в схеме — рядом с полями, рёбрами, хуками семнадцатого урока и политикой девятнадцатого, — методом func (T) Indexes() []ent.Index. Пакет ent/schema даёт builder index. index.Fields("created_at") — индекс по колонке поля; имена внутри — имена полей схемы, как они объявлены в Fields(), не имена колонок базы. index.Edges("author") — индекс по колонке внешнего ключа, которую ребро author создало ещё в пятом уроке: в таблице постов это author_id, и лента «посты автора» ищет именно её. Цепочка index.Edges("author").Fields("created_at") — составной индекс сразу по author_id и created_at. Метод Unique() превращает индекс в ограничение уникальности: поле email с field.Unique() из третьего урока уже создало уникальный индекс на одной колонке, а index.Fields("name", "website").Unique() — новый уровень: уникальна вся пара. Метод StorageKey задаёт имя индекса в базе — posts_created_idx вместо сгенерированного по умолчанию; это подарок ревью миграций из пятнадцатого урока, о котором подробнее в финале. Всё объявленное в Indexes() генератор из седьмого урока переносит в код, а миграции — в DDL: схема остаётся единственным источником правды о базе.

Первые индексы в схеме Post

GO
// ent/schema/post.go
func (Post) Indexes() []ent.Index {
        return []ent.Index{
                // колонка внешнего ключа ребра author -> author_id:
                index.Edges("author"),
                // составной: лента автора, упорядоченная по времени:
                index.Edges("author").Fields("created_at"),
                // индекс по полю со стабильным именем в DDL:
                index.Fields("created_at").
                        StorageKey("posts_created_idx"),
        }
}

index.Edges("author") — индекс на author_id, колонку, которую ребро создало в пятом уроке. Edges("author").Fields("created_at") добавляет составной порядок: внутри одного автора строки лежат по времени. StorageKey фиксирует имя в DDL: без него ent сгенерирует имя сам, а переупорядочение колонок такого индекса в будущей миграции обернётся drop и create вместо аккуратного переименования. Имена в Fields — имена полей схемы, как в Fields(), не имена колонок базы.

Составные индексы: порядок колонок решает#

Составной индекс — это книга, отсортированная по двум признакам сразу: сначала по первой колонке, внутри неё по второй. Отсюда главное правило составных индексов — левый префикс: индекс (author_id, created_at) обслуживает WHERE author_id = ?, WHERE author_id = ? AND created_at > ? и WHERE author_id = ? ORDER BY created_at DESC, но запрос WHERE created_at > ? без условия на author_id не сможет взять его как основной путь: деревья отсортированы по author_id, а время внутри — искать придётся по всем веткам. Поэтому порядок объявления — не вкус, а решение: колонка равенства — первой, диапазон и сортировка — за ней. Лента анны просит именно (author_id, created_at): зафиксировали автора — и внутри его строк время уже упорядочено, ключсет-пагинация двенадцатого урока с CreatedAtLT и IDGT скачет по дереву, не трогая чужие посты. Если бы лента была глобальной — «все посты подряд по времени» — порядок развернулся бы: (created_at, id). Составных индексов на таблицу не напасёшься на каждый запрос: три-четыре осмысленных покрывают десятки запросов через левый префикс, а каждый лишний индекс тормозит мутации из тринадцатого урока. Проверяйте планы в EXPLAIN: попал ли запрос в префикс, виден ли ваш индекс.

Индекс ленты: составной и убывающий

GO
// ent/schema/post.go: лента автора «новые сверху».
index.Edges("author").Fields("created_at").
        Annotations(entsql.Desc()).
        StorageKey("posts_author_created_idx"),

entsql.Desc() объявляет колонки индекса убывающими: лента «новые сверху» совпадает с порядком дерева, и планировщику не нужен обратный проход или сортировка. Зеркальная аннотация — entsql.Asc(); для смешанных составных порядков есть точечные entsql.DescColumns("created_at") и entsql.AscColumns — направление задаётся по отдельным колонкам. StorageKey здесь уже не украшение: у составного индекса самое длинное имя по умолчанию, и без стабильного ключа ревью миграций превращается в угадайку.

Проверка: левый префикс

Есть составной индекс (author_id, created_at). Какой запрос не сможет взять его как основной путь доступа?

Уникальность вместе с полями: поле против пары#

Уникальность в ent живёт на двух этажах, и путать их нельзя. field.Unique() — уникальность одной колонки: email из третьего урока, slug поста, телефон; индекс на такой колонке создаётся автоматически. index.Fields(...).Unique() — уникальность комбинации: пара (name, website) уникальна целиком — два «анна» с разными сайтами проходят, повторение пары запрещено. Комбинация с рёбрами даёт прикладной паттерн «одно на владельца»: index.Edges("author").Fields("kind").Unique() запрещает автору иметь два поста одного вида — уникальность в границах владельца, а не всей таблицы. И нюанс, который ловит всех: SQL-уникальность не распространяется на NULL. Строка с NULL не равна другой строке с NULL — даже при одинаковом остальном; значит, Optional-колонка из четвёртого урока внутри уникальной пары — дыра в ограничении: сколько угодно строк (name, NULL) пройдут проверку. Лечится строго: колонки пары должны быть NOT NULL, либо контроль дубликатов уходит в хук семнадцатого урока, который увидит мутацию до SQL. Ниже — предсказание на этот кейс: прежде чем читать объяснение, попробуйте ответить сами.

Уникальный составной индекс в схеме User

GO
// ent/schema/user.go: пара name + website уникальна.
func (User) Indexes() []ent.Index {
        return []ent.Index{
                index.Fields("name", "website").Unique(),
        }
}

Уникальна пара целиком: (анна, https://anna.dev) и (анна, https://anna-2.dev) — разные строки, повтор пары — ConstraintError из первого квиза. Это не то же, что Unique() на каждом поле по отдельности: там запрещались бы любые повторные «анна». Нюанс с NULL не отменяется: website — Optional из четвёртого урока, колонка допускает NULL, и две строки с одинаковым name и пустым website уникальность не нарушат — смотрите предсказание.

Предскажите результат

Прочитайте код и выберите, что он выведет:

// User: index.Fields("name", "website").Unique()
// website — Optional, у обеих записей NULL
client.User.Create().
        SetName("гонец").SetEmail("g1@entgo.io").
        Save(ctx)
client.User.Create().
        SetName("гонец").SetEmail("g2@entgo.io").
        Save(ctx)

entsql-аннотации: тип, префикс, порядок, имя#

Аннотации — способ сказать диалекту то, чего нет в общем словаре index-билдера. Все индексные аннотации живут в пакете entsql и передаются методом Annotations(...). entsql.Desc() и entsql.Asc() задают направление сортировки колонок индекса целиком, а DescColumns и AscColumns — по отдельным колонкам составного индекса; убывающий индекс ленты избавляет планировщик от обратного прохода. entsql.IndexType("hash") выбирает тип индекса для MySQL: hash мгновенен на равенстве, но слеп к диапазонам и сортировке — для лент и ключсета нужен btree, значение по умолчанию. entsql.IndexPrefix(n) — префиксный индекс: индексируются первые n символов длинной строки; для TEXT-колонок MySQL требует его обязательно, без префикса миграция откажет. entsql.IndexWhere(expr) — частичный индекс, главная герой следующего раздела. Имена и типы попадают в DDL миграций и в versioned-файлы Atlas из пятнадцатого урока — потому аннотации и называются аннотациями: они выражают намерение, а мигратор каждого диалекта решает, как его исполнить, и где-то честно разводит руками.

Каталог: API индексов ent#

ИнструментЧто даётНюанс
index.Fields(...)индекс по колонкам полейимена — как в Fields() схемы
index.Edges(...)индекс по колонке внешнего ключаребро author даёт колонку author_id
Fields(...).Edges(...)составной по полям и ребрупорядок колонок задаёт левый префикс
.Unique()уникальный индекс-ограничениеNULL не сравниваются: дыра в strict-уникальности
.StorageKey(name)имя индекса в DDLстабильные миграции и честное ревью
entsql.Desc() / entsql.Asc()направление сортировки колонокDescColumns/AscColumns — по отдельным
entsql.IndexType("hash")тип индекса для MySQLhash — только равенство; диапазонам нужен btree
entsql.IndexPrefix(n)префикс длинной строки (MySQL)обязателен для TEXT-колонок
entsql.IndexWhere(expr)частичный индексPostgres и SQLite; MySQL — нет
entsql.Annotation{Table}имя таблицы схемыобъявляется в Annotations() схемы
field.Unique()уникальность одного поляиндекс создаётся автоматически
Indexes() []ent.Indexсписок индексов схемыметод типа рядом с Fields и Edges

Частичный индекс: IndexWhere и мягкое удаление#

Четырнадцатый урок закончился обещанием: мягкое удаление пишет deleted_at, но живые строки тонут в таблице, где рядом лежат мёртвые, — и никакой обычный индекс это не лечит, потому что дерево в равной мере набито обоими. Интерсептор восемнадцатого урока вшивает DeletedAtIsNil в каждую выборку: SQL каждого запроса содержит WHERE deleted_at IS NULL — но без подходящего индекса условие всё равно работает по скану. Частичный индекс entsql.IndexWhere("deleted_at IS NULL") строит дерево только из живых строк: он меньше по размеру, не деградирует от накопления удалённых записей, а предикат интерсептора совпадает с условием индекса дословно — планировщик берёт его без раздумий. Канонический вид — лента живых постов автора: составной (author_id, created_at), убывающий по времени, с условием deleted_at IS NULL. Поддержка: Postgres и SQLite умеют частичные индексы, MySQL — нет, там остаётся обычный индекс, а условие фильтрует строки на чтении: платите размером дерева, но запрос работает. Заодно ответ на вопрос «почему бы не индексировать deleted_at просто так»: колонка, где почти все значения NULL, малоселективна, и полный индекс на ней почти бесполезен — ценен именно частичный.

Частичный индекс ленты живых постов

GO
// ent/schema/post.go: дерево только из живых строк.
index.Edges("author").Fields("created_at").
        Annotations(
                entsql.DescColumns("created_at"),
                entsql.IndexWhere("deleted_at IS NULL"),
        ),

IndexWhere вшивает условие в определение индекса: база строит B-tree только по постам с deleted_at IS NULL — индекс меньше, не портится от удалений, а WHERE интерсептора из восемнадцатого урока совпадает с ним дословно. DescColumns("created_at") — убывание только по времени, ребро остаётся возрастающим. Поддержка — Postgres и SQLite; в MySQL условие придётся оставить фильтру на чтении, а индекс объявить без WHERE. Именно такой индекс я обещал в четырнадцатом уроке.

Путь индекса от схемы до плана

Соберите жизнь индекса по шагам: от объявления в схеме до ленты без скана.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Соберите индекс ленты

Впишите API: ребро, поле, аннотацию убывающего порядка и стабильное имя.

// лента автора: author_id + created_at, «новые сверху». index.("author").("created_at"). Annotations(entsql.()). ("posts_author_created_idx")

Аннотация схемы и жизнь в миграциях#

Индексы — не единственное, что настраивают аннотации. entsql.Annotation на уровне схемы переопределяет имя таблицы: func (User) Annotations() []schema.Annotation { return []schema.Annotation{ entsql.Annotation{Table: "users"} } } — явная фиксация, которая спасает, когда база старше кода и таблицы зовутся не так, как сгенерирует ent. schema.Annotation — общий интерфейс: любая аннотация реализует Name() string, а мигратор читает только те, что знает. Дальше — миграции, пятнадцатый урок. automigrate сравнивает схему с базой и создаёт недостающие индексы; но у индексов есть свои правила эволюции: переименование колонок в составе индекса или новый порядок — это, чаще всего, drop и create, и в большом проекте без StorageKey вы узнаете об этом по логу миграции, когда уже поздно. Versioned-миграции Atlas фиксируют каждый индекс в SQL-файле, который проходит ревью: именно в этом файле видно, что DescColumns породил DESC в определении, IndexWhere — WHERE deleted_at IS NULL, а IndexPrefix — длину префикса. Разные диалекты дают разный DDL для одних и тех же аннотаций — и это нормально: аннотация выражает намерение, диалект решает, как его исполнить, а где намерение неподдерживаемо — там ревью и ловит проблему, а не продакшен.

Проверка: диалекты и IndexWhere

IndexWhere("deleted_at IS NULL") объявлен в схеме Post. На какой паре диалектов частичный индекс создастся без изменений?

Чеклист индексов ent#

  • Индекс — цена записи и скорость чтения: каждый Create и Update обслуживает все индексы таблицы; добавляйте индексы под конкретные WHERE и ORDER BY из реальных запросов, а не «на всякий случай».
  • Левый префикс правит составными индексами: равенство — первой колонкой, диапазон и сортировка — за ней; индекс (author_id, created_at) не спасёт WHERE created_at > ? без автора.
  • Уникальность одного поля — field.Unique(); уникальность комбинации — index.Fields(...).Unique(); «одно на владельца» — index.Edges(...).Fields(...).Unique().
  • NULL ломает уникальные пары: Optional-колонка в составе Unique-индекса — дыра; либо NOT NULL на колонках пары, либо контроль дубликатов в хуке семнадцатого урока.
  • StorageKey — на каждый индекс с шансом эволюционировать: без стабильного имени переупорядочение колонок в миграции оборачивается drop и create.
  • entsql.Desc и IndexType с IndexPrefix — настройки диалекта: hash и префиксы — про MySQL, не переносите их слепо на Postgres.
  • Частичный индекс IndexWhere("deleted_at IS NULL") — вторая половина мягкого удаления: первая — траверзер DeletedAtIsNil из восемнадцатого урока; поддерживают Postgres и SQLite.
  • Индексы — часть миграций: ревью versioned-файлов Atlas из пятнадцатого урока — место, где видно каждый созданный, переименованный и удалённый индекс.

Что дальше#

Вы умеете вооружать схему: метод Indexes() с index.Fields и index.Edges, уникальные поля и уникальные пары с их NULL-дырой, составные индексы с правилом левого префикса, entsql-аннотации Desc и IndexType, IndexPrefix и IndexWhere, StorageKey для стабильных имён, entsql.Annotation{Table} на уровне схемы — и полная картина миграций из пятнадцатого урока, где каждый индекс виден в DDL. Скорость без порядка — только половина истории: ключсет двенадцатого урока опирается на составной индекс, интерсептор восемнадцатого — на частичный. Дальше — GraphQL-интеграция двадцать первого урока: entgql, автогенерация резолверов и связь графа с внешним API, где каждая связь рождает новые выборки, и держать их будет снова индекс. Домашнее задание ниже заставит индекс показать себя в плане запроса. Полный план из 25 уроков — на странице курса, уникальные поля и валидаторы — в третьем уроке, ключсет-пагинация — в двенадцатом, мягкое удаление — в четырнадцатом, миграции и Atlas — в пятнадцатом, интерсептор живых строк — в восемнадцатом.

Карточки: индексы и аннотации

Термин

index.Fields и index.Edges

нажмите, чтобы перевернуть
Определение

index.Fields — индекс по колонкам полей, имена как в Fields() схемы; index.Edges — по колонке внешнего ключа ребра (author даёт author_id). Цепочка index.Edges("author").Fields("created_at") — составной индекс по author_id + created_at. Объявляются в методе func (T) Indexes() []ent.Index схемы.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • Индексы объявляются в схеме методом func (T) Indexes() []ent.Index: index.Fields — по колонкам полей, index.Edges — по колонке внешнего ключа ребра, цепочка Fields(...).Edges(...) — составной.
  • Уникальность одного поля — field.Unique() из третьего урока; уникальность комбинации — index.Fields(...).Unique(); SQL-уникальность не распространяется на NULL — Optional-колонка в паре делает дыру.
  • Составной индекс подчиняется левому префиксу: (author_id, created_at) обслуживает WHERE author_id = ? с диапазоном и сортировкой, но не WHERE created_at > ?; равенство — первой колонкой.
  • entsql-аннотации индекса: Desc/Asc и DescColumns/AscColumns — направление сортировки, IndexType — тип (hash против btree в MySQL), IndexPrefix — префикс длинных строк, StorageKey — имя в DDL.
  • Частичный индекс entsql.IndexWhere("deleted_at IS NULL") строит дерево только из живых строк — вторая половина мягкого удаления после интерсептора восемнадцатого урока; поддерживают Postgres и SQLite, MySQL — нет.
  • entsql.Annotation{Table: ...} в schema.Annotations() фиксирует имя таблицы; schema.Annotation — общий интерфейс аннотаций, из которого мигратор читает намерения.
  • Миграции: automigrate из пятнадцатого урока создаёт индексы по схеме; без StorageKey переупорядочение колонок = drop и create; versioned-файлы Atlas показывают каждый индекс в ревью.
  • Индекс — цена записи и скорость чтения: каждый Create и Update обслуживает все индексы таблицы; добавляйте индексы под реальные WHERE и ORDER BY и проверяйте планы в EXPLAIN.

Домашнее задание

Работайте на базе из восьмого урока — анна, борис, вера, их посты и группа go-ninja. Первое: снимите план запроса ленты анны — WHERE author_id = ? ORDER BY created_at DESC — до индексов: в EXPLAIN будет скан и сортировка; запишите время исполнения. Второе: объявите в схеме Post индексы из урока: index.Edges("author"), составной index.Edges("author").Fields("created_at") с Annotations(entsql.Desc()) и StorageKey("posts_author_created_idx"), частичный entsql.IndexWhere("deleted_at IS NULL") — прогоните go generate и automigrate и снимите план снова: скан обязан смениться поиском по индексу, а сортировка — исчезнуть. Третье: найдите созданный индекс в базе и убедитесь, что имя — posts_author_created_idx, а определение содержит DESC и WHERE deleted_at IS NULL; затем смените StorageKey и прогоните automigrate ещё раз — в логе миграции вы увидите drop и create: запишите в коммент, почему стабильные имена — часть контракта миграций из пятнадцатого урока. Четвёртое: соберите уникальный составной index.Fields("name", "website").Unique() на User и повторите эксперимент с двумя «гонцами» с пустым website — обе вставки пройдут; затем сделайте website обязательным и повторите: вторая вставка упадёт с ConstraintError. Пятое: если под рукой MySQL — объявите IndexType("hash") на name и IndexPrefix(20) на bio и сравните DDL с Postgres; если MySQL нет — просто зафиксируйте в комменте, какие аннотации урока зависят от диалекта. Вопрос на подумать: почему частичный индекс живёт в схеме, хотя его смысл задаёт предикат интерсептора, и что сломается, если держать их врозь?

Личные заметкиПусто
Хранятся только в вашем браузере (ent · Индексы и аннотации)

Вопросы и ответы по уроку

ent создаёт индексы автоматически?

Часть — да: уникальное поле field.Unique() уже даёт уникальный индекс, а колонки внешних ключей получают индексы по правилам конкретного мигратора и диалекта. Всё остальное — ваша забота: index.Fields и index.Edges в методе Indexes() схемы. Правило простое: любой частый запрос обязан найти индекс в плане (EXPLAIN), и если не нашёл — индекс объявляете вы, а не генератор.

Чем index.Fields(...).Unique() отличается от Unique() на каждом поле?

Уникальность на поле — на колонку: два одинаковых email запрещены, но два одинаковых name — пожалуйста. Уникальный составной индекс — на комбинацию: (анна, сайт1) и (анна, сайт2) — разные строки, и только полный повтор пары запрещён. И помните про NULL: строка, где хоть одна колонка пары NULL, не считается дубликатом ни с кем — строгая уникальность требует NOT NULL на всех колонках пары.

Нужен ли индекс, если данных пока мало?

Обычно нет: на сотнях строк скан быстрее дерева, а каждый лишний индекс платит на каждой записи. Но проектируйте от запросов, а не от объёма: лента, поиск по email и ключсет известны заранее — их индексы появляются вместе с запросами и StorageKey-именами. Правило: индекс добавляет запрос, который ищет его в EXPLAIN, а не гипотеза о будущем трафике; схему с индексами дешевле объявить сразу — миграции довезут.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

go-kit25 уроков~14 чпродвинутый25 уроков уже доступны

go-kit: микросервисный тулкит

Курс go-kit: endpoint, транспорт, логирование, метрики, трейсинг, circuit breaker, service discovery. 25 уроков с итоговым микросервисом.

  • Понимать архитектуру endpoint → transport → service
  • Оборачивать сервисы middleware: логи, метрики, трейсинг
  • Подключать HTTP и gRPC транспорты
  • Ставить rate limiting и circuit breaker