Запросы к БД
Запросы к БД

PostgreSQL против 10 миллионов записей: оптимизация запросов, которая спасла наш проект

Пролог: Когда база данных говорит «нет»

Это был обычный понедельник. Я пил кофе, проверял почту, и вдруг — волна уведомлений в Slack. «Сайт не грузится!», «Отчеты зависли!», «Что происходит?».

Наш проект, который успешно работал с несколькими сотнями тысяч записей, перешагнул психологически важный рубеж — 10 миллионов строк в таблице заказов. И PostgreSQL, который раньше летал, внезапно начал ползти как улитка.

Диагноз: почему 10 миллионов — это магия?

Многие думают: «10 миллионов записей — это же немного!». На практике это точка, где:

  • Индексы перестают помещаться в оперативную память

  • Планировщик запросов начинает выбирать неоптимальные пути

  • Простые JOIN'ы превращаются в многоминутные операции

Наш главный проблемный запрос выглядел так:

EXPLAIN (ANALYZE, BUFFERS)
SELECT 
    u.id,
    u.name,
    COUNT(o.id) as order_count,
    SUM(o.amount) as total_amount
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE o.created_at >= '2023-01-01'
    AND o.status = 'completed'
    AND u.is_active = true
GROUP BY u.id, u.name
HAVING SUM(o.amount) > 10000
ORDER BY total_amount DESC
LIMIT 50;

Время выполнения: 28 секунд. Для веб-интерфейса — смерть.

Шаг 1: Анализ — снимаем показания с пациента

Первое правило оптимизации: измеряй всё!

-- Включаем детальный мониторинг
ALTER SYSTEM SET shared_preload_libraries = 'pg_stat_statements';
SELECT pg_reload_conf();

-- Смотрим самые тяжелые запросы
SELECT query, calls, total_time, mean_time, rows
FROM pg_stat_statements 
ORDER BY total_time DESC 
LIMIT 10;

Результаты показали, что наш запрос:

  • Выполнялся 127 раз в день

  • Суммарно тратил 56 минут процессорного времени

  • Читал 15 миллионов строк для возврата 50 результатов

Шаг 2: Индексы — правильная расстановка приоритетов

Оказалось, наши индексы были созданы без понимания того, как их будет использовать планировщик.

Было:

CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_created_at ON orders(created_at);

Стало:

-- Составной индекс, покрывающий фильтрацию и джойны
CREATE INDEX idx_orders_covering ON orders(user_id, created_at, status)
WHERE status = 'completed';

-- Частичный индекс для активных пользователей
CREATE INDEX idx_users_active ON users(id, name) 
WHERE is_active = true;

Магия частичных индексов: они в 3 раза меньше и в 5 раз быстрее.

Шаг 3: Переписывание запроса — искусство компромиссов

Иногда нужно не добавлять индексы, а пересмотреть саму логику:

WITH potential_users AS (
    SELECT u.id, u.name
    FROM users u
    WHERE u.is_active = true
),
user_totals AS (
    SELECT 
        pu.id,
        pu.name,
        COUNT(o.id) as order_count,
        SUM(o.amount) as total_amount
    FROM potential_users pu
    JOIN orders o ON pu.id = o.user_id
    WHERE o.created_at >= '2023-01-01'
        AND o.status = 'completed'
    GROUP BY pu.id, pu.name
    HAVING SUM(o.amount) > 10000
)
SELECT *
FROM user_totals
ORDER BY total_amount DESC
LIMIT 50;

Разбивка на CTE (Common Table Expressions) помогла планировщику лучше оптимизировать выполнение.

Шаг 4: Расширенные техники — когда стандартных методов недостаточно

Анализ статистики

-- Обновляем статистику для планировщика
ANALYZE orders;
ANALYZE users;

-- Проверяем селективность индексов
SELECT schemaname, tablename, attname, n_distinct
FROM pg_stats
WHERE tablename IN ('orders', 'users')
ORDER BY tablename, attname;

Настройка параметров БД

-- Увеличиваем память для работы с большими данными
ALTER SYSTEM SET work_mem = '256MB';
ALTER SYSTEM SET shared_buffers = '4GB';
SELECT pg_reload_conf();

Шаг 5: Результаты — магия в цифрах

После всех оптимизаций:

Метрика

До оптимизации

После оптимизации

Время выполнения

28 секунд

120 миллисекунд

Чтение строк

15 миллионов

8 тысяч

Размер индексов

4.2 ГБ

1.1 ГБ

Нагрузка на CPU

95%

12%

Ускорение в 233 раза — и это без апгрейда железа!

Выводы: уроки, которые мы усвоили

  1. Индексы ≠ волшебная палочка. Нужно понимать, как их будет использовать планировщик

  2. 10 миллионов — это психологический барьер. После него меняется поведение СУБД

  3. Анализируй до оптимизации. Без EXPLAIN ANALYZE и pg_stat_statements ты работаешь вслепую

  4. Иногда проще переписать запрос, чем добавить еще один индекс

  5. Статистика — твой друг. Регулярный ANALYZE помогает планировщику принимать правильные решения

Эпилог: Жизнь после оптимизации

Сейчас наша база перевалила за 50 миллионов записей, и те запросы, которые мы оптимизировали, до сих пор работают быстро. Мы внедрили регулярный мониторинг медленных запросов и ревизию индексов.

Самое главное — мы перестали бояться больших объемов данных. PostgreSQL справляется с ними великолепно, если знать, как с ним работать.

Совет напоследок: не ждите, пока проблемы с производительностью ударят по пользователям. Регулярно проверяйте pg_stat_statements и находите медленные запросы до того, как они найдут вас.

А с какими проблемами производительности PostgreSQL сталкивались вы? Делитесь опытом в комментариях!

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


  1. alan008
    03.10.2025 15:34

    Исходный запрос простейший и если его PG выполняет 28 секунд при наличии двух исходных индексов, это как минимум странно.

    Хотелось бы посмотреть план выполнения исходного запроса и точное кол-во записей в таблицах Users и Orders.

    Немного смутил GROUP BY по u.name, это некрасиво, надеюсь PG такое оптимизирует на автомате, понимая что группировки по u.id достаточно. Можно было не тащить u.name в запрос, а потом уже подставить по справочнику (внешней логикой).

    То, что status хранится строкой а не ID на справочник этих статусов - тоже попахивает )


    1. Gromilo
      03.10.2025 15:34

      надеюсь PG такое оптимизирует на автомате, понимая что группировки по u.id достаточно. 

      Должен оптимизировать, т.к. u.name можно даже не писать в GROUP BY , ид достаточно


  1. redballtoy
    03.10.2025 15:34

    Пробовали в первую часть cte добавить еще фильтр по дате и статусу, тогда бы агрегация далее происходила бы по еще меньшему набору данных. Пробовали? Какой был бы результат. Хотелось бы подробнее про то как вы пришли именно к такому решению по оптимизации запроса которое вы здесь описали. С разбором планов запроса например.


  1. yanchick
    03.10.2025 15:34

    Увидел даты. Первый вопрос возник, а таблица партицирована? Даже если не на этапе дизайна, а в процессе эксплуатации.


  1. Gromilo
    03.10.2025 15:34

    CREATE INDEX idx_orders_covering ON orders(user_id, created_at, status)WHERE status = 'completed';

    Имеет ли смысл в индекс добавлять статус, если он всегда completed? Я бы сказал, что нет, но может какая хитрость есть.