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

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

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

Урок 14 из 25 ~22 мин +50 XP

Preloading и жадная загрузка — Preload, Joins, N+1 и вложенные загрузки в GORM

Связи объявлены, записи сохранены — осталось научиться читать их целиком, а не по одной записи. Тринадцатый урок закрыл триаду связей обещанием показать Preload — и вот он. Ключевую мысль проговорим сразу: gorm preload — это не способ «включить ленивую загрузку», потому что ленивой загрузки в GORM нет вовсе. Find по спискам вернёт срез Items пустым и молча: ни одного дополнительного запроса GORM не сделает, «дочитывать» связь при обращении к полю он тоже не станет — в отличие от Hibernate или Rails, ассоциация всегда ровно та, что вы загрузили, а чаще всего пустая. Жадная загрузка — единственный способ наполнить срезы, и дока формулирует это спокойно: GORM allows eager loading relations in other SQL with Preload. Проблема N+1 в мире GORM возникает не сама, а из рук: читая связи вручную в цикле, по одному запросу на каждую запись, вы получаете то, что в поиске так и осталось n+1 problem gorm — сотню SELECT на страницу. Preload лечит это одной IN-выборкой на уровень связей, вложенный Preload строит цепочку уровней, условный Preload фильтрует и сортирует детей, а Joins-предзагрузка собирает belongs to одним запросом с LEFT JOIN. Одна честная оговорка о формате: СУБД в песочницу не поставишь, поэтому runner-ов в уроке нет — вас проверяют quiz, predict, fill, order и карточки, а SQL-след рассчитан на локальный Go с PostgreSQL или SQLite; печатает его логгер из второго урока.

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

gorm preload: ассоциации пусты, пока вы их не попросили#

Начнём с демистификации. В предыдущих уроках поле-ассоциация при чтении всегда было пустым: Find по спискам возвращал TodoList, у которого Items — пустой срез, Find по комментариям — Comment с нулевым TodoItem. Кто-то ждёт, что GORM «дочитает» связь при первом обращении к полю — так работают ленивые загрузчики в других ORM: прокси-объекты, сессии, магия под капотом. В GORM этого нет ни в каком виде: после SELECT * FROM todo_lists поля заполняются колонками этой самой таблицы, и на этом запрос закончен. Пустой Items — не «отложенная загрузка» и не баг: запроса к todo_items просто никогда не было, срезу неоткуда наполниться. Это осознанный дизайн: GORM не делает ни одного запроса без вашей команды, и каждая выборка в логе — чья-то явная. Читать связь по одной записи вы уже умеете: db.Model(&list).Association("Items").Find(&items) из двенадцатого урока и Preload-тизер из тринадцатого показывали оба направления — belongs to держит ключ у себя, has many у детей, а одиннадцатый урок объясняет, откуда GORM берёт условия. А вот для списка записей Association-подход ломается: одна запись — один запрос, двадцать списков — двадцать запросов. Классическая ловушка называется N+1: один SELECT владельцев плюс по одному на каждую запись. В поиске она так и осталась n+1 problem gorm — и важная оговорка именно для GORM: ORM не создаст N+1 сам, без Preload он вообще не читает связей; N+1 возникает только тогда, когда вы читаете их вручную в цикле. Ниже — как выглядит и то и другое в SQL-следе.

Пустые срезы и ручной цикл: как выглядит N+1

GO
// Канон: списки «Дом», «Работа», «Ремонт» - и задачи в каждом
var lists []TodoList
db.Find(&lists)
// SELECT * FROM todo_lists;  -- lists[0].Items, lists[1].Items ... ПУСТЫЕ срезы
// Ни одного запроса к todo_items: GORM не телепат и не ленив - он просто молчит

// Антипаттерн: дочитываем каждого в цикле
for i := range lists {
    db.Model(&lists[i]).Association("Items").Find(&lists[i].Items)
}
// SELECT * FROM todo_items WHERE todo_list_id = 1   -- «Дом»
// SELECT * FROM todo_items WHERE todo_list_id = 2   -- «Работа»
// SELECT * FROM todo_items WHERE todo_list_id = 3   -- «Ремонт»
// ...и так по одному SELECT на каждый список страницы

