В PostgreSQL устранили проблему невозможности выполнить контрольную точку на большом кэше буферов. В статье описываются условия возникновения проблемы, даётся пример диагностики статистики ввода-вывода в ситуациях, когда процесс checkpointer не успевает очищать свой круговой буфер. Также приводится алгоритм синхронизации файлов данных.

Новшества

Если экземпляр PostgreSQL был погашен некорректно, то после запуска экземпляра процесс startup восстанавливает блоки данных, накладывая на них изменения из WAL. Восстановление блоков идёт в кэше буферов. Процесс startup подгружает блоки в буфера и вносит в них изменения. В результате, буфера  массово "грязнятся". После восстановления выполняется контрольная точка и только после ее выполнения экземпляр дает возможность работать с кластером баз данных. Если размер кэша буферов большой (не меньше 82Гб), то грязных буферов может быть много. После выосстановления выполняется контрольная точка перед открытием экземпляра. До 27.07.2025г. процесс checkpointer мог попытался выделить память больше 1Гб, такая попытка приводила к ошибке "invalid memory alloc request size". В функции AbsorbSyncRequests выполнялся вызов:

n = CheckpointerShmem->num_requests;
palloc(n * sizeof(CheckpointerRequest))

который был заменён коммитом Александра Короткова на:

n = Min(CheckpointerShmem->num_requests, CKPT_REQ_BATCH_SIZE)

Из-за ошибки выделения памяти функцией palloc, контрольная точка не могла выполниться, и экземпляр не мог открыть доступ к кластеру баз данных. Так как контрольная точка не доходила до конца, то после рестарта экземпляра восстановление начиналось заново и всё повторялось, при этом клиенты не обслуживались. Обойти ошибку можно, уменьшив размер кэша буферов и перезапустив экземпляр, но догадаться том, что это поможет сложно. С ошибкой встретилась Екатерина Соколова и сообщила сообществу. По невнятному тексту сообщения сервера об ошибке было сложно понять, как её обойти, что возмутило Максима Орлова, и он предложил кардинальное решение: исправить ошибку. Может ли ошибка проявляться при обычной работе экземпляра? Может, но для того, чтобы это произошло, размер буферного кэша должен был быть большим, интервал между контрольными точками относительно большой и файлы данных должны увеличиваться на сотни гигабайт.

Кольцевой буфер

PostgreSQL работает с файлами через страничный кэш linux. Чтобы обеспечить отказоустойчивость, по умолчанию, на linux используются системные вызовы fdatasync для файлов журнала и fsync для файлов данных. fdatasync для WAL файлов и выполняется при фиксации транзакции, чтобы не потерять изменения, выполненные транзакций, что известно многим. Про fsync по файлам данных известно меньше. fsync для файлов данных выполняется реже, так как восстановление файлов данных выполняется с момента начала последней завершенной контрольной точки. В идеале, fsync по файлам данных, блоки которых менялись с начала контрольной точки, выполняется процессом checkpointer и в конце контрольной точки. Если размеры файлов меняются или файлы создаются или удаляются, то такое действие требует синхронизации (fsync/unlink) по этом файлу. Процесс передает идентификатор файла в массив в общей структуре памяти Checkpointer Data, ставший кольцевым буфером, с подачи Heikki Linnakangas. Кроме кольцевого буфера в структуре хранится дюжина чисел (счетчики, указатели), относящихся к работе процесса checkpointer и кольцевому буферу. Хейки предложил и значения в 10тыс. и 10млн., которые, без единого вопроса, были приняты сообществом и удивительно точно вписались в 1Гб памяти.

Для кэша буферов размером 128Мб, кольцевой (с лета 2025 года) буфер занимает 512Кб:

SELECT name, allocated_size, pg_size_pretty(allocated_size) FROM pg_shmem_allocations where name like '%Check%' ORDER BY size DESC;
         name         | allocated_size | pg_size_pretty 
----------------------+----------------+----------------
 Checkpointer Data    |         524416 | 512 kB
 Checkpoint BufferIds |         327680 | 320 kB
(2 rows)

В структуре Checkpoint BufferIds checkpointer сохраняет ссылки на блоки, которые будет передавать (writeback) в страничный кэш linux при выполнении контрольной точки.

