
Предисловие
Оптимизировать запрос в вакууме — просто. Но как он поведет себя, когда десятки таких же запросов одновременно борются за ресурсы?
Методология исследования
Тестовая среда и инструменты:
Тестовая ВМ: CPU = 8 / RAM = 8GB
Инструмент нагрузочного тестирования и статистического анализа производительности СУБД: pg_expecto
Тестовая база данных: "Демобаза 2.0" (большой размер, сложная схема)
Условия тестирования: параллельная нагрузка, ресурсоемкий запрос
СУБД: PostgreSQL 17
Конфигурационные параметры СУБД
track_io_timing = 'on'
listen_addresses = '0.0.0.0'
logging_collector = 'on'
log_directory = '/log/pg_log'
log_destination = 'stderr'
log_rotation_size = '0'
log_rotation_age = '1d'
log_filename = 'postgresql-%u.log'
log_line_prefix = '%m| %d| %a| %u| %h| %p| %e| '
log_truncate_on_rotation = 'on'
log_checkpoints = 'on'
archive_mode = 'on'
archive_command = 'true'
archive_timeout = '30min'
checkpoint_timeout = '15min'
checkpoint_warning = '60'
checkpoint_completion_target = '0.9'
min_wal_size = '2GB'
max_wal_size = '8GB'
synchronous_commit = 'on'
wal_compression = 'on'
random_page_cost = '1.1'
effective_io_concurrency = '300'
wal_sender_timeout = '0'
autovacuum_naptime = '1s'
autovacuum_vacuum_scale_factor = '0.01'
autovacuum_analyze_scale_factor = '0.005'
autovacuum_vacuum_cost_delay = '2ms'
autovacuum_max_workers = '4'
autovacuum_work_mem = '256MB'
vacuum_cost_limit = '4000'
bgwriter_delay = '10ms'
bgwriter_lru_multiplier = '4'
bgwriter_lru_maxpages = '400'
max_locks_per_transaction = '256'
max_pred_locks_per_transaction = '256'
shared_buffers = '1919MB'
effective_cache_size = '5757MB'
work_mem = '14MB'
temp_buffers = '14MB'
maintenance_work_mem = '479MB'
max_worker_processes = '8'
max_parallel_workers = '8'
max_parallel_workers_per_gather = '4'
idle_in_transaction_session_timeout = '1h'
statement_timeout = '8h'
pg_stat_statements.track_utility = 'off'
max_parallel_maintenance_workers = '4'
hash_mem_multiplier = '2'
autovacuum_vacuum_insert_scale_factor = '0.01'
shared_preload_libraries = 'pg_stat_statements , pg_wait_sampling'
commit_delay = '1000'
log_autovacuum_min_duration = '0'
wipe_file_on_delete = 'on'
wipe_heaptuple_on_delete = 'on'
wipe_mem_on_free = 'on'
wipe_memctx_on_free = 'on'
wipe_xlog_on_free = 'on'
log_connections = 'on'
log_disconnections = 'on'
pg_stat_statements.track = 'all'
max_connections = '1000'План нагрузочного тестирования (param.conf)
# НАСТРОЙКИ НАГРУЗОЧНОГО ТЕСТИРОВАНИЯ
# Максимальная нагрузка
finish_load = 20
# Тестовая БД
testdb = demo
# Веса сценариев
scenario1 = 1.0
Нагрузка на СУБД