// Тот же след даёт и сырой цикл по первичным ключам:
for i := range lists {
    db.Where("todo_list_id = ?", lists[i].ID).Find(&lists[i].Items)
}

Два шага одного антипаттерна. Первый: главный запрос вернул владельцев с пустыми ассоциациями — не «загрузка отложена», а запроса к детям не было вовсе. Второй: чтение в цикле — для каждой записи отдельный SELECT с условием по её ключу. На странице из 50 списков уедет 51 запрос: классическое 1+N. Сетевые задержки, пул соединений и логгер страдают раньше самой базы: каждая поездка стоит примерно одинаково, сколько бы строк она ни вернула, — и именно это делает N+1 дороже одной пакетной выборки.

Проверка: сколько запросов у ручного цикла

Страница из 50 списков. В цикле для каждого вызывается db.Model(&lists[i]).Association("Items").Find(&items). Сколько SELECT-запросов уедет в базу?

Как Preload устроен изнутри: Statement.Preloads и колбэк gorm:preload#

Механика прозаичнее, чем кажется. Метод Preload живёт в chainable_api.go — среди Where и Order, а не среди finisher-ов, и это честная подсказка: сам он ничего не запрашивает. Его тело — одна строка: tx.Statement.Preloads[query] = args. Имя ассоциации и аргументы ложатся в карту Statement.Preloads и ждут своего часа; повторный Preload с тем же именем перезапишет запись карты — последнее условие выигрывает, об этом ловушка ниже. Час настаёт на Find: цепочка колбэков запроса выполняет gorm:query — главный SELECT, — а сразу за ним зарегистрирован gorm:preload из callbacks/preload.go. Сначала parsePreloadMap режет имена по точкам (Items.Comments — уровень Items и вложенный Comments), а запись clause.Associations разворачивает во все прямые связи схемы — ровно как Select(clause.Associations) при каскаде из двенадцатого урока. Затем имена сортируются: sort.Strings с комментарием «avoid random traversal of the map» — порядок загрузки ассоциаций определяется алфавитом, а не порядком ваших вызовов. Дальше preload(): GetIdentityFieldValuesMap собирает значения ключей владельцев из уже прочитанных строк, и строится clause.IN — одна выборка на уровень; для many2many предварительно читается join-таблица, поэтому на уровень уходит две выборки — механика тринадцатого урока. Перед раскладкой поля очищаются — «clean up old values before preloading»: срезы has many и m2m пересоздаются через reflect.MakeSlice (пустой, но НЕ nil), единичные структуры — через reflect.New. Раскладка идёт по identityMap: у каждой загруженной строки читается её ключ, и строка приписывается владельцу, чей ключ совпал; строка без хозяина — ошибка failed to assign association, make sure foreign fields exists. Опечатка в имени — ошибка ErrUnsupportedRelation: «Items: unsupported relations for schema TodoList».

Preload: одна IN-выборка на уровень связей

GO
// Жадная загрузка has many: ключи владельцев собираются в IN
var lists []TodoList
db.Preload("Items").Find(&lists)
// SELECT * FROM todo_lists;
// SELECT * FROM todo_items WHERE todo_list_id IN (1,2,3);
// lists[0].Items, lists[1].Items, lists[2].Items - наполнены одним вторым запросом

// Жадная загрузка belongs to: ключи уже лежат в читаемых строках
var comments []Comment
db.Preload("TodoItem").Find(&comments)
// SELECT * FROM comments;
// SELECT * FROM todo_items WHERE todo_items.id IN (4,4,7,8);  -- дубликат 4 схлопнется в IN

// Задача со своим списком и комментариями: три запроса
db.Preload("TodoList").Preload("Comments").Find(&items)
// SELECT * FROM todo_items;
// SELECT * FROM todo_lists WHERE todo_lists.id IN (1);
// SELECT * FROM comments WHERE comments.todo_item_id IN (4);

