Кругом только и разговоров о том, как бы заставить 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 г.

Комментарии (2)


  1. stanukih
    23.08.2025 08:11

    Дубль.https://habr.com/ru/companies/ruvds/articles/932278/


    1. kaze_no_saga Автор
      23.08.2025 08:11

      Там паршивый машинный перевод, а тут душевный, ручками, с любовью к делу и даже не с корпоративного аккаунта!

      Вы почитайте и согласитесь.