Тестовый запрос, используемый в качестве baseline : LEFT JOIN
Запрос демонстрирует:
LEFT JOIN bookings → tickets
Все бронирования, даже без привязанных билетовLEFT JOIN tickets → segments
Все билеты, даже без перелётовLEFT JOIN segments → flights
Все перелёты, даже без информации о рейсахLEFT JOIN segments → boarding_passes
Все перелёты, даже без посадочных талоновВыбирает случайную дату из существующих бронирований
Гарантирует, что период полностью входит в доступный диапазон дат
Такой запрос может быть полезен для анализа:
Неполных бронирований
Билетов без привязки к рейсам
Рейсов без процедуры посадки
Статистики по незавершённым операциям
Таблицы, используемые в тестовом запросе
demo=# \d bookings
Unlogged table "bookings.bookings"
Column | Type | Collation | Nullable | Default
--------------+--------------------------+-----------+----------+---------
book_ref | character(6) | | not null |
book_date | timestamp with time zone | | not null |
total_amount | numeric(10,2) | | not null |
Indexes:
"bookings_pkey" PRIMARY KEY, btree (book_ref)
"idx_bookings_book_date" btree (book_date)
Referenced by:
TABLE "tickets" CONSTRAINT "tickets_book_ref_fkey" FOREIGN KEY (book_ref) REFERENCES bookings(book_ref)
demo=# \d tickets
Unlogged table "bookings.tickets"
Column | Type | Collation | Nullable | Default
----------------+--------------+-----------+----------+---------
ticket_no | text | | not null |
book_ref | character(6) | | not null |
passenger_id | text | | not null |
passenger_name | text | | not null |
outbound | boolean | | not null |
Indexes:
"tickets_pkey" PRIMARY KEY, btree (ticket_no)
"idx_tickets_book_ref" btree (book_ref)
"tickets_book_ref_passenger_id_outbound_key" UNIQUE CONSTRAINT, btree (book_ref, passenger_id, outbound)
Foreign-key constraints:
"tickets_book_ref_fkey" FOREIGN KEY (book_ref) REFERENCES bookings(book_ref)
Referenced by:
TABLE "segments" CONSTRAINT "segments_ticket_no_fkey" FOREIGN KEY (ticket_no) REFERENCES tickets(ticket_no)
demo=# \d segments
Unlogged table "bookings.segments"
Column | Type | Collation | Nullable | Default
-----------------+---------------+-----------+----------+---------
ticket_no | text | | not null |
flight_id | integer | | not null |
fare_conditions | text | | not null |
price | numeric(10,2) | | not null |
Indexes:
"segments_pkey" PRIMARY KEY, btree (ticket_no, flight_id)
"idx_segments_flight_id" btree (flight_id)
"idx_segments_flight_id_fare_conditions" btree (flight_id, fare_conditions)
"idx_segments_ticket_no" btree (ticket_no)
"segments_flight_id_idx" btree (flight_id)
Check constraints:
"segments_fare_conditions_check" CHECK (fare_conditions = ANY (ARRAY['Economy'::text, 'Comfort'::text, 'Business'::text]))
"segments_price_check" CHECK (price >= 0::numeric)
Foreign-key constraints:
"segments_flight_id_fkey" FOREIGN KEY (flight_id) REFERENCES flights(flight_id)
"segments_ticket_no_fkey" FOREIGN KEY (ticket_no) REFERENCES tickets(ticket_no)
Referenced by:
TABLE "boarding_passes" CONSTRAINT "boarding_passes_ticket_no_flight_id_fkey" FOREIGN KEY (ticket_no, flight_id) REFERENCES segments(ticket_no, flight_id)
demo=# \d boarding_passes
Unlogged table "bookings.boarding_passes"
Column | Type | Collation | Nullable | Default
---------------+--------------------------+-----------+----------+---------
ticket_no | text | | not null |
flight_id | integer | | not null |
seat_no | text | | not null |
boarding_no | integer | | |
boarding_time | timestamp with time zone | | |
Indexes:
"boarding_passes_pkey" PRIMARY KEY, btree (ticket_no, flight_id)
"boarding_passes_flight_id_boarding_no_key" UNIQUE CONSTRAINT, btree (flight_id, boarding_no)
"boarding_passes_flight_id_boarding_time" btree (flight_id, boarding_time)
"boarding_passes_flight_id_seat_no_key" UNIQUE CONSTRAINT, btree (flight_id, seat_no)
Foreign-key constraints:
"boarding_passes_ticket_no_flight_id_fkey" FOREIGN KEY (ticket_no, flight_id) REFERENCES segments(ticket_no, flight_id)
demo=# \d flights
Unlogged table "bookings.flights"
Column | Type | Collation | Nullable | Default
---------------------+--------------------------+-----------+----------+------------------------------
flight_id | integer | | not null | generated always as identity
route_no | text | | not null |
status | text | | not null |
scheduled_departure | timestamp with time zone | | not null |
scheduled_arrival | timestamp with time zone | | not null |
actual_departure | timestamp with time zone | | |
actual_arrival | timestamp with time zone | | |
Indexes:
"flights_pkey" PRIMARY KEY, btree (flight_id)
"flights_route_no_scheduled_departure_key" UNIQUE CONSTRAINT, btree (route_no, scheduled_departure)
"idx_flights_route_no" btree (route_no)
"idx_flights_scheduled_departure" btree (scheduled_departure DESC)
Check constraints:
"flight_actual_check" CHECK (actual_arrival IS NULL OR actual_departure IS NOT NULL AND actual_arrival IS NOT NULL AND actual_arrival > actual_departure)
"flight_scheduled_check" CHECK (scheduled_arrival > scheduled_departure)
"flight_status_check" CHECK (status = ANY (ARRAY['Scheduled'::text, 'On Time'::text, 'Delayed'::text, 'Boarding'::text, 'Departed'::text, 'Arrived'::text, 'Cancelled'::text]))
Referenced by:
TABLE "segments" CONSTRAINT "segments_flight_id_fkey" FOREIGN KEY (flight_id) REFERENCES flights(flight_id)Тестовый запрос - LEFT JOIN
WITH random_period AS (
SELECT
start_date,
start_date + INTERVAL '30 days' AS end_date
FROM (
SELECT
book_date AS start_date
FROM bookings
WHERE book_date <= (SELECT MAX(book_date) FROM bookings) - INTERVAL '30 days'
ORDER BY RANDOM()
LIMIT 1
) AS random_date
)
SELECT
b.book_ref,
b.book_date,
t.ticket_no,
t.passenger_name,
s.flight_id,
f.status,
f.scheduled_departure,
f.actual_departure,
bp.seat_no,
bp.boarding_no,
rp.start_date AS period_start,
rp.end_date AS period_end
FROM random_period rp
CROSS JOIN LATERAL (
SELECT *
FROM bookings b
WHERE b.book_date BETWEEN rp.start_date AND rp.end_date
) b
LEFT JOIN tickets t ON b.book_ref = t.book_ref
LEFT JOIN segments s ON t.ticket_no = s.ticket_no
LEFT JOIN flights f ON s.flight_id = f.flight_id
LEFT JOIN boarding_passes bp ON
s.ticket_no = bp.ticket_no
AND s.flight_id = bp.flight_id
ORDER BY b.book_date, t.ticket_no;План выполнения тестового запроса - LEFT JOIN
Sort (cost=2544355.52..2550077.32 rows=2288717 width=93) (actual time=58778.563..59050.999 rows=1146771 loops=1)
Sort Key: b.book_date, t.ticket_no
Sort Method: external merge Disk: 121832kB
CTE random_period
-> Subquery Scan on random_date (cost=66429.70..66429.71 rows=1 width=16) (actual time=2883.937..2883.945 rows=1 loops=1)
-> Limit (cost=66429.70..66429.70 rows=1 width=16) (actual time=2883.919..2883.925 rows=1 loops=1)
InitPlan 2
-> Result (cost=0.45..0.46 rows=1 width=8) (actual time=1.994..1.996 rows=1 loops=1)
InitPlan 1
-> Limit (cost=0.43..0.45 rows=1 width=8) (actual time=1.989..1.991 rows=1 loops=1)
-> Index Only Scan Backward using idx_bookings_book_date on bookings (cost=0.43..128154.91 rows=7113192 width=8) (actual time=1.988..1.989 rows=1 loops=1)
Heap Fetches: 0
-> Sort (cost=66429.24..72356.90 rows=2371064 width=16) (actual time=2883.917..2883.918 rows=1 loops=1)
Sort Key: (random())
Sort Method: top-N heapsort Memory: 25kB
-> Index Only Scan using idx_bookings_book_date on bookings bookings_1 (cost=0.43..54573.91 rows=2371064 width=16) (actual time=2.183..1714.099 rows=6894590 loops=1)
Index Cond: (book_date <= ((InitPlan 2).col1 - '30 days'::interval))
Heap Fetches: 0
-> Hash Left Join (cost=685482.92..2046557.85 rows=2288717 width=93) (actual time=49378.694..57307.629 rows=1146771 loops=1)
Hash Cond: (s.flight_id = f.flight_id)
-> Merge Right Join (cost=681880.13..2036946.95 rows=2288717 width=69) (actual time=49326.107..56627.660 rows=1146771 loops=1)
Merge Cond: (s.ticket_no = t.ticket_no)
-> Merge Left Join (cost=1.12..1264914.46 rows=20598448 width=25) (actual time=3.919..32375.016 rows=6564405 loops=1)
Merge Cond: ((s.ticket_no = bp.ticket_no) AND (s.flight_id = bp.flight_id))
-> Index Only Scan using segments_pkey on segments s (cost=0.56..424624.50 rows=20598448 width=18) (actual time=3.875..1950.364 rows=6564405 loops=1)
Heap Fetches: 0
-> Index Scan using boarding_passes_pkey on boarding_passes bp (cost=0.56..637941.24 rows=19870556 width=25) (actual time=0.033..25169.883 rows=6564405 loops=1)
-> Materialize (cost=681879.01..690532.20 rows=1730639 width=58) (actual time=21308.253..22022.391 rows=1146771 loops=1)
-> Sort (cost=681879.01..686205.61 rows=1730639 width=58) (actual time=21308.245..21812.491 rows=862459 loops=1)
Sort Key: t.ticket_no
Sort Method: external merge Disk: 58600kB
-> Nested Loop Left Join (cost=0.87..464448.40 rows=1730639 width=58) (actual time=2886.101..18909.459 rows=862459 loops=1)
-> Nested Loop (cost=0.43..31711.08 rows=790355 width=31) (actual time=2884.013..3268.567 rows=397632 loops=1)
-> CTE Scan on random_period rp (cost=0.00..0.02 rows=1 width=16) (actual time=2883.941..2883.946 rows=1 loops=1)
-> Index Scan using idx_bookings_book_date on bookings b (cost=0.43..23807.51 rows=790355 width=15) (actual time=0.063..308.659 rows=397632 loops=1)
Index Cond: ((book_date >= rp.start_date) AND (book_date <= rp.end_date))
-> Index Scan using idx_tickets_book_ref on tickets t (cost=0.43..0.52 rows=3 width=34) (actual time=0.038..0.038 rows=2 loops=397632)
Index Cond: (book_ref = b.book_ref)
-> Hash (cost=2247.35..2247.35 rows=108435 width=28) (actual time=52.383..52.385 rows=108435 loops=1)
Buckets: 131072 Batches: 1 Memory Usage: 7377kB
-> Seq Scan on flights f (cost=0.00..2247.35 rows=108435 width=28) (actual time=0.024..22.614 rows=108435 loops=1)
Planning Time: 37.456 ms
Execution Time: 59151.413 msЭксперимент-1 : вариант с использованием EXISTS
Эксперимент-2 : вариант с оптимизацией структуры запроса (TUNING)
Эксперимент-3 : Частичная агрегация
Эксперимент-4 «Временная таблица»
Временная таблица
-- Создаем временную таблицу для отфильтрованных бронирований
CREATE TEMPORARY TABLE temp_filtered_bookings AS
WITH random_period AS (
SELECT
book_date AS start_date,
book_date + INTERVAL '30 days' AS end_date
FROM bookings
ORDER BY RANDOM()
LIMIT 1
)
SELECT b.*
FROM bookings b
CROSS JOIN random_period rp
WHERE b.book_date BETWEEN rp.start_date AND rp.end_date;
-- Создаем индексы на временной таблице
CREATE INDEX tmp_idx_book_ref ON temp_filtered_bookings (book_ref);
CREATE INDEX tmp_idx_book_date ON temp_filtered_bookings (book_date);
Тестовый запрос - TEMP TABLE
SELECT
b.book_ref,
b.book_date,
t.ticket_no,
t.passenger_name,
s.flight_id,
f.status,
f.scheduled_departure,
f.actual_departure,
bp.seat_no,
bp.boarding_no
FROM temp_filtered_bookings b
LEFT JOIN tickets t ON b.book_ref = t.book_ref
LEFT JOIN segments s ON t.ticket_no = s.ticket_no
LEFT JOIN flights f ON s.flight_id = f.flight_id
LEFT JOIN boarding_passes bp ON s.ticket_no = bp.ticket_no AND s.flight_id = bp.flight_id
ORDER BY b.book_date, t.ticket_no;План выполнения тестового запроса -TEMP TABLE
Sort (cost=2211998.73..2215844.96 rows=1538491 width=98) (actual time=34246.862..34542.140 rows=1211093 loops=1)
Sort Key: b.book_date, t.ticket_no
Sort Method: external merge Disk: 109672kB
-> Hash Left Join (cost=579129.28..1925563.26 rows=1538491 width=98) (actual time=23831.991..32643.918 rows=1211093 loops=1)
Hash Cond: (s.flight_id = f.flight_id)
-> Merge Right Join (cost=575526.49..1917921.78 rows=1538491 width=74) (actual time=23774.473..31882.934 rows=1211093 loops=1)
Merge Cond: (s.ticket_no = t.ticket_no)
-> Merge Left Join (cost=1.12..1264914.56 rows=20598448 width=25) (actual time=2.411..22735.924 rows=3430362 loops=1)
Merge Cond: ((s.ticket_no = bp.ticket_no) AND (s.flight_id = bp.flight_id))
-> Index Only Scan using segments_pkey on segments s (cost=0.56..424624.50 rows=20598448 width=18) (actual time=1.704..1121.887 rows=3430362 loops=1)
Heap Fetches: 0
-> Index Scan using boarding_passes_pkey on boarding_passes bp (cost=0.56..637941.32 rows=19870556 width=25) (actual time=0.698..18861.672 rows=3430362 loops=1)
-> Materialize (cost=575525.37..581342.10 rows=1163347 width=63) (actual time=6939.314..7652.509 rows=1211093 loops=1)
-> Sort (cost=575525.37..578433.73 rows=1163347 width=63) (actual time=6939.289..7437.005 rows=902132 loops=1)
Sort Key: t.ticket_no
Sort Method: external merge Disk: 47192kB
-> Nested Loop Left Join (cost=0.43..432700.18 rows=1163347 width=63) (actual time=0.175..4432.691 rows=902132 loops=1)
-> Seq Scan on temp_filtered_bookings b (cost=0.00..6791.41 rows=410341 width=36) (actual time=0.096..71.373 rows=410341 loops=1)
-> Index Scan using idx_tickets_book_ref on tickets t (cost=0.43..1.01 rows=3 width=34) (actual time=0.009..0.010 rows=2 loops=410341)
Index Cond: (book_ref = b.book_ref)
-> Hash (cost=2247.35..2247.35 rows=108435 width=28) (actual time=57.236..57.238 rows=108435 loops=1)
Buckets: 131072 Batches: 1 Memory Usage: 7377kB
-> Seq Scan on flights f (cost=0.00..2247.35 rows=108435 width=28) (actual time=0.055..24.930 rows=108435 loops=1)
Planning Time: 42.697 ms
Execution Time: 34655.641 msСравнительный анализ планов выполнения запросов
1.Подход к работе с данными:
TEMP TABLE: Использует временную таблицу с предварительной фильтрацией и индексами
2.Использование ресурсов
TEMP TABLE: Меньше дисковых операций сортировки (109,672 kB)
Оба запроса используют external merge сортировку из-за большого объема данных
3.Операции в плане
TEMP TABLE: Разделяет логику - сначала фильтрация во временную таблицу, затем JOIN
4.Чтение данных
TEMP TABLE: Работает с уже отфильтрованными строками во временной таблице
Производительность и ожидания СУБД
Операционная скорость