Обратите внимание на разницу направлений. Для has many IN собирается из первичных ключей владельцев, а условие — по чужой колонке todo_list_id. Для belongs to всё наоборот: IN собирается из значений внешнего ключа читаемых строк, условие — по первичному ключу соседней таблицы, как в доковском SELECT * FROM roles WHERE id IN (4,5,6). Сколько бы строк ни вернул главный запрос, дочерних выборок будет ровно по одной на каждую запрошенную ассоциацию.

Условный Preload: фильтры, порядок и func-условия#

Второй аргумент Preload — условия на выборку ДЕТЕЙ. Inline-условие выглядит как аргументы Where: db.Preload("Items", "status = ?", "done").Find(&lists) — и уедет во второй запрос: WHERE todo_list_id IN (1,2,3) AND status = 'done'. Дока показывает зеркальный пример: Preload("Orders", "state NOT IN (?)", "cancelled") даёт SELECT FROM orders WHERE user_id IN (1,2,3,4) AND state NOT IN ('cancelled') — «it works similar to Inline Conditions», как inline-условия Create. Более гибкая форма — func-условие: вместо строки передаётся func(db gorm.DB) gorm.DB, который получает ВЕСЬ конвейер дочернего запроса и волен добавить не только Where, но и Order, Select, Limit. Доковский пример — сортировка: db.Preload("Orders", func(db gorm.DB) *gorm.DB { return db.Order("orders.amount DESC") }).Find(&users) — след показывает «order by orders.amount DESC»; в исходниках func-условия применяются напрямую к tx, а inline-условия уходят в tx.Where(inlineConds[0], inlineConds[1:]...). Важнейшее правило, которое экономит вечер отладки: условие Preload фильтрует детей, а НЕ владельцев. Семантики INNER JOIN нет: список, у которого не нашлось ни одной выполненной задачи, вернётся — с пустым (но ненулевым) срезом. И вложенный нюанс из доки: условие родителя сужает следующий уровень — если заказы отфильтрованы условием state = 'paid', то их OrderItems не загрузятся: вложенная запись Preload("Orders.OrderItems") катается на родительском запросе, и «unmatched» заказы до третьего уровня не доедут.

Условный Preload: условие во второй запрос, порядок детей

GO
// Inline-условие: только выполненные задачи
db.Preload("Items", "status = ?", "done").Find(&lists)
// SELECT * FROM todo_lists;
// SELECT * FROM todo_items WHERE todo_list_id IN (1,2,3) AND status = 'done'
// Список без выполненных задач вернётся - с пустым срезом Items

// func-условие: полный конвейер дочернего запроса - сортировка
db.Preload("Items", func(db *gorm.DB) *gorm.DB {
    return db.Order("items.id ASC")
}).Find(&lists)
// SELECT * FROM todo_lists;
// SELECT * FROM todo_items WHERE todo_list_id IN (1,2,3) order by items.id ASC

// Условие родителя сужает вложенный уровень
db.Preload("Items", "status = ?", "done").Preload("Items.Comments").Find(&lists)
// комментарии загрузятся только для задач, переживших фильтр status = 'done'

Три градации одного приёма. Inline-условие — строка с плейсхолдерами, самый частый случай. func-условие — когда нужны Order, Select или что-то ещё, чего inline не умеет. Условие на промежуточный уровень — каскадное сужение: комментарии поедут только за теми задачами, которые прошли фильтр. Владельцев это не фильтрует никогда: за «только списки с выполненными задачами» идите через Where с EXISTS-подзапросом — задача другого слоя.

Что вернёт условный Preload

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

// База: списки «Дом» (id=1) и «Работа» (id=2).
// В «Дом» есть выполненные задачи; в «Работа» все задачи в статусе todo.
var lists []TodoList
db.Preload("Items", "status = ?", "done").Find(&lists)
fmt.Println(len(lists), lists[0].Name, len(lists[1].Items), lists[1].Items != nil)

Вложенный Preload и Preload All через clause.Associations#

