---
title: "Eager, lazy и batch: когда приложение должно грузить данные"
date: 2026-08-18T17:30:00
tags: [ruby, rails, nodejs, go, graphql, performance, frontend, backend]
description: "Eager loading в ActiveRecord, его аналоги в Node.js и Go, DataLoader в GraphQL и ленивая загрузка картинок в браузере - одна и та же задача о том, что грузить заранее, что по требованию и что предугадывать."
---

![Eager, lazy и batch loading](img/eager-lazy-loading-strategies-preview.jpg)

Eager loading в Rails, `await import()` в Node.js, DataLoader в GraphQL и `loading="lazy"` у картинки выглядят как четыре несвязанные техники из разных слоёв стека. На самом деле это четыре ответа на один вопрос: **в какой момент система платит за ресурс, который может понадобиться, а может и нет**. Разберём механику каждого подхода, а затем соберём из них общую модель принятия решения.

<!-- truncate -->

## За что мы на самом деле платим

Интуиция «загрузка данных стоит столько, сколько весят данные» почти всегда ошибочна. Реальный счёт складывается из трёх независимых статей расходов.

| Ресурс | Что тратится | Кто страдает первым |
|---|---|---|
| Латентность | время на круговой обход (round trip) до источника | пользователь, ждущий ответа |
| Пропускная способность | байты по сети, память под результат | мобильный клиент, GC, кэш БД |
| Ёмкость источника | соединения, воркеры, IOPS, лимиты API | все остальные пользователи одновременно |

Ключевой момент: эти статьи оптимизируются по-разному и часто конфликтуют. Один большой запрос экономит латентность, но грузит память и может утроить объём переданных байтов. Сто маленьких запросов экономят трафик, но съедают пул соединений и добавляют сто задержек подряд.

Поэтому базовая величина при проектировании загрузки - не объём данных, а **число последовательных обходов на критическом пути**:

```text
время ответа ≈ Σ(round trip) + Σ(работа источника) + Σ(работа приложения)

100 запросов × 1 мс сети = 100 мс, даже если каждый запрос вернул 40 байт
```

Отсюда растут все дальнейшие решения. Их удобно разложить по трём осям:

- **когда** грузим: заранее (eager), по требованию (lazy), с опережением по прогнозу (speculative);
- **какой гранулярностью**: по одной записи, пачкой (batch), потоком (streaming);
- **кто решает**: автор кода на этапе разработки, рантайм во время выполнения или сам пользователь своим поведением.

Ruby, Node.js, Go, GraphQL и браузер отвечают на эти три вопроса по-разному, и разница между ними объясняется не модой, а свойствами среды выполнения.

## Ruby: ленивое по умолчанию, явно жадное по требованию

ActiveRecord - редкий пример среды, где ленивость встроена в объектную модель. Ассоциация `user.posts` - это не массив, а прокси-объект, который выполнит SQL только в момент первого обращения.

```ruby
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 в зависимости от того, что дописали рядом.

```mermaid
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 даёт инструменты, чтобы ленивость не оставалась незамеченной:

```ruby
# 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 заставляют перечислять связи в запросе:

```ts
// 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 ближе к рубиновой модели и допускают ленивые связи, но и там ленивость выражена типом:

```ts
// TypeORM lazy relation: свойство возвращает Promise
const author = await post.author; // await внутри цикла = N+1
```

Тип `Promise<Author>` заставляет писать `await` - и в этом есть неожиданная польза: ленивая загрузка перестаёт маскироваться под чтение поля. Но появляется специфичная для Node ловушка:

```ts
// последовательно: 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`:

```go
// собрать ключи, сходить один раз, разложить по мапе
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`, который заодно ограничивает конкурентность:

```go
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 это предположение ломается: форму ответа задаёт клиент, и один и тот же резолвер вызывается в контексте, который на этапе написания неизвестен.

```graphql
query {
  posts(limit: 200) {       # 1 запрос
    author { name }          # 200 вызовов резолвера author
  }
}
```

Резолвер `author` физически не может сделать eager loading: он вызывается по одному разу на каждый пост и ничего не знает о соседях. [DataLoader](https://github.com/graphql/dataloader) решает это переносом батчинга из времени написания кода во время выполнения:

1. резолвер не идёт в базу, а кладёт ключ в очередь и получает промис;
2. рантайм даёт всем резолверам отработать;
3. в конце шага исполнения накопленные ключи уезжают одним запросом `WHERE id IN (...)`;
4. промисы резолвятся, дубликаты ключей обслуживаются из кэша запроса.

```mermaid
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](/ru/blog/tech/backend/put-k-federativnomu-graphql/) и [масштабируемые GraphQL-подписки](/ru/blog/tech/dream-of-scalable-enriched-graphql-subscriptions/).

## Браузер: тот же принцип, другая экономика

