← Назад к вопросам

Как оптимизировать запросы к БД?

2.0 Middle🔥 251 комментариев
#Базы данных и SQL#Кэширование и производительность

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI29 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Как оптимизировать запросы к БД?

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

Шаг 1: Профилирование (EXPLAIN ANALYZE)

Перыми я всегда профилирую запрос, чтобы понять, где проблема.

EXPLAIN ANALYZE
SELECT u.id, u.name, COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.created_at > '2023-01-01'
GROUP BY u.id, u.name
ORDER BY order_count DESC;

На что смотреть:

  • Sequential Scan (плохо) vs Index Scan (хорошо)
  • Filter — значит не используется индекс
  • Nested Loop — может быть проблемой для больших таблиц
  • Estimated rows vs Actual rows — расхождение означает нужно обновить статистику

Интерпретация вывода:

Plan:
  Seq Scan on users u  (cost=0.00..35.50 rows=1000) ← плохо, Sequential Scan
    Filter: (created_at > '2023-01-01')
  
Execution time: 152.45 ms

Шаг 2: Создание индексов

Правило: индексируй колонки из WHERE, ORDER BY, JOIN

-- Индекс на created_at (использование в WHERE)
CREATE INDEX idx_users_created_at ON users(created_at);

-- Индекс на user_id (использование в JOIN)
CREATE INDEX idx_orders_user_id ON orders(user_id);

-- Композитный индекс (несколько колонок)
CREATE INDEX idx_orders_user_status ON orders(user_id, status);

-- Partial индекс (только для активных)
CREATE INDEX idx_users_active ON users(id) WHERE status = 'active';

-- BRIN индекс для больших таблиц (временные серии)
CREATE INDEX idx_events_timestamp ON events USING BRIN(event_time);

-- Covering индекс (включает колонки для Index-only scan)
CREATE INDEX idx_orders_lookup ON orders(user_id, status) INCLUDE (total);

Результат после индекса:

После индекса:
  Index Scan using idx_users_created_at on users u  (cost=0.42..12.50 rows=500)
    Index Cond: (created_at > '2023-01-01')

Execution time: 2.15 ms  ← улучшение в 70 раз!

Шаг 3: Решение N+1 проблемы

Это самая частая проблема. ORM генерирует один запрос, а потом N запросов для каждой строки.

Проблема (N+1 запросы):

// Плохо — 1 запрос + N запросов
const users = await User.find();
for (const user of users) {
  user.orders = await Order.find({ userId: user.id }); // N запросов!
}

Решение 1: JOIN в одном запросе

SELECT u.id, u.name, o.id as order_id, o.total
FROM users u
LEFT JOIN orders o ON u.id = o.user_id;

Решение 2: Batch loading (загрузка одно порциями)

// Получаем всех пользователей
const users = await User.find();

// Получаем все заказы этих пользователей за ОДИН запрос
const userIds = users.map(u => u.id);
const orders = await Order.find({ userId: { $in: userIds } });

// Объединяем в памяти
const ordersMap = new Map();
orders.forEach(o => {
  if (!ordersMap.has(o.userId)) ordersMap.set(o.userId, []);
  ordersMap.get(o.userId).push(o);
});

users.forEach(u => {
  u.orders = ordersMap.get(u.id) || [];
});

Решение 3: DataLoader (Facebook library для автоматического batch loading)

const DataLoader = require('dataloader');

const orderLoader = new DataLoader(async (userIds) => {
  // Один запрос для всех userIds
  const orders = await db.query(
    'SELECT * FROM orders WHERE user_id = ANY($1)',
    [userIds]
  );
  
  // Возвращаем в том же порядке
  const ordersMap = new Map();
  orders.forEach(o => {
    if (!ordersMap.has(o.user_id)) ordersMap.set(o.user_id, []);
    ordersMap.get(o.user_id).push(o);
  });
  
  return userIds.map(id => ordersMap.get(id) || []);
});

// Использование
const user = await User.findById(123);
const orders = await orderLoader.load(user.id); // Будут батчены автоматически

Шаг 4: Query optimization

Правило: SELECT только то, что нужно

// Плохо — выбираем всё
const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);

// Хорошо — выбираем только нужные колонки
const user = await db.query(
  'SELECT id, name, email FROM users WHERE id = $1',
  [userId]
);

Агрегирование в БД вместо приложения

// Плохо — загружаем все строки, потом подсчитываем
const allOrders = await db.query('SELECT * FROM orders WHERE user_id = $1', [userId]);
const totalSpent = allOrders.reduce((sum, o) => sum + o.total, 0);

// Хорошо — расчёт в БД
const result = await db.query(
  'SELECT SUM(total) as totalSpent FROM orders WHERE user_id = $1',
  [userId]
);
const totalSpent = result.rows[0].totalSpent;

Использование LIMIT и OFFSET для пагинации

// С большим OFFSET может быть медленно
SELECT * FROM orders OFFSET 1000000 LIMIT 10;  // Сканирует 1000010 строк!

// Лучше использовать keyset pagination
SELECT * FROM orders WHERE id > $1 LIMIT 10;

Шаг 5: Денормализация (когда это имеет смысл)

Нормализация хороша, но иногда дорого стоит в плане производительности.

Когда использовать денормализацию:

  • Часто считаются агрегаты (сумма, средний заказ пользователя)
  • Read-heavy операции (много чтения, мало записи)
  • Данные редко меняются

Пример:

-- Нормализованная версия (много JOINов)
SELECT u.id, u.name, COUNT(o.id) as order_count, SUM(o.total) as lifetime_value
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name;

