Сегодняшняя тема посвящена нелегким взаимоотношениям клиентского приложения и сервера PostgreSQL: как на ровном месте, неудачной архитектурой приложения, можно обеспечить себе хронические проблемы производительности.
Рассмотрим классические ситуации, когда разработчики начинают жаловаться на производительность БД - а виновата-то и не она!
Начнем с самого простого - с организации примитивной тестовой программы для Node.js, которая умеет подключаться к серверу PostgreSQL (модуль pg) в несколько соединений, включать "параноидальный" режим анализа запросов с помощью модуля auto_explain и этот самый запрос выполнять:
const pg = require('pg');
const {Client} = pg;
const connInfo = {
user : 'postgres'
, password : 'postgres'
, host : 'localhost'
, port : 5432
, database : 'postgres'
};
const jobs = 1; // количество одновременных соединений
(async () => {
const clients = await Promise.all(
Array(jobs).fill().map(async () => {
// подключаемся к базе
const client = new Client(connInfo);
await client.connect();
// активируем auto_explain
await client.query(`
LOAD 'auto_explain';
SET auto_explain.log_analyze = 'on';
SET auto_explain.log_buffers = 'on';
SET auto_explain.log_min_duration = 0;
SET auto_explain.log_nested_statements = 'on';
SET auto_explain.log_timing = 'on';
SET auto_explain.log_triggers = 'on';
`);
return Promise.resolve(client);
})
);
// одновременно выполняем на каждом из них необходимые запросы
const rows = await Promise.all(
clients.map(async client => {
const res = await client.query('SELECT 1');
return Promise.resolve(res.rows);
})
);
// закрываем все соединения
await clients.forEach(client => client.end());
})();
Если все было сделано правильно, то в логе сервера мы увидим примерно такую запись:
2024-10-07 13:37:13.173 MSK [29048] LOG: duration: 0.015 ms plan:
Query Text: SELECT 1
Result (cost=0.00..0.01 rows=1 width=4) (actual time=0.004..0.007 rows=1 loops=1)
С помощью визуализации на explain.tensor.ru мы сразу видим, что время непосредственного выполнения запроса сервером - 0.007ms, а примерный объем возвращенных данных - 4 байта (actual rows=1 x width=4
):
Напомню, что вставлять план для анализа можно сразу целым блоком...
... а сервис сам его разделит на запрос и план:
В отличие от actual time
, duration
- время полного выполнения запроса, включая передачу результата клиенту - получилось уже 0.015ms, то есть сама передача ответа "стоила" нам 0.008ms.
Размер resultset
TL;DR: Минимизируйте объем результата, получаемого от БД.
Типы столбцов
Давайте теперь нагрузим сервер какой-то относительно реальной работой - пусть он отдает нам все числа от 1 до миллиона:
SELECT i FROM generate_series(1, 1e6) i
В таком варианте столбец i
будет иметь тип numeric
, возвращаемый из generate_series
, и занимать 32 байта
для каждой записи, поскольку передается блоками цифр, общий размер resultset при этом составит немалые 31MB:
... LOG: duration: 737.459 ms plan:
Query Text: SELECT i FROM generate_series(1, 1e6) i
Function Scan on generate_series i (cost=0.00..10.00 rows=1000 width=32) (actual time=225.135..362.437 rows=1000000 loops=1)
Buffers: temp read=2073 written=2073
Средняя задержка передачи при этом составила 360ms.
Поскольку мы точно знаем, что первый миллион "умещается" в integer, давайте попробуем возвращать значение именно этого типа:
SELECT i::integer FROM generate_series(1, 1e6) i
Прикладной результат ровно тот же ("числа до миллиона"), но из-за фиксированной "ширины" integer
в 4 байта, общий возвращаемый сервером объем трафика сокращается в 8 раз, а среднее время передачи снижается до 312ms.
Обратите внимание, что в наших тестах это "локальный", а не сетевой трафик. А если сервер БД стоит где-то "далеко" от клиентского приложения, то множество одновременно "ломанувшихся" на сервер клиентов легко "завалят слона" если не по производительности CPU, то по пропускной способности сети, которая может закончиться гораздо раньше.
Давайте прикинем: numeric
-запрос "на миллион" в среднем выполнялся сервером 360ms и еще 360ms длилась передача 31MB результата - то есть мы передали [минимум] 244Mb за 0.72s = ~340Mbps.
Логика далеко от данных
Обычно проблемы такого рода возникают либо от тотального непонимания принципов работы с БД, вроде рассматривавшихся в статье "PostgreSQL Antipatterns: навигация по реестру" вариантов "сначала все получу, потом отсортирую":
cur.execute("SELECT * FROM events;")
rows = cur.fetchall();
rows.sort(key=lambda row: row.ts, reverse=True);
limit = 26
print(rows[offset:offset+limit]);
Второй вариант - неудачный ORM, используемый на проекте (или неумеющий его грамотно применять разработчик), который вместо вложенного запроса, оперирующего с данными в пределах сервера БД, "вытаскивает" их всегда на сторону клиентского приложения:
ids <- SELECT id FROM doc WHERE owner = 'Alice';
SELECT sum(qty) FROM doc_item WHERE doc_id IN (${ids.join(',')});
vs.
SELECT sum(qty) FROM doc_item WHERE doc_id IN (
SELECT id FROM doc WHERE owner = 'Alice'
);
Синхронность выполнения
TL;DR: Принудительно десинхронизируйте выполнение запросов.
Получается, что всего 3 таких клиента (или 300, которые одновременно будут спрашивать всего-то по 10K integer
) легко "положат" и гигабитную сеть. Если в реальных условиях у вас не 300 клиентов, так и записи у вас не по 32 байта, а побольше...
Давайте теперь перенесем нагрузку на сторону сервера, и считать будем сумму всех тех чисел до миллиона:
SELECT sum(i) FROM generate_series(1, 1e6) i
... LOG: duration: 507.984 ms plan:
Query Text: SELECT sum(i) FROM generate_series(1, 1e6) i
Aggregate (cost=12.50..12.51 rows=1 width=32) (actual time=507.969..507.970 rows=1 loops=1)
Buffers: temp read=2073 written=2073
-> Function Scan on generate_series i (cost=0.00..10.00 rows=1000 width=32) (actual time=223.259..348.167 rows=1000000 loops=1)
Buffers: temp read=2073 written=2073
Мы получаем в ответ теперь всего одну запись, поэтому тип столбца нам не столь важен, задержки на передачу все равно минимальны.
Если провести несколько последовательных тестов, мы получим среднее время выполнения порядка 508ms.
А теперь давайте запустим наш тест на 16 подключениях одновременно (jobs = 16
в нашей программе) при выделенных серверу 4 ядрах CPU:
2024-10-07 15:30:19.565 MSK [28936] LOG: duration: 1767.403 ms plan: ...
2024-10-07 15:30:19.642 MSK [20360] LOG: duration: 1859.703 ms plan: ...
2024-10-07 15:30:19.678 MSK [41036] LOG: duration: 1947.635 ms plan: ...
2024-10-07 15:30:19.686 MSK [12032] LOG: duration: 1938.924 ms plan: ...
2024-10-07 15:30:19.686 MSK [18300] LOG: duration: 1939.397 ms plan: ...
2024-10-07 15:30:19.699 MSK [40900] LOG: duration: 1917.059 ms plan: ...
2024-10-07 15:30:19.711 MSK [12728] LOG: duration: 1965.203 ms plan: ...
2024-10-07 15:30:19.759 MSK [38540] LOG: duration: 2027.971 ms plan: ...
2024-10-07 15:30:19.777 MSK [40276] LOG: duration: 1992.891 ms plan: ...
2024-10-07 15:30:19.796 MSK [18644] LOG: duration: 2064.386 ms plan: ...
2024-10-07 15:30:19.825 MSK [22932] LOG: duration: 2043.790 ms plan: ...
2024-10-07 15:30:19.850 MSK [41732] LOG: duration: 2087.980 ms plan: ...
2024-10-07 15:30:19.855 MSK [38024] LOG: duration: 2093.014 ms plan: ...
2024-10-07 15:30:19.872 MSK [32480] LOG: duration: 2141.214 ms plan: ...
2024-10-07 15:30:19.872 MSK [32544] LOG: duration: 2109.777 ms plan: ...
2024-10-07 15:30:19.880 MSK [38348] LOG: duration: 2118.152 ms plan: ...
Легко заметить, что с каждым новым запросом, встающим на выполнение, итоговое время для него становится все больше и больше, дойдя до 4-кратного замедления относительно "однопоточной" версии. Это вполне объяснимо тем фактом, что мы одномоментно захотели получить "семь шапок" - выполнить 16 CPU-активных процессов на 4 ядра.
Чтобы не получить такого эффекта, достаточно ввести некую рандомизированную задержку при старте обработки события (запуска пула процессов, получения сигнала, ...), хотя бы даже так:
client.query('SELECT pg_sleep(random() * 4)'); // разбежка до 4 сек
const res = await client.query('SELECT sum(i) FROM generate_series(1, 1e6) i');
2024-10-07 16:12:23.203 MSK [23228] LOG: duration: 579.658 ms plan: ...
2024-10-07 16:12:23.374 MSK [30036] LOG: duration: 572.722 ms plan: ...
2024-10-07 16:12:23.483 MSK [33332] LOG: duration: 617.321 ms plan: ...
2024-10-07 16:12:23.521 MSK [35592] LOG: duration: 630.321 ms plan: ...
2024-10-07 16:12:23.827 MSK [38368] LOG: duration: 611.399 ms plan: ...
2024-10-07 16:12:23.883 MSK [10440] LOG: duration: 582.978 ms plan: ...
2024-10-07 16:12:23.929 MSK [35468] LOG: duration: 562.947 ms plan: ...
2024-10-07 16:12:23.959 MSK [30792] LOG: duration: 552.995 ms plan: ...
2024-10-07 16:12:24.398 MSK [19448] LOG: duration: 539.472 ms plan: ...
2024-10-07 16:12:24.575 MSK [21936] LOG: duration: 525.542 ms plan: ...
2024-10-07 16:12:24.607 MSK [40748] LOG: duration: 533.656 ms plan: ...
2024-10-07 16:12:25.479 MSK [32416] LOG: duration: 538.157 ms plan: ...
2024-10-07 16:12:25.566 MSK [11204] LOG: duration: 531.848 ms plan: ...
2024-10-07 16:12:25.690 MSK [27716] LOG: duration: 561.769 ms plan: ...
2024-10-07 16:12:26.128 MSK [34596] LOG: duration: 550.124 ms plan: ...
2024-10-07 16:12:26.165 MSK [29528] LOG: duration: 533.798 ms plan: ...
Понятно, что в реальных условиях "ждать" лучше не с помощью соединения до БД, а в коде приложения.
Перегрузка клиента
TL;DR: Ресурсы могут кончаться не только на сервере, но и на клиенте - а видно все равно в логе сервера.
Поменяем в последнем "параллельном" тесте с "разбежкой" запрос на исходный, который возвращал "весь миллион":
client.query('SELECT pg_sleep(random() * 4)'); // разбежка до 4 сек
const res = await client.query('SELECT i FROM generate_series(1, 1e6) i');
Мы же помним, что запрос выполнялся у нас что-то порядка 500-700ms, и...
2024-10-07 16:31:38.570 MSK [13836] LOG: duration: 855.744 ms plan:
actual time=..409.293 ...
2024-10-07 16:31:39.201 MSK [19448] LOG: duration: 1383.853 ms plan:
actual time=..413.909 ...
2024-10-07 16:31:40.321 MSK [38264] LOG: duration: 1515.595 ms plan:
actual time=..435.973 ...
2024-10-07 16:31:40.366 MSK [41512] LOG: duration: 1560.709 ms plan:
actual time=..447.622 ...
2024-10-07 16:31:42.032 MSK [24476] LOG: duration: 2600.114 ms plan:
actual time=..579.276 ...
2024-10-07 16:31:42.124 MSK [41900] LOG: duration: 2692.630 ms plan:
actual time=..549.336 ...
2024-10-07 16:31:44.588 MSK [39248] LOG: duration: 3350.654 ms plan:
actual time=..773.811 ...
2024-10-07 16:31:44.878 MSK [40748] LOG: duration: 3617.941 ms plan:
actual time=..893.361 ...
2024-10-07 16:31:45.411 MSK [35176] LOG: duration: 4359.566 ms plan:
actual time=..801.552 ...
2024-10-07 16:31:45.505 MSK [12416] LOG: duration: 4453.663 ms plan:
actual time=..863.834 ...
2024-10-07 16:31:45.693 MSK [23112] LOG: duration: 4455.087 ms plan:
actual time=..863.590 ...
2024-10-07 16:31:46.128 MSK [36044] LOG: duration: 4890.108 ms plan:
actual time=..947.209 ...
2024-10-07 16:31:49.449 MSK [38768] LOG: duration: 6888.474 ms plan:
actual time=..696.957 ...
2024-10-07 16:31:49.541 MSK [35268] LOG: duration: 6980.557 ms plan:
actual time=..667.114 ...
2024-10-07 16:31:49.632 MSK [18764] LOG: duration: 7071.027 ms plan:
actual time=..698.535 ...
2024-10-07 16:31:49.720 MSK [28356] LOG: duration: 7135.465 ms plan:
actual time=..690.181 ...
Наш клиентский код на JavaScript хоть и асинхронен, все равно ограничен ресурсами одного потока выполнения. Поэтому даже когда сервер уже все сделал за 700ms, он вынужден тратить в 10 раз больше времени на ожидание, пока клиент "переварит" предыдущие полученные ответы и заберет до конца этот - и в его логе мы можем это увидеть.
Размеры тела запроса и параметров
Повторяться отдельно в этот раз не буду, каждый желающий может легко воспроизвести эффект от "гигантских" запросов и параметров к ним - просто сошлюсь на прошлую статью "PostgreSQL Antipatterns: «слишком много золота»", где подробно разобраны примеры возникновения такой беды и способы борьбы с ней.