Связи канона глубже одного уровня: у списка задачи, у задачи комментарии и теги. Вложенный Preload записывается через точку — db.Preload("Items.Comments").Find(&lists) — и выполняется по уровням: сначала главный запрос списков, затем задачи IN-выборкой по собранным ключам списков, затем комментарии IN-выборкой по собранным ключам задач. Каждый уровень — отдельный запрос, и это важная инварианта: цепочка из трёх уровней — это три запроса при трёх записях и всё те же три запроса при трёх тысячах, тогда как ручной цикл дал бы тысячи. Дока показывает трёхуровневый пример db.Preload("Orders.OrderItems.Product").Preload("CreditCard").Find(&users) — уровни комбинируются в одном вызове с одиночными ассоциациями. Вторая возможность — Preload All: запись clause.Associations разворачивается во все ПРЯМЫЕ ассоциации модели, как и при каскаде удаления из двенадцатого урока. Для TodoList с единственным полем-срезом Items это эквивалент Preload("Items"). Ограничение доки важно запомнить: «clause.Associations won’t preload nested associations, but you can use it with Nested Preloading together» — Preload All не заходит вглубь, но сочетается с явной вложенностью: db.Preload("Items.Comments").Preload(clause.Associations).Find(&lists) загрузит задачи, их комментарии и всё остальное первого уровня. Это удобный дефолт для «страницы всего» с точечными доработками: широкая загрузка одним словом плюс целевые уровни через точку.

Вложенный Preload: уровень = запрос

GO
// Два уровня канона: списки -> задачи -> комментарии
db.Preload("Items.Comments").Find(&lists)
// SELECT * FROM todo_lists;
// SELECT * FROM todo_items WHERE todo_list_id IN (1,2,3);
// SELECT * FROM comments WHERE comments.todo_item_id IN (4,6,7);

// Preload All: все ПРЯМЫЕ ассоциации TodoList - то есть только Items
db.Preload(clause.Associations).Find(&lists)
// SELECT * FROM todo_lists;
// SELECT * FROM todo_items WHERE todo_list_id IN (1,2,3);
// Комментарии НЕ загрузились: clause.Associations не заходит вглубь

// Комбинация: всё прямое плюс целевой глубокий уровень
db.Preload("Items.Comments").Preload(clause.Associations).Find(&lists)
// три запроса: списки, задачи, комментарии

Имя вложенной записи — это путь по ПОЛЯМ структур: Items.Comments значит «у каждого элемента Items возьми поле Comments». Первое звено всегда сверяется со схемой владельца, каждое следующее — со схемой предыдущего уровня. Три запроса на страницу — не расточительство: каждый вернул нужный набор целиком, и на это не влияет ни число списков, ни число задач в них.

Проверка: глубина Preload All

У TodoList объявлен Items []TodoItem; у TodoItem — Comments и Tags. Что загрузит db.Preload(clause.Associations).Find(&lists)?

Joins-предзагрузка: один запрос с LEFT JOIN#

У жадной загрузки есть второй сорт — Joins-предзагрузка. Дока разводит их одной фразой: «Preload loads the association data in a separate query, Join Preload will loads association data using left join». Вместо двух поездок — одна: главный SELECT сразу собирает владельца и соседа. Выглядит это как db.Joins("TodoItem").Find(&comments): первым аргументом идёт ИМЯ ПОЛЯ-ассоциации, и GORM отличает Joins-предзагрузку от «сырого» Joins с SQL-строкой — у него первым аргументом идёт условие JOIN, а здесь имя поля. В SELECT появляются колонки соседа с префиксом — TodoItem__id, TodoItem__title, — а LEFT JOIN строится по связи: FROM comments LEFT JOIN todo_items TodoItem ON comments.todo_item_id = TodoItem.id. После чтения сканирование раскладывает префиксные колонки прямо в поле TodoItem каждого комментария — Preload вызывать не нужно. Ограничение доки жёсткое: «Join Preload works with one-to-one relation, e.g: has one, belongs to» — has many и many2many так не загрузишь, JOIN дал бы декартово произведение строк владельцев. Условия передаются вторым аргументом и уезжают в ON: db.Joins("TodoItem", db.Where(&TodoItem{Status: "done"})).Find(&comments) — ON ... AND TodoItem.status = 'done'. Работает и вложенность: db.Joins("TodoItem").Joins("TodoItem.TodoList").Find(&comments) — второй JOIN с алиасом TodoItem__TodoList, ровно как доковский Manager__Company. Держите в голове различие: Joins("JOIN todo_items ...") с сырой строкой — это фильтр владельцев, поля ассоциаций он НЕ заполняет; Joins("TodoItem") по имени поля — предзагрузка.