-- Денормализованная версия (один запрос)
SELECT id, name, order_count, lifetime_value FROM users;

-- Обновляем денормализованные колонки триггером при вставке заказа
CREATE OR REPLACE FUNCTION update_user_stats()
RETURNS TRIGGER AS $$
BEGIN
  UPDATE users SET
    order_count = order_count + 1,
    lifetime_value = lifetime_value + NEW.total
  WHERE id = NEW.user_id;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trigger_order_inserted AFTER INSERT ON orders
FOR EACH ROW EXECUTE FUNCTION update_user_stats();

Шаг 6: Кэширование результатов

In-application кэш (в памяти):

const NodeCache = require('node-cache');
const cache = new NodeCache({ stdTTL: 600 }); // 10 минут

async function getUserWithCache(userId) {
  // Проверяем кэш
  const cached = cache.get(`user:${userId}`);
  if (cached) return cached;
  
  // Загружаем из БД
  const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
  
  // Кэшируем
  cache.set(`user:${userId}`, user);
  
  return user;
}

Redis кэш (распределённый):

const redis = require('redis');
const client = redis.createClient();

async function getUserWithRedis(userId) {
  // Проверяем Redis
  let user = await client.get(`user:${userId}`);
  if (user) return JSON.parse(user);
  
  // Загружаем из БД
  user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
  
  // Кэшируем на 1 час
  await client.setex(`user:${userId}`, 3600, JSON.stringify(user));
  
  return user;
}

Шаг 7: Connection pooling

Проблема: Каждое подключение к БД дорого

Решение: используй connection pool

const { Pool } = require('pg');

// Создаём pool с ограничением соединений
const pool = new Pool({
  host: 'localhost',
  port: 5432,
  database: 'mydb',
  user: 'user',
  password: 'pass',
  max: 20,  // Максимум 20 соединений
  idleTimeoutMillis: 30000, // Закрыть неиспользуемое соединение после 30 сек
  connectionTimeoutMillis: 2000, // Ждать соединение 2 сек
});

app.get('/api/users/:id', async (req, res) => {
  const client = await pool.connect(); // Берём соединение из pool
  try {
    const result = await client.query('SELECT * FROM users WHERE id = $1', [req.params.id]);
    res.json(result.rows[0]);
  } finally {
    client.release(); // Возвращаем соединение в pool
  }
});

Шаг 8: Партиционирование (для очень больших таблиц)

Проблема: Таблица из 100 млн строк — даже с индексом медленно

Решение: разделяем таблицу на части по дате

-- Партиционирование по месяцам
CREATE TABLE orders (
  id SERIAL,
  user_id INT,
  total DECIMAL,
  created_at TIMESTAMP
) PARTITION BY RANGE (EXTRACT(YEAR FROM created_at), EXTRACT(MONTH FROM created_at));

CREATE TABLE orders_2024_01 PARTITION OF orders
  FOR VALUES FROM (2024, 1) TO (2024, 2);

CREATE TABLE orders_2024_02 PARTITION OF orders
  FOR VALUES FROM (2024, 2) TO (2024, 3);

-- Теперь запрос по дате сканирует только нужную партицию!
SELECT * FROM orders WHERE created_at > '2024-02-01';

Шаг 9: Асинхронная обработка

Для долгих операций, не блокируй основной запрос

app.post('/api/reports/generate', async (req, res) => {
  // Запускаем report асинхронно
  generateReport(req.body).then(report => {
    sendEmailWithReport(report);
  });
  
  // Сразу возвращаем клиенту
  res.json({ message: 'Report is being generated' });
});

Шаг 10: Мониторинг

Отслеживаем медленные запросы

-- PostgreSQL: включить логирование медленных запросов
ALTER SYSTEM SET log_min_duration_statement = 1000; -- логируем запросы > 1 сек
ALTER SYSTEM SET log_line_prefix = '%t [%p] %u@%d ';
SELECT pg_reload_conf();

-- Смотрим логи
tail -f /var/log/postgresql/postgresql.log | grep Duration

New Relic, DataDog или другие APM инструменты:

const newrelic = require('newrelic');

app.get('/api/users/:id', newrelic.startWebTransaction('/users/:id', async (req, res) => {
  const user = await db.query('SELECT * FROM users WHERE id = $1', [req.params.id]);
  res.json(user);
}));

Реальный пример оптимизации

Было:

  • Report выполнялся 45 секунд
  • 23 JOIN'а и 5 subquery'ев
  • N+1 проблема в приложении
  • Отсутствовали индексы

Сделал:

  1. Добавил индексы на все JOIN колонки
  2. Переписал 3 subquery'я в window functions
  3. Устранил N+1 batch loading'ом
  4. Денормализовал 2 часто используемые агрегата
  5. Добавил Redis кэширование на 1 час

Результат:

  • 45 сек → 200 мс (без кэша)
  • 45 сек → 10 мс (с кэшем)
  • Улучшение в 4500 раз!

Чеклист оптимизации

  • Профилировал запрос через EXPLAIN ANALYZE
  • Добавил индексы на используемые колонки
  • Устранил N+1 проблемы
  • Выбираю только нужные колонки
  • Агрегирование в БД, а не в приложении
  • Использую connection pooling
  • Кэширую часто используемые данные
  • Монитню производительность
  • Документирую критичные запросы

Оптимизация БД — это не одноразовая задача, а постоянный процесс. Главное — измеряй, анализируй, оптимизируй.

Как оптимизировать запросы к БД? | PrepBro