
Кругом только и разговоров о том, как бы заставить Postgres работать быстрее, эффективнее, и так далее. Но при этом никто даже не задумывается о том, как бы его замедлить. Само собой, о продуктивности и эффективности парятся в основном те, кому за это платят. Я не из из числа (можете это исправить -- дайте мне знать). На днях я работал над чуть более полезным руководством (EN) и в какой-то момент решил, что этому миру нужна такая конфигурация Postgres, которая будет выполнять запросы максимально медленно. Нахрена? Черт знает, но вот что у меня получилось.
Параметры
Мы не ищем простых путей, поэтому замедлять процессор и вайпать индексы мы не будем. Я буду трогать только параметры в postgresql.conf
. При этом БД должна сохранить возможность обрабатывать запросы в разумное время -- хотя бы по одному. Затормозить Postgres в ноль слишком просто. А вот заставить её работать медленно, но всё же работать -- задача нетривиальная. Postgres делает все возможное, чтобы этого не допустить: вводит ограничения на значения параметров, не принимает определенные конфигрурации, и так далее.
Для измерения производительности я выбрал TPC-C в бенчмарке Benchbase. Параметры:
128 warehouses
100 соединений каждый
10 000 попыток выполнения транзакций в секунду
Софт:
Postgres 19devel (сборка от 7/14/2025) на Linux 6.15.6
Железо:
Ryzen 7950x
32GB RAM
2TB SSD
Каждый тест выполняется 120 секунд, дважды: сначала для прогрева кэша, потом по-серьезному.
С чем сравниваю: дефолтный postgresql.conf
, только поднял shared_buffers
, work_mem
и число рабочих процессов. На такой конфигурации получаю 7082 TPS. А теперь посмотрим, как сильно мы сможем его отрицательно разогнать.
Кэширование? К черту!
Postgres очень эффективно обрабатывает запросы на чтение -- в том числе благодаря развитой системе кэширования. Читать с диска долго, поэтому когда Postgres читает блок данных с диска, он сразу кладет его в оперативную память, чтобы следующий запрос к этому же самому блоку лишний раз не лазал на диск. Само собой, я этого так оставить не могу. Чтобы большинство запросов читали данные с диска, мне надо ужать размер кэша как можно меньше. Здесь мне на помощь приходит замечательный параметр shared_buffers
, который отвечает за размер буфера кэша и прочие куски выделяемой общей памяти. К моему великому сожалению, просто задать shared_buffers = 0
не получится: Postgres ко всему прочему хранит здесь активные и непосредственно обрабатываемые страницы БД. Однако я могу ужать его довольно серьезно.
Для начала я скакнул с 10ГБ
до 8МБ
.
shared_buffers = 8MB

И вот уже Postgres работает в семь раз медленнее! Меньше буфер -- меньше страниц хранится в оперативке. Процент запросов, обрабатываемых без обращения к ОС, рухнул с 99.90% до 70.52%, а число циклов чтения с диска выросло в 300 раз.
Но это только начало. 70% -- это все еще слишком много кэша. Вторая попытка: 128кБ.

М-да. 128кБ кэша вмещают максимум 16 страниц ДБ (это без учета всего остального, что Postgres хочет там хранить). А наша СУБД будто бы хочет видеть больше чем 16 страниц одновременно. Я поковырялся еще и в итоге остановился примерно на 2МБ -- ниже уже никак. Прямо сейчас у меня Postgres вытягивает 500 TPS.
shared_buffers = 2MB

Загружаем Postgres безумным количеством фоновых задач
Кроме обработки транзакций, Postgres выполняет еще несколько вычислительно дорогих задач. Задач, которые можно использовать в моих целях. Процесс автоочистки -- autovacuum -- ищет пустые места в памяти, оставшиеся после удаления и других операций, и наполняет их актуальными версиями строк. Обычно этот процесс запускается спустя некоторое время, когда в таблице произошло достаточно изменений -- чтобы не тратить ресурсы на чтение и запись постоянно. Я могу это время между запусками сократить.
autovacuum_vacuum_insert_threshold = 1 # autovacuum срабатывает на каждый insert
autovacuum_vacuum_threshold = 0 # минимальное количество insert, update или deletes для запуска autovacuum
autovacuum_vacuum_scale_factor = 0 # доля незамороженной части таблицы, надо для расчета применения autovacuum
autovacuum_vacuum_max_threshold = 1 # максимальное количество insert, update или deletes для запуска autovacuum
autovacuum_naptime = 1 # минимальная задержка межу запуском autovacuum, сек; к сожалению, ниже 1 сек не ставится
vacuum_cost_limit = 10000 # лимит стоимости запроса, при привышении останавливает очистку; я не хочу останавливать очистку, так что ставлю максимум
vacuum_cost_page_dirty = 0
vacuum_cost_page_hit = 0
vacuum_cost_page_miss = 0 # эти три штуки снижают стоимость при вычислении `vacuum_cost_limit`
У автоочистки есть анализатор -- он собирает статистику, которая потом используется для запуска автоочистки, а еще в планировщике запросов. Его настройки я тоже могу подпортить. (А про планировщик поговорим отдельно -- ему правильная статистика от анализатора уже не поможет.)
autovacuum_analyze_threshold = 0 # то же, что и autovacuum_vacuum_threshold, но для ANALYZE
autovacuum_analyze_scale_factor = 0 # то же, что и autovacuum_vacuum_scale_factor
Ну и попытаемся замедлить сам процесс автоочистки:
maintenance_work_mem = 128kB # сколько памяти выделяется на очистку
log_autovacuum_min_duration = 0 # сколько должна длиться очистка (мс), чтобы написать про нее в журнал; я хочу писать всё
logging_collector = on # включить журналирование в принципе
log_destination = stderr,jsonlog # формат записи файлов журнала
Тут стоит заметить, что противоположный подход тоже имеет право на жизнь: если отключить автоочистку вообще, то память будет постепенно забиваться старыми версиями строк, сводя производительность СУБД на нет. Однако в моем эксперименте нагрузка -- в основном на запись, да и работает две минуты, так что этот вариант я серьезно не рассматривал.

Сейчас мой Postgres работает в 20 раз медленнее обычного. Почему -- видно в логах:
2025-07-20 09:10:20.455 EDT [25210] LOG: automatic vacuum of table "benchbase.public.warehouse": index scans: 0
pages: 0 removed, 222 remain, 222 scanned (100.00% of total), 0 eagerly scanned
tuples: 0 removed, 354 remain, 226 are dead but not yet removable
removable cutoff: 41662928, which was 523 XIDs old when operation ended
frozen: 0 pages from table (0.00% of total) had 0 tuples frozen
visibility map: 0 pages set all-visible, 0 pages set all-frozen (0 were all-visible)
index scan not needed: 0 pages from table (0.00% of total) had 0 dead item identifiers removed
avg read rate: 116.252 MB/s, avg write rate: 4.824 MB/s
buffer usage: 254 hits, 241 reads, 10 dirtied
WAL usage: 2 records, 2 full page images, 16336 bytes, 1 buffers full
system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.01 s
2025-07-20 09:10:20.773 EDT [25210] LOG: automatic analyze of table "benchbase.public.warehouse"
avg read rate: 8.332 MB/s, avg write rate: 0.717 MB/s
buffer usage: 311 hits, 337 reads, 29 dirtied
WAL usage: 36 records, 5 full page images, 42524 bytes, 4 buffers full
system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.31 s
2025-07-20 09:10:20.933 EDT [25210] LOG: automatic vacuum of table "benchbase.public.district": index scans: 0
pages: 0 removed, 1677 remain, 1008 scanned (60.11% of total), 0 eagerly scanned
tuples: 4 removed, 2047 remain, 557 are dead but not yet removable
removable cutoff: 41662928, which was 686 XIDs old when operation ended
frozen: 0 pages from table (0.00% of total) had 0 tuples frozen
visibility map: 0 pages set all-visible, 0 pages set all-frozen (0 were all-visible)
index scan bypassed: 2 pages from table (0.12% of total) have 9 dead item identifiers
avg read rate: 50.934 MB/s, avg write rate: 9.945 MB/s
buffer usage: 1048 hits, 1009 reads, 197 dirtied
WAL usage: 6 records, 1 full page images, 8707 bytes, 0 buffers full
system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.15 s
2025-07-20 09:10:21.220 EDT [25210] LOG: automatic analyze of table "benchbase.public.district"
avg read rate: 47.235 MB/s, avg write rate: 1.330 MB/s
buffer usage: 115 hits, 1705 reads, 48 dirtied
WAL usage: 30 records, 1 full page images, 17003 bytes, 1 buffers full
system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.28 s
2025-07-20 09:10:21.543 EDT [25212] LOG: automatic vacuum of table "benchbase.public.warehouse": index scans: 0
pages: 0 removed, 222 remain, 222 scanned (100.00% of total), 0 eagerly scanned
tuples: 0 removed, 503 remain, 375 are dead but not yet removable
removable cutoff: 41662928, which was 845 XIDs old when operation ended
frozen: 0 pages from table (0.00% of total) had 0 tuples frozen
visibility map: 0 pages set all-visible, 0 pages set all-frozen (0 were all-visible)
index scan not needed: 0 pages from table (0.00% of total) had 0 dead item identifiers removed
avg read rate: 131.037 MB/s, avg write rate: 5.083 MB/s
buffer usage: 268 hits, 232 reads, 9 dirtied
WAL usage: 1 records, 0 full page images, 258 bytes, 0 buffers full
system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.01 s
2025-07-20 09:10:21.813 EDT [25212] LOG: automatic analyze of table "benchbase.public.warehouse"
avg read rate: 10.244 MB/s, avg write rate: 0.851 MB/s
buffer usage: 307 hits, 337 reads, 28 dirtied
WAL usage: 33 records, 3 full page images, 30864 bytes, 2 buffers full
system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.25 s
... И так далее
Postgres выполняет автоочистку и анализ на горячих таблицах раз в секунду. Я урезал кэш, поэтому страниц в оперативке очень мало, и системе постоянно приходится читать диск. Самое прикольное -- все эти запросы на чтение совершенно бесполезны, потому что данные между прогонами не меняются! Однако 293 TPS -- это всё еще слишком много.
Превращаем Postgres в Дарью Донцову
Дарья Донцова пишет довольно много. Знаете, кто еще много пишет? Postgres после моих экспериментов с конфигурацией WAL. Прежде чем внести данные в файлы БД, Postgres сначала записывает из в журнал предварительной записи (WAL, Write Ahead Log), и время от времени сохраняет их в постоянную память, создавая чекпоинты. Этот самый журнал довольно гибко настраивается, к его сожалению. Во-первых, Postgres обычно хранит часть WAL в памяти, прежде чем записать на диск. Недопустимо!
wal_writer_flush_after = 0 # Минимальный объем WAL для записи
wal_writer_delay = 1 # Минимальная пауза между записью WAL
А еще пускай WAL делает чекпоинты как можно чаще!
min_wal_size = 32MB # Минимальный размер WAL после чекпоинта; я хочу как можно чаще чекпоинтить
max_wal_size = 32MB # Максимальный размер WAL, после которого происходит чекпоинт.
К сожалению, оба значения придется оставить не ниже 32МБ -- минимальный размер для двух сегментов WAL
checkpoint_timeout = 30 # Максимальная пауза между чекпоинтами; меньше 30 секунд не даёт
checkpoint_flush_after = 1 # Писать на диск каждые 8kB
И, само собой, WAL должен записываться на диск как можно чаще.
wal_sync_method = open_datasync # Как писать на диск; выбираем самый медленный
wal_level = logical # Добавлять в журнал дополнительную информацию для репликации; Нам эта дополнительная информация без надобности, но с ней приходится писать больше
wal_log_hints = on # Писать измененные страницы целиком
summarize_wal = on # Еще один лишний процесс для бекапов
track_wal_io_timing = on # Собираем больше информации
checkpoint_completion_target = 0 # Не раздавать I/O всем процессам

Производительность Postgres упала ниже сотни транзакций в секунду: примерно в 70 раз ниже дефолтной. Журнал действительно показывет, что мои эксперименты с WAL не прошли зря:
2025-07-20 12:33:17.211 EDT [68697] LOG: checkpoint complete: wrote 19 buffers (7.4%), wrote 2 SLRU buffers; 0 WAL file(s) added, 3 removed, 0 recycled; write=0.094 s, sync=0.042 s, total=0.207 s; sync files=57, longest=0.004 s, average=0.001 s; distance=31268 kB, estimate=31268 kB; lsn=1B7/3CDC1B80, redo lsn=1B7/3C11CD48
2025-07-20 12:33:17.458 EDT [68697] LOG: checkpoints are occurring too frequently (0 seconds apart)
2025-07-20 12:33:17.458 EDT [68697] HINT: Consider increasing the configuration parameter "max_wal_size".
2025-07-20 12:33:17.494 EDT [68697] LOG: checkpoint starting: wal
2025-07-20 12:33:17.738 EDT [68697] LOG: checkpoint complete: wrote 18 buffers (7.0%), wrote 1 SLRU buffers; 0 WAL file(s) added, 2 removed, 0 recycled; write=0.089 s, sync=0.047 s, total=0.280 s; sync files=50, longest=0.009 s, average=0.001 s; distance=34287 kB, estimate=34287 kB; lsn=1B7/3F1F7B18, redo lsn=1B7/3E298BA0
2025-07-20 12:33:17.923 EDT [68697] LOG: checkpoints are occurring too frequently (0 seconds apart)
2025-07-20 12:33:17.923 EDT [68697] HINT: Consider increasing the configuration parameter "max_wal_size".
2025-07-20 12:33:17.971 EDT [68697] LOG: checkpoint starting: wal
Да, обычно WAL не чекпоинтится каждые *487 миллисекунд*. But I am still not done.
(По сути) удаляем индексы
Да, в начале статьи я обещал, что я не буду трогать индексы. Так я и не буду. Когда Postgres строит планы запросов, он считает, что доступ к страницам, которые расположены на диске последовательно, дешевле, чем к страницам, которые раскиданы в случайном порядке. Это логично, потому что на жестком диске читать последовательно быстрее, чем скакать туда-сюда. Когда к таблице выполняется запрос по индексу, то страницы на диске обычно расположены в случайном порядке. При чтении же всей таблицы страницы читаются последовательно. Планировщик это понимает и учитывает при расчете стоимости запроса.
random_page_cost = 1e300 # sets the cost of accessing a random page
cpu_index_tuple_cost = 1e300 # sets the cost of processing one tuple from an index
Изменив эти два параметра, я фактически полностью отключил индексы. Мне пришлось поднять shared_buffers
обратно до 8МБ
, потому что иначе выходили ошибки при сканировании таблиц, но на производительности это в итоге особо не сказалось.

Мой Postgres теперь обрабатывает меньше одной транзакции в секунду, в 7000 медленнее версии из коробки, и при этом я не трогал ничего кроме postgresql.conf
. Однако у меня есть еще один туз в рукаве.
Принудительный вывод всего I/O в один поток
Postgres однопоточным не сделать, потому что каждое из сотни соединений -- это свой собственный процесс. Но свеженький Postgres 18 позволяет мне свести в один поток весь ввод-вывод. В 18-й версии появился параметр io_method
. Он отвечает за то, как параллельные процессы используют вызовы системы ввода-вывода: синхронно (io_method = sync
), асинхронно просят серверные процессы передавать системные запросы (io_method = worker
) или же используют свежеиспеченный API Linux io_uring
(io_method = io_uring
). При этом параметр io_workers
всё еще позволяет задать максимальное количество рабочих процессов. Если указать io_method=worker
и ограничить количество рабочих процессов до одного, то весь ввод-вывод будет идти через один-единственный процесс.
io_method = worker
io_workers = 1

Ну вот, Postgres теперь не дотягивает даже до 0.1 TPS: в 42 000 раз медленнее, чем на старте. Если исключить транзакции, которые попали во взаимные блокировки, то ситуация еще хуже (лучше?): на 100 соединений и за 120 секунд было обработано всего-то 11 транзакций.
Заключение
Штош. Несколько часов работы и 32 измененных параметра конфигурации -- и Postgres убит. Кто бы мог подумать, что можно натворить таких дел, не вылезая из postgresql.conf
? Когда я начинал этот эксперимент, я надеялся замедлить систему хотя бы до десяти транзакций в секунду. Что за дела Postgres? Вот моя финальная конфигурация для желающих повторить это всё самостоятельно:
shared_buffers = 8MB
autovacuum_vacuum_insert_threshold = 1
autovacuum_vacuum_threshold = 0
autovacuum_vacuum_scale_factor = 0
autovacuum_vacuum_max_threshold = 1
autovacuum_naptime = 1
vacuum_cost_limit = 10000
vacuum_cost_page_dirty = 0
vacuum_cost_page_hit = 0
vacuum_cost_page_miss = 0
autovacuum_analyze_threshold = 0
autovacuum_analyze_scale_factor = 0
maintenance_work_mem = 128kB
log_autovacuum_min_duration = 0
logging_collector = on
log_destination = stderr,jsonlog
wal_writer_flush_after = 0
wal_writer_delay = 1
min_wal_size = 32MB
max_wal_size = 32MB
checkpoint_timeout = 30
checkpoint_flush_after = 1
wal_sync_method = open_datasync
wal_level = logical
wal_log_hints = on
summarize_wal = on
track_wal_io_timing = on
checkpoint_completion_target = 0
random_page_cost = 1e300
cpu_index_tuple_cost = 1e300
io_method = worker
io_workers = 1
Для тестирования использовалась BenchBase Postgres с конфигурацией TPC-C: 120 секунд тест, 120 разгон, 128 warehouses, 100 соединений, максимальная пропускная способность 50k TPS. Можете попробовать замедлить систему и больше. Я ковырял те параметры, которые по моему опыту больше всего влияют на производительность СУБД, а остальные не трогал.
А теперь я задолбался это всё писать, и вообще у меня спина болит. Пойду гулять.
Автор оригинала: Джейкоб Джексон
27 июля 2025 г.
stanukih
Дубль.https://habr.com/ru/companies/ruvds/articles/932278/
kaze_no_saga Автор
Там паршивый машинный перевод, а тут душевный, ручками, с любовью к делу и даже не с корпоративного аккаунта!
Вы почитайте и согласитесь.