Joins-предзагрузка: след одного запроса

GO
// belongs to одним запросом: колонки соседа с префиксом
db.Joins("TodoItem").Find(&comments)
// SELECT comments.*, TodoItem.id AS "TodoItem__id", TodoItem.title AS "TodoItem__title", ...
// FROM comments LEFT JOIN todo_items TodoItem ON comments.todo_item_id = TodoItem.id
// поле Comment.TodoItem заполнено из префиксных колонок - Preload не нужен

// Условие уходит в ON, а не в WHERE
db.Joins("TodoItem", db.Where(&TodoItem{Status: "done"})).Find(&comments)
// ... LEFT JOIN todo_items TodoItem ON comments.todo_item_id = TodoItem.id
//     AND TodoItem.status = 'done' AND TodoItem.deleted_at IS NULL

// Вложенная цепочка belongs to: комментарий -> задача -> список
db.Joins("TodoItem").Joins("TodoItem.TodoList").Find(&comments)
// ... LEFT JOIN todo_lists "TodoItem__TodoList"
//     ON TodoItem.todo_list_id = "TodoItem__TodoList".id

Один запрос вместо трёх: комментарии, их задачи и списки задач приехали одним SELECT. LEFT выбран не случайно: комментарий с битым todo_item_id не должен потеряться — LEFT сохранит строку владельца, а префиксные колонки останутся NULL, и поле TodoItem у такой записи окажется нулевым. Условие в ON, а не в WHERE: фильтр соседа не срезает строки владельцев, а лишь решает, приехал ли сосед.

Preload против Joins: что приедет

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

// Comment: TodoItemID uint, TodoItem *TodoItem - belongs to
// Вариант A
db.Preload("TodoItem").Find(&comments)

// Вариант B
db.Joins("TodoItem").Find(&comments)

Joins или Preload: два сорта жадной загрузки#

Тринадцатый урок обронил, что жадная загрузка бывает двух сортов; теперь у вас есть механика обоих. Считайте стоимость. Preload: по одной поездке на уровень (для m2m — две: join-таблица плюс записи), но каждая поездка возвращает компактный набор одной таблицы, дублирования нет, а условия и порядок детей настраиваются свободно. Joins-предзагрузка: одна поездка, но колонки соседа повторяются в каждой строке владельца — при широком соседе ответ раздувается, зато LIMIT-ловушка рукописных JOIN-ов здесь исключена: LIMIT уезжает в главный запрос по владельцам, соседи вклеиваются без ограничений — при ручном JOIN с LIMIT пагинация режет уже склеенные строки, и часть владельцев теряет соседей. Выбор сорта — это выбор по форме связи: belongs to и has one — территория Joins (один запрос, одна запись соседа на строку); срезы has many и many2many — территория Preload, потому что Joins-предзагрузки для них не существует (NOTE доки). Отсюда практическая сетка канона: «комментарий → задача → список» — Joins("TodoItem").Joins("TodoItem.TodoList") одной поездкой; «список → задачи → комментарии и теги» — Preload("Items.Comments").Preload("Items.Tags") отдельными выборками. Смешивать законно: Joins для одно-к-одному хвоста и Preload для срезов живут в одном конвейере, потому что gorm:preload и сканирование JOIN-колонок — разные этапы одного Find. На задачах «вывести и отфильтровать по соседу» выигрывает Joins — фильтр по TodoItem.status ложится прямо в главный запрос; на задачах «выгрузить дерево» выигрывает Preload — данные не дублируются.

