Назад к блогу

max_notify_queue_pages: зачем в PostgreSQL появился новый GUC

max_notify_queue_pages: зачем в PostgreSQL появился новый GUC

В статье разбирается новый параметр PostgreSQL 17 `max_notify_queue_pages`, заменивший неявное ограничение очереди LISTEN/NOTIFY в разделяемой памяти. Автор показывает, как устроена очередь, какими неочевидными способами слушающие сессии могут её заблокировать и почему при исчерпании лимита `COMMIT` начинает падать с ошибкой `54000`.

Очередь LISTEN/NOTIFY живёт в разделяемой памяти и до недавнего времени имела неявный предел, о котором почти никто не знал. В PostgreSQL 17 этот предел перестал действовать сам собой, и его пришлось заменить явным параметром max_notify_queue_pages. Разбираем, как устроена очередь, где именно она может застрять и почему новый GUC устроен так, а не иначе.

Три способа заполнить очередь

Очередь LISTEN/NOTIFY одна на кластер и не пишется в WAL. Заполнить её можно тремя способами, и они не равнозначны.

Первый — документированный: сессия выполняет LISTEN, а затем висит в состоянии idle in transaction. Такой сеанс держит хвост очереди и не даёт её усечь.

Второй способ в документации не описан: слушающая сессия находится в состоянии active внутри длинного оператора, без BEGIN. Транзакция не открыта явно, но запрос всё ещё выполняется, и хвост очереди удерживается так же.

Третий — самый неожиданный: слушающая сессия просто idle, без всякой транзакции, но её клиент перестал читать свой сокет. Бэкенд заполняет буфер сокета, блокируется с wait_event ClientWrite и удерживает хвост очереди, как и остальные.

Реалистичный сценарий — первый: сессия, которая двадцать часов висит в idle in transaction. К этому моменту она уже успела навредить сильнее, чем заполнением очереди, — держит блокировки, а если что-то записала, то тормозит и vacuum. Искусственный сценарий — выставить минимальное значение 64 на промежуточном стенде, чтобы за день выяснить, как приложение ведёт себя, когда COMMIT начинает возвращать 54000.

Как устроена очередь в разделяемой памяти

Разделяемое состояние LISTEN/NOTIFY состоит из трёх частей.

Первая — управляющая структура AsyncQueueControl, доступная через макрос NotifyCtl. Это «шапка» всей очереди: в ней хранятся позиции очереди head и tail (голова — куда пишется следующее уведомление, хвост — до какого места очередь уже прочитана всеми слушателями), страница stopPage (самая старая не удалённая страница очереди), идентификатор первого слушателя, время последнего предупреждения о заполнении очереди, дескрипторы DSA и dshash глобальной таблицы каналов, а также массив backend[] с записями о каждом слушающем процессе.

Вторая — собственно очередь уведомлений в SLRU-страницах (каталог pg_notify). Доступ к ним идёт через буферный пул, разбитый на банки, и для каждого банка используется отдельная блокировка.

Третья — глобальная таблица каналов globalChannelTable. Это динамическая разделяемая хеш-таблица (dshash — хеш-таблица, которую несколько процессов видят в общей памяти и могут менять согласованно), отображающая ключ из идентификатора базы и имени канала в запись GlobalChannelEntry, которая ссылается на массив слушателей. По ней система понимает, каким бэкендам нужно доставить уведомление по конкретному каналу. Каждый элемент массива хранит номер процесса слушателя и флаг, означающий, что запись нужно удалить, если текущая транзакция прервётся. Таблица создаётся лениво, а её дескрипторы сохраняются в AsyncQueueControl.

Отдельно от всего этого в каждой транзакции ведётся uniqueChannelHash — хеш уникальных имён каналов, по которым уже был NOTIFY в текущей транзакции, чтобы отбрасывать дубликаты. Элемент этого хеша хранит только имя канала, без payload.

Как формируется запись очереди

При отправке уведомления сначала заполняется локальная структура AsyncQueueEntry. Длина записи вычисляется как размер пустой записи плюс длины payload и имени канала, затем выравнивается. В запись попадают идентификатор текущей базы, идентификатор текущей транзакции и PID текущего процесса, а сами данные уведомления копируются вместе с двумя завершающими нулями — после имени канала и после payload.

