
Предисловие
Оптимизировать запрос в вакууме — просто. Но как он поведет себя, когда десятки таких же запросов одновременно борются за ресурсы?
Методология исследования
Тестовая среда и инструменты:
Тестовая ВМ: 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: Условие WHERE
Тестовый запрос — WHERE
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.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 bookings b
CROSS JOIN random_period rp
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
WHERE b.book_date BETWEEN rp.start_date AND rp.end_date
AND (f.status IS NULL OR f.status IN ('Scheduled', 'On Time', 'Departed')) -- Фильтр по статусу
ORDER BY b.book_date, t.ticket_no;План выполнения тестового запроса — WHERE
Sort (cost=1445335.77..1445902.80 rows=226814 width=77) (actual time=26918.538..27291.773 rows=0 loops=1)
Sort Key: b.book_date, t.ticket_no
Sort Method: quicksort Memory: 25kB
CTE random_period
-> Limit (cost=191879.84..191879.84 rows=1 width=24) (actual time=4017.386..4017.390 rows=1 loops=1)
-> Sort (cost=191879.84..209662.82 rows=7113192 width=24) (actual time=4017.383..4017.385 rows=1 loops=1)
Sort Key: (random())
Sort Method: top-N heapsort Memory: 25kB
-> Seq Scan on bookings (cost=0.00..156313.88 rows=7113192 width=24) (actual time=0.093..2706.240 rows=7113192 loops=1)
-> Nested Loop Left Join (cost=381293.02..1217287.31 rows=226814 width=77) (actual time=26918.440..27291.672 rows=0 loops=1)
-> Nested Loop (cost=381292.45..1081318.76 rows=226814 width=84) (actual time=26918.438..27291.667 rows=0 loops=1)
Join Filter: ((b.book_date >= rp.start_date) AND (b.book_date <= rp.end_date))
Rows Removed by Join Filter: 748969
-> CTE Scan on random_period rp (cost=0.00..0.02 rows=1 width=16) (actual time=4017.403..4017.410 rows=1 loops=1)
-> Gather (cost=381292.45..1050698.88 rows=2041324 width=84) (actual time=16712.775..23184.614 rows=748969 loops=1)
Workers Planned: 4
Workers Launched: 4
-> Parallel Hash Left Join (cost=380292.45..845566.48 rows=510331 width=84) (actual time=16677.978..22499.516 rows=149794 loops=5)
Hash Cond: (s.flight_id = f.flight_id)
Filter: ((f.status IS NULL) OR (f.status = ANY ('{Scheduled,"On Time",Departed}'::text[])))
Rows Removed by Filter: 3969896
-> Parallel Hash Right Join (cost=377694.29..829449.88 rows=5149612 width=60) (actual time=16654.165..20348.485 rows=4119689 loops=5)
Hash Cond: (t.book_ref = b.book_ref)
-> Parallel Hash Right Join (cost=279382.58..608356.43 rows=5149612 width=52) (actual time=9621.931..13771.242 rows=4119689 loops=5)
Hash Cond: (s.ticket_no = t.ticket_no)
-> Parallel Seq Scan on segments s (cost=0.00..224686.12 rows=5149612 width=18) (actual time=1.173..1985.149 rows=4119689 loops=5)
-> Parallel Hash (cost=200286.37..200286.37 rows=3893937 width=34) (actual time=5974.638..5974.640 rows=3115150 loops=5)
Buckets: 524288 Batches: 64 Memory Usage: 20928kB
-> Parallel Seq Scan on tickets t (cost=0.00..200286.37 rows=3893937 width=34) (actual time=0.962..4413.744 rows=3115150 loops=5)
-> Parallel Hash (cost=67398.98..67398.98 rows=1778298 width=15) (actual time=685.837..685.839 rows=1422638 loops=5)
Buckets: 524288 Batches: 16 Memory Usage: 25088kB
-> Parallel Seq Scan on bookings b (cost=0.00..67398.98 rows=1778298 width=15) (actual time=0.026..266.292 rows=1422638 loops=5)
-> Parallel Hash (cost=1800.85..1800.85 rows=63785 width=28) (actual time=22.852..22.853 rows=21687 loops=5)
Buckets: 131072 Batches: 1 Memory Usage: 7904kB
-> Parallel Seq Scan on flights f (cost=0.00..1800.85 rows=63785 width=28) (actual time=0.046..10.141 rows=21687 loops=5)
-> Index Scan using boarding_passes_pkey on boarding_passes bp (cost=0.56..0.60 rows=1 width=25) (never executed)
Index Cond: ((ticket_no = s.ticket_no) AND (flight_id = s.flight_id))
Planning Time: 16.612 ms
Execution Time: 27293.468 msСравнительный анализ планов выполнения запросов
«Тестовый запрос — WHERE» имеет проблемы с производительностью:
Случайный период выбирается через сортировку всей таблицы bookings (дорогая операция)
Фильтрация в WHERE происходит после всех соединений, что создает промежуточные большие наборы данных
Параллельное выполнение помогает, но не компенсирует неоптимальный план
Проблема — неоптимальный выбор случайного периода и поздняя фильтрация, несмотря на использование параллельного выполнения
Производительность и ожидания СУБД
Операционная скорость