Шпаргалка: preloading в GORM#

ЗадачаКодЧто происходит
Загрузить срез детейdb.Preload("Items").Find(&lists)второй SELECT: WHERE todo_list_id IN (…) — одна IN-выборка на уровень
Загрузить belongs todb.Preload("TodoItem").Find(&comments)второй SELECT: WHERE todo_items.id IN (…) из ключей прочитанных строк
Условие на детейdb.Preload("Items", "status = ?", "done")условие уедет в IN-выборку; владельцы не фильтруются
Порядок и Select детейdb.Preload("Items", func(db gorm.DB) gorm.DB { return db.Order("items.id ASC") })func-условие получает весь конвейер дочернего запроса
Вложенная загрузкаdb.Preload("Items.Comments")по одному запросу на уровень; условие родителя сужает следующий
Все прямые ассоциацииdb.Preload(clause.Associations)разворачивается в прямые связи схемы; вложенность не заходит
Один запрос с JOINdb.Joins("TodoItem").Find(&comments)LEFT JOIN + колонки TodoItem__*; только one-to-one
JOIN с условиемdb.Joins("TodoItem", db.Where(&TodoItem{Status: "done"}))условие уходит в ON, а не в WHERE
Вложенный JOINdb.Joins("TodoItem").Joins("TodoItem.TodoList")алиас TodoItem__TodoList; вся цепочка belongs to одной поездкой
Joins для has manydb.Joins("Items") — по имени поля-срезане сработает: Join Preload работает только с has one и belongs to
Чтение одной записиdb.Model(&list).Association("Items").Find(&items)один SELECT по ключу владельца; для списков записей — Preload

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

На странице 10 списков. Выполняется db.Preload("Items.Comments").Preload("Items.Tags").Find(&lists) — теги связаны с задачами через many2many:todo_tags. Сколько SELECT уедет?

Заполните пропуски: от вложенности до сортировки

Соберите пять вызовов preloading: вложенный уровень, Preload All, Joins-предзагрузка, сортировка детей и условие — пропуски идут по порядку.

// 1) списки -> задачи -> комментарии db.Preload("").Find(&lists) // 2) все ПРЯМЫЕ ассоциации модели одним словом db.Preload(clause.).Find(&lists) // 3) belongs to одним запросом с LEFT JOIN db.("TodoItem").Find(&comments) // 4) отсортировать детей внутри Preload db.Preload("Items", func(db *gorm.DB) *gorm.DB { return db.("items.id ASC") }).Find(&lists) // 5) условие на детей: только выполненные db.Preload("Items", "status = ", "done").Find(&lists)

Соберите работу gorm:preload для Preload("Items.Comments")

Расставьте шаги колбэка предзагрузки после того, как главный SELECT списков уже выполнен.

Строки

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

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

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

  • ленивой загрузки в GORM нет: без Preload ассоциации пусты, N+1 возникает только от ручного чтения в цикле
  • Preload ничего не запрашивает сам: пишет в Statement.Preloads, а работает колбэк gorm:preload после главного SELECT
  • одна IN-выборка на уровень; m2m — две: join-таблица, затем записи; belongs to — IN по чужим первичным ключам
  • условный Preload: inline-условие строкой или func-условие с Order/Select/Limit; фильтруются дети, не владельцы
  • вложенный Preload через точку: уровень = отдельный запрос; условие родителя сужает следующий уровень
  • clause.Associations — Preload All только прямых ассоциаций; вложенность комбинируется явным Preload("Items.Comments")
  • Joins-предзагрузка — один запрос с LEFT JOIN, колонки TodoItem__*, только has one и belongs to; условие уходит в ON
  • Joins по SQL-строке поля ассоциаций не заполняет; повторный Preload не складывает условия — перезаписывает последним вызовом

Канон одним маршрутом: пять запросов на страницу#