Страница помечается dirty до записи в неё. Запись выполняется через memcpy в общий буфер, и флаг должен быть установлен заранее, чтобы последующая запись не осталась незамеченной для механизма вытеснения SLRU. В комментарии это сформулировано прямо: страница помечается грязной перед записью, и только потом идёт копирование в буфер.

Размер payload ограничен так, чтобы уведомление целиком помещалось на одну SLRU-страницу:

#define NOTIFY_PAYLOAD_MAX_LENGTH	(BLCKSZ - NAMEDATALEN - 128)

Поле с данными записи вмещает имя канала и payload такого размера. В конфигурации по умолчанию payload должен быть короче 8000 байт.

Путь NOTIFY от транзакции до слушателя

На коммите транзакции AtCommit_Notify применяет отложенные LISTEN/UNLISTEN, а если есть накопленные уведомления, запускает рассылку сигналов слушателям: под блокировкой NotifyQueueLock собираются PID-ы слушателей и им отправляются сигналы, чтобы разбудить их и заставить прочитать новые уведомления из очереди. Самому себе процесс просто ставит флаг notifyInterruptPending, не задействуя ядро.

Получив сигнал, бэкенд в обработчике HandleNotifyInterrupt выставляет notifyInterruptPending, а затем ProcessNotifyInterrupt — если процесс не внутри транзакции — в цикле вызывает ProcessIncomingNotify. Доставка идёт через asyncQueueReadAllNotifications: под разделяемой блокировкой сбрасывается флаг ожидания пробуждения, читаются текущая позиция бэкенда и head, и если позиция уже равна head, работа заканчивается. Иначе выставляется флаг продвижения, и в цикле обрабатываются страницы до stop-позиции — это head, зафиксированный на входе в функцию. При встрече с ещё не закоммиченной записью позиция откатывается назад, и обработка останавливается. По завершении позиция бэкенда обновляется, а флаг продвижения сбрасывается.

Отдельно asyncQueueAdvanceTail под двумя блокировками вычисляет минимум по позициям всех слушателей, присваивает его tail, и если хвост перешёл границу SLRU-сегмента, усекает сегменты и обновляет stopPage.

Уведомления, адресованные другим базам, отбрасываются по сравнению идентификатора базы записи с текущей. Для записей своей базы сначала проверяется, видна ли транзакция-источник в текущем MVCC-снимке: если она ещё выполняется, чтение останавливается и запись будет обработана позже. Если слушающих каналов нет, запись пропускается до проверки факта коммита — это ускоряет случай отсутствия LISTEN. Зафиксированные транзакции копируют запись в локальный буфер, прерванные и упавшие игнорируются. После освобождения блокировки SLRU уведомления из локального буфера рассылаются своему бэкенду через NotifyMyFrontEnd, но только для каналов, на которые есть подписка.

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

Заморозка XID и усечение CLOG

AsyncNotifyFreezeXids готовит очередь к усечению CLOG. Вызывается она из VACUUM перед продвижением datfrozenxid и проходит очередь от хвоста к голове, заменяя XID: для зафиксированных транзакций ставится FrozenTransactionId, для прерванных и упавших — InvalidTransactionId.

Обрабатываются только XID меньше newFrozenXid, потому что именно их страницы CLOG будут усечены. Такой XID не может всё ещё выполняться — иначе он удерживал бы newFrozenXid через ProcArray. Значит, если транзакция не зафиксирована, она либо явно прервана, либо упала, и её можно безопасно пометить недействительной.

Пороги, предупреждения и поведение при заполнении

Предупреждение начинает формироваться, когда очередь заполнена не менее чем наполовину: вычисляется доля заполнения, и если она меньше 0,5, функция сразу возвращается. Повторное логирование ограничено по времени — предупреждение выдаётся не чаще одного раза за заданный интервал, после чего время последнего предупреждения сохраняется.

В текст предупреждения попадает процент заполнения очереди. Если найден процесс с самой старой позицией в очереди, в detail добавляется его PID, а в hint — пояснение, что очередь не может быть очищена, пока этот процесс не завершит текущую транзакцию.

Долю заполнения возвращает SQL-функция pg_notification_queue_usage. Сначала она продвигает хвост очереди, чтобы не сообщить слишком большое значение, затем под разделяемой блокировкой вычисляет долю: берёт номер страницы головы и номер страницы хвоста, считает их разность, при нуле возвращает 0, иначе делит разность на max_notify_queue_pages. Измеряется расстояние именно до логической хвостовой страницы, а не до физической, потому что положение физического хвоста зависит от границ сегментов SLRU и результат был бы нестабильным.

