Оптимизация пагинации в PostgreSQL: Как настройка work_mem превратила ROW_NUMBER в лидера производительности
Взято с основного технического канала Postgres DBA (возможны правки в исходной статье).
Предисловие
В мире высоконагруженных баз данных выбор метода пагинации может стать решающим фактором для производительности системы. Эксперимент, проведённый с двумя подходами — классическим ROW_NUMBER и отложенным соединением (Deferred Join) — показал, что даже архитектурно более совершенный метод не гарантирует победы без тонкой настройки СУБД. Исследование раскрывает, как правильная конфигурация памяти PostgreSQL перевесила преимущества Deferred Join и позволила ROW_NUMBER добиться превосходства на параллельной нагрузке до 22 сессий.
Начало экспериментов с методом пагинации ROW_NUMBER
Прогноз нейросети
Вопрос
Используя информацию о размерах таблиц, схеме баз данных и тестовых запросах, дай прогноз и обоснование - какой тестовый запрос покажет большую производительность для нагрузочного тестирования с параллельной нагрузкой от 5 до 22 сессий для СУБД PostgreSQL 17 c ресурсами CPU=8 RAM=8GB
Прогноз:
Наибольшую производительность при параллельной нагрузке (5–22 сессии) покажет запрос plan-1 (ROW_NUMBER()) после оптимизации индексами.
Экспериментальная проверка
Анализ ситуации нейросетью
Почему не оправдался прогноз нейросети о большей эффективности метода ROW_NUMBER для заданных условий ?
5. Условия, при которых ROW_NUMBER() действительно был бы эффективнее:
Сценарий 2: Адекватный размер work_mem
Если бы work_mem был ≥ 256MB для сортировки в памяти
какие значение work_mem необходимо установить для получения преимущества метода row_number при максимальной нагрузке 22 параллельные сессии при общем размере RAM=8GB ?
-- Для получения преимущества ROW_NUMBER():
SET work_mem = '180MB';
SET max_parallel_workers_per_gather = 1;
SET max_parallel_workers = 16;
-- В postgresql.conf:
work_mem = 180MB
max_parallel_workers_per_gather = 1
max_parallel_workers = 16
max_worker_processes = 16
shared_buffers = 2GB
effective_cache_size = 6GB
Задача
Экспериментальная проверка рекомендации нейросети по оптимизации метода пагинации ROW_NUMBER
Изменение конфигурационных параметров СУБД
ALTER SYSTEM SET work_mem = '180MB';
ALTER SYSTEM SET max_parallel_workers_per_gather = 1;
ALTER SYSTEM SET max_parallel_workers = 16;
ALTER SYSTEM SET max_worker_processes = 16 ;
ALTER SYSTEM SET shared_buffers = '2GB';
ALTER SYSTEM effective_cache_size = '6GB';
Производительность и ожидания СУБД в ходе нагрузочного тестирования
Операционная скорость
График изменения относительной разницы операционной скорости при использовании метода DIFFERED JOIN по сравнению с методом ROW_NUMBER в ходе нагрузочного тестирования
Результат
Среднее превышение операционной скорости , при использовании метода ROW_NUMBER составило 12.59%
Ожидания СУБД
График изменения относительной разницы ожиданий СУБД при использовании метода DIFFERED JOIN по сравнению с методом ROW_NUMBER в ходе нагрузочного тестирования
Результат
Среднее снижение ожиданий СУБД, при использовании метода ROW_NUMBER составило 18.06%
Характерные особенности тестовых запросов и планов выполнения
1. Общая цель запросов
Оба запроса решают задачу случайной пагинации (выбор случайной "страницы" из 100 строк) для отфильтрованных данных (билеты с бизнес-классом).
2. Основные различия в подходах
Первый запрос (ROW_NUMBER()):
Использует оконную функцию ROW_NUMBER() для нумерации всех строк
Фильтрует по диапазону номеров строк после нумерации
Ключевая проблема: Выполняет полную сортировку и нумерацию всех 2.15 млн строк
Второй запрос (Deferred Join):
Использует отложенное соединение (deferred join)
Сначала выбирает только ticket_no с помощью OFFSET/LIMIT
Затем соединяет остальные данные по отобранным ключам
Преимущество: Сортирует только ключи, а не все данные
3. Производительность
Первый запрос:
Время выполнения: 12.65 секунд
Основные затраты:
Merge Semi Join: 9.3 секунды
Сортировка 2.15 млн строк: 850-1000 мс (quicksort, 170 МБ памяти)
WindowAgg (оконная функция): 1723 мс
Второй запрос:
Время выполнения: ~16.1 секунды (неполные данные в выводе)
Основные затраты:
Merge Semi Join: 8.75 секунды
Сортировка для OFFSET: 5.5 секунды (top-N heapsort, 15 МБ памяти)
Hash Join: 5575 мс
4. Критические различия в планах
Сортировка:
Первый запрос: Сортирует все столбцы (ticket_no, book_ref, passenger_name) - 92 байта на строку
Второй запрос: Сортирует только ticket_no - 32 байта на строку
Использование памяти:
Первый: 170 МБ для сортировки всех данных
Второй: 15 МБ для сортировки только ключей
Фильтрация:
Первый: Сначала нумерует все строки, затем фильтрует 100
Второй: Сначала выбирает 100 ключей, затем соединяет данные
5. Проблемы обоих подходов
Общая проблема: floor(random() * COUNT(*)/100) требует полного сканирования для подсчета строк
Производительность OFFSET: Во втором запросе OFFSET должен "пропустить" много строк (случайная страница * 100)
6. Рекомендации
Для оптимизации:
Второй подход лучше архитектурно (deferred join), но в данном случае медленнее из-за:
Необходимости вычислять OFFSET
Hash Join вместо более эффективного соединенияПотенциальные улучшения:
Использовать keyset pagination вместо OFFSET
Кэшировать общее количество строк
Рассмотреть материализованные представления для filtered_tickets
В текущей реализации:
Первый запрос быстрее (12.65с vs ~16.1с), но использует больше памяти
Второй запрос масштабируется лучше при увеличении размера данных, так как сортирует меньше данных
7. Вывод
Оба запроса имеют проблемы с производительностью из-за необходимости обработки всех 2.15 млн строк для случайной выборки. Deferred Join (второй подход) - более правильная архитектурная паттерн, но требует оптимизации OFFSET и соединения для повышения эффективности в данном конкретном случае.
Характерные особенности производительности СУБД
1. Производительность (SPEED)
ROW_NUMBER подход:
Начальная производительность: 60
Стабильная после 45 итерации: 96
Максимум: 96 (после адаптации)
Прирост: 60 → 96 (+60%)
Deferred Join подход:
Начальная производительность: 52
Стабильная после 55 итерации: 84
Максимум: 84
Прирост: 52 → 84 (+61.5%)
Вывод: ROW_NUMBER показывает на 14.3% выше абсолютную производительность (96 vs 84).
2. Динамика адаптации
ROW_NUMBER:
Быстрый рост до 96 за 55 итераций
Более резкие скачки производительности
Ранняя стабилизация (с 55 итерации)
Deferred Join:
Более плавный рост до 84
Дольше адаптируется (до 55 итерации)
Стабильнее на низких нагрузках
3. Ожидания и блокировки
ROW_NUMBER:
TIMEOUT появляются с итерации 46 (при нагрузке 10 соединений)
LWLOCK стабилизируются на уровне 3-6
WAITINGS: 4-7
Deferred Join:
IO ожидания появляются с итерации 73 (при нагрузке 15+ соединений)
LWLOCK: 4-8 (выше, чем у ROW_NUMBER)
WAITINGS: до 21 пикового значения
Более выраженные скачки в ожиданиях (74 итерация: 21 ожидание)
4. Корреляция с нагрузкой
Нагрузка растет от 5 до 22 соединений
Критические точки:
10 соединений (итерация 46): ROW_NUMBER начинает показывать TIMEOUT
15 соединений (итерация 73): Deferred Join показывает IO ожидания
18+ соединений: оба подхода стабилизируются на максимальных значениях ожиданий
5. Ключевые различия в поведении
ROW_NUMBER:
Выше пиковая производительность (96 vs 84)
Раннее появление TIMEOUT (с 10 соединений)
Меньше LWLOCK в среднем (3-6 vs 4-8)
Более предсказуемые паттерны ожиданий
Deferred Join:
Лучше масштабируется при низких нагрузках
Более стабильные WAITINGS (кроме пиков)
Появление IO ожиданий вместо TIMEOUT
Сильнее страдает от LWLOCK
6. Анализ проблемных точек
ROW_NUMBER:
Проблема: TIMEOUT при 10+ соединениях
Причина: WindowAgg + сортировка больших данных конкурируют за ресурсы
Симптом: Конкуренция за CPU/память
Deferred Join:
Проблема: IO ожидания при 15+ соединениях
Причина: Hash Join + сортировка для OFFSET требуют disk I/O
Симптом: Конкуренция за disk I/O и LWLOCK
7. Рекомендации по оптимизации
Для ROW_NUMBER:
Увеличить work_mem для уменьшения disk spills
Рассмотреть материализованные представления для filtered_tickets
Кэшировать COUNT(*) для random_page
Для Deferred Join:
Оптимизировать индексы для сортировки ticket_no
Увеличить shared_buffers для кэширования hash таблиц
Рассмотреть использование keyset pagination вместо OFFSET
8. Общий вывод
ROW_NUMBER лучше для:
Максимальной производительности (14.3% выше)
Систем с достаточными CPU/памятью
Сценариев с <10 конкурентных соединенийDeferred Join лучше для:
Систем с ограничениями по памяти
Сценариев с предсказуемыми IO паттернами
Приложений, чувствительных к TIMEOUTОба подхода требуют оптимизации при >10 конкурентных соединений
Критическая нагрузка: 10-15 соединений (точка деградации производительности)
Метрики производительности инфраструктуры
1. Использование CPU
ROW_NUMBER:
Начало: 61% user, 3% system, 36% idle
Критическая точка (итерация 50): резкий переход на 95-96% user, 4% system, 0% idle
Стабильная нагрузка: 96% user, 4% system, 0% idle
Вывод: Полная загрузка CPU с преобладанием пользовательского времени
Deferred Join:
Начало: 61% user, 2% system, 36% idle
Критическая точка (итерация 52): переход на 97% user, 3% system, 0% idle
Стабильная нагрузка: 97% user, 3% system, 0% idle
Вывод: Аналогичная полная загрузка, но меньше системного времени
Сравнение: Deferred Join показывает на 1% выше user CPU и на 1% ниже system CPU.
2. Использование памяти
ROW_NUMBER:
Свободная память: 1791 → 2753 KB (+53% рост)
Буферы: 47 → 6 KB (-87% снижение)
Кэш: 4955 → 2751 KB (-44% снижение)
Своп: 70 → 67 KB (минимальное изменение)
Вывод: Активное использование кэша и буферов
Deferred Join:
Свободная память: 4567 → 2207 KB (-51% снижение)
Буферы: 18 → 6 KB (-67% снижение)
Кэш: 2211 → 2728 KB (+23% рост)
Своп: 69 → 73 KB (незначительный рост)
Вывод: Увеличение использования кэша, снижение свободной памяти
Сравнение: ROW_NUMBER активнее использует кэш в начале, Deferred Join наращивает кэш в процессе.
3. Ввод-вывод (IO)
ROW_NUMBER:
io_bo: 52 → 58 (+11.5% рост)
Пиковые значения: до 59
Вывод: Умеренный рост IO при увеличении нагрузки
Deferred Join:
io_bo: 59 → 65 (+10% рост)
Пиковые значения: до 68
Вывод: Более высокий базовый уровень IO
Сравнение: Deferred Join имеет на 10-15% выше активность IO, что соответствует ожиданиям из предыдущего анализа (IO ожидания).
4. Системные метрики
ROW_NUMBER:
Прерывания (system_in): 5687 → 8279 (+45% рост)
Переключения контекста (system_cs): 657 → 1053 (+60% рост)
Процессы в run queue (procs_r): 6 → 15 (+150% рост)
Deferred Join:
Прерывания (system_in): 5652 → 8274 (+46% рост)
Переключения контекста (system_cs): 652 → 1063 (+63% рост)
Процессы в run queue (procs_r): 5 → 15 (+200% рост)
Сравнение: Оба подхода показывают схожий рост системных метрик, Deferred Join имеет немного больше переключений контекста.
5. Критические точки перехода
ROW_NUMBER:
Итерация 50 (нагрузка 10): переход на 0% idle CPU
Сопровождается: Ростом procs_r до 9
Симптом: Резкое изменение паттерна использования памяти
Deferred Join:
Итерация 52 (нагрузка 10): переход на 0% idle CPU
Сопровождается: Ростом procs_r до 9
Симптом: Снижение свободной памяти до 4000 KB
6. Корреляция с нагрузкой
5-8 соединений: Оба подхода работают с простоями CPU (36% idle)
10 соединений: Критическая точка (0% idle)
12-15 соединений: Стабилизация на максимальной нагрузке
18-22 соединений: Дальнейший рост системных метрик
7. Ключевые различия по метрикам
ROW_NUMBER преимущества:
Меньше IO операций (58 vs 68)
Лучшее использование памяти (растущая свободная память)
Более стабильный кэш (меньше колебаний)
Deferred Join преимущества:
Меньше системного времени CPU (3% vs 4%)
Более предсказуемое использование кэша (постепенный рост)
Меньше начальных буферов (более эффективное использование)
8. Проблемные паттерны
ROW_NUMBER:
Проблема: Резкое падение кэша с 4955 до 2751
Причина: Оконные функции требуют больших рабочих наборов
Риск: Потенциальные page faults при недостатке памяти
Deferred Join:
Проблема: Высокий базовый уровень IO (59 vs 52)
Причина: Hash Join и сортировка для OFFSET
Риск: Disk I/O bottlenecks при высокой нагрузке
9. Общие выводы по метрикам
Оба подхода CPU-bound: 0% idle при нагрузке ≥10 соединений
Deferred Join более IO-intensive: на 10-15% выше IO активность
ROW_NUMBER более memory-intensive: активнее использует кэш и буферы
Критическая нагрузка: 10 соединений для обоих подходов
Масштабирование: Deferred Join лучше сохраняет кэш, но требует больше IO
10. Рекомендации по оптимизации системы
Для ROW_NUMBER:
Увеличить work_mem для сортировки в памяти
Настроить shared_buffers для кэширования рабочих наборов
Мониторить page faults и swap активность
Для Deferred Join:
Оптимизировать индексы для уменьшения IO
Рассмотреть SSD для уменьшения latency IO операций
Настроить effective_io_concurrency для параллельного IO
Итог:
Выбор между подходами зависит от конфигурации системы -
ROW_NUMBER для CPU-обеспеченных архитектур
Deferred Join для систем с хорошим IO.



































