Go: GORM
Теория: Производительность и оптимизация
Когда данных становится много, «просто рабочий» код на GORM начинает упираться в базу. Одни запросы становятся медленными, другие бьют по диску и памяти, третьи неожиданно превращаются в десятки маленьких обращений (N+1). Оптимизация здесь всегда про три вещи:
- как устроена схема (индексы и ограничения);
- как мы загружаем связи;
- сколько данных реально вытаскиваем из базы.
Разберём каждую из этих частей.
Индексы и ограничения
База данных не «угадывает», какие поля для нас важные. По умолчанию любой фильтр — это просмотр всей таблицы. Индексы и ограничения — способ заранее подсказать СУБД, по каким полям мы будем искать и какие правила должны всегда выполняться.
Индекс — это структура данных (упрощённо, отсортированное дерево), по которой база быстро находит строки для WHERE, JOIN, ORDER BY и GROUP BY. Без индекса:
вынуждает СУБД просканировать всю таблицу. С индексом по email — это поиск в дереве, который практически не зависит от количества строк.
Ограничения — это правила целостности: NOT NULL, UNIQUE, CHECK, FOREIGN KEY. Они не ускоряют запросы напрямую, но гарантируют, что в таблицу не попадут бессмысленные значения (например, отрицательная сумма или дублирующийся email).
В GORM всё это задаётся прямо в модели.
Пример: пользователь с уникальным email и индексом по дате создания:
Здесь:
uniqueIndex— создаст уникальный индекс поEmail(нельзя вставить двух пользователей с одним email);indexнаNameиCreatedAt— обычные индексы для частых фильтров и сортировок.
При миграции GORM сгенерирует команды вида:
Если вы часто фильтруете по сочетанию полей, нужен составной индекс:
Этот тег index:idx_user_title,unique говорит: создать один составной уникальный индекс по (user_id, title). Такой индекс ускоряет запросы:
и одновременно не даст одному пользователю создать два поста с одинаковым заголовком.
Ограничения CHECK и NOT NULL описываются так же:
При миграции появится ограничение:
Теперь любые попытки вставить отрицательный Amount будут падать на уровне базы, даже если где-то забыли отдельную валидацию.
Тонкая настройка возможна через Migrator:
Так можно постепенно добавлять индексы в существующий проект, ориентируясь на реальные запросы.
Главная мысль: индексы ставят под конкретные паттерны использования (частые WHERE, JOIN, ORDER BY), а ограничения — под критичные бизнес-правила. Всё остальное — лишний груз: каждый индекс ускоряет чтение, но замедляет вставки и обновления, потому что базе нужно обновлять индексы при каждой операции.
Избежание N+1 при загрузке связей
Классическая проблема ORM — запрос N+1. Сценарий выглядит так:
- Вы делаете один запрос за списком сущностей (например, пользователей).
- Потом в цикле для каждого пользователя отдельно вытаскиваете его посты.
- В результате база получает 1 запрос на список + N запросов на связи.
На десятках пользователей это быстро превращается в лавину.
Плохой код:
Логи будут такими:
Если пользователей 50 — это уже 51 запрос.
Правильный подход — жадная загрузка (eager loading) через Preload():
GORM сделает всего два запроса:
и разложит посты по пользователям в памяти.
Можно сразу фильтровать связи:
Здесь пользователи будут загружены все, а в поле Posts попадут только опубликованные посты. SQL для постов:
Если у вас вложенные связи, их можно подгружать цепочкой:
ORM сделает три отдельных запроса (users, posts, comments, плюс profiles) и соберёт дерево.
Иногда нужно не просто подгрузить связи, но и отфильтровать сущности по ним. Тогда помогают Joins():
Здесь база сразу отберёт только тех пользователей, у которых есть посты с большим количеством лайков. Такой подход тоже избегает N+1, но результат — плоская выборка, а не автоматически собранные связи.
Что важно:
- N+1 — это не баг GORM, а побочный эффект ленивых запросов к связям.
- Чтобы его избежать, нужно осознанно использовать
Preload()и/илиJoins(), а не ходить к association в цикле. Preload("*")— тяжёлая артиллерия. Она убирает N+1, но подгружает всё подряд, что может стать отдельной проблемой производительности.
Хорошее правило: для каждого публичного метода репозитория заранее решить, какие связи ему реально нужны, и явно указать их через Preload(), вместо того чтобы полагаться на автоматическую загрузку где-то в глубине кода.
Выборка только нужных полей
Даже когда запросов немного и индексы настроены, можно легко «убить» производительность, таща лишние данные. GORM по умолчанию генерирует SELECT *, то есть выбирает все колонки модели. Для сущностей с большим количеством полей, JSON-колонками или BLOB’ами это лишняя нагрузка на базу, сеть и парсер драйвера.
Метод Select() позволяет задать, какие именно колонки нужны.
Простейший пример:
SQL:
В структуру User будут заполнены только эти поля, остальные останутся нулевыми.
Для списков и отчётов это особенно важно:
Нет смысла вытаскивать полный текст поста, если вы показываете только заголовок в ленте.
Отдельный часто используемый паттерн — выборка в «облегчённую» структуру через Scan():
Это удобно для API-слоя: одна модель (User) для полной работы с таблицей, другая (UserLite) — для DTO в ответе.
Select() хорошо сочетается с Preload():
SQL будет примерно таким:
То есть и у пользователя, и у профиля грузятся только действительно использующиеся поля.
Чего делать не стоит: выбирать часть полей в полноценную модель и потом пытаться её сохранить назад. Незаполненные поля останутся нулевыми, и при неосторожном db.Save(&u) можно случайно перетереть значения в базе. Для чтения — да, для записи — лучше отдельные структуры или аккуратные Updates()/Omit().
Подведем итоги
Производительность кода с GORM держится на трёх опорах:
- Схема: индексы под реальные запросы и ограничения на критичные инварианты.
- Запросы к связям: явное использование
Preload/Joins, чтобы не попадать в N+1 и не тянуть лишнее. - Объём данных:
Select()и лёгкие DTO вместо бесконечныхSELECT *.
GORM даёт удобный декларативный слой над SQL, но за выбор схемы и запросов по-прежнему отвечает разработчик. Регулярно смотреть в логи SQL, запускать EXPLAIN и считать количество запросов — хорошая привычка, которая быстро показывает, где именно приложение начинает тормозить.