Когда очередь заполнена на 100%, каждая транзакция, выполнившая NOTIFY, завершается ошибкой на этапе COMMIT с сообщением «too many notifications in the NOTIFY queue», SQLSTATE 54000. Вся транзакция откатывается целиком — и уведомление, и все остальные её изменения. Если NOTIFY живёт в триггере на таблице orders, писать в orders больше нельзя. При 50% отправитель получает WARNING с PID отстающего процесса. Очередь одна на кластер, поэтому переполнение затрагивает все базы данных, включая те, где нет ни одного слушателя.

Пограничные случаи

При старте или перезагрузке сервера каталог pg_notify очищается: в AsyncShmemInit вызывается сканирование каталога с удалением всего содержимого.

Для LISTEN/UNLISTEN в разных сессиях очередь действий ведётся отдельно: сохраняется упорядоченный список, чтобы учесть взаимодействия вроде UNLISTEN ALL, а итоговое намерение по каждому каналу вычисляется на этапе PreCommit_Notify.

При первом LISTEN в сессии указатель чтения ставится на глобальный хвост и продвигается вперёд по уже зафиксированным уведомлениям — так гарантируется, что ни одно ещё не закоммиченное уведомление не будет пропущено. Дополнительно можно принять максимальную позицию других бэкендов той же базы: всё, что они уже прошли, точно зафиксировано и не требует повторной проверки. Это ускоряет старт чтения.

Если после применения отложенных действий сессия больше ни на что не подписана, AtCommit_Notify удаляет её из массива слушателей. Потери записей в описанной механике нет: наоборот, при первом LISTEN гарантируется, что уже зафиксированные уведомления не будут пропущены.

Почему GUC устроен именно так

max_notify_queue_pages появился не по чьему-то запросу, а потому что прежний неявный предел перестал действовать сам собой, и что-то должно было его заменить.

До PostgreSQL 16 очередь LISTEN/NOTIFY ограничивалась 8 ГБ случайно, из-за устройства SLRU: имена сегментных файлов были четырёхзначными шестнадцатеричными и вмещали по 32 страницы, а код SLRU решал, что очередь завернулась, если использовано больше половины этого диапазона. В PostgreSQL 17 SLRU получили 64-битные номера страниц, обёртывание исчезло вместе с единственным ограничителем очереди, и коммит добавил параметр со значением по умолчанию 1048576 страниц — ровно старый предел.

Значение — это просто счётчик страниц, а не размер памяти, поэтому '512kB' отвергается. Минимум — 64, максимум — 2147483647. Параметр ничего не выделяет: разделяемая память очереди задаётся отдельным параметром notify_buffers. Сервер обращается к max_notify_queue_pages лишь в двух местах — при проверке заполненности очереди и в знаменателе pg_notification_queue_usage(). Изменение требует перезапуска, который сам опустошает очередь, так что новое значение просто едет вместе с исправлением.

Что из этого следует на практике

Понижать max_notify_queue_pages имеет смысл только в одной ситуации: когда сам объём данных таков, что 8 ГБ незапланированных файлов в pg_notify/ уже был бы аварией, и вы предпочитаете меньшую аварию.

В остальных случаях параметр лучше не трогать. Повышение даёт лишь более поздний сигнал тревоги, а не устранение причины. Изменить значение во время инцидента нельзя: изменение требует перезапуска, а перезапуск опустошает очередь — она не пишется в WAL и не переживает перезапуск — и отключает того, кто её блокировал. Перезапуск сам устраняет проблему, а новое значение просто едет вместе с ним.

Минимум 64 имеет ещё одно применение — на стенде, чтобы за день узнать, как приложение ведёт себя, когда COMMIT начинает возвращать 54000.

По типу ресурса max_notify_queue_pages отличается от остальных параметров раздела Resource Consumption. Он задаёт лимит очереди в страницах, а не объём памяти, и сам ничего не выделяет. shared_buffers задаёт объём разделяемой памяти под буферы, huge_pages управляет запросом огромных страниц для основной области разделяемой памяти, work_mem задаёт базовый максимум памяти на операцию запроса до записи во временные файлы, а maintenance_work_mem — максимум памяти для обслуживающих операций вроде VACUUM, CREATE INDEX и ALTER TABLE ADD FOREIGN KEY.

Где смотреть в коде

Источники

Похожее