Содержание урока · 13 разделов
- Зачем индексы: скан против поиска
- Индексы в схеме: Fields, Edges и Unique
- Составные индексы: порядок колонок решает
- Уникальность вместе с полями: поле против пары
- entsql-аннотации: тип, префикс, порядок, имя
- Каталог: API индексов ent
- Частичный индекс: IndexWhere и мягкое удаление
- Аннотация схемы и жизнь в миграциях
- Чеклист индексов ent
- Что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Маршрут урока — от скорости к схеме. Сначала — мотив: полнотабличный скан против поиска по дереву на примере ленты постов анны и 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") | тип индекса для MySQL | hash — только равенство; диапазонам нужен 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: ребро, поле, аннотацию убывающего порядка и стабильное имя.
Аннотация схемы и жизнь в миграциях#
Индексы — не единственное, что настраивают аннотации. 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 из пятнадцатого урока — место, где видно каждый созданный, переименованный и удалённый индекс.
Runner-ов в курсе ent нет, и здесь они не появятся: индексы живут в схеме и требуют кодогенерации с базой — правило всего курса. Рабочий круг: объявите индексы урока в схеме Post, прогоните go generate из седьмого урока и automigrate из пятнадцатого, затем снимите план запроса ленты анны до и после — EXPLAIN покажет скан и сортировку до и поиск по индексу после. Проверьте имя в базе: без StorageKey вы найдёте сгенерированное имя, с ним — posts_author_created_idx. Наконец, попробуйте вставить двух «гонцов» с одинаковым name и пустым website: обе вставки пройдут — и это лучший способ запомнить, что SQL-уникальность не распространяется на NULL.
Что дальше#
Вы умеете вооружать схему: метод Indexes() с index.Fields и index.Edges, уникальные поля и уникальные пары с их NULL-дырой, составные индексы с правилом левого префикса, entsql-аннотации Desc и IndexType, IndexPrefix и IndexWhere, StorageKey для стабильных имён, entsql.Annotation{Table} на уровне схемы — и полная картина миграций из пятнадцатого урока, где каждый индекс виден в DDL. Скорость без порядка — только половина истории: ключсет двенадцатого урока опирается на составной индекс, интерсептор восемнадцатого — на частичный. Дальше — GraphQL-интеграция двадцать первого урока: entgql, автогенерация резолверов и связь графа с внешним API, где каждая связь рождает новые выборки, и держать их будет снова индекс. Домашнее задание ниже заставит индекс показать себя в плане запроса. Полный план из 25 уроков — на странице курса, уникальные поля и валидаторы — в третьем уроке, ключсет-пагинация — в двенадцатом, мягкое удаление — в четырнадцатом, миграции и Atlas — в пятнадцатом, интерсептор живых строк — в восемнадцатом.
Карточки: индексы и аннотации
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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 создаёт индексы автоматически?
Часть — да: уникальное поле field.Unique() уже даёт уникальный индекс, а колонки внешних ключей получают индексы по правилам конкретного мигратора и диалекта. Всё остальное — ваша забота: index.Fields и index.Edges в методе Indexes() схемы. Правило простое: любой частый запрос обязан найти индекс в плане (EXPLAIN), и если не нашёл — индекс объявляете вы, а не генератор.
Чем index.Fields(...).Unique() отличается от Unique() на каждом поле?
Уникальность на поле — на колонку: два одинаковых email запрещены, но два одинаковых name — пожалуйста. Уникальный составной индекс — на комбинацию: (анна, сайт1) и (анна, сайт2) — разные строки, и только полный повтор пары запрещён. И помните про NULL: строка, где хоть одна колонка пары NULL, не считается дубликатом ни с кем — строгая уникальность требует NOT NULL на всех колонках пары.
Нужен ли индекс, если данных пока мало?
Обычно нет: на сотнях строк скан быстрее дерева, а каждый лишний индекс платит на каждой записи. Но проектируйте от запросов, а не от объёма: лента, поиск по email и ключсет известны заранее — их индексы появляются вместе с запросами и StorageKey-именами. Правило: индекс добавляет запрос, который ищет его в EXPLAIN, а не гипотеза о будущем трафике; схему с индексами дешевле объявить сразу — миграции довезут.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
go-kit: микросервисный тулкит
Курс go-kit: endpoint, транспорт, логирование, метрики, трейсинг, circuit breaker, service discovery. 25 уроков с итоговым микросервисом.
- Понимать архитектуру endpoint → transport → service
- Оборачивать сервисы middleware: логи, метрики, трейсинг
- Подключать HTTP и gRPC транспорты
- Ставить rate limiting и circuit breaker