Соберём страницу целиком: списки, их задачи, комментарии задач и теги задач. Ручной цикл сделал бы 1 + N списков + M задач + K тегов запросов; preloading делает это пятью SELECT: db.Preload("Items.Comments").Preload("Items.Tags").Find(&lists) — списки; задачи WHERE todo_list_id IN; комментарии WHERE todo_item_id IN; строки join-таблицы todo_tags WHERE todo_item_id IN; теги WHERE tags.id IN. Пять запросов при любом объёме данных — вот цена вопроса, которую обсуждали под меткой n+1 problem gorm. Добавьте clause.Associations, чтобы захватить прямые ассоциации, которые появятся у TodoList позже, — и маршрут переживёт эволюцию модели без правки запроса. Обратное направление читается зеркально: теги со своими задачами — db.Preload("Items").Find(&tags), задача со своим списком — db.Preload("TodoList").First(&item, 4). Прелесть канона в том, что всё уже знакомо: модели и связи — из одиннадцатого, двенадцатого и тринадцатого уроков, чтение одной записи — Association из двенадцатого, а теперь и вся страница собирается одной цепочкой Preload. Сверьте следы с логгером: пять строк SELECT на страницу — норма; если видите десятки одинаковых SELECT с разным todo_list_id — где-то остался ручной цикл.

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

Жадная загрузка встала на обе ноги: Preload с IN-выборками для срезов, Joins — одним LEFT JOIN для одно-к-одному, вложенные уровни и clause.Associations для широкой выгрузки. Полезно перечитать следы своих запросов: где-то в проекте наверняка живёт ручной цикл, который заменяется одной IN-выборкой, а зеркальные модели канона удобно сверить по одиннадцатому уроку и двенадцатому; join-таблица todo_tags и её двухступенчатое чтение — в тринадцатом уроке; карта курса из 25 уроков — на странице курса. Дальше — надёжность записи: Пятнадцатый урок «Транзакции» покажет, как собрать несколько шагов сохранения в атомарный маршрут через db.Transaction, что происходит при вложенных вызовах, как работают SavePoint и уровни изоляции — и почему Preload внутри транзакции видит те же данные, что и её владелец. Домашка ниже собирает preloading канона в один скрипт.

Карточки: preloading и жадная загрузка

Термин

Preload

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

Жадная загрузка отдельными запросами: db.Preload("Items").Find(&lists) выполняет главный SELECT, затем одну IN-выборку на уровень — SELECT * FROM todo_items WHERE todo_list_id IN (…). Сам метод ничего не запрашивает: пишет в Statement.Preloads, работает колбэк gorm:preload после gorm:query. Раскладка по владельцам через identityMap по ключам.

1 / 6

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

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

Вывод

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

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

  • Ленивой загрузки в GORM нет: Find заполняет поля только своей таблицы, ассоциации остаются пустыми; N+1 — это не поведение ORM, а ручное чтение связей в цикле (1 главный запрос плюс по одному на запись).
  • Preload ничего не запрашивает: пишет имя ассоциации и аргументы в Statement.Preloads (chainable_api.go), а раскладку выполняет колбэк gorm:preload из callbacks/preload.go сразу после gorm:query; имена сортируются, порядок вызовов не важен.
  • Механика: ключи владельцев собираются в identityMap, строится clause.IN — одна выборка на уровень; belongs to — IN по чужим первичным ключам; m2m — две выборки (join-таблица, затем записи); срезы пересоздаются («clean up old values»), опечатка даёт ErrUnsupportedRelation.
  • Условный Preload: inline-условие строкой (status = ?) или func-условие с доступом ко всему конвейеру детей — Order, Select, Limit; фильтруются дети, а не владельцы; условие промежуточного уровня сужает вложенный.
  • Вложенный Preload через точку — по одному запросу на уровень независимо от числа записей; clause.Associations разворачивается только в прямые ассоциации и комбинируется с явной вложенностью.
  • Joins-предзагрузка — один SELECT с LEFT JOIN и префиксными колонками (TodoItem__*), работает только для has one и belongs to; условие уходит в ON; вложенная цепочка — Joins("TodoItem.TodoList"); сырой Joins с SQL-строкой поля не заполняет.
  • Выбор сорта: срезы и выгрузка дерева — Preload; belongs to-цепочки и фильтрация по соседу — Joins; страница канона (списки, задачи, комментарии, теги) — пять SELECT вместо сотен.

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

