Kravchenko

Web Lab

АудитБлогКонтакты

Kravchenko

Web Lab

Разрабатываем сайты и автоматизацию на современных фреймворках под ключ

Услуги
ЛендингМногостраничныйВизитка
E-commerceБронированиеПортфолио
Навигация
БлогКонтактыАудит
Обратная связь
+7 921 567-11-16
info@kravlab.ru
с 09:00 до 18:00

© 2026 Все права защищены

•

ИП Кравченко Никита Владимирович

•

ОГРНИП: 324784700339743

Политика конфиденциальности

Слияние одинаковых запросов и пакетная выборка: минус N+1, быстрее API и дешевле база

Разработка и технологии13 января 2026 г.
Многие продукты теряют деньги на лишних обращениях к базе и внешним сервисам. Разберём, как слияние одинаковых запросов (request coalescing) и пакетная выборка (DataLoader‑подход) убирают N+1, ускоряют p95 и снижают нагрузку на инфраструктуру — с примерами кода и чек‑листом внедрения.
Слияние одинаковых запросов и пакетная выборка: минус N+1, быстрее API и дешевле база

  • Оглавление
  • Введение: зачем бизнесу это нужно
  • Что такое N+1 и дублирующиеся запросы
  • Подход 1: слияние одинаковых запросов (request coalescing)
  • Подход 2: пакетная выборка (DataLoader‑паттерн)
  • Реализация в продакшене: от одного инстанса к кластеру
  • Взаимодействие с базой данных: индексы, лимиты, чанки
  • Наблюдаемость: метрики и трассировка
  • Подводные камни и как их обойти
  • Чек‑лист внедрения
  • Короткий кейс из практики

Введение: зачем бизнесу это нужно

Команда часто оптимизирует отдельные запросы, но забывает про их количество. Когда страница делает 50–200 мелких обращений к базе или внешнему API, итоговый счёт за инфраструктуру растёт, а пользователи получают медленные ответы, особенно на пиках трафика. Две простые техники решают большую часть проблемы:

  • Слияние одинаковых запросов: если несколько потоков одновременно просят одни и те же данные, мы выполняем реальную работу один раз, а результат раздаём всем ожидающим.
  • Пакетная выборка (паттерн DataLoader): собираем множество похожих запросов (например, загрузить пользователей по id) в один «пакет» и выполняем одним SQL с WHERE IN или одним вызовом внешнего сервиса.

Эти приёмы дают быстрый выигрыш: минус N+1, p95 уменьшается на 20–60%, нагрузка на базу падает в разы, а счета за облако становятся предсказуемее.

Что такое N+1 и дублирующиеся запросы

N+1 — классическая ситуация: чтобы отрендерить список из N заказов, код делает один запрос на список и ещё N запросов «получить пользователя заказа». В итоге имеем 101 запрос вместо 2–3. Даже если вы используете кеш, параллельно приходящие одинаковые запросы всё равно могут «пробивать» его и мультиплицироваться (эффект «стада»).

Признаки проблемы:

  • Много мелких SELECT по первичному ключу в логе СУБД.
  • Внешний сервис видит всплески абсолютно одинаковых запросов в одну и ту же миллисекунду.
  • Трассировки показывают «зубчатый гребень» из десятков коротких одинаковых спанов вместо одного.

Подход 1: слияние одинаковых запросов (request coalescing)

Идея простая: если 10 потоков одновременно просят данные для одного и того же ключа (например, user

), выполняем обработку один раз, а остальные ждут и получают тот же результат.

Пример на Go: singleflight

Библиотека golang.org/x/sync/singleflight упрощает слияние запросов в памяти одного процесса.

package main

import (
    "context"
    "fmt"
    "time"

    "golang.org/x/sync/singleflight"
)

type User struct { ID int64; Name string }

// Заглушка: имитация медленного чтения из БД
func loadUserFromDB(ctx context.Context, id int64) (User, error) {
    time.Sleep(50 * time.Millisecond)
    return User{ID: id, Name: fmt.Sprintf("user-%d", id)}, nil
}

var group singleflight.Group

func getUser(ctx context.Context, id int64) (User, error) {
    key := fmt.Sprintf("user:%d", id)
    v, err, _ := group.Do(key, func() (any, error) {
        return loadUserFromDB(ctx, id)
    })
    if err != nil { return User{}, err }
    return v.(User), nil
}

func main() {
    // Одновременные обращения за одним и тем же пользователем сольются в один вызов loadUserFromDB
}

Плюсы: простота и мгновенный эффект. Минусы: работает только внутри одного инстанса; в кластере каждый инстанс будет делать «один» запрос на себя.

Пример на Node.js: слияние промисов

// TypeScript
type Loader<T> = (key: string) => Promise<T>;

class SingleFlight<T> {
  private inflight = new Map<string, Promise<T>>();

  constructor(private readonly loader: Loader<T>) {}

  get(key: string): Promise<T> {
    const existing = this.inflight.get(key);
    if (existing) return existing;

    const p = this.loader(key)
      .finally(() => this.inflight.delete(key));

    this.inflight.set(key, p);
    return p;
  }
}

// Пример использования
const sf = new SingleFlight(async (key: string) => {
  // имитация БД
  await new Promise(r => setTimeout(r, 50));
  return { id: key, name: `user-${key}` } as any;
});

// Одновременные вызовы sf.get("123") сольются

Подход 2: пакетная выборка (DataLoader‑паттерн)

Пакетная выборка решает N+1. Мы накапливаем запросы ключей в коротком окне (обычно 5–10 миллисекунд) и выполняем их одним обращением: один SQL с WHERE IN, одна командa к кэшу, один запрос к внешнему API.

Пример на Node.js: простейший DataLoader

// TypeScript: минимальный DataLoader для пользователей
import { setTimeout as delay } from 'node:timers/promises'

type User = { id: number; name: string }

class DataLoader<K, V> {
  private queue: { key: K; resolve: (v: V) => void; reject: (e: any) => void }[] = []
  private scheduled = false

  constructor(private readonly batchLoadFn: (keys: K[]) => Promise<Map<K, V>>, private readonly windowMs = 10) {}

  load(key: K): Promise<V> {
    return new Promise<V>((resolve, reject) => {
      this.queue.push({ key, resolve, reject })
      if (!this.scheduled) {
        this.scheduled = true
        void this.flushSoon()
      }
    })
  }

  private async flushSoon() {
    await delay(this.windowMs)
    const batch = this.queue
    this.queue = []
    this.scheduled = false

    const keys = batch.map(x => x.key)
    let resultMap: Map<K, V>
    try {
      resultMap = await this.batchLoadFn(keys)
    } catch (e) {
      batch.forEach(x => x.reject(e))
      return
    }
    batch.forEach(x => {
      const v = resultMap.get(x.key)
      if (v === undefined) x.reject(new Error('Not found'))
      else x.resolve(v)
    })
  }
}

// Реализация batch-загрузки из Postgres: SELECT ... WHERE id = ANY($1)
import pg from 'pg'
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL })

async function batchLoadUsers(ids: number[]): Promise<Map<number, User>> {
  const client = await pool.connect()
  try {
    const res = await client.query(
      'SELECT id, name FROM users WHERE id = ANY($1)',
      [ids]
    )
    const map = new Map<number, User>()
    for (const row of res.rows) map.set(Number(row.id), { id: Number(row.id), name: row.name })
    return map
  } finally {
    client.release()
  }
}

export const userLoader = new DataLoader<number, User>(batchLoadUsers, 8)

// Где-то в коде:
// await Promise.all(orderIds.map(id => userLoader.load(id)))

Пример SQL для пакетной выборки

Для PostgreSQL удобно использовать массивы и ANY:

SELECT id, name
FROM users
WHERE id = ANY($1::bigint[]);

Индекса достаточно по первичному ключу (users_pkey). Для вторичных ключей (например, email) также необходим индекс.

Реализация в продакшене: от одного инстанса к кластеру

Слияние запросов в памяти помогает, но в кластере из N инстансов каждый из них сделает «по одному» запросу. Чтобы объединять работу кросс‑инстансно, используем распределённую координацию — чаще всего Redis.

Слияние через Redis: «распределённый singleflight»

Идея: первый инстанс создаёт ключ inflight: с малым TTL (например, 1–5 секунд). Остальные видят ключ и ждут результата в другом месте — например, в кэше result:. Алгоритм:

  1. Попытаться установить SET NX inflight
    TTL=5s.
  2. Если получилось — мы «лидер», грузим данные, пишем результат в результат‑кэш с TTL, удаляем inflight
    .
  3. Если нет — ждём появления result
    (с экспоненциальной задержкой до 100–200 мс) либо таймаутимся и грузим сами как запасной путь.
// TypeScript: псевдокод с ioredis
import Redis from 'ioredis'
const redis = new Redis(process.env.REDIS_URL!)

async function loadWithCoalescing(key: string, loader: () => Promise<string>, ttlSec = 60) {
  const inflightKey = `inflight:${key}`
  const resultKey = `result:${key}`

  // Сначала проверим готовый результат
  const cached = await redis.get(resultKey)
  if (cached) return cached

  const lock = await redis.set(inflightKey, '1', 'NX', 'EX', 5)
  if (lock) {
    try {
      const val = await loader()
      await redis.set(resultKey, val, 'EX', ttlSec)
      return val
    } finally {
      await redis.del(inflightKey)
    }
  } else {
    // Ждём результат лидера короткими ожиданиями
    const deadline = Date.now() + 500 /* мс */
    while (Date.now() < deadline) {
      const v = await redis.get(resultKey)
      if (v) return v
      await new Promise(r => setTimeout(r, 25))
    }
    // Фоллбэк: грузим сами (редко)
    const val = await loader()
    await redis.set(resultKey, val, 'EX', ttlSec)
    return val
  }
}

Такой подход одновременно решает и слияние запросов, и базовый кеш результатов. TTLи нужно подбирать под доменную модель.

API‑эндпоинты для пакетной выборки

Чтобы фронтенд тоже помогал, добавьте пакетные эндпоинты:

  • GET /users?ids=1,2,3,4
  • POST /inventory/batch — принимает массив sku и возвращает наличие по каждому

Серверный DataLoader может «склеивать» запросы не только между функциями в одном рендере, но и между HTTP‑запросами, если окно пакетирования небольшое (5–10 мс) и у вас высокое параллельное число запросов.

Взаимодействие с базой данных: индексы, лимиты, чанки

Пакетирование переносит нагрузку с числа запросов на размеры выборок. Это хорошо, но требует аккуратности.

  • Индексы: для WHERE IN (id) достаточно первичного ключа. Для нестандартных ключей создайте B‑tree индекс. Проверяйте планы EXPLAIN — оптимизатор иногда неверно оценивает селективность.
  • Размер пакета: 50–200 ключей обычно безопасно. При большем размере растёт время сортировки и передачи данных. Разбивайте на чанки по 100–200.
  • Память и сеть: используйте проекцию (SELECT только необходимые поля). Лишние колонки — это мегабайты трафика и переполненный сетевой буфер.
  • Порядок результатов: WHERE IN не гарантирует порядок. Сопоставляйте по map и восстанавливайте порядок на уровне приложения.

Пример чанкинга на SQL стороне (PostgreSQL) лучше не делать, вместо этого разбивайте массив ключей на уровне приложения и выполняйте несколько запросов последовательно или с ограниченной параллельностью.

Наблюдаемость: метрики и трассировка

Чтобы понимать эффект и ловить регрессии, добавьте метрики:

  • Доля слитых запросов: сколько запросов к одному ключу было обслужено одним обращением к источнику.
  • Средний/медианный размер пакета.
  • Время ожидания в окне пакетирования.
  • Число промахов и таймаутов координации (Redis‑lock fallbacks).

Пример метрик на Prometheus (Go)

import "github.com/prometheus/client_golang/prometheus"

var (
    batchSize = prometheus.NewHistogram(prometheus.HistogramOpts{
        Name:    "dataloader_batch_size",
        Help:    "Размер пакетов загрузки",
        Buckets: []float64{1, 2, 5, 10, 20, 50, 100, 200},
    })
    coalesced = prometheus.NewCounter(prometheus.CounterOpts{
        Name: "singleflight_coalesced_total",
        Help: "Сколько запросов было слито",
    })
)

// Регистрируйте метрики в init()

В трассировке (OpenTelemetry) полезно добавлять атрибуты resource.key, batch.size и флаг coalesced=true. Это быстро показывает, где именно сработала оптимизация.

Подводные камни и как их обойти

  • Горячие ключи. Один особо популярный ключ может собирать слишком большие пакеты и задерживать ответы. Лечится лимитом размера пакета и отдельным TTL.
  • Задержка окна. Слишком большой интервал пакетирования ухудшит p50. Обычно 5–10 мс дают хороший компромисс: p95 падает, а p50 почти не меняется.
  • Ошибки лидера. Если «лидер» в схеме с Redis упал между загрузкой и записью результата, ожидающие зависнут. Решение — короткие ожидания и фоллбэк на самостоятельную загрузку.
  • Несогласованность данных. Пакетирование подразумевает, что данные могут слегка «отставать» на время окна. Для критичных операций (денежные движения) используйте строгие чтения и не кешируйте, оставляя пакетирование только для неключевых справочников.
  • Большие IN‑списки. Некоторые драйверы ограничивают размер параметров. Разбивайте пакеты на чанки и отправляйте несколько запросов с ограниченной параллельностью.
  • Многорегиональные кластеры. Координация через Redis в другом регионе добавит RTT. Разместите Redis рядом с приложением или используйте локальные редисы на регион и не объединяйте между регионами.

Чек‑лист внедрения

  1. Найдите горячие точки: включите трассировку и лог количества одинаковых запросов к ключам.
  2. Начните с простого: singleflight в памяти на один инстанс для самых дорогих ключей.
  3. Добавьте пакетную выборку для сущностей, которые чаще всего грузятся по id (пользователи, товары, остатки, цены).
  4. Введите пакетные эндпоинты во внешнем API, договоритесь с фронтендом о формате.
  5. Перенесите координацию в Redis, если у вас несколько инстансов и высокий параллелизм.
  6. Поставьте метрики и алерты: резкий рост fallbacks или времени ожидания — повод пересмотреть настройки.
  7. Пересмотрите индексы БД и убедитесь, что планы запросов стабильны под нагрузкой.
  8. Проведите нагрузочное тестирование с реальным профилем трафика — именно там проявляются горячие ключи и «стадный инстинкт».

Короткий кейс из практики

Интернет‑магазин с 8 бэкенд‑инстансами и 2 репликами Postgres жаловался на p95=780 мс и всплески нагрузки при обновлении каталога. Разработчики внедрили:

  • Слияние одинаковых запросов к товарам и остаткам через Redis‑координацию (окно ожидания до 300 мс, TTL результата 30 с для справочных данных).
  • Пакетную выборку пользователей и продавцов (окно 8 мс, пакеты до 100 ключей).
  • Пакетные эндпоинты для фронтенда.

Итог за 2 недели:

  • p95 упал до 420 мс (−46%).
  • Нагрузка на Postgres по числу запросов уменьшилась в 3,7 раза, а по трафику — в 2,1 раза.
  • Счёт за облачный Redis снизился на 18% за счёт уменьшения «штормов» одинаковых запросов.
  • Команда поддержки перестала регулярно ловить таймауты со стороны внешней системы остатков.

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


производительностьоптимизациябазы данных