Среднее увеличение операционной скорости при использовании запроса "TEMP TABLE" составило 1406.03%
Ожидания СУБД


Сравнительный анализ wait_event_type
1. wait_event_type = IO
Начинается с 361, достигает максимум 1276
В 10-12 раз меньше, чем в JOIN
2. wait_event_type = IPC
Начинается с 632, затем падает до 0-5
Практически отсутствует межпроцессное взаимодействие
3. wait_event_type = LWLOCK
В основном 1-2, иногда до 5
Минимальные блокировки
4. wait_event_type = TIMEOUT
В основном 0-1, изредка 4
Практически отсутствуют таймауты
Итог
Преимущества при использовании временной таблицы:
На 88-95% меньше IO ожиданий - эффективная работа с диском
На 99% меньше IPC ожиданий - минимальное межпроцессное взаимодействие
На 95% меньше LWLOCK - минимальные блокировки
На 95% меньше TIMEOUT - предсказуемое время выполнения
Метрики iostat для дискового устройства, используемого файловой системой /data
Дисковая подсистема не участвует в ограничении производительности данных запросов.
Метрики vmstat
Сравнительный анализ ключевых метрик
Процессы (procs)
Параметр |
LEFT JOIN |
TEMP TABLE |
procs_r |
4-9 |
6-15 |
procs_b |
1-2 (постоянно есть заблокированные процессы) |
отсутствует в данных |
Вывод: При использовании временной таблицы - отсутствуют заблокированные процессы.
Память
Параметр |
LEFT JOIN |
TEMP TABLE |
memory_swpd |
203-217(использует swap) |
213-214(стабильно) |
memory_free |
179-182(низкая свободная память) |
209-460 (значительно больше свободной памяти) |
memory_buff |
10-147(нестабильные буферы) |
40-193 (умеренный рост) |
memory_cache |
6800-7100 (высокий уровень кэширования) |
6400-6900 (эффективное использование кэша ) |
Вывод: При использовании временной таблицы - память используется эффективно.
Ввод-вывод
Параметр |
LEFT JOIN |
TEMP TABLE |
io_bi |
42442-86819 (очень высокая активность чтения) |
24-58 (стабильно низкие) |
io_bo |
3474-7528(высокая активность записи) |
38529-62982 (высокие, но стабильные ) |
Вывод: При использовании временной таблицы снижается нагрузка на IO.
Системные события
Параметр |
LEFT JOIN |
TEMP TABLE |
system_in |
10097-14285 (высокие прерывания) |
6051-8858 (значительно ниже) |
system_cs |
9511-13765 (частые переключения контекста) |
1106-1890 ( на порядок ниже ) |
Вывод: Использовании временной таблицы создает значительно меньше системной нагрузки.
Загрузка CPU
Параметр |
LEFT JOIN |
TEMP TABLE |
cpu_us |
35-57% (умеренная пользовательская нагрузка) |
60-95% (очень высокое, особенно в конце ) |
cpu_sy |
4-5% (низкая системная нагрузка) |
3-5% (стабильно ) |
cpu_id |
17-47% (простой уменьшается со временем) |
0-36% (снижается до 0%) |
cpu_wa |
10-17% (высокое ожидание IO) |
0-1% → практически отсутствует |
Вывод: При создании временной таблицы эффективнее используется CPU, меньше времени тратится на ожидание.
Общий итог : Часть-4 "TEMP TABLE"
Для условий высокой параллельной нагрузки и конкуренции за вычислительные ресурсы, с точки зрения производительности СУБД - использование временной таблицы оказывает кардинальное влияние на увеличение производительности СУБД .
Производительность СУБД:
Среднее увеличение операционной скорости при использовании запроса "TEMP TABLE" составило 1406.03%
Общий итог сравнения производительности и показателей инфраструктуры
1. Кардинальное улучшение производительности
Подход с временной таблицей демонстрирует:
Рост операционной скорости на 1406%
2. Эффективное использование ресурсов СУБД
Использование временной таблицы обеспечивает:
На 92-99% меньше ожиданий (IO, IPC, блокировки)
Минимальные таймауты выполнения
Упрощенные планы выполнения запросов
3. Оптимизация системных ресурсов
На 94% меньше ожидания I/O (CPU не простаивает)
На 90% больше свободной памяти
На 87% меньше переключений контекста
Отсутствие блокированных процессов
4. Масштабируемость и стабильность
TEMP TABLE показывает линейный рост производительности
Нет экспоненциального роста системных издержек
Предсказуемое поведение под нагрузкой
Продолжение цикла "Анализ вариантов оптимизации ресурсоёмкого SQL-запроса"
Вариант-5: Оптимизация с помощью условий в JOIN
Вариант-6: Пакетная обработка с LIMIT-OFFSET
Вариант-7: Использование покрывающих индексов
Вариант-8: Материализованное представление
Итог: сравнительный анализ вариантов оптимизации ресурсоемкого запроса
n0wheremany
Запихните в переменные @start_date и @end_date, прежде чем выполнять запрос и проверьте себя еще раз.