Идентификатор файла передается при каждом изменении размера файла. Что, если checkpointer будет сильно загружен или планировщик linux будет тормозить работу checkpointer, не станет ли он узким местом?

В случае, если checkpointer не работает или структура Checkpointer Data полностью заполнена, процессы (кроме postmaster и checkpointer) могут выполнить fsync. Перед этим Checkpointer Data дедуплицируется (устраняются повторяющиеся значения) самим процессом, чтобы, по возможности, освободить в очереди ссылок на файлы место и уберечь серверные процессы от выполнения fsync. Устранение дубликатов довольно эффективно, так как новые файлы создаются не очень часто, а дубликатов по отдельным файлам довольно много, так как файлы, обычно, расширяются поблочно. Это нежелательно, так как процессам придётся выполнять fsync при добавлении даже одного блока к файлу или вытеснение (передача в страничный кэш linux) грязного блока из кэша буферов (eviction) грязного блока, что может происходи��ь довольно часто. На практике, очередь переполняется редко. Она может переполняться, когда хост сильно нагружен, особенно, в конце контрольной точки ("sync" phase of a checkpoint), когда checkpointer посылает fsync по всем файлам, которые менялись в течение контрольной точки.

Структуры памяти, которые использует checkpointer, отмечены зелёным цветом
Структуры памяти, которые использует checkpointer, отмечены зелёным цветом

В Checkpointer Data пишут все процессы. Из структуры читает процесс checkpointer и перемещает идентификаторы блоков в хэш-таблицу в своей локальной памяти, которая называется "Pending Ops Table" или "pending sync hash".

Структура Checkpoint BufferIds используется для сортировки идентификаторов буферов при выполнении контрольной точки. Помеченные на запись в страничный кэш linux (writeback) буфера отправляются упорядоченно (после сортировки). Только после writeback отправляются fsync по файлам, тоже упорядоченно. Размер структуры Checkpoint BufferIds пропорционален числу буферов в кэше буферов.

Зачем нужна синхронизация файлов

При чтении файлов данных (основной слой,vm, fsm таблиц, индексов и других отношений), содержимое файлов читаются в страничный кэш linux в виде страниц размером 4Кб, а оттуда две смежные страницы копируются в виде блока размером 8Кб в буфер кэша буферов экземпляра PostgreSQL. Изменения в кэше буферов не меняют содержимое страничного кэша linux и, если питание хоста выключится, содержимое кэша буферов исчезнет. Содержимое страничного кэша linux самостоятельно и периодически записывает на "диск".

Если содержимое блока файла данных, который находится в буфере кэша буферов, меняется, то linux об этом не знает. Изменившийся с момента чтения с диска блок называется "грязным". Блок из кэша буферов должен быть записан на диск, если процессу понадобится буфер, который в кэше буферов занимает блок. Такой блок процесс посылает в страничный кэш linux, что называется writeback, и в буфер, который он занимает, можно будет записать какой-то другой блок. Это называется eviction - извлечение блока из буфера.

Процесс экземпляра, извлекающий блок, копирует содержимое буфера в свою локальную память, рассчитывает и сохраняет в блоке его контрольную сумму и только потом передаёт (writeback) блок в страничный кэш linux, то есть из памяти в память, а не на "диск", поэтому writeback относительно быстрая операция.

Также процессы checkpointer и bgwriter периодически посылают грязные блоки на запись в страничный кэш linux, но делают это оптимизировано. Например, сортируют блоки в порядке их расположения по файлам, чтобы linux было "удобнее" их записывать на жесткие диски (HDD), у которых запись последовательно хранящихся блоков на порядки быстрее.

В идеале, серверные процессы не должны сталкиваться с необходимостью evict и writeback, так как эти действия увеличивают время выполнения SQL-запросов, то есть большую часть грязных блоков должны посылать в страничный кэш linux процессы checkpointer и bgwriter. Посылают ли они большую часть, можно увидеть в столбце writebacks представления pg_stat_io.