Результат: изменение условия WHERE приводит к деградации производительности после 15 соединений.
Ожидания СУБД


Сравнительный анализ wait_event_type
1. wait_event_type = IO
Умеренные показатели (7928)
2. wait_event_type = IPC
Относительно низкие значения (4329)
3. wait_event_type = LWLOCK
Высокие значения (271) — в 2 раза больше по сравнению с «JOIN»
Больше конкуренции за блокировки, что характерно для параллельных запросов с несколькими воркерами.
4. wait_event_type = TIMEOUT
Повышенные значения (36)
Чаще ожидания ресурсов, что также коррелирует с параллельным выполнением.
Итог:
Проблема при изменение условия WHERE — параллельная координация, что проявляется в повышенном количестве легковесных блокировок и таймаутов.
Метрики iostat для дискового устройства, используемого файловой системой /data
Дисковая подсистема не участвует в ограничении производительности данных запросов.
Метрики vmstat
1. Процессорная нагрузка (CPU)
-
Очень высокая CPU-нагрузка (72% user + 11.6% system = ~84%)
Нагрузка на CPU в 1.7 раза сильнее
2. Ввод/вывод (I/O)
Экстремальный ввод (199K блоков/сек), экстремальный вывод (226K блоков/сек)
В 2.6 раза больше операций ввода и в 46 раз больше операций вывода
3. Ожидание I/O (cpu_wa)
Низкое время ожидания I/O (2.4%) - диск успевает за запросами
4. Использование памяти
Больше свободной памяти (313 КБ), меньше кэша (6.4 МБ)
5. Системные события
Умеренные прерывания (8.4K/сек) и низкие переключения контекста (2.6K/сек)
Общий итог: Часть-5 «WHERE»
Для условий высокой параллельной нагрузки и конкуренции за вычислительные ресурсы, с точки зрения производительности СУБД — изменение условий WHERE — неприемлемо.
Производительность СУБД:
Производительность полностью деградирует при нагрузке выше 15 соединений.
Продолжение цикла «Анализ вариантов оптимизации ресурсоёмкого SQL-запроса»
Вариант-6: Пакетная обработка
Вариант-7: Использование покрывающих индексов
Вариант-8: Материализованное представление
Итог: сравнительный анализ вариантов оптимизации ресурсоемкого запроса
Комментарии (5)

ChePeter
03.12.2025 06:00Я правильно понимаю, что при каждом обращении к CTE random_period происходит сортировка по random всей таблицы bookings и только после выбирается первый как случайный? И так много раз
Расскажите, пожалуйста, по подробней про эту часть
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
pg_expecto Автор
03.12.2025 06:00Какие подробности вас интересуют ?
Задача очень простая - получить случайную дату из периода.

baldr
03.12.2025 06:00Вы понимаете, что вы эти сортировки включаете в измерения, хотя, по факту, этот параметр должен приходить от пользователя? Ваши запросы и измерения некорректны - я вам на это указывал несколько раз.
Ну и вообще вся "сложная схема" базы данных у вас - это 9 табличек и 12 Gb? У меня есть таблицы в два раза больше этих 12Gb и это даже не big data. 12 Gb влезет даже в RAM на небольшом сервере. Перестаньте заниматься ерундой.

baldr
03.12.2025 06:00@moderator, а можно как-то ограничить поток автогенерированного псевдотехнического бреда от этого пользователя? Он засоряет всем ленту, отвлекает от нормальных статей..
Все предыдущие статьи заминусованы, на замечания автор не реагирует. Реального смысла во всех этих "измерениях" нет.
Xexa
О чём статья? О том как ведёт себя join с where на вакууме? Ой, простите, на какой-то там тестовой базе.
Не видно причин, не видно мыслей, не видно выводов и идей как вести себя если данные иначе распределены.
Лабораторная работа начального уровня.