Разверните канон локально и пройдите preloading по кругу. Заполните базу: два-три списка, в каждом задачи с разными Status, у задач комментарии и теги через many2many. Шаг первый: db.Find(&lists) — убедитесь по логгеру, что выполнен один SELECT, а срезы Items пусты; напишите в комментарии к коду, почему GORM не сделал ни одного запроса к todo_items. Шаг второй: воспроизведите N+1 — прочитайте задачи каждого списка в цикле через Association("Items").Find и посчитайте запросы в следе (1+N). Шаг третий: замените цикл на db.Preload("Items").Find(&lists) и сверьте след: два SELECT, второй — с WHERE todo_list_id IN (…); проверьте, что при пяти и пятидесяти списках число запросов не меняется. Шаг четвёртый: условный Preload — db.Preload("Items", "status = ?", "done") и func-условие с db.Order("items.id ASC"); найдите оба варианта условия в SQL-следе, проверьте, что список без выполненных задач вернулся с ненулевым пустым срезом, а повторный Preload("Items", ...) с другим условием перезаписал первый. Шаг пятый: вложенная загрузка db.Preload("Items.Comments").Preload("Items.Tags") — сверьте, что m2m-уровень дал две выборки (todo_tags, затем tags), и посчитайте суммарные запросы страницы (пять). Шаг шестой: Joins-предзагрузка — db.Joins("TodoItem").Joins("TodoItem.TodoList").Find(&comments), найдите в следе алиасы TodoItem__* и TodoItem__TodoList, проверьте, что поле TodoItem у комментария заполнено без Preload; попробуйте db.Joins("Items") на списках и разберите ошибку unsupported relations. Финал: соберите «страницу всего» через Preload(clause.Associations) плюс целевые вложенные уровни и объясните по следу, почему clause.Associations не загрузил комментарии сам.

Личные заметкиПусто
Хранятся только в вашем браузере (GORM · Preloading и жадная загрузка — Preload, Joins, N+1 и вложенные загрузки в GORM)

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

Есть ли в GORM ленивая загрузка и откуда берётся N+1?

Ленивой загрузки нет: GORM никогда не дочитывает связи сам, поэтому без Preload поля-ассоциации просто пусты — запроса к соседней таблице не было. Классическое N+1 в мире GORM возникает из рук: чтение связей вручную в цикле — Association(...).Find или Where по ключу каждой записи — даёт один главный запрос плюс по одному на каждую строку. Лечится жадной загрузкой: Preload собирает ключи всех владельцев и делает одну IN-выборку на уровень.

Чем Preload отличается от Joins-предзагрузки?

Preload грузит ассоциацию отдельным запросом: одна IN-выборка на уровень, данные не дублируются, условия и сортировка детей свободные — работает для всех типов связей. Joins-предзагрузка (db.Joins("TodoItem")) вклеивает соседа в главный SELECT через LEFT JOIN с колонками TodoItem__* — одна поездка, но только для one-to-one (has one, belongs to). Сырой db.Joins("JOIN ...") с SQL-строкой поля ассоциаций не заполняет — это лишь фильтр.

Почему после условного Preload у владельца пустой срез и как загрузить всё дерево связей?

Условие Preload фильтрует выборку детей, а не владельцев: список без подходящих задач вернётся с пустым, но ненулевым срезом — GORM пересоздаёт поля перед раскладкой («clean up old values»). Если владельцы без детей не нужны — фильтруйте их сами через Where с EXISTS. Всё дерево грузится комбинацией: Preload(clause.Associations) разворачивается в прямые ассоциации, а вложенные уровни добавляются явно через Preload("Items.Comments") — clause.Associations сам вглубь не заходит.

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

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

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

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

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

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

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

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

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