Ленивая загрузка картинок выглядит как далёкий родственник, но отличается только тремя параметрами: что дефицитно, кто предсказывает и чем платят за ошибку.

| | Данные на сервере | Ресурсы в браузере |
|---|---|---|
| Дефицитный ресурс | соединения к БД, ёмкость источника | канал пользователя, главный поток, память вкладки |
| Предсказатель необходимости | код резолвера | вьюпорт и поведение пользователя |
| Цена лишней загрузки | нагрузка на общую БД | трафик и батарея конкретного человека |
| Цена запоздалой загрузки | медленный ответ API | пустое место на экране, скачок вёрстки |

Отсюда практические следствия, которые регулярно нарушают.

**Ленивое по умолчанию - только ниже сгиба.** Атрибут `loading="lazy"` откладывает запрос, пока картинка не приблизится к вьюпорту, причём порог браузер выбирает сам, ориентируясь в том числе на качество соединения. Для главной картинки экрана это прямой вред: она почти всегда и есть LCP-элемент, и ленивость добавляет к нему целый лишний раунд обнаружения ресурса.

```html
<!-- герой экрана: грузим максимально рано и с высоким приоритетом -->
<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](/ru/blog/tech/frontend/turbo-frames-s-nulya/) с `loading="lazy"` откладывают запрос за куском HTML до появления фрейма во вьюпорте. Ленивая загрузка данных, разметки и пикселей - одна и та же идея на разной высоте стека.

## Спекуляция: платить заранее по сигналу пользователя

Есть четвёртая стратегия, которая не сводится ни к eager, ни к lazy: грузить то, что ещё не запрошено, но с высокой вероятностью будет.

Здесь предсказателем становится поведение. Наведение курсора на ссылку и задержка на ней - неплохой признак намерения, и Speculation Rules API формализует это через уровни «жадности»:

```html
<script type="speculationrules">
{
  "prerender": [{
    "where": { "href_matches": "/ru/blog/*" },
    "eagerness": "moderate"
  }]
}
</script>
```

`moderate` запускает предзагрузку примерно после 200 мс наведения, `conservative` - только по нажатию кнопки мыши, `immediate` - сразу. Поддержка сейчас в основном в браузерах на Chromium; Safari подключает её постепенно, в Firefox её пока нет, поэтому это оптимизация, а не механизм, на который можно закладываться.

Тот же приём применяется в бесконечной ленте: следующую страницу разумно запрашивать не в момент, когда пользователь упёрся в конец списка, а когда до конца остаётся пара экранов - именно это превращает [бесконечный скролл](/ru/blog/tech/backend/infinite-scolling/) из дёрганого в плавный.

У спекуляции своя цена ошибки, и она нетипичная:

- трафик и батарея тратятся на страницы, которые не откроют, поэтому уважать `Save-Data` и режим экономии обязательно;
- prerender реально исполняет JavaScript, поэтому аналитика без проверки `document.prerendering` начнёт считать несуществующие просмотры;
- предзагружать можно только безопасные GET-переходы: ссылка «выйти», «добавить в корзину» или любой URL с побочным эффектом должна быть исключена явно.

На бэкенде тот же принцип называется прогревом кэша и `stale-while-revalidate`: отдать чуть устаревшее значение мгновенно и обновить его в фоне - тоже форма оплаты вперёд.

## Пятый вариант: не выбирать, а отдавать частями

Иногда правильный ответ на «рано или поздно» - «и то и другое». Потоковая отдача разрывает связь между началом и полнотой ответа:

- streaming SSR и `Suspense` отдают каркас страницы сразу, а медленные блоки - по мере готовности;
- директивы `@defer` и `@stream` в GraphQL позволяют клиенту пометить дорогие поля как некритичные;
- курсорная пагинация вообще снимает вопрос, отдавая ровно один экран данных;
- Turbo Streams и WebSocket доставляют то, что не влезло в первый ответ.

Стриминг ценен тем, что оптимизирует не общее время, а **время до первого полезного пикселя**, а именно его воспринимает пользователь как скорость.

## Как выбирать

Сведём всё к одной оценке. Пусть `P` - вероятность, что ресурс понадобится, `C` - стоимость его загрузки, `L` - задержка, которую увидит пользователь, если грузить в момент нужды.

```text
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`: пагинация, проекция колонок, денормализованный счётчик.

```mermaid
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-агенты | по одному вызову инструмента на сущность | батч-инструменты, параллельные вызовы |

Инструменты в каждой строке разные, вопрос один и тот же: сколько раз мы уходим за границу процесса и можно ли объединить эти уходы. Практический вывод для любого слоя укладывается в четыре пункта: **батчить всегда, кэшировать строго в границах запроса, жадно грузить только то, что нужно почти наверняка, и спекулировать лишь по явному сигналу пользователя.**
