
Статистический анализ производительности СУБД PostgreSQL
Нагрузочное тестирование — это не п��осто «нагрузить систему до падения». Это точный инструмент для поиска причинно-следственных связей. В этой статье описан пример использования связки из Демобазы 2.0 и комплекса pg_expecto, чтобы провести контролируемый эксперимент. Изменим один SQL-запрос, запустим тест и проанализируем, как это изменение отразилось на производительности СУБД и показателях инфраструктуры.
ℹ️ Демобаза 2.0
Демобаза 2.0 для PostgreSQL / Хабр
ℹ️ Новый инструмент с открытым исходным кодом для статистического анализа, нагрузочного тестирования и построения отчетов доступен в репозитории GitFlic и GitHub
kznalp/PG_EXPECTO: Комплекс статистического анализа производительности СУБД PostgreSQL
Постановка эксперимента
Оценить влияние изменения паттерна отдельного SQL запроса на производительность СУБД и показатели инфраструктуры в ходе нагрузочного тестирования
Тестовая виртуальная машина
CPU = 8
RAM = 8GB
PostgreSQL 17
Генерация Демобазы 2.0
Заполнение данных
Подключитесь в psql к любой базе, кроме demo, убедитесь, что находитесь в каталоге репозитория, и выполните установку: \! pwd \i install При необходимости смените текущий каталог командой \cd. В ходе установки будет заново создана база данных demo. Если она существовала, то все данные в ней будут потеряны! В этой базе данных будут созданы две схемы: gen для объектов генератора и bookings для создаваемой демобазы. Устанавливаются расширения btree_gist (для реализации темпорального ключа), earthdistance и cube (для расчета расстояний на сфере), а также dblink (для запуска параллельных процессов). Эти расширения входят в стандартный набор, но убедитесь, что они присутствуют в вашей установке PostgreSQL. Генерация запускается процедурой generate, которой передаются начальное и конечное модельное время (которое будет фигурировать в таблицах демобазы). Например: CALL generate( now(), now() + interval '2 year' ); Такая команда выполнит необходимую инициализацию, создаст очередь событий и начнет генерацию демобазы за 2 года.
План нагрузочного тестирования (конфигурационный файл param.conf)
param.conf
# НАСТРОЙКИ НАГРУЗОЧНОГО ТЕСТИРОВАНИЯ # Максимальная нагрузка finish_load = 20 # Тестовая БД testdb = demo # Веса сценариев scenario1 = 1.0 scenario2 = 1.0 scenario3 = 1.0 scenario4 = 1.0
Тестовый сценарий-1 : Простой точечный SELECT по первичному ключу.
scenario1.sql
CREATE OR REPLACE FUNCTION scenario2() RETURNS integer AS $$ DECLARE test_rec record ; test_code text ; BEGIN SET application_name = 'scenario1'; SELECT array_to_string(array(select substr('ABCDEFGHIJKLMNOPQRSTUVWXYZ',((random()*(26-1)+1)::integer),1) from generate_series(1,3)),'') INTO test_code ; SELECT * INTO test_rec FROM airports_data WHERE airport_code = test_code ; return 0 ; END $$ LANGUAGE plpgsql;
Тестовый сценарий-2 : GROUP BY .
scenario2.sql
CREATE OR REPLACE FUNCTION scenario3() RETURNS integer AS $$ DECLARE test_rec record ; BEGIN SET application_name = 'scenario2'; WITH empty_flights AS ( SELECT f.flight_id, count( bp.flight_id ) FROM bookings.flights f LEFT JOIN bookings.boarding_passes bp ON bp.flight_id = f.flight_id WHERE f.status IN ( 'Departed', 'Arrived' ) GROUP BY f.flight_id HAVING count( bp.flight_id ) = 0 ) SELECT count(*) empty, CASE WHEN count(*) > 0 THEN 'WARNING: empty cabin' ELSE 'Ok' END verdict INTO test_rec FROM empty_flights; return 0 ; END $$ LANGUAGE plpgsql;
Тестовый сценарий-3 : ORDER BY ... LIMIT
scenario3.sql
CREATE OR REPLACE FUNCTION scenario4() RETURNS integer AS $$ DECLARE test_rec record ; test_limit integer ; BEGIN SET application_name = 'scenario3'; SELECT random() * 1000 + 1 INTO test_limit ; SELECT a.airplane_code, a.model->>'en' AS model, count(DISTINCT r.route_no) AS no_flights, CASE WHEN count(DISTINCT r.route_no) > 0 AND a.in_use THEN 'Ok' WHEN count(DISTINCT r.route_no) = 0 AND a.in_use THEN 'NOT USED' WHEN count(DISTINCT r.route_no) > 0 AND NOT a.in_use THEN 'WRONGLY USED' WHEN count(DISTINCT r.route_no) = 0 AND NOT a.in_use THEN 'Ok (not in use)' END AS verdict INTO test_rec FROM bookings.routes r RIGHT JOIN gen.airplanes_data a ON a.airplane_code = r.airplane_code GROUP BY a.airplane_code, a.model, a.in_use ORDER BY a.airplane_code LIMIT test_limit ; return 0 ; END $$ LANGUAGE plpgsql;
Тестовый сценарий-4.1 : JOIN
scenario4.sql
CREATE OR REPLACE FUNCTION scenario5() RETURNS integer AS $$ DECLARE test_rec record ; BEGIN SET application_name = 'scenario4'; WITH seats_available AS ( SELECT airplane_code, fare_conditions, count( * ) AS seats_cnt FROM bookings.seats GROUP BY airplane_code, fare_conditions ), seats_booked AS ( SELECT flight_id, fare_conditions, count( * ) AS seats_cnt FROM bookings.segments GROUP BY flight_id, fare_conditions ), overbook AS ( SELECT f.flight_id, r.route_no, r.airplane_code, sb.fare_conditions, sb.seats_cnt AS seats_booked, sa.seats_cnt AS seats_available FROM bookings.flights AS f JOIN bookings.routes AS r ON r.route_no = f.route_no AND r.validity @> f.scheduled_departure JOIN seats_booked AS sb ON sb.flight_id = f.flight_id JOIN seats_available AS sa ON sa.airplane_code = r.airplane_code AND sa.fare_conditions = sb.fare_conditions WHERE sb.seats_cnt > sa.seats_cnt ) SELECT count(*) overbookings, CASE WHEN count(*) > 0 THEN 'ERROR: overbooking' ELSE 'Ok' END verdict INTO test_rec FROM overbook; return 0 ; END $$ LANGUAGE plpgsql;
Тестовый сценарий-4.2 : Условный "коррелированный подзапрос"
Создание индексов
demo=# CREATE INDEX CONCURRENTLY idx_seats_airplane_fare ON bookings.seats(airplane_code, fare_conditions); CREATE INDEX demo=# CREATE INDEX CONCURRENTLY idx_segments_flight_fare ON bookings.segments(flight_id, fare_conditions); CREATE INDEX demo=# CREATE INDEX CONCURRENTLY idx_routes_no_validity ON bookings.routes(route_no, validity); CREATE INDEX
scenario4.sql
CREATE OR REPLACE FUNCTION scenario5() RETURNS integer AS $$ DECLARE test_rec record ; BEGIN SET application_name = 'scenario4'; WITH seats_agg AS MATERIALIZED ( SELECT airplane_code, fare_conditions, COUNT(*) AS seats_total FROM bookings.seats GROUP BY airplane_code, fare_conditions ) SELECT COUNT(*) AS overbookings, CASE WHEN COUNT(*) > 0 THEN 'ERROR: overbooking' ELSE 'Ok' END AS verdict INTO test_rec FROM ( SELECT 1 FROM bookings.flights f JOIN bookings.routes r ON r.route_no = f.route_no AND r.validity @> f.scheduled_departure JOIN ( SELECT flight_id, fare_conditions, COUNT(*) AS seats_booked FROM bookings.segments GROUP BY flight_id, fare_conditions ) sb ON sb.flight_id = f.flight_id WHERE sb.seats_booked > ( SELECT sa.seats_total FROM seats_agg sa WHERE sa.airplane_code = r.airplane_code AND sa.fare_conditions = sb.fare_conditions ) ) overbooked;
Планы выполнения тестовых запросов с использованием JOIN и "Коррелированного подзапроса"
План выполнения сценарий-4.1 : JOIN
QUERY PLAN
Aggregate (cost=9825.94..9825.95 rows=1 width=40) (actual time=262.702..262.707 rows=1 loops=1) -> Hash Join (cost=9431.95..9825.94 rows=1 width=0) (actual time=262.696..262.701 rows=0 loops=1) Hash Cond: ((f.route_no = r.route_no) AND (seats.airplane_code = r.airplane_code)) Join Filter: (r.validity @> f.scheduled_departure) Rows Removed by Join Filter: 217 -> Nested Loop (cost=9407.50..9796.79 rows=567 width=19) (actual time=218.641..259.306 rows=11355 loops=1) -> Hash Join (cost=9407.22..9623.25 rows=567 width=8) (actual time=218.539..235.320 rows=11355 loops=1) Hash Cond: (segments.fare_conditions = seats.fare_conditions) Join Filter: ((count(*)) > (count(*))) Rows Removed by Join Filter: 66545 -> HashAggregate (cost=9366.21..9507.87 rows=14166 width=20) (actual time=217.266..219.770 rows=10888 loops=1) Group Key: segments.flight_id, segments.fare_conditions Batches: 1 Memory Usage: 1425kB -> Seq Scan on segments (cost=0.00..6654.55 rows=361555 width=12) (actual time=0.071..90.350 rows=361489 loops=1) -> Hash (cost=40.71..40.71 rows=24 width=20) (actual time=1.228..1.230 rows=20 loops=1) Buckets: 1024 Batches: 1 Memory Usage: 10kB -> HashAggregate (cost=40.47..40.71 rows=24 width=20) (actual time=1.205..1.211 rows=20 loops=1) Group Key: seats.airplane_code, seats.fare_conditions Batches: 1 Memory Usage: 24kB -> Seq Scan on seats (cost=0.00..27.41 rows=1741 width=12) (actual time=0.059..0.420 rows=1741 loops=1) -> Index Scan using flights_pkey on flights f (cost=0.28..0.31 rows=1 width=19) (actual time=0.002..0.002 rows=1 loops=11355) Index Cond: (flight_id = segments.flight_id) -> Hash (cost=15.78..15.78 rows=578 width=33) (actual time=0.631..0.632 rows=578 loops=1) Buckets: 1024 Batches: 1 Memory Usage: 45kB -> Seq Scan on routes r (cost=0.00..15.78 rows=578 width=33) (actual time=0.083..0.375 rows=578 loops=1) Planning Time: 5.779 ms Execution Time: 263.774 ms
План выполнения сценарий-4.2 : "Коррелированный подзапрос"
QUERY PLAN
Aggregate (cost=334506.18..334506.19 rows=1 width=40) (actual time=12894.579..12899.785 rows=1 loops=1) CTE seats_agg -> HashAggregate (cost=40.47..40.71 rows=24 width=20) (actual time=0.751..0.757 rows=20 loops=1) Group Key: seats.airplane_code, seats.fare_conditions Batches: 1 Memory Usage: 24kB -> Seq Scan on seats (cost=0.00..27.41 rows=1741 width=12) (actual time=0.024..0.203 rows=1741 loops=1) -> Hash Join (cost=326910.78..334463.31 rows=862 width=0) (actual time=12894.575..12899.777 rows=0 loops=1) Hash Cond: (segments.flight_id = f.flight_id) Join Filter: ((count(*)) > (SubPlan 2)) Rows Removed by Join Filter: 249660 -> Finalize HashAggregate (cost=315588.67..318101.77 rows=251310 width=20) (actual time=4473.982..4907.592 rows=249660 loops=1) Group Key: segments.flight_id, segments.fare_conditions Batches: 1 Memory Usage: 28177kB -> Gather (cost=1000.44..308049.37 rows=1005240 width=20) (actual time=18.306..4125.482 rows=253416 loops=1) Workers Planned: 4 Workers Launched: 4 -> Partial GroupAggregate (cost=0.44..206525.37 rows=251310 width=20) (actual time=0.606..4288.777 rows=50683 loops=5) Group Key: segments.flight_id, segments.fare_conditions -> Parallel Index Only Scan using idx_segments_flight_fare on segments (cost=0.44..167521.05 rows=4865495 width=12) (actual time=0.072..2456.531 rows=3892859 loops=5) Heap Fetches: 271389 -> Hash (cost=11309.28..11309.28 rows=1026 width=8) (actual time=4365.785..4365.789 rows=99609 loops=1) Buckets: 131072 (originally 2048) Batches: 1 (originally 1) Memory Usage: 4915kB -> Nested Loop (cost=0.42..11309.28 rows=1026 width=8) (actual time=0.061..4277.802 rows=99609 loops=1) -> Seq Scan on routes r (cost=0.00..148.02 rows=5502 width=33) (actual time=0.019..1.508 rows=5502 loops=1) -> Index Scan using flights_route_no_scheduled_departure_key on flights f (cost=0.42..2.02 rows=1 width=19) (actual time=0.408..0.772 rows=18 loops=5502) Index Cond: (route_no = r.route_no) Filter: (r.validity @> scheduled_departure) Rows Removed by Filter: 168 SubPlan 2 -> CTE Scan on seats_agg sa (cost=0.00..0.60 rows=1 width=8) (actual time=0.005..0.011 rows=1 loops=249660) Filter: ((airplane_code = r.airplane_code) AND (fare_conditions = segments.fare_conditions)) Rows Removed by Filter: 19 Planning Time: 5.223 ms Execution Time: 12929.075 ms
Анализ результатов нагрузочного тестирования - производительность СУБД
Нагрузка на СУБД

Операционная скорость СУБД


Средняя разница операционной скорости СУБД при использовании JOIN и Коррелированного подзапроса составила 0.58%.
Операционная скорость сценария-4.1 и сценария-4.2


Средняя разница операционной скорости тестового запроса при использовании JOIN и "Коррелированного подзапроса" составила 2.44%.
Ожидания типа LWLock


С ростом нагрузки, количество ожиданий типа LWLock - снижается, при использовании коррелированного подзапроса, в сравнении с использованием JOIN.
Вывод по результатам анализа метрик производительности СУБД
Использование для тестового запроса JOIN или Коррелированного подзапроса - не оказывает существенного влияния на производительность СУБД в целом и тестового сценария в частности.
Анализ результатов нагрузочного тестирования - состояние инфраструктуры (vmstat)
Корреляция ожиданий СУБД и метрик vmstat

Чек-лист CPU

Чек-лист RAM

Метрики vmstat

free : Свободная память


cs : Количество переключений контекста


Вывод по результатам анализа метрик vmstat
1. При использовании коррелированного подзапроса давление на RAM существенно снижается.
2. В целом , существенной разницы во влиянии на инфраструктуру использования JOIN или Коррелированного подзапроса - не установлено.
