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

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

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

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

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

Семнадцатый урок закончил обещанием: 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.

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

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" без имени и настроек. Как объект назовётся в базе?

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-оговорка пятого урока целится ровно в этот объект базы.

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

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

// модель из доки, сжатая до двух полей
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"))

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

Составной индекс в 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". Какая колонка будет первой в индексе?

Настройки индекса: 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". Какой запрос базы гарантированно сможет взять этот индекс?

Мигратор: 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

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

// канон из первого блока уже прогнан через 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"))

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 — это скорость индекса; два урока — одна связка.

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

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

Строки

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

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

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

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

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;"` }

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

  • теги 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, которым индекс нужен как воздух, разобраны в семнадцатом уроке про пагинацию, карта AutoMigrate и договор «ничего не терять» — в четвёртом уроке про миграции; там же — модель, из которой здесь выросли теги. Ошибка уникальности 23505 и upsert-оговорка остались в пятом уроке — теперь вы знаете, какой объект базы за ними стоит. Дальше по маршруту: девятнадцатый урок «Скоупы и переиспользование запросов» развернёт скопы — на них построен пагинатор из семнадцатого, и наши индексные запросы самое время будет упаковать в переиспользуемые фильтры. Карта всех двадцати пяти уроков — на странице курса. Домашка ниже прогоняет полный цикл: тег, DDL, план, замер.

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

Термин

Как объявить индекс в GORM

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

Тегами на полях: gorm:"index" и gorm:"uniqueIndex". Дока: those indexes will be created when AutoMigrate or CreateTable with GORM. Имя — до первой запятой в секции (index:idx_name), настройки — после неё через запятую; несколько индексов на одном поле режутся точкой с запятой. Механика — ParseIndexes в schema/index.go.

1 / 6

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

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

Вывод

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

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

  • Индексы в 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 из середины урока; затем временно переименуйте индекс в модели и убедитесь, что план деградировал до сортировки. Финал: в комментарии к домашке напишите, какой из индексов вы бы оставили в проде и почему — с опорой на замеры, а не на интуицию.

Личные заметкиПусто
Хранятся только в вашем браузере (GORM · Индексы и производительность — теги index и uniqueIndex, составной индекс и EXPLAIN в GORM)

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

Чем 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 вручную.

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

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

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

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

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

sqlc25 уроков~12 чпрактика25 уроков уже доступны

sqlc: типобезопасный код из SQL-запросов

Курс sqlc: настройка, запросы, параметры, NULL-типы, транзакции, джойны, пагинация, CI и сравнение с ORM. 25 уроков с практикой.

  • Настраивать sqlc.yaml для Postgres/MySQL/SQLite
  • Писать аннотированные запросы с типизированными параметрами
  • Работать с NULL, массивами и JSON-колонками
  • Организовывать транзакции через WithTx