Eager, lazy и batch: когда приложение должно грузить данные
Eager loading в Rails, await import() в Node.js, DataLoader в GraphQL и loading="lazy" у картинки выглядят как четыре несвязанные техники из разных слоёв стека. На самом деле это четыре ответа на один вопрос: в какой момент система платит за ресурс, который может понадобиться, а может и нет. Разберём механику каждого подхода, а затем соберём из них общую модель принятия решения.
За что мы на самом деле платим
Интуиция «загрузка данных стоит столько, сколько весят данные» почти всегда ошибочна. Реальный счёт складывается из трёх независимых статей расходов.
| Ресурс | Что тратится | Кто страдает первым |
|---|---|---|
| Латентность | время на круговой обход (round trip) до источника | пользователь, ждущий ответа |
| Пропускная способность | байты по сети, память под результат | мобильный клиент, GC, кэш БД |
| Ёмкость источника | соединения, воркеры, IOPS, лимиты API | все остальные пользователи одновременно |
Ключевой момент: эти статьи оптимизируются по-разному и часто конфликтуют. Один большой запрос экономит латентность, но грузит память и может утроить объём переданных байтов. Сто маленьких запросов экономят трафик, но съедают пул соединений и добавляют сто задержек подряд.
Поэтому базовая величина при проектировании загрузки - не объём данных, а число последовательных обходов на критическом пути:
время ответа ≈ Σ(round trip) + Σ(работа источника) + Σ(работа приложения)
100 запросов × 1 мс сети = 100 мс, даже если каждый запрос вернул 40 байт
Отсюда растут все дальнейшие решения. Их удобно разложить по трём осям:
- когда грузим: заранее (eager), по требованию (lazy), с опережением по прогнозу (speculative);
- какой гранулярностью: по одной записи, пачкой (batch), потоком (streaming);
- кто решает: автор кода на этапе разработки, рантайм во время выполнения или сам пользователь своим поведением.
Ruby, Node.js, Go, GraphQL и браузер отвечают на эти три вопроса по-разному, и разница между ними объясняется не модой, а свойствами среды выполнения.
Ruby: ленивое по умолчанию, явно жадное по требованию
ActiveRecord - редкий пример среды, где ленивость встроена в объектную модель. Ассоциация user.posts - это не массив, а прокси-объект, который выполнит SQL только в момент первого обращения.
posts = Post.limit(200) # SQL ещё не выполнен
posts.each do |post|
puts post.author.name # каждый вызов = отдельный SELECT
end
Это классический N+1: один запрос за списком и по одному запросу на каждую запись. При 200 постах и 1 мс на обход это 200 мс, потраченных не на данные, а на ожидание. Причём в коде проблема невидима: post.author.name выглядит как обращение к полю в памяти.
Rails предлагает четыре разных инструмента, которые часто путают:
| Метод | SQL | Загружает в память | Позволяет фильтровать по связи |
|---|---|---|---|
joins(:author) |
INNER JOIN | нет | да |
preload(:author) |
2 отдельных запроса, второй с IN (...) |
да | нет |
eager_load(:author) |
1 запрос с LEFT OUTER JOIN | да | да |
includes(:author) |
выбирает preload или eager_load сам | да | да, с references |
includes - это не «третий способ», а эвристика: без условий по связанной таблице он ведёт себя как preload, а при references(:authors) или при where("authors.name = ?") переключается на eager_load. Отсюда знаменитый сюрприз: одна и та же строка кода может выполнить два запроса или один JOIN в зависимости от того, что дописали рядом.
flowchart TB
Q["Post.limit(200)"]
subgraph N1["N+1: ленивая ассоциация"]
direction TB
A1["SELECT * FROM posts<br/>LIMIT 200"] --> A2["SELECT * FROM authors<br/>WHERE id = 1"]
A2 --> A3["SELECT * FROM authors<br/>WHERE id = 2"]
A3 --> A4["... ещё 198 запросов"]
end
subgraph PL["preload: батч по ключам"]
direction TB
B1["SELECT * FROM posts<br/>LIMIT 200"] --> B2["SELECT * FROM authors<br/>WHERE id IN (1, 2, ... 87)"]
end
subgraph EL["eager_load: один JOIN"]
direction TB
C1["SELECT posts.*, authors.*<br/>FROM posts<br/>LEFT OUTER JOIN authors"]
end
Q --> N1
Q --> PL
Q --> EL
Важно, что eager_load не всегда лучше preload. При загрузке нескольких коллекций одновременно JOIN даёт декартово произведение строк: пользователь с 20 постами и 30 комментариями вернётся из базы 600 раз, и каждый раз с полным набором своих колонок. Два-три таких JOIN превращают экономию одного обхода в мегабайты трафика и заметную работу по дедупликации на стороне Ruby. Правило простое: JOIN хорош для связей «многие к одному», отдельные запросы - для «один ко многим».
Дальше Rails даёт инструменты, чтобы ленивость не оставалась незамеченной:
# 1. Запретить неявные дозагрузки: любое обращение к незагруженной связи
# падает с ActiveRecord::StrictLoadingViolationError (Rails 6.1+)
user = User.strict_loading.first
user.posts # => исключение вместо тихого лишнего запроса
# 2. Независимые запросы можно отправить параллельно (Rails 7.0+)
@users = User.where(active: true).load_async
@stats = Report.recent.load_async
strict_loading меняет модель ответственности: раньше N+1 был проблемой производительности, которую ловили в проде через логи или гем Bullet, теперь это ошибка, которая падает в тестах. А load_async бьёт по другой оси - он не убирает запросы, но убирает их последовательность.
Отдельный приём - вообще не грузить данные, а держать производную величину рядом: counter_cache хранит posts_count в строке пользователя, превращая агрегат в поле. Самая дешёвая загрузка - та, которой не произошло.
Node.js: связи явные, но проблема та же
В экосистеме Node ленивых прокси почти не осталось, и это осознанное решение. Prisma и Drizzle заставляют перечислять связи в запросе:
// Prisma: связь существует, только если её попросили
const posts = await prisma.post.findMany({
take: 200,
include: { author: true },
relationLoadStrategy: "join", // или "query"
});
relationLoadStrategy - это в точности выбор между eager_load и preload из Rails, только вынесенный в явный параметр: join собирает результат в базе через LATERAL JOIN и JSON-агрегацию, query шлёт несколько запросов и склеивает данные в приложении. Драйвер стал честнее, но компромисс остался прежним: один обход и риск раздувания строк против нескольких обходов и склейки в рантайме.
Sequelize и TypeORM ближе к рубиновой модели и допускают ленивые связи, но и там ленивость выражена типом:
// TypeORM lazy relation: свойство возвращает Promise
const author = await post.author; // await внутри цикла = N+1
Тип Promise<Author> заставляет писать await - и в этом есть неожиданная польза: ленивая загрузка перестаёт маскироваться под чтение поля. Но появляется специфичная для Node ловушка:
// последовательно: 200 обходов подряд, ~200 мс
for (const post of posts) {
post.authorName = (await getAuthor(post.authorId)).name;
}
// параллельно: 200 запросов одновременно, ~1 обход по времени,
// но пул соединений выдаст 10 и остальные встанут в очередь
await Promise.all(posts.map(p => getAuthor(p.authorId)));
Однопоточный цикл событий делает конкурентный I/O почти бесплатным по CPU, поэтому Promise.all кажется исправлением. На деле он переносит очередь из приложения в пул соединений и в базу: 200 одновременных запросов при пуле в 10 соединений - это те же 20 волн ожидания плюс риск исчерпать пул для всех остальных запросов процесса. Конкурентность без батчинга не решает N+1, а перекладывает его на слой ниже.
Go: никакой магии, только явный код
В Go привычная связка - pgx плюс sqlc или ручной SQL, где никаких ассоциаций и ленивых прокси нет в принципе. Поэтому N+1 в Go выглядит как N+1 и в коде: цикл с запросом внутри видно глазами на ревью.
Естественное решение - батч по ключам, который в Rails прячется внутри preload:
// собрать ключи, сходить один раз, разложить по мапе
ids := make([]int64, 0, len(posts))
for _, p := range posts {
ids = append(ids, p.AuthorID)
}
rows, err := db.Query(ctx,
`SELECT id, name FROM authors WHERE id = ANY($1)`, ids)
authors := map[int64]Author{} // индекс для склейки в памяти
GORM повторяет рубиновую дихотомию почти дословно: Preload("Author") шлёт второй запрос с IN, Joins("Author") делает один запрос с JOIN. А для параллельных независимых загрузок используется errgroup, который заодно ограничивает конкурентность:
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8) // явный потолок вместо неограниченного Promise.all
g.Go(func() error { return loadUsers(ctx) })
g.Go(func() error { return loadStats(ctx) })
err := g.Wait()
Ещё один инструмент, которого нет в типичном ORM-мышлении, - singleflight: если сто горутин одновременно просят один и тот же ключ, к источнику уходит один запрос, а результат получают все сто. Это не eager и не lazy, а дедупликация во времени - лекарство от cache stampede, когда истёкший ключ кэша мгновенно порождает лавину одинаковых запросов.
| Свойство | Ruby / ActiveRecord | Node / Prisma, TypeORM | Go / sqlc, GORM |
|---|---|---|---|
| Поведение по умолчанию | ленивое, неявное | явное перечисление связей | ничего не происходит само |
| Где видно N+1 | в логах SQL | в коде, если связь ленивая | в коде всегда |
| Выбор JOIN или отдельных запросов | eager_load / preload |
relationLoadStrategy |
Joins / Preload или руками |
| Параллельные запросы | load_async, потоки |
Promise.all, цикл событий |
горутины, errgroup |
| Цена удобства | скрытые запросы | схема как источник правды | больше кода на склейку |
Разница между экосистемами - это в первую очередь разница в том, насколько дорого не заметить лишний обход. Rails оптимизирует скорость написания и требует дисциплины (strict_loading, Bullet), Go оптимизирует предсказуемость и требует терпения.
DataLoader: батч без знания формы запроса
Всё вышеописанное предполагает, что автор кода знает, какие данные понадобятся. В GraphQL это предположение ломается: форму ответа задаёт клиент, и один и тот же резолвер вызывается в контексте, который на этапе написания неизвестен.
query {
posts(limit: 200) { # 1 запрос
author { name } # 200 вызовов резолвера author
}
}
Резолвер author физически не может сделать eager loading: он вызывается по одному разу на каждый пост и ничего не знает о соседях. DataLoader решает это переносом батчинга из времени написания кода во время выполнения:
- резолвер не идёт в базу, а кладёт ключ в очередь и получает промис;
- рантайм даёт всем резолверам отработать;
- в конце шага исполнения накопленные ключи уезжают одним запросом
WHERE id IN (...); - промисы резолвятся, дубликаты ключей обслуживаются из кэша запроса.
sequenceDiagram
participant R as Резолверы (200 шт)
participant DL as DataLoader
participant DB as База данных
R->>DL: load(1), load(2), load(2), ... load(87)
Note over DL: накопление ключей<br/>дедупликация 200 → 87
DL->>DB: SELECT * FROM authors WHERE id IN (87 ключей)
DB-->>DL: 87 строк
Note over DL: раскладка строго в порядке ключей
DL-->>R: 200 промисов резолвятся
Момент «конца шага» реализован в каждой среде по-своему, и это самое интересное различие:
| Среда | Механизм батчинга | Границы окна |
|---|---|---|
| Node.js | микрозадачи и process.nextTick |
конец текущего тика цикла событий |
Ruby (GraphQL::Dataloader) |
Fiber: резолвер приостанавливается, планировщик исполняет источники | когда все файберы дошли до ожидания |
Go (dataloaden, dataloadgen) |
таймер ожидания или лимит размера батча | обычно окно 1-16 мс или N ключей |
Node и Ruby используют естественную точку синхронизации своей среды. В Go такой точки нет: горутины не «заканчивают тик», поэтому батч закрывается по таймауту. Это добавляет к каждому батчу фиксированную задержку и превращает выбор окна в компромисс между латентностью и размером пачки.
Два контракта DataLoader, которые чаще всего нарушают:
- порядок и длина результата. Batch-функция обязана вернуть ровно столько элементов, сколько было ключей, в том же порядке. База при
IN (...)порядок не гарантирует и молча пропускает отсутствующие строки, поэтому результат почти всегда нужно раскладывать через мапу, а пропуски заполнятьnullили ошибкой. - область жизни кэша. DataLoader создаётся на каждый запрос. Кэш, переживший запрос, - это не оптимизация, а утечка данных между пользователями: закэшированная сущность обойдёт проверку прав следующего запроса и отдаст устаревшее значение после записи.
Чем DataLoader отличается от eager loading по существу:
| Eager loading | DataLoader | |
|---|---|---|
| Кто решает | автор кода заранее | рантайм по факту обращений |
| Что знает о нужных данных | форму запроса целиком | только ключи, накопленные за окно |
| Число запросов | 1 на связь | 1 на связь и уровень дерева |
| Лишние данные | грузит и то, что не понадобилось | грузит ровно затребованное |
| Главный риск | over-fetching и раздувание JOIN | забытый лоадер, кэш вне запроса |
Точнее всего DataLoader описывается так: ленивая загрузка с принудительным батчингом. Он не отменяет ленивость, он лечит её главный побочный эффект - множество мелких обходов. Поэтому эти техники не конкурируют: на верхнем уровне дерева разумно сделать eager loading для гарантированно нужных связей, а всё, что зависит от формы запроса, отдать лоадеру. О том, как это уживается с федерацией и подписками, я писал в статьях про путь к федеративному GraphQL и масштабируемые GraphQL-подписки.
Браузер: тот же принцип, другая экономика
Ленивая загрузка картинок выглядит как далёкий родственник, но отличается только тремя параметрами: что дефицитно, кто предсказывает и чем платят за ошибку.
| Данные на сервере | Ресурсы в браузере | |
|---|---|---|
| Дефицитный ресурс | соединения к БД, ёмкость источника | канал пользователя, главный поток, память вкладки |
| Предсказатель необходимости | код резолвера | вьюпорт и поведение пользователя |
| Цена лишней загрузки | нагрузка на общую БД | трафик и батарея конкретного человека |
| Цена запоздалой загрузки | медленный ответ API | пустое место на экране, скачок вёрстки |
Отсюда практические следствия, которые регулярно нарушают.
Ленивое по умолчанию - только ниже сгиба. Атрибут loading="lazy" откладывает запрос, пока картинка не приблизится к вьюпорту, причём порог браузер выбирает сам, ориентируясь в том числе на качество соединения. Для главной картинки экрана это прямой вред: она почти всегда и есть LCP-элемент, и ленивость добавляет к нему целый лишний раунд обнаружения ресурса.
<!-- герой экрана: грузим максимально рано и с высоким приоритетом -->
<img src="/hero.avif" fetchpriority="high" width="1200" height="630" alt="">
<!-- всё, что ниже сгиба: откладываем и резервируем место -->
<img src="/gallery-14.avif" loading="lazy" decoding="async"
width="800" height="600" alt="">
Место резервируется жадно, даже когда байты грузятся лениво. Атрибуты width/height (или aspect-ratio) нужны именно потому, что загрузка отложена: без них поздно приехавшая картинка сдвигает вёрстку. Это общий принцип, работающий и на бэкенде: отложить можно данные, но не структуру ответа. Скелетоны, плейсхолдеры и размытые превью решают ту же задачу - показать форму до содержимого.
Ленивость порождает каскады. Самый дорогой сценарий выглядит так: HTML грузит JS-бандл, бандл гидрируется и запрашивает данные, данные приносят URL картинки, и только теперь браузер начинает её качать. Четыре последовательных обхода вместо одного - это тот же N+1, только распределённый по слоям. Лечится он теми же приёмами: подсказками (preload, modulepreload), объединением (сервер отдаёт данные вместе с HTML) и потоковой отдачей.
Лениво грузится не только сеть, но и рендеринг. content-visibility: auto вместе с contain-intrinsic-size позволяет браузеру пропускать вёрстку и отрисовку невидимых блоков - экономия главного потока без единого сетевого запроса. Разделение бандлов по маршрутам (React.lazy, динамический import()) - то же самое для JavaScript.
На стороне сервера у этого есть прямой аналог: Turbo Frames с loading="lazy" откладывают запрос за куском HTML до появления фрейма во вьюпорте. Ленивая загрузка данных, разметки и пикселей - одна и та же идея на разной высоте стека.
Спекуляция: платить заранее по сигналу пользователя
Есть четвёртая стратегия, которая не сводится ни к eager, ни к lazy: грузить то, что ещё не запрошено, но с высокой вероятностью будет.
Здесь предсказателем становится поведение. Наведение курсора на ссылку и задержка на ней - неплохой признак намерения, и Speculation Rules API формализует это через уровни «жадности»:
<script type="speculationrules">
{
"prerender": [{
"where": { "href_matches": "/ru/blog/*" },
"eagerness": "moderate"
}]
}
</script>
moderate запускает предзагрузку примерно после 200 мс наведения, conservative - только по нажатию кнопки мыши, immediate - сразу. Поддержка сейчас в основном в браузерах на Chromium; Safari подключает её постепенно, в Firefox её пока нет, поэтому это оптимизация, а не механизм, на который можно закладываться.
Тот же приём применяется в бесконечной ленте: следующую страницу разумно запрашивать не в момент, когда пользователь упёрся в конец списка, а когда до конца остаётся пара экранов - именно это превращает бесконечный скролл из дёрганого в плавный.
У спекуляции своя цена ошибки, и она нетипичная:
- трафик и батарея тратятся на страницы, которые не откроют, поэтому уважать
Save-Dataи режим экономии обязательно; - prerender реально исполняет JavaScript, поэтому аналитика без проверки
document.prerenderingначнёт считать несуществующие просмотры; - предзагружать можно только безопасные GET-переходы: ссылка «выйти», «добавить в корзину» или любой URL с побочным эффектом должна быть исключена явно.
На бэкенде тот же принцип называется прогревом кэша и stale-while-revalidate: отдать чуть устаревшее значение мгновенно и обновить его в фоне - тоже форма оплаты вперёд.
Пятый вариант: не выбирать, а отдавать частями
Иногда правильный ответ на «рано или поздно» - «и то и другое». Потоковая отдача разрывает связь между началом и полнотой ответа:
- streaming SSR и
Suspenseотдают каркас страницы сразу, а медленные блоки - по мере готовности; - директивы
@deferи@streamв GraphQL позволяют клиенту пометить дорогие поля как некритичные; - курсорная пагинация вообще снимает вопрос, отдавая ровно один экран данных;
- Turbo Streams и WebSocket доставляют то, что не влезло в первый ответ.
Стриминг ценен тем, что оптимизирует не общее время, а время до первого полезного пикселя, а именно его воспринимает пользователь как скорость.
Как выбирать
Сведём всё к одной оценке. Пусть P - вероятность, что ресурс понадобится, C - стоимость его загрузки, L - задержка, которую увидит пользователь, если грузить в момент нужды.
lazy: ожидаемая стоимость = P × (C + L)
eager: ожидаемая стоимость = C, задержка = 0, но платим всегда
speculative: ожидаемая стоимость = C, задержка ≈ 0, ошибка = (1 − P) × C
batch: уменьшает L для N ресурсов до L одного обхода
Из этого следуют рабочие правила:
- при
Pблизком к единице ленивость бессмысленна: она добавляет обход и ничего не экономит (автор поста, аватар, права доступа); - при малом
Pи большомCленивость обязательна (вложения, история, тяжёлые агрегаты); - когда
Pнеизвестна заранее, потому что форму запроса задаёт клиент, нужен батчинг: он уменьшаетL, не требуя знанияP; - спекуляция оправдана, только когда есть внешний сигнал, поднимающий
P(наведение, позиция скролла, типичный маршрут); - если
Cвелика по любой причине, лучший ход - уменьшить самC: пагинация, проекция колонок, денормализованный счётчик.
flowchart TB
START["Ресурс может понадобиться"] --> P1{"Нужен почти всегда?"}
P1 -->|да| EAGER["Eager loading<br/>preload / include / Preload"]
P1 -->|нет| P2{"Форму запроса<br/>задаёт клиент?"}
P2 -->|да| DL["DataLoader<br/>ленивый батч на запрос"]
P2 -->|нет| P3{"Есть сигнал намерения?<br/>hover, скролл, маршрут"}
P3 -->|да| SPEC["Prefetch / prerender<br/>прогрев кэша"]
P3 -->|нет| P4{"Ответ можно<br/>отдать частями?"}
P4 -->|да| STREAM["Streaming, @defer,<br/>пагинация"]
P4 -->|нет| LAZY["Lazy loading<br/>по факту обращения"]
И главное наблюдение, ради которого всё это стоило раскладывать: N+1 - это фрактал. Он воспроизводится на каждом уровне, где есть обход к чему-то внешнему:
| Уровень | Как выглядит N+1 | Чем лечится |
|---|---|---|
| SQL | запрос в цикле по записям | preload, IN (...), JOIN |
| HTTP API | вызов на каждый элемент списка | батч-эндпоинт, ?ids= |
| GraphQL | резолвер на каждый узел | DataLoader |
| Микросервисы | цепочка синхронных вызовов | агрегация, денормализация, события |
| Браузер | каскад HTML → JS → данные → картинка | preload, отдача данных с HTML |
| LLM-агенты | по одному вызову инструмента на сущность | батч-инструменты, параллельные вызовы |
Инструменты в каждой строке разные, вопрос один и тот же: сколько раз мы уходим за границу процесса и можно ли объединить эти уходы. Практический вывод для любого слоя укладывается в четыре пункта: батчить всегда, кэшировать строго в границах запроса, жадно грузить только то, что нужно почти наверняка, и спекулировать лишь по явному сигналу пользователя.