---
title: "Индексы и производительность — теги index и uniqueIndex, составной индекс и EXPLAIN в GORM"
description: "gorm индексы через теги: index и uniqueIndex, составной индекс с priority, частичный where, EXPLAIN против Seq Scan и цена индексов при записи."
course: "GORM: ORM-библиотека для Go"
url: "https://golangmaster.ru/courses/gorm/lessons/18-indeksy/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Индексы и производительность — теги index и uniqueIndex, составной индекс и EXPLAIN в GORM

*Курс «GORM: ORM-библиотека для Go», урок 18 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/gorm/lessons/18-indeksy/*

Семнадцатый урок закончил обещанием: keyset-пагинация ищет по индексу, и именно индекс делает её быстрой на любой глубине ленты. Пора заплатить по векселю. gorm индексы объявляются тегами прямо на полях модели — дока формулирует это одной фразой: GORM allows create database index with tag index, uniqueIndex, those indexes will be created when AutoMigrate or CreateTable with GORM; никакого отдельного API «создать индекс» — тег, AutoMigrate, и в схеме появился CREATE INDEX. В этом уроке — полная карта тегов: автоимена вида idx_таблица_колонка, уникальные uniqueIndex и их разница с тегом unique, составной индекс с настройкой priority и направлением sort:desc, частичные индексы через where, служебные настройки class, type и option, ручные операции мигратора и честный разговор про EXPLAIN и цену записи. Одна оговорка о формате: GORM требует рядом настоящую СУБД, поставить её в браузерной песочнице невозможно, поэтому runner-ов в уроке нет — вас проверяют quiz, predict, fill, order и карточки, а EXPLAIN-планы рассчитаны на локальный Go с PostgreSQL или SQLite.

### gorm индексы: что стоит за тегом index

Индекс — служебная структура, чаще всего B-tree, сбалансированное дерево, которое СУБД держит рядом с таблицей и обновляет при каждой записи. Поиск по дереву стоит логарифм от размера таблицы, полный проход — линейный: на тысяче строк разница незаметна, на миллионе — это разница между «мгновенно» и «секунды». В GORM индекс живёт не в SQL-скрипте, а в теге поля: gorm:"index" — и AutoMigrate из четвёртого урока достроит его отдельным CREATE INDEX, если его ещё нет. Поисковый запрос «индексы базы go» обычно приводит не в раздел GORM, а в мануалы PostgreSQL и MySQL — и это честно: GORM не строит деревья сам, он переводит теги в DDL конкретной базы, а быстрый или медленный план построит уже планировщик СУБД. Поэтому урок двуслоен: нижний слой — механика тегов внутри GORM (schema/index.go и мигратор), верхний — то, что эти теги значат для базы: составные ключи, частичные предикаты, EXPLAIN и цена записи. План тот же, что и в предыдущих уроках: от простого к дорогому, и всё — на каноне TodoItem, который мы начали собирать ещё в третьем уроке.

### Канон с индексами: лента и три следа DDL

```go
// Канон из четвёртого-пятого уроков, повзрослевший до индексов:
// Done и CreatedAt образуют составной индекс ленты, Priority
// сохраняет свой одиночный индекс, Slug переведён на uniqueIndex.
type TodoItem struct {
        ID        uint      `gorm:"primaryKey"`
        Title     string    `gorm:"size:140;not null"`
        Done      bool      `gorm:"not null;default:false;index:idx_done_created,priority:1"`
        CreatedAt time.Time `gorm:"index:idx_done_created,priority:2;sort:desc"`
        Priority  int       `gorm:"default:0;index"`
        Slug      string    `gorm:"size:64;uniqueIndex"`
}

db.AutoMigrate(&TodoItem{})
// среди прочего мигратор выпустит (PostgreSQL):
// CREATE INDEX idx_done_created ON todo_items (done, created_at desc)
// CREATE INDEX idx_todo_items_priority ON todo_items (priority)
// CREATE UNIQUE INDEX idx_todo_items_slug ON todo_items (slug)

// запрос ленты, ради которого всё затевалось:
var feed []TodoItem
db.Where("done = ?", false).Order("created_at DESC").Limit(10).Find(&feed)
// SELECT * FROM todo_items WHERE done = false ORDER BY created_at DESC LIMIT 10
```

Три тега — три индекса. Составной собрался из одинакового имени idx_done_created на двух полях: priority расставил колонки, sort:desc направил created_at по убыванию — под запрос ленты. Одиночный тег без имени породил автоимя idx_todo_items_priority, уникальный uniqueIndex — CREATE UNIQUE INDEX с автоименем от колонки. Формулы имён разберём в следующем блоке.

### ParseIndexes: как тег превращается в индекс

Механика — в schema/index.go. Разбором схемы заведует ParseIndexes: она проходит по полям структуры и берёт те, у которых в TagSettings есть INDEX или UNIQUEINDEX — то есть любые теги gorm:"index" и gorm:"uniqueIndex" в любом месте общего тега поля. Для каждого поля parseFieldIndexes режет значение тега по точке с запятой (поэтому одно поле может участвовать в нескольких индексах) и выделяет индексные секции: имя — всё до первой запятой, дальше — настройки, которые ParseTagSetting раскладывает в карту с ключами капсом; экранированная запятая \, склеивается обратно, так что дока пишет comment:hello \, world без страха разрезать комментарий. Имя пустое — не беда: его генерирует namer.IndexName(table, subName), а в naming.go это formatName("idx", таблица, колонка) — отсюда формула автоимени idx_таблица_колонка, знакомая по четвёртому уроку: idx_todo_items_priority. Итог разбора — структура Index{Name, Class, Type, Where, Comment, Option, Fields}: в комментариях исходников Class — это UNIQUE | FULLTEXT | SPATIAL, Type — btree, hash, gist, spgist, gin и brin. Каждая колонка попадает в Fields как IndexOption{Expression, Sort, Collate, Length, Priority} — в исходниках у Sort комментарий DESC, ASC, то есть направление — это настройка колонки, а не часть имени. Поля одного индекса дописываются в порядке объявления структуры, затем slice сортируется по Priority по возрастанию — младший приоритет становится первой колонкой индекса; равные приоритеты оставляют порядок полей структуры, как и обещает дока, а без настройки вовсе приоритет — десять: в исходниках ошибка strconv.Atoi записывает priority = 10.

### Проверка: имя индекса без имени

**Вопрос:** Полю Done у TodoItem достался тег gorm:"index" без имени и настроек. Как объект назовётся в базе?

- ✅ idx_todo_items_done — namer.IndexName склеивает idx, таблицу и колонку
- idx_done — таблица в автоимя не входит
- todo_items_done_idx — суффикс, а не префикс
- Имя будет пустым: без явного имени индекс создаётся анонимным

В parseFieldIndexes пустое имя подменяется вызовом namer.IndexName(table, subName), а NamingStrategy.IndexName в naming.go — это formatName("idx", таблица, колонка). Отсюда все автоимена курса: idx_todo_items_priority, idx_todo_items_slug. Явное имя в теге отключает генерацию — и тогда вы отвечаете за уникальность имён сами.

### uniqueIndex: уникальность как объект базы

Дока формулирует связь коротко: uniqueIndex works similar like index, it equals to index:,unique. В исходниках то же самое: если ключ тега UNIQUEINDEX или в настройках встретился UNIQUE, parseFieldIndexes записывает settings["CLASS"] = "UNIQUE" — и шаблон CREATE <class> INDEX мигратора выведет CREATE UNIQUE INDEX. Отсюда обе доковские формы: голый uniqueIndex и именованный uniqueIndex:idx_name,sort:desc — да, уникальному индексу доступна вся та же карта настроек, включая направление. Но рядом в доке стоит оговорка: Note that this will not work for unique composite indexes — уникальный составной индекс собирается не тегом uniqueIndex на каждом поле, а общим именем с настройкой unique: gorm:"index:idx_member,unique" на каждом из полей. Вторая тонкость — разница с тегом unique, который стоит на Slug в модели четвёртого-пятого уроков: unique ставит field.Unique (в исходниках utils.CheckTruth(tagSetting["UNIQUE"])) и превращается в ограничение колонки в её типе, uniqueIndex — отдельный объект CREATE UNIQUE INDEX. В field.go про это честный комментарий: In some db (e.g. MySQL), Unique and UniqueIndex are indistinguishable — при миграции колонок мигратор сверяет фактическую уникальность с моделью (MigrateColumnUnique) и на расхождение отвечает AlterColumn. И связка с пятым уроком, которую теперь видно целиком: именно уникальный индекс рождает знакомую ошибку вставки дубликата — 23505 в PostgreSQL, 1062 Duplicate entry в MySQL, — а upsert-оговорка пятого урока целится ровно в этот объект базы.

### Что напечатает порядок колонок

```go
// модель из доки, сжатая до двух полей
type User struct {
        Name   string `gorm:"index:idx_member,priority:12"`
        Number string `gorm:"index:idx_member"`
}

db.AutoMigrate(&User{})
fmt.Println(db.Migrator().HasIndex(&User{}, "idx_member"))
```

**Что выведет программа?**

- ✅ true — индекс создан, колонки в порядке (number, name): у Number дефолтный priority 10, он меньше 12
- true — колонки в порядке (name, number): объявление полей важнее priority
- false — два поля с одним именем индекса конфликтуют, и AutoMigrate вернёт ошибку
- true — колонки в порядке (name, number): priority:12 выносит Name первым

Индексы с одинаковым именем сливаются в один составной: HasIndex вернёт true. Порядок колонок определяет priority по возрастанию: у Number настройка не указана, а дефолт равен 10 — число меньше двенадцати, поэтому number идёт первой колонкой. Это доковский пример про приоритет: выше число — дальше колонка, а не наоборот.

### Составной индекс: один объект, несколько колонок

Составной индекс в GORM — это не отдельный тег, а следствие общего имени: дока говорит Use same index name for two fields will creates composite indexes и показывает idx_member из Name и Number с порядком колонок name, number. Уникальная версия — та же пара тегов с настройкой unique: gorm:"index:idx_member,unique" на каждом поле — тогда дубликат пары значений станет невозможен, и в этот объект попадёт ошибка вставки из пятого урока. Почему порядок колонок — не вкусовщина, дока произносит прямо: The column order of a composite index has an impact on its performance so it must be chosen carefully. Правило B-tree: колонки-равенства ставятся вперёд, сортировки и диапазоны — следом. Для ленты канона это (done, created_at desc): равенство done = false работает как точка входа в дерево, а внутри одного значения done строки уже лежат в порядке created_at — базе остаётся отрезать LIMIT 10. Индекс (created_at, done) тоже найдёт нужные строки, но лежат они в нём в порядке времени по всем значениям done вперемешку — полезный префикс короче, работы больше. Механика порядка — priority: дока определяет её одной строкой — the default priority value is 10, if priority value is the same, the order will be based on model struct's field index (в исходниках ошибку strconv.Atoi отдаёт та же десятка), а разборщик сортирует колонки по возрастанию приоритета. Доковские примеры стоит проговорить дословно: priority:2 против priority:1 даёт порядок number, name; priority:12 против тега без priority — тот же порядок number, name, потому что 10 меньше 12. Для встраиваемых структур с повторяющимся составным индексом у доки отдельный приём: тег composite задаёт часть имени — Foo с composite:myname получит idx_foo_myname, встроившая её Bar0 — idx_bar0_myname; работает composite только когда явного имени в теге нет.

### Лента целиком: третья колонка для тай-брейка

```go
// Полная версия индекса ленты: равенство, время по убыванию, id
// третьей колонкой — тай-брейк из семнадцатого урока.
type TodoItem struct {
        ID        uint      `gorm:"primaryKey;index:idx_feed,priority:3"`
        Done      bool      `gorm:"not null;default:false;index:idx_feed,priority:1"`
        CreatedAt time.Time `gorm:"index:idx_feed,priority:2;sort:desc"`
}
// CREATE INDEX idx_feed ON todo_items (done, created_at desc, id)

// keyset-запрос по времени из семнадцатого урока:
var feed []TodoItem
db.Where("done = ? AND created_at < ?", false, lastTime).
    Order("created_at DESC").Order("id DESC").
    Limit(20).Find(&feed)
// SELECT * FROM todo_items
// WHERE done = false AND created_at < ?
// ORDER BY created_at DESC, id DESC LIMIT 20
```

Первичный ключ — тоже поле, и ему можно раздать тег индекса: ID с тем же именем idx_feed и priority:3 замыкает полный порядок из семнадцатого урока — строки с равным временем не теряются на границе курсора. Дерево отдаёт ключи в порядке done, created_at desc, id — ровно то, что просит keyset. Чем шире индекс, тем дороже запись — об этом ниже по курсу урока.

### Проверка: priority:12 против тега без приоритета

**Вопрос:** У Name тег gorm:"index:idx_member,priority:12", у Number — gorm:"index:idx_member". Какая колонка будет первой в индексе?

- ✅ Number: без настройки приоритет равен 10, а сортировка по возрастанию ставит меньший первым
- Name: число 12 больше, значит важнее и идёт первым
- Name: при разных приоритетах порядок полей структуры побеждает
- Порядок не определён: GORM раскладывает колонки случайно

Дефолтный priority равен 10 — это видно и в доке (пример с priority:12 и тегом без настройки даёт порядок number, name), и в исходниках, где ошибка strconv.Atoi отдаёт десятку. Меньшее значение — первая колонка. Если хотите порядок из структуры — ставьте равные приоритеты или не ставьте их вовсе.

### Настройки индекса: class, type, where и компания

Кроме имени и priority, в индексной секции тега живут ещё восемь настроек — дока перечисляет их списком: class, type, where, comment, expression, sort, collate, option. Теперь по каждой видно место в DDL, потому что шаблон создания лежит в миграторе: CreateIndex собирает строку по шаблону из исходников — CREATE, class, INDEX ? ON ?? (имя и таблица), потом USING type, затем COMMENT и option. Комментарии исходников подтверждают словарь: Class — это UNIQUE | FULLTEXT | SPATIAL (полнотекстовые и пространственные индексы MySQL), Type — btree, hash, gist, spgist, gin и brin (структуры PostgreSQL). Сами колонки печатает BuildIndexOptions: имя колонки — в кавычках диалекта; настройка expression заменяет имя целиком (индекс по выражению), length дописывает префикс name(N) — MySQL-овская индексация начала строки; collate и sort добавляются суффиксами после имени. Направление sort:desc из канона — это ровно этот суффикс: колонка с desc в DDL. Разбор настроек идёт через ParseTagSetting, ключи капсулятся, а экранированная запятая \, позволяет держать запятую внутри значения — живой пример из доки: comment:hello \, world. Осталась самая интересная настройка — where: она не про форму индекса, а про то, какие строки в него попадают; ей посвящён следующий блок, как и вопросу, когда такое сужение выгодно.

### Настройки из доки: полнотекст, выражение, префикс

```go
// Доковские примеры, сверенные с gorm.io/docs/indexes.html:
type User struct {
        Name3 string `gorm:"index:,sort:desc,collate:utf8,type:btree,length:10,where:name3 != 'jinzhu'"`
        Age   int64  `gorm:"index:,class:FULLTEXT,comment:hello \, world,where:age > 10"`
        Age2  int64  `gorm:"index:,expression:ABS(age)"`
        OID   int64  `gorm:"index:idx_id;index:idx_oid,unique"`
}

// несколько индексов на одном поле режутся по точке с запятой:
// OID получает и обычный idx_id, и уникальный idx_oid

// опции диалектов из доки:
// MySQL:      gorm:"index:,class:FULLTEXT,option:WITH PARSER ngram INVISIBLE"
// PostgreSQL: gorm:"index:,option:CONCURRENTLY"
```

Каждая настройка приезжает в своё место шаблона: expression:ABS(age) создаёт индекс по выражению, length:10 — префикс первых десяти символов (MySQL), class:FULLTEXT — полнотекстовый класс, comment и option — служебные фразы DDL. where сужает индекс до строк, подходящих под предикат, — это частичный индекс. CONCURRENTLY в PostgreSQL строит дерево без блокировки записи — деталь для прода, о которой скажем в блоке про мигратор.

### Частичный индекс: where: как сужение

Частичный индекс — индекс не по всей таблице, а только по строкам, подходящим под предикат; в теге это настройка where:, и оба доковских примера её используют: where:name3 != 'jinzhu' и where:age > 10. Для канона случай хрестоматийный: лента ищет только невыполненные задачи, а выполненные (done = true) в выборках не участвуют никогда — значит, и дерево по ним не нужно. Вешаем на CreatedAt тег gorm:"index:idx_open,where:done = false" — и в DDL уезжает CREATE INDEX ... ON todo_items (created_at) WHERE done = false. Выигрыш тройной: индекс меньше по размеру, каждая вставка выполненной задачи его не трогает, и все записи дерева — только «живые» строки. Цена тоже честная: планировщик применит частичный индекс, только когда сможет вывести его предикат из вашего WHERE — запрос с литералом done = false попадает точно, а запрос вида WHERE done = ? с параметром СУБД сверит по значению конкретного прогона (PostgreSQL на первом выполнении строит custom plan — обычно это работает в вашу пользу). Вторая цена — практика ревью: предикат живёт внутри DDL, и без AutoMigrate-лога его не видно; в четвёртом уроке мы договаривались смотреть SQL в ревью миграций — частичный индекс тот случай, когда это не пунктуальность, а необходимость. База знаний: если предикат перестал соответствовать запросам, индекс молча перестанет использоваться — без ошибок, только по замедлениям.

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

**Вопрос:** На CreatedAt висит gorm:"index:idx_open,where:done = false". Какой запрос базы гарантированно сможет взять этот индекс?

- ✅ SELECT ... WHERE done = false ORDER BY created_at DESC — предикат запроса совпадает с предикатом индекса
- SELECT ... WHERE done = true — индекс покрывает и противоположный случай
- Любой запрос к todo_items: частичный индекс работает как полный
- Только COUNT(*): частичные индексы читает лишь подсчёт

Частичный индекс содержит только строки под своим предикатом, поэтому планировщик использует его, когда условие запроса выводит этот предикат: done = false — да; done = true — строки в дереве просто не лежат; «как полный» он не работает по определению. Ограничение честное: за сужение платим применимостью.

### Мигратор: HasIndex, CreateIndex и идемпотентность

Что происходит при перезапуске сервиса? AutoMigrate в миграторе проходит по разобранным индексам схемы и для каждого проверяет HasIndex, и только если индекса нет — вызывает CreateIndex; проверка идёт по information_schema.statistics. Перезапуски ничего не задублируют, но и лишней работы не будет: индекс на месте — CREATE INDEX не выпустится. Внутри HasIndex есть деталь, которая спасает от опечаток: аргумент сначала прогоняется через LookIndex, который находит индекс и по имени, и по имени поля — HasIndex(&TodoItem{}, "Done") вернёт правду про idx_done_created, а выдуманное имя честно даст false. При создании таблицы с нуля индексы строятся после таблицы: у диалекторов PostgreSQL и SQLite настройка CreateIndexAfterCreateTable стоит true, мигратор откладывает CreateIndex defer-ом до успеха CREATE TABLE; MySQL собирает индексы прямо внутрь CREATE TABLE. Ручной интерфейс из доковского Migrator: CreateIndex, DropIndex, HasIndex, RenameIndex (переименование — ALTER TABLE ... RENAME INDEX). Зачем это руками, если AutoMigrate сам? Два случая. Первый: тяжёлая таблица продакшена, где индекс нужно строить с опцией CONCURRENTLY — вне окна миграции, отдельным шагом с ревью. Второй: снятый с модели индекс — AutoMigrate удалять ничего не будет, это против его договорённости «ничего не терять», DropIndex вызывается руками. Карта самого AutoMigrate целиком осталась в четвёртом уроке — здесь мы вытащили только индексную его часть.

### Что напечатает тройка HasIndex

```go
// канон из первого блока уже прогнан через AutoMigrate
fmt.Println(db.Migrator().HasIndex(&TodoItem{}, "idx_done_created"))
fmt.Println(db.Migrator().HasIndex(&TodoItem{}, "Done"))
fmt.Println(db.Migrator().HasIndex(&TodoItem{}, "idx_open"))
```

**Что выведет программа?**

- ✅ true true false — LookIndex находит индекс и по имени, и по имени поля, а выдуманное имя даёт false
- true false false — HasIndex понимает только полные имена индексов
- false false false — HasIndex работает лишь для уникальных индексов
- true true true — любое имя вернёт true, если таблица существует

HasIndex сначала пробует LookIndex: он ищет совпадение и среди имён индексов, и среди имён полей в их колонках, — поэтому Done разрешается в idx_done_created. Третье имя в схеме не существует ни как индекс, ни как поле с индексом — information_schema.statistics по нему пуст, метод вернёт false. Отсюда практический совет: проверяйте HasIndex после рефакторингов имён, не после деплоя.

### EXPLAIN: как проверить, что индекс работает

EXPLAIN — команда вашей СУБД, а не GORM: библиотека не прогоняет планы сама, она лишь показывает сгенерированный SQL, который вы и отдаёте планировщику. Словарь PostgreSQL, которого хватит на 90 % случаев: Seq Scan — полный проход по таблице; Index Scan — поиск по дереву с заходами в таблицу за остальными колонками; Bitmap Index Scan вместе с Bitmap Heap Scan — индекс строит битовую карту подходящих строк, затем куча читается одним заходом; Index Only Scan — все нужные запросу колонки уже лежат в индексе. EXPLAIN без слов просто показывает план и ничего не исполняет; EXPLAIN ANALYZE реально выполняет запрос и печатает фактическое время и число строк на каждом узле — это уже дорогая операция, а на изменяющих запросах (INSERT, UPDATE, DELETE) она честно их выполнит, так что на проде её обкладывают транзакцией с откатом. Как проверить GORM-запрос: включите логгер при открытии базы (gorm.Config с Logger в режиме Info), скопируйте SELECT из следа и выполните в psql с EXPLAIN ANALYZE. На узле-скане целевой таблицы должно произойти одно из двух: либо Seq Scan сменился на Index Scan с Index Cond по вашему индексу, либо планировщик вправе отказал — маленькая таблица, слабая селективность (done = false при девяноста пяти процентах таких строк — проще прочитать почти всё). Индекс — предложение планировщику, а не приказ; EXPLAIN — способ услышать ответ.

### Разбор плана ленты: до и после индекса

```go
// Шаг 1: снять SQL ленты с включённым логгером
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
        Logger: logger.Default.LogMode(logger.Info), // SQL в stdout
})
// ... db.Where("done = ?", false).Order("created_at DESC").
//         Limit(10).Find(&feed)

// Шаг 2: тот же запрос в psql:
//
//   EXPLAIN ANALYZE
//   SELECT * FROM todo_items WHERE done = false
//   ORDER BY created_at DESC LIMIT 10;
//
// Без индекса (план-миниатюра):
//   Limit
//     -> Sort
//          -> Seq Scan on todo_items
// С индексом idx_done_created (done, created_at desc):
//   Limit
//     -> Index Scan using idx_done_created on todo_items
//          Index Cond: (done = false)
```

Пара «до/после» — типичный рабочие карточки: без индекса база читает таблицу и сортирует, с индексом дерево отдаёт строки сразу в нужном порядке, и Sort исчезает из плана. Цифры cost и actual time печатает только живая база — GORM в плане не участвует. На маленькой каноновой таблице планировщик может оставить Seq Scan и быть правым: индекс оценивают на объёмах, приближенных к продовым.

### Когда индекс вредит: цена записи

Каждый индекс — вторая структура, которую база обязана поддерживать при каждой вставке, каждом удалении и каждом апдейте, затрагивающем индексные колонки. Пять индексов на таблице — пять дополнительных правок дерева на каждую строку; в цикле вставок это видно по таймингам даже без профайлера, а на диске индексы живут своей жизнью и занимают место, сопоставимое с данными. Отсюда три линии гигиены. Первая: индекс заводят под конкретный запрос, а не под поле «на всякий случай» — гипотетические индексы тормозят все вставки ради несуществующего SELECT. Вторая: не дублировать префиксы — отдельный индекс на done при существующем составном (done, created_at) бесполезен: левый префикс составного уже отвечает на фильтр по done, а каждая лишняя структура — та же надбавка к записи. Третья: следить за жизненным циклом — индекс, снятый с модели, AutoMigrate не удаляет (его договор «ничего не терять» из четвёртого урока), и мёртвый индекс будет платить налог на запись вечно, пока вы не вызовете DropIndex руками. Проверка выгоды всегда замером: EXPLAIN ANALYZE до и после для чтения, тайминги вставки для записи — решение принимается по цифрам, а не по ощущениям. И помните про выборочность: индекс на колонку с двумя значениями почти всегда дешевле пропустить — B-tree по done мало что отсекает, составной с done в роли равенства — да, потому что отсекать начинает вторая колонка.

### Индексы под пагинацию: keyset без индекса мёртв

Теперь promise семнадцатого урока можно закрыть формулой. Keyset-пагинация выигрывает у offset именно поиском по индексу: WHERE id < ? ORDER BY id DESC LIMIT n без индекса на сортировочном ключе — это Seq Scan всей таблицы и сортировка всех подходящих строк ради десяти; с индексом — спуск по дереву от курсора, логарифм на любой глубине страницы. Первичный ключ индексирован всегда, поэтому keyset по id работает из коробки; курсор по времени (лента с тай-брейком ORDER BY created_at DESC, id DESC) уже требует составного индекса — и наш idx_feed из середины урока собран ровно под неё: done как равенство, created_at desc как порядок, id как тай-брейк. Уберите любую из трёх колонок — и keyset либо потеряет фильтр, либо потеряет порядок, либо начнёт терять строки на границе курсора. С offset-страницами индекс тоже дружит, но меньше: OFFSET 100000 всё равно прочтёт сто тысяч записей дерева — просто из дерева, а не из таблицы; поэтому для глубоких лент совет семнадцатого урока не меняется: keyset. И последний мостик: приём «запасной строки» n+1 для метки HasNext едет по тому же индексу — лишняя строка достаётся почти бесплатно, только если дерево отдаёт строки в нужном порядке. Скорость keyset — это скорость индекса; два урока — одна связка.

### Соберите путь индекса: от замера до замера

Расставьте шаги рабочего цикла — от обнаружения медленного запроса до проверки цены записи.

Правильный порядок строк:

1. `включить SQL-след логгером в режиме Info и скопировать медленный SELECT из следа`
2. `прогнать запрос через EXPLAIN ANALYZE на живой базе и найти узел Seq Scan по целевой таблице`
3. `спроектировать составной индекс: колонки-равенства с меньшим priority вперёд, сортировка — следом с sort:desc`
4. `повесить общий индекс на нужные поля модели с приоритетами — порядок колонок решает производительность`
5. `применить AutoMigrate: он проверит HasIndex и достроит индекс отдельным CREATE INDEX`
6. `повторить EXPLAIN ANALYZE и убедиться, что узел сменился на Index Scan по новому индексу`
7. `замерить цену записи таймингами вставок до и после; бесполезный индекс снять DropIndex`

### Заполните пропуски: индекс ленты тегами

Три пропуска: приоритет поля-равенства, настройка направления у created_at и тег уникального индекса на Slug.

```go
type TodoItem struct {
        ID        uint      `gorm:"primaryKey;index:idx_feed,priority:3"`
        Done      bool      `gorm:"not null;default:false;index:idx_feed,priority:___"`
        CreatedAt time.Time `gorm:"index:idx_feed,priority:2;___:desc"`
        Slug      string    `gorm:"size:64;___"`
}
```

Подсказка: Равенства идут вперёд — у Done самый младший приоритет; направление колонки задаёт настройка sort; уникальность как отдельный индекс — не констрейнт-тег unique.

Правильные ответы в пропусках: `1`, `sort`, `uniqueIndex`

> **Пять ловушек индексов.** Первая: ожидание чужого имени — тег без имени создаёт idx_таблица_колонка, и HasIndex по выдуманному имени вернёт false, хотя индекс есть; сверяйтесь через LookIndex или автоимя. Вторая: порядок колонок «как в структуре» — без priority составной соберётся по декларациям, равенство может уехать в хвост, индекс будет, а пользы не будет. Третья: uniqueIndex с настройками в роли составного — дока прямо предупреждает, this will not work for unique composite indexes; уникальный составной собирается общим именем с unique на каждом поле. Четвёртая: забыть цену записи — каждая вставка платит за все индексы таблицы, а снятые с модели индексы AutoMigrate не удаляет: мёртвые деревья снимаются DropIndex вручную. Пятая: поверить плану без факта — EXPLAIN показывает замысел планировщика, а не время; на маленьких таблицах честный Seq Scan бывает быстрее Index Scan, поэтому решающий замер — EXPLAIN ANALYZE на объёмах, близких к продовым.

### Чек-лист урока

- теги gorm:"index" и gorm:"uniqueIndex" на полях модели — единственный способ объявить индекс; создаются AutoMigrate или CreateTable, как обещает дока
- имя без значения генерируется namer.IndexName по формуле idx_таблица_колонка; пустое имя с тегом composite даёт idx_таблица_идентификатор
- uniqueIndex равен index:,unique: в исходниках CLASS становится UNIQUE и DDL выходит CREATE UNIQUE INDEX; уникальный составной собирается общим именем с unique, а не uniqueIndex-тегом
- составной индекс — это одинаковые имена на нескольких полях; порядок колонок задаёт priority (дефолт 10, сортировка по возрастанию, ничья — по порядку полей структуры)
- правило составного: равенства вперёд, сортировка следом; тай-брейк по ключу добавляется третьей колонкой, даже если это primaryKey
- настройки: class (UNIQUE, FULLTEXT, SPATIAL), type (btree, hash, gin и другие), length — префикс MySQL, collate и sort — суффиксы колонки, expression — индекс по выражению, option — служебные фразы диалекта
- частичный индекс — настройка where:; сужает дерево до строк предиката, экономит размер и запись, но планировщик применяет его, только если предикат выводим из запроса
- проверка — только замером: EXPLAIN ANALYZE на живой базе показывает смену Seq Scan на Index Scan; цена записи меряется таймингами вставок, бесполезные индексы снимаются DropIndex

### Что дальше по курсу

Индексы держатся за соседние темы курса. Keyset-пагинация и метка HasNext, которым индекс нужен как воздух, разобраны в [семнадцатом уроке про пагинацию](/courses/gorm/lessons/17-paginaciya/), карта AutoMigrate и договор «ничего не терять» — в [четвёртом уроке про миграции](/courses/gorm/lessons/04-migracii/); там же — модель, из которой здесь выросли теги. Ошибка уникальности 23505 и upsert-оговорка остались в пятом уроке — теперь вы знаете, какой объект базы за ними стоит. Дальше по маршруту: девятнадцатый урок «Скоупы и переиспользование запросов» развернёт скопы — на них построен пагинатор из семнадцатого, и наши индексные запросы самое время будет упаковать в переиспользуемые фильтры. Карта всех двадцати пяти уроков — на [странице курса](/courses/gorm/). Домашка ниже прогоняет полный цикл: тег, DDL, план, замер.

### Карточки: индексы и производительность

- **Как объявить индекс в GORM** — Тегами на полях: gorm:"index" и gorm:"uniqueIndex". Дока: those indexes will be created when AutoMigrate or CreateTable with GORM. Имя — до первой запятой в секции (index:idx_name), настройки — после неё через запятую; несколько индексов на одном поле режутся точкой с запятой. Механика — ParseIndexes в schema/index.go.
- **Автоимя индекса** — Пустое имя в теге генерирует namer.IndexName(table, subName), а NamingStrategy.IndexName в naming.go — это formatName("idx", таблица, колонка): idx_todo_items_priority. subName — имя поля, либо идентификатор тега composite для встраиваемых структур (Foo с composite:myname даст idx_foo_myname). Явное имя отключает генерацию.
- **uniqueIndex против unique** — uniqueIndex равен index:,unique: в parseFieldIndexes CLASS становится UNIQUE, мигратор печатает CREATE UNIQUE INDEX. Тег unique ставит field.Unique — ограничение колонки. В field.go: In some db (e.g. MySQL), Unique and UniqueIndex are indistinguishable. Уникальный составной — не uniqueIndex, а общее имя с unique на каждом поле. Именно уникальный индекс даёт ошибку дубликата 23505/1062 из пятого урока.
- **Составной индекс и priority** — Одно имя на нескольких полях = один составной индекс. Порядок колонок — priority по возрастанию, дефолт 10, ничья — порядок полей структуры (sort.Slice в ParseIndexes). Примеры доки: priority:2/1 и priority:12/дефолт дают порядок number, name. Правило: равенства вперёд, сортировка следом; направление колонки — настройка sort:desc, а не часть имени.
- **Настройки индексной секции** — class: UNIQUE | FULLTEXT | SPATIAL; type: btree, hash, gist, spgist, gin, brin; length — префикс name(N) для MySQL; collate и sort — суффиксы колонки; expression заменяет колонку выражением (ABS(age)); where — частичный индекс (сужение строками предиката); comment и option — фразы DDL (CONCURRENTLY у PostgreSQL). Шаблон собирает CreateIndex мигратора.
- **EXPLAIN и цена индексов** — EXPLAIN показывает план (Seq Scan, Index Scan, Bitmap, Index Only), EXPLAIN ANALYZE исполняет и мерит время — на изменяющих запросах обкладывать откатом. Проверка GORM-запроса: логгер Info, SQL в psql. Цена записи: каждый индекс — правка дерева на вставку/удаление/апдейт колонки; AutoMigrate индексы не удаляет — DropIndex руками; выгоду доказывать замером до/после.

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

- Индексы в GORM объявляются тегами gorm:"index" и gorm:"uniqueIndex" на полях модели и создаются AutoMigrate или CreateTable; механика — ParseIndexes в schema/index.go.
- Автоимя — namer.IndexName по формуле idx_таблица_колонка; явное имя задаётся до первой запятой, настройки — после; несколько индексов на поле режутся точкой с запятой.
- uniqueIndex равен index:,unique и выводит CREATE UNIQUE INDEX; уникальный составной собирается общим именем с unique, а не uniqueIndex-тегом; уникальный индекс — источник ошибки дубликата из пятого урока.
- Составной индекс — одинаковые имена на нескольких полях; порядок колонок задаёт priority (дефолт 10, по возрастанию, ничья — порядок полей); правило: равенства вперёд, сортировка с sort:desc следом, тай-брейк ключом третьей колонкой.
- Карта настроек: class (UNIQUE, FULLTEXT, SPATIAL), type (btree, hash, gin и другие), length — префикс MySQL, collate, sort, expression — индекс по выражению, where — частичный индекс, comment и option — фразы DDL.
- Частичный индекс where: сужает дерево до строк предиката — меньше размер и дешевле запись, но работает только когда предикат выводим из запроса.
- Мигратор идемпотентен: HasIndex перед CreateIndex (поиск по имени и по полю через LookIndex); CreateIndexAfterCreateTable у диалекторов true; снятые с модели индексы удаляются только DropIndex.
- EXPLAIN ANALYZE на живой базе — единственный судья: Seq Scan должен смениться на Index Scan; каждый индекс стоит записи, поэтому индекс заводят под запрос, а пользы и вреда меряют замером.

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

Разверните канон локально и пройдите полный цикл индексного инженера. Шаг первый: соберите модель из первого блока — Done и CreatedAt с общим idx_done_created, Priority с одиночным тегом, Slug с uniqueIndex — и запустите AutoMigrate; по логу выпишите все CREATE INDEX и сверьте имена с формулой idx_таблица_колонка. Шаг второй: наполните таблицу парой тысяч строк циклом вставок и прогоните ленту WHERE done = false ORDER BY created_at DESC LIMIT 10 с включённым логгером; скопируйте SQL и выполните его в psql два раза — с EXPLAIN и с EXPLAIN ANALYZE; найдите в плане Index Scan по idx_done_created и Index Cond с done = false. Шаг третий: снимите с модели теги idx_done_created и перезапустите AutoMigrate — убедитесь по HasIndex, что индекс остался в базе: AutoMigrate не удаляет. Верните теги, а отдельный индекс снимите руками DropIndex и зафиксируйте, что HasIndex теперь false. Шаг четвёртый: сделайте частичный индекс на created_at с where:done = false и сравните EXPLAIN ANALYZE с полным — посмотрите на размер узлов и оцените, как изменились тайминги вставки тысячи строк до и после появления каждого нового индекса; запишите три цифры: полный, частичный, без индексов. Шаг пятый: постройте keyset-запрос по времени из семнадцатого урока с тай-брейком по id и проверьте, что план использует idx_feed из середины урока; затем временно переименуйте индекс в модели и убедитесь, что план деградировал до сортировки. Финал: в комментарии к домашке напишите, какой из индексов вы бы оставили в проде и почему — с опорой на замеры, а не на интуицию.

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

### Чем uniqueIndex отличается от unique и почему это важно для ошибок вставки?

uniqueIndex создаёт отдельный объект CREATE UNIQUE INDEX (в исходниках настройка уходит в CLASS = UNIQUE), а тег unique — ограничение колонки в её типе; в MySQL оба проявляются одинаково, поэтому мигратор сверяет фактическую уникальность через MigrateColumnUnique. Для логики вставки разницы нет: и тот и другой рождает ошибку дубликата — 23505 в PostgreSQL и 1062 Duplicate entry в MySQL, — которую пятый урок превращал в upsert-оговорку. Для уникального составного индекса тег uniqueIndex не подходит — дока предлагает общее имя с настройкой unique на каждом поле.

### Как выбрать порядок колонок в составном индексе и что делает priority?

Порядок колонок влияет на производительность, потому что B-tree ищет по префиксу: колонки-равенства ставятся первыми, сортировка или диапазон — следом. Внутри одного индекса порядок задаётся настройкой priority: дефолт равен 10, колонки сортируются по возрастанию приоритета, при равных значениях — по порядку полей структуры. Направление отдельной колонки задаётся настройкой sort:desc — оно попадает суффиксом в DDL. Если запрос сортирует по времени с тай-брейком по ключу, добавьте первичный ключ третьей колонкой с большим приоритетом.

### Как убедиться, что GORM-запрос использует индекс?

GORM планов не показывает — он выдаёт SQL в следе логгера (режим Info). Скопируйте запрос и выполните его в psql с EXPLAIN ANALYZE: на живой базе вы увидите и план, и фактическое время. Признак успеха — Index Scan или Bitmap Index Scan по вашему индексу вместо Seq Scan; на маленьких таблицах планировщик вправе оставить полный проход, и это не поломка. Решение об индексе принимайте по замерам на объёмах, близких к продовым, и не забывайте цену записи: каждый индекс замедляет вставки, а снятые с модели индексы AutoMigrate не удаляет — их снимают DropIndex вручную.
