
Команда часто оптимизирует отдельные запросы, но забывает про их количество. Когда страница делает 50–200 мелких обращений к базе или внешнему API, итоговый счёт за инфраструктуру растёт, а пользователи получают медленные ответы, особенно на пиках трафика. Две простые техники решают большую часть проблемы:
Эти приёмы дают быстрый выигрыш: минус N+1, p95 уменьшается на 20–60%, нагрузка на базу падает в разы, а счета за облако становятся предсказуемее.
N+1 — классическая ситуация: чтобы отрендерить список из N заказов, код делает один запрос на список и ещё N запросов «получить пользователя заказа». В итоге имеем 101 запрос вместо 2–3. Даже если вы используете кеш, параллельно приходящие одинаковые запросы всё равно могут «пробивать» его и мультиплицироваться (эффект «стада»).
Признаки проблемы:
Идея простая: если 10 потоков одновременно просят данные для одного и того же ключа (например, user
), выполняем обработку один раз, а остальные ждут и получают тот же результат.Библиотека 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
}
Плюсы: простота и мгновенный эффект. Минусы: работает только внутри одного инстанса; в кластере каждый инстанс будет делать «один» запрос на себя.
// 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") сольются
Пакетная выборка решает N+1. Мы накапливаем запросы ключей в коротком окне (обычно 5–10 миллисекунд) и выполняем их одним обращением: один SQL с WHERE IN, одна командa к кэшу, один запрос к внешнему API.
// 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)))
Для PostgreSQL удобно использовать массивы и ANY:
SELECT id, name
FROM users
WHERE id = ANY($1::bigint[]);
Индекса достаточно по первичному ключу (users_pkey). Для вторичных ключей (например, email) также необходим индекс.
Слияние запросов в памяти помогает, но в кластере из N инстансов каждый из них сделает «по одному» запросу. Чтобы объединять работу кросс‑инстансно, используем распределённую координацию — чаще всего Redis.
Идея: первый инстанс создаёт ключ inflight:
// 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и нужно подбирать под доменную модель.
Чтобы фронтенд тоже помогал, добавьте пакетные эндпоинты:
Серверный DataLoader может «склеивать» запросы не только между функциями в одном рендере, но и между HTTP‑запросами, если окно пакетирования небольшое (5–10 мс) и у вас высокое параллельное число запросов.
Пакетирование переносит нагрузку с числа запросов на размеры выборок. Это хорошо, но требует аккуратности.
Пример чанкинга на SQL стороне (PostgreSQL) лучше не делать, вместо этого разбивайте массив ключей на уровне приложения и выполняйте несколько запросов последовательно или с ограниченной параллельностью.
Чтобы понимать эффект и ловить регрессии, добавьте метрики:
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. Это быстро показывает, где именно сработала оптимизация.
Интернет‑магазин с 8 бэкенд‑инстансами и 2 репликами Postgres жаловался на p95=780 мс и всплески нагрузки при обновлении каталога. Разработчики внедрили:
Итог за 2 недели:
Вывод: слияние запросов и пакетная выборка — дешёвые по внедрению, но очень эффективные приёмы. Они не требуют сложной архитектуры и окупаются практически сразу, особенно в системах с высоким параллелизмом и повторяющимися запросами.