Однако, выполнить writeback недостаточно, так как writeback не приводит к гарантированной записи на диск. Страничный кэш - кэш на запись и страницы там могут удерживаться без записи на диск. Если питание хоста выключится, то в файлах данных будут старые образы блоков, к которым при восстановлении журнальные записи из WAL-файлов не смогут примениться. Поэтому выполнять fdatasync по WAL недостаточно, нужно выполнять fsync еще и по айлам данных. Когда это нужно делать? Поскольку при запуске экземпляра восстановление начинается с начала последней завершенной контрольной точки, то нужно, чтобы при завершении контрольной точки файлы данных были синхронизированы - все страницы в страничном кэше по файлам, содержимое которых менялось в течение контрольной точки, были записаны на диск. Для записи на диск можно использовать sync по каждой смонтированной файловой системе, в которой располагаются файлы кластера, но PostgreSQL не использует использует синхронизацию по файловой системе предполагая, что её может использовать не только экземпляр PostgreSQL. Экземпляр использует fsync по отдельным файлам. Размер файлов данных - не больше 1Гб и запись "грязных" страниц из страничного кэша по файлам такого, относительно небольшого размера (1Гб), создаст не очень долгую задержку. fsync выполняется "синхронно", что означает, что процесс не сможет работать, пока linux не передаст на диск все грязные страницы по этому файлу. Поэтому, стараются минимизировать число fsync при работе экземпляра и, в идеале, fsync должен даваться по одному изменившемуся файлу данных в конце контрольной точки. Самое оптимальное, чтобы это делал процесс, выполняющий контрольную точку - checkpointer.

Остаётся определить, по каким файлам в конце контрольной точки нужно будет делать fsync. И вот для определения этого, при writeback любого блока процессом экземпляра, этот процесс записывает идентификатор файла, к которому относится блок, в круговой буфер структуры Checkpointer Data. Так как писать в буфер могут процессы экземпляра, то структура находится в разделяемой памяти.

В одном файле может быть 1Гб/8Кб=128тыс. блоков (размер файла данных поделить на размер блока данных), что довольно много. Поэтому, число идентификаторов одного и того же файла в круговом буфере может быть много и имеет смысл выполнять дедупликацию - оставлять по одному идентифкатору на файл. Дкдупликацией занимается серверный процесс, пытающийся записать в буфер ссылку на файл при writeback очередного блока. Если в буфере даже после дедупликации останется мало места (половина), то процесс установит флаг для процесса checkpointer, чтобы тот перенёс идентификаторы файлов в структуру в своей локальной памяти Pending Ops Table, которая выделяется в начале контрольной точки, что освободит место в буфере. Для наиболее быстрого переноса идентификаторов структура является хэш-таблицей, а не списком.

Кроме изменения блоков данных, файлы данных могут удаляться по командам drop table, index, database, ... Такая операция называется unlink. В Checkpointer Data вместе с идентификатором файла записывается, что нужно сделать с файлом - fsync или unlink. Процесс checkpointer запросы unlink помещает не в Pending Ops Table, а в локальную структуру памяти pendingUnlinks, которая имеет тип списка, а не хэш таблицы, так как таких файлов, обычно,  немного и дубликаты редки. Почему? Потому, что если файл удаляется, то после этого запросов на удаление больше не может быть и запрос на удаление файла всегда один.

Синхронизация файлов (fsync) также выполняется утилитой pg_basebackup после окончания резервирования по каждому файлу созданного бэкапа. Это делается, чтобы бэкап не оказался битым, если питание пропадёт и такой бэкп нельзя использовать, а утилита pg_basebackup уже сообщила, что бэкап создан.

Если checkpointer не справляется

Рассмотрим пример, когда checkpointer не работает. В этом случае fsync по файлам данных отправляют серверные процессы.

Создание схемы, выполнение контрольной точки и сброс накопительной статистики:

create schema test;
checkpoint;
select pg_stat_reset_shared('io');
select pg_stat_reset_shared('bgwriter');

Скрипт test.sql:

select format('create table test.t%s (id int primary key, name text) with (autovacuum_enabled=off);', g.id) from generate_series(1, 10000) as g(id) 
\gexec

Запуск скрипта:

time psql -f test.sql > /dev/null 2> /dev/null
real    0m16.647s

Скрипт создаёт 40000 отношений:

select count(*) from pg_class;
 count 
-------
 40423

Статистика ввода-вывода:

select backend_type, context, writes w, round(write_time::numeric,2) wt, writebacks wb, round(writeback_time::numeric,2) wbt, extends ex, round(extend_time::numeric) et, hits, evictions ev, reuses ru, fsyncs fs, round(fsync_time::numeric) fst from pg_stat_io where writes>0 or extends>0 or evictions>0 or reuses>0 or hits>0;
   backend_type    | context | w |  wt  | wb | wbt  |  ex  | et |  hits   | ev | ru | fs | fst 
-------------------+---------+---+------+----+------+------+----+---------+----+----+----+-----
 client backend    | normal  | 0 | 0.00 |  0 | 0.00 | 7241 |  0 | 3610088 |  0 |    |  0 |   0
 autovacuum worker | normal  | 0 | 0.00 |  0 | 0.00 |    0 |  0 |    2449 |  0 |    |  0 |   0
 autovacuum worker | vacuum  | 0 | 0.00 |  0 | 0.00 |    0 |  0 |      40 |  0 |  0 |    |    
(3 rows)

В столбцах fsyncs fs, fsync_time fst у всех процессов, кроме процесса checkpointer нули. У процесса checkpointer ненулевые значения обновятся и увеличатся только после окончания контрольной точки:

checkpoint;

   backend_type    | context |  w   |  wt  |  wb  | wbt  |  ex  | et |  hits   | ev | ru |  fs   | fst 
-------------------+---------+------+------+------+------+------+----+---------+----+----+-------+-----
 client backend    | normal  |    0 | 0.00 |    0 | 0.00 | 7241 |  0 | 3610181 |  0 |    |     0 |   0
 autovacuum worker | normal  |    0 | 0.00 |    0 | 0.00 |    0 |  0 |    2449 |  0 |    |     0 |   0
 autovacuum worker | vacuum  |    0 | 0.00 |    0 | 0.00 |    0 |  0 |      40 |  0 |  0 |       |    
 checkpointer      | normal  | 9621 | 0.00 | 9621 | 0.00 |      |    |         |    |    | 40054 |   0
(4 rows)

После окончания контрольной точки fsyncs=40054, что равно числу файлов в табличных пространствах кластера, блоки которых обновлялись в течение контрольной точки. Это 40000 создаваемых скриптом таблиц и индексов и 54 отношений системного каталога.  При обычной работе, fsync выполняется один раз по каждому файлу (где были изменения хоть в одном блоке или файл был создан) в конце контрольной точки.

Удалим созданные таблицы:

DO
$$
begin
 for i in 1..10000 loop
   execute concat('drop table if exists test.t',i);
commit;
 end loop;
end;
$$ LANGUAGE plpgsql;

При нормальной работе процессов экземпляра, в столбцах fsyncs fs, fsync_time fst у всех процессов, кроме процесса checkpointer, будут только нули. Однако, под большой нагрузкой и неравномерным доступом к памяти (NUMA), есть вероятность, что checkpointer не сможет обработать очередь блоков на синхронизацию (writeback) и в этом случае процессы, у которых в столбце fsyncs fs нули могут посылать системные вызовы writeback и fsync в операционную систему самостоятельно. Операции fsyncs при работе с буферными кольцами считаются и учитываются в строках с context=normal.
Номера процессов checkpointer и background writer:

ps -ef | egrep "backgr|checkp"
postgres     909     850  0 Jan05 ?        00:00:05 postgres: checkpointer 
postgres     911     850  0 Jan05 ?        00:00:49 postgres: background writer 
postgres  408816  408812  0 19:24 pts/2    00:00:00 grep -E backgr|checkp

Пересоздадим таблицы, выполним контрольную точку, затем сбросим статистику и остановим процесс checkpointer:

kill -STOP 909

Проверим, что процесс остановлен:

ps -eo pid,s,comm | grep postgres | grep T
    909 T postgres
Запустим удаление таблиц. После удаления 1025 таблиц (4023-36323)/4:
select count(*) from pg_class;
 count 
-------
 36323

Команда удаления не сможет выполниться и сессия подвиснет. При этом блокировки в pg_locks отражаться не будут:

select * from pg_locks;
  locktype  | database | relation | page | tuple | virtualxid | transactionid | classid | objid | objsubid | virtualtransaction |  pid   |      mode       | granted | fastpath | waitstart 
------------+----------+----------+------+-------+------------+---------------+---------+-------+----------+--------------------+--------+-----------------+---------+----------+-----------
 relation   |        5 |    12073 |      |       |            |               |         |       |          | 41/2               | 410072 | AccessShareLock | t       | t        | 
 virtualxid |          |          |      |       | 41/2       |               |         |       |          | 41/2               | 410072 | ExclusiveLock   | t       | t        | 
(2 rows)

Событие ожидания RegisterSyncRequest

 Для наблюдения за такими задержками можно использовать представление pg_stat_activity. В представлении будет событие ожидания RegisterSyncRequest:

select * from pg_stat_activity where wait_event_type='Timeout'\gx
-[ RECORD 1 ]----+----------------------------------------------------
datid            | 5
datname          | postgres
pid              | 410072
leader_pid       | 
usesysid         | 10
usename          | postgres
application_name | psql
client_addr      | 
client_hostname  | 
client_port      | -1
backend_start    | 2026-01-20 20:06:30.582543+03
xact_start       | 2026-01-20 20:10:44.391695+03
query_start      | 2026-01-20 20:10:43.108027+03
state_change     | 2026-01-20 20:10:43.108032+03
wait_event_type  | Timeout
wait_event       | RegisterSyncRequest
state            | active
backend_xid      | 
backend_xmin     | 
query_id         | 
query            | DO                                                 +
                 | $$                                                 +
                 | begin                                              +
                 |  for i in 1..10000 loop                            +
                 |    execute concat('drop table if exists test.t',i);+
                 | commit;                                            +
                 |  end loop;                                         +
                 | end;                                               +
                 | $$ LANGUAGE plpgsql;
backend_type     | client backend

Ожидание RegisterSyncRequest - это передача запросов синхронизации процессу контрольной точки из-за переполнения локальной очереди запросов. Событие не связано со способом удаления (не зависит от наличия кода plpgsql, execute, commit). Размер очереди не зависит от параметров конфигурации *_flush_after:

\dconfig *_flush_after
List of configuration parameters
       Parameter        | Value 
------------------------+-------
 backend_flush_after    | 0
 bgwriter_flush_after   | 512kB
 checkpoint_flush_after | 256kB
 wal_writer_flush_after | 1MB
(4 rows)

Описание события ожидания есть в документации, где написано:

RegisterSyncRequest - Ожидание отправки запросов синхронизации к процессу контрольной точки, так как очередь запросов заполнена.

Чтобы развесить сессию, возобновим работу процесса checkpointer:

kill -CONT 909

Выполним контрольную точку, затем сбросим статистику, остановим процесс checkpointer и запустим скрипт создания таблиц.

Параллельно можно наблюдать, как серверные процессы начнут посылать вызовы fsync:

select backend_type, context, writes w, round(write_time::numeric,2) wt, writebacks wb, round(writeback_time::numeric,2) wbt, extends ex, round(extend_time::numeric) et, hits, evictions ev, reuses ru, fsyncs fs, round(fsync_time::numeric) fst from pg_stat_io where writes>0 or extends>0 or evictions>0 or reuses>0 or hits>0;

Воспользуемся командой psql, периодически выполняющей последний запрос:

\watch 10
                         (every 10s)

   backend_type    | context | w |  wt  | wb | wbt  | ex  | et |  hits  | ev | ru | fs | fst 
-------------------+---------+---+------+----+------+-----+----+--------+----+----+----+-----
 client backend    | normal  | 0 | 0.00 |  0 | 0.00 | 336 |  0 | 200199 |  0 |    |  0 |   0
 autovacuum worker | normal  | 0 | 0.00 |  0 | 0.00 |   0 |  0 |    198 |  0 |    |  0 |   0
(2 rows)

После того, как значения в столбце extends достигнут ~2500 блоков, в столбцах fsyncs, fsync_time у client backend (серверных процессов) появятся ненулевые значения и начнут расти:

                         (every 10s)

   backend_type    | context | w |  wt  | wb | wbt  |  ex  | et |  hits   | ev | ru |  fs  | fst 
-------------------+---------+---+------+----+------+------+----+---------+----+----+------+-----
 client backend    | normal  | 0 | 0.00 |  0 | 0.00 | 2707 |  0 | 1581591 |  0 |    | 1879 |   0
 autovacuum worker | normal  | 0 | 0.00 |  0 | 0.00 |    0 |  0 |     198 |  0 |    |    0 |   0
(2 rows)

                           (every 10s)

   backend_type    | context | w |  wt  | wb | wbt  |  ex  | et |  hits   | ev | ru |  fs  | fst 
-------------------+---------+---+------+----+------+------+----+---------+----+----+------+-----
 client backend    | normal  | 0 | 0.00 |  0 | 0.00 | 3113 |  0 | 1817828 |  0 |    | 6209 |   0
 autovacuum worker | normal  | 0 | 0.00 |  0 | 0.00 |    0 |  0 |     198 |  0 |    |    0 |   0
(2 rows)

...

За время работы число fsync, инициированных серверным процессом:

   backend_type    | context | w |  wt  | wb | wbt  |  ex  | et |  hits   | ev | ru |  fs   | fst 
-------------------+---------+---+------+----+------+------+----+---------+----+----+-------+-----
 client backend    | normal  | 0 | 0.00 |  0 | 0.00 | 6151 |  0 | 3622205 |  0 |    | 39055 |   0
 autovacuum worker | normal  | 0 | 0.00 |  0 | 0.00 |    0 |  0 |   33956 |  0 |    |     0 |   0
 autovacuum worker | vacuum  | 0 | 0.00 |  0 | 0.00 |    0 |  0 |   24758 |  0 |  0 |       |    
(3 rows)

Время создания 40000 отношений увеличится в ~6 раз, с 0m16.647s до 1m39.814s:

time psql -f test.sql > /dev/null 2> /dev/null
real    1m39.814s

Если процесс checkpointer не справляется и другие процессы не могут записать в буфер  Checkpointer Data указатели на свои блоки, то эффективность работы серверных процессов снижается.

Почему серверный процесс начал посылать fsync? Как только серверный процесс заполнил буфер Checkpointer Data идентификаторами блоков в разделяемой памяти, так что в ней не осталось места, серверному процессу пришлось писать в локальную память.

В Checkpointer Data пишут все процессы. Из структуры читает процесс checkpointer и перемещает идентификаторы блоков в хэш-таблицу в своей локальной памяти, которая называется "Pending Ops Table" или "pending sync hash". Если checkpointer не работает и в Checkpointer Data нет дублей, то серверный процесс будет выполнять fsync по файлам данных самостоятельно. Возобновим работу процесса checkpointer:

kill -CONT 909

По контрольной точке процесс checkpointer пошлёт fsync по файлам, ссылки на блоки которых, были в Checkpointer Data:

checkpoint;

   backend_type    | context |   w   |  wt  |  wb   | wbt  |  ex  | et |  hits   | ev | ru |  fs   | fst 
-------------------+---------+-------+------+-------+------+------+----+---------+----+----+-------+-----
 client backend    | normal  |     0 | 0.00 |     0 | 0.00 | 6157 |  0 | 3623478 |  0 |    | 39061 |   0
 autovacuum worker | normal  |     0 | 0.00 |     0 | 0.00 |    0 |  0 |  119640 |  0 |    |     0 |   0
 autovacuum worker | vacuum  |     0 | 0.00 |     0 | 0.00 |    0 |  0 |   54412 |  0 |  0 |       |    
 checkpointer      | normal  | 11083 | 0.00 | 11083 | 0.00 |      |    |         |    |    | 16434 |   0
(4 rows)

writebacks выполняются серверными процессами при расширении файлов (extends) и поиске буфера на вытеснение вызовом функции GetVictimBuffer, процессами bgwriter и checkpointer. Также могут выполняться процессами автовакуума, фоновыми процессами (background worker), startup, walsender.

Так как кэш буферов не был полностью заполнен, то серверные процессы не выполняли writebacks.

Заключение

При использовании большого буферного кэша экземпляр PosgreSQL мог не запуститься и выдать ошибку invalid memory alloc request size , что было исправлено в июле 2025 года и портировано в предыдущие (до 13) версии PostgreSQL. Рассмотрен функционал разделяемого буфера в Checkpointer Data, который стал кольцевым. Описан алгоритм, по которому синхронизируются файлы данных. Показан пример остановки процесс checkpointer. Экземпляр продолжил работать, что показало  устойчивость работы экземпляра PostgreSQL. О других новшествах в PostgreSQL можно прочесть в статьях Павла Лузанова, где собраны навшества PostgreSQL, начиная с 15 версии.