Всех постоянно интересует, как сделать Postgres быстрее, эффективнее и всё такое, но никто не задумывался, как её замедлить. Да, большинству этих людей платят за то, чтобы они повышали скорость, но ко мне это не относится. Недавно я писал более полезное руководство, и мне вдруг пришла мысль, что кто-то должен попробовать создать такую конфигурацию Postgres, которая будет настроена на максимально медленную обработку запросов. Зачем? Сложно сказать, но вот такая возникла мысль.

Параметры

Сделать это легко не получится. Мне предстоит заняться детальным тюнингом Postgres, а не решить задачку в стиле «замедлить процессор до одного МГц и удалить индексы», так что все изменения будут касаться параметров в postgresql.conf. Кроме того, база данных должна будет сохранить возможность обрабатывать не менее одной транзакции в течение разумного количества времени — было бы слишком легко просто привести Postgres в бездействие. Задача сложнее, чем может показаться, так как Postgres стремится максимально затруднить подобные безумства, накладывая ограничения и минимизируя возможность конфигурации.

Для измерения производительности я буду использовать реализацию TPC-C из Benchbase со 128 складами, задействовав 100 подключений, каждое из которых будет стараться совершить 10 000 транзакций в секунду. Обрабатываться все эти транзакции будут в Postgres 19devel (последняя версия на 14.07.2025), запущенной под Linux 6.15.6 на системе с Ryzen 7950x, 32ГБ RAM и 2 ТБ SSD. Каждый тест будет длиться 120 секунд и выполняться дважды: сначала для прогрева кэша и затем для выполнения измерений.

Я измерил базовые показатели, оставив почти все настройки в postgresql.conf по умолчанию и увеличив только shared_buffers, work_mem и количество рабочих процессов. В результате тест показал неплохие 7082 TPS. Что ж, теперь посмотрим, насколько мне удастся замедлить Postgres.

Кэширование? В топку… 

Один из способов, которым Postgres может обеспечивать эффективное чтение запросов, подразумевает обширное кэширование. Получение данных с диска происходит медленно, поэтому при считывании с него блока данных Postgres кэширует этот блок в RAM, чтобы в следующий раз обращаться к нему быстрее. Естественно, я хочу обусловить для всех запросов максимально медленный метод чтения, так что кэш должен быть минимальным. Я могу свободно регулировать размер буферного кэша и прочих элементов общей памяти Postgres через параметр shared_buffers. Но просто установить его на 0, увы, нельзя, поскольку БД также использует этот кэш для обработки активных страниц. Хотя я всё же могу кардинально сократить его объём.

Изначально я попробовал сократить 10GB, которые выделил в базовой конфигурации, до 8MB.

shared_buffers = 8MB
Результат Benchbase в 1052 TPS.
Результат Benchbase в 1052 TPS.

Итак, я уже замедлил Postgres до 1/7 её базовой скорости. Уменьшение буферного кэша вынудило БД хранить в RAM меньше страниц данных, сократив число запросов, которые можно было выполнить без обращения к ОС, с 99,9% до 70,52%. Это привело к 300-кратному увеличению количества системных вызовов read.

Но можно добиться большего. Всё же 70% — это очень много, и должна быть возможность сократить размер кэша ещё сильнее. Теперь я попробовал установить для него объём 128 КБ.

Ага. Похоже, в 128 КБ может умещаться не более 16 страниц БД (исключая любое другое содержимое общих буферов), а Postgres требуется одновременный доступ более, чем к 16 страницам. Немного поэкспериментировав, я выяснил, что минимальный допустимый размер составляет примерно 2 МБ. В результате скорость Postgres упала ниже 500 TPS.

shared_buffers = 2MB

Максимум фоновой работы

Помимо обработки транзакций, у этой БД есть несколько задач, создающих высокую вычислительную нагрузку, чем я решил воспользоваться. Чтобы минимизировать фрагментацию хранилища, Postgres применяет процесс autovacuum, который находит пустое пространство (оставшееся от операций вроде удаления) и заполняет его другими строками. Как правило, этот процесс выполняется только после определённого числа изменений, чтобы не вредить производительности. Но я могу перенастроить autovacuum, сократив промежутки между его запусками.

autovacuum_vacuum_insert_threshold = 1 # autovacuum может активироваться после всего одного добавления.
autovacuum_vacuum_threshold = 0 # минимальное число добавлений, обновлений или удалений, необходимых для запуска autovacuum.
autovacuum_vacuum_scale_factor = 0 # доля «незамороженной» части таблицы, учитываемая при вычислении порога для запуска autovacuum.
autovacuum_vacuum_max_threshold = 1 # максимальное число добавлений, обновлений или удалений, необходимых для запуска autovacuum.
autovacuum_naptime = 1 # минимальная задержка между запусками autovacuum в секундах; к сожалению, установить меньше 1 её нельзя, что слегка нас ограничивает.
vacuum_cost_limit = 10000 # предел вычислительной стоимости запросов; его превышение ведёт к приостановке autovacuum. Я не хочу, чтобы этот процесс останавливался, поэтому установил максимальное значение.
vacuum_cost_page_dirty = 0
vacuum_cost_page_hit = 0
vacuum_cost_page_miss = 0 # эта группа параметров минимизирует стоимость операций при вычислении vacuum_cost_limit.

Кроме того, я перенастроил анализатор autovacuum, который собирает статистику для регулирования очистки и планирования запросов (спойлер: наличие точной статистики не помешает мне вмешаться в планирование запросов).

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 # устанавливает выходной формат/файл для записи журналов.

Важно отметить, что противоположный подход тоже должен сработать. Если полностью отключить очистку, страницы заполнятся «мёртвыми строками», и производительность будет постепенно падать. Тем не менее, поскольку эта рабочая нагрузка подразумевает интенсивное добавление строк в течение всего 2 минут, мне такой подход показался недостаточно эффективным.

Benchbase показывает результат 293 TPS
Benchbase показывает результат 293 TPS

Теперь мне удалось замедлить 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 по-прежнему слишком много.

* Прим. пер.: в оригинале hot tables. «Горячими» автор называет таблицы, в которых часто происходит обновление/удаление данных.

Превращаем Postgres в Брэндона Сандерсона

Брэндон Сандерсон пишет очень много. А знает, кто сможет с ним потягаться? Мой экземпляр Postgres, как только я закончу с настройкой WAL. Прежде чем отправлять изменения в файлы базы данных, Postgres пишет их в WAL (журнал предварительной записи), после чего сбрасывает через операцию контрольной точки. WAL имеет множество настроек, которые я могу использовать в своих целях. Во-первых, Postgres обычно сохраняет часть WAL в памяти, прежде чем заливать на диск. Этому надо помешать.

wal_writer_flush_after = 0 # минимальный объём генерируемого WAL, после которого его данные сбрасываются на диск.
wal_writer_delay = 1 # минимальная задержка между сбросами.

Я также хочу, чтобы WAL делал сброс в контрольной точке максимально часто.

min_wal_size = 32MB # минимальный размер WAL после контрольной точки; хочу производить сброс при максимуме.
max_wal_size = 32MB # максимальный размер WAL, после которого выполняется операция контрольной точки. К сожалению, вынужден установить оба параметра на 32 МБ, чтобы вписать два сегмента WAL.
checkpoint_timeout = 30 # максимальное время между контрольными точками в секундах; 30 с – это минимальное возможное значение.
checkpoint_flush_after = 1 # сброс выполненных записей на диск после каждых 8 КБ.

Естественно, мне также нужно максимально увеличить количество записей в WAL.

wal_sync_method = open_datasync # метод для сброса данных на диск; нужно установить самый медленный.
wal_level = logical # заставляет WAL выводить дополнительную информацию для повторения. Это ненужная информация, но она просаживает скорость.
wal_log_hints = on # заставляет WAL записывать изменённые страницы целиком.
summarize_wal = on # ещё один процесс для резервного копирования.
track_wal_io_timing = on # сбор дополнительной информации.
checkpoint_completion_target = 0 # не даёт распределять связанную с вводом/выводом нагрузку во времени.
Результат Benchbase понизился до 98 TPS
Результат Benchbase понизился до 98 TPS

Теперь скорость обработки транзакций упала до двухзначного показателя и составляет уже меньше 1/70 от изначальной. Как и в случае с autovacuum, журналы подтверждают, что это объясняется неэффективной работой 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

Да, обычно операции контрольных точек не должны выполняться каждые (проверяя записи) 487 миллисекунд. Но на этом я ещё не закончил.

Удаление индексов

Помните, как в самом начале я сказал, что нельзя лезть в индексы? Что ж, а нам и не нужно. При вычислении планов запросов Postgres оценивает случайный доступ к страницам на диске не так, как последовательный. Дело в том, что страницы, к которым происходит случайный доступ, на жёстких дисках загружаются медленнее. Запрос таблицы с помощью индекса обычно подразумевает случайный доступ к страницам, в то время как сканирование таблицы сопряжено с последовательным. Значит, корректировка относительных затрат на обращение к случайным страницам позволит нам исключить использование индексов.

random_page_cost = 1e300 # устанавливает стоимость доступа к случайной странице.
cpu_index_tuple_cost = 1e300 # устанавливает стоимость обработки одной строки из индекса.

Изменения лишь двух этих параметров достаточно, чтобы исключить использование индекса практически во всех случаях. В конечном итоге мне пришлось снова увеличить объём shared_buffers до 8 МБ, чтобы избежать ошибок при сканировании таблиц, но для производительности это в плюс практически не сыграло.

Результаты Benchbase упали до 0.87 TPS
Результаты Benchbase упали до 0.87 TPS

Мне удалось замедлить Postgres до выполнения менее одной транзакции в секунду, то есть более чем в 7 000 раз по сравнению с базовой конфигурацией. И всё это без изменения чего-либо за пределами postgresql.conf. Но у меня в запасе остался ещё один козырь.

Впихивание ввода/вывода в один поток

Я не могу сделать Postgres однопоточной, так как каждое из 100 подключений выполняется в собственном процессе. Тем не менее, благодаря новым возможностям Postgres 18, я могу сделать однопоточным ввод-вывод. В Postgres 18 появилась новая настройка — io_method. Она устанавливает, будут ли основные потоки синхронно использовать системные вызовы ввода-вывода (io_method = sync), асинхронно просить рабочие потоки отправлять системные вызовы (io_method = worker), или использовать новый Linux API io_uring (io_method = io_uring). Используя эту опцию в сочетании с параметром io_workers, который устанавливает максимальное число рабочих потоков при использовании io_method=worker, я могу втиснуть весь ввод-вывод в один рабочий поток.

io_method = worker
io_workers = 1
Результат Benchbase опустился до 0.016 TPS
Результат Benchbase опустился до 0.016 TPS

Что ж, теперь Postgres замедлилась до выполнения менее 0,1 транзакции в секунду, то есть в 42 000 раз по сравнению с начальными показателями. Если исключить транзакции, которые не завершаются из-за взаимных блокировок, то ситуация окажется ещё хуже (или лучше?): по всем 100 подключениям в течение 120 секунд успешно завершились лишь 11 транзакций.

Заключение

Итак, спустя несколько часов возни с 32 параметрами, я успешно положил базу данных Postgres на лопатки. Кто бы мог подумать, что можно так сильно урезать производительность через один только postgresql.conf? Я понимал, что могу опустить скорость БД вплоть до однозначного показателя TPS, но не ожидал, что 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 складами и 100 подключениями при максимальной пропускной способности в 50 000 TPS. Можете попробовать снизить производительность ещё больше. Я выбрал параметры, которые, на мой взгляд, должны наиболее сильно определять скорость Postgres. Большинство других я не тестировал.

Ну да ладно, пока писал, у меня уже заболела поясница, так что пора прогуляться или заняться чем-нибудь другим.

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


  1. Fragster
    01.08.2025 13:34

    Григорий Остер аплодирует.


  1. mlnw
    01.08.2025 13:34

    RU-VDS, когда IPv6 добавите своим VDSкам? А то на хабре первые, а в бизнесе базовой базы даже нет...