Движок PostgreSQL: Память, процессы и WAL

Глубокое погружение во внутреннюю архитектуру PostgreSQL для системных администраторов и DevOps. Вы изучите жизненный цикл запроса, механизмы кэширования в shared_buffers, работу фоновых процессов и критическую роль WAL в обеспечении сохранности данных.

1. Процессная модель и жизненный цикл соединения: от Postmaster до Backend-процесса

Процессная модель и жизненный цикл соединения: от Postmaster до Backend-процесса

Если вы заглянете в диспетчер задач операционной системы на сервере с активно работающим PostgreSQL, вы не увидите одного монолитного процесса, утилизирующего все ресурсы. Вместо этого перед вами предстанут десятки, а иногда и сотни процессов с именем postgres. В отличие от многих современных многопоточных систем (например, MySQL или веб-серверов вроде Nginx), PostgreSQL исторически и архитектурно опирается на многопроцессную модель (process-based architecture). Каждое клиентское подключение обслуживается отдельным тяжеловесным процессом операционной системы. Это фундаментальное дизайнерское решение определяет всё: от потребления оперативной памяти до способов защиты от сбоев и стратегий масштабирования.

Архитектура процессов: почему не потоки?

В операционных системах семейства Linux существует два основных способа конкурентной обработки задач: процессы и потоки (threads). Потоки делят между собой единое адресное пространство памяти — это делает их легковесными, они быстро создаются и требуют минимум накладных расходов на переключение контекста. Процессы же полностью изолированы друг от друга на уровне ядра ОС. Каждый процесс имеет собственную виртуальную память.

PostgreSQL использует процессы. Когда разработчики принимали это решение в 90-х годах, стандарты работы с потоками (POSIX threads) ещё не были окончательно сформированы и вели себя по-разному на разных Unix-системах. Процессная модель обеспечивала максимальную переносимость. Но сегодня главная причина сохранения этой архитектуры — надежность и изоляция.

Если в многопоточной СУБД из-за бага в коде или повреждения данных один поток выполнит недопустимую операцию с памятью (Segmentation fault), рухнет весь процесс СУБД целиком, оборвав соединения всех пользователей. В PostgreSQL изоляция процессов означает, что фатальная ошибка при выполнении конкретного SQL-запроса убьет только тот процесс, который его выполнял. Соседние клиентские сессии даже не заметят сбоя на уровне ОС (хотя сам кластер предпримет защитные действия, о которых пойдет речь ниже).

Обратная сторона этой надежности — высокая стоимость. Процесс ОС требует выделения структур в ядре, собственной памяти для стека и кучи. Базовое потребление памяти одним простаивающим соединением в PostgreSQL составляет от 2 до 10 мегабайт в зависимости от ОС и конфигурации, не считая памяти, которая потребуется для сортировок или хеширования при выполнении реального запроса.

Postmaster: Главный дирижёр кластера

Всё начинается с одного процесса. Когда systemd или утилита pg_ctl запускает кластер, стартует корневой процесс postgres. В терминологии администраторов и в исходном коде его традиционно называют Postmaster.

!Иерархия процессов PostgreSQL

Postmaster не выполняет SQL-запросы пользователей. Его задача — инициализация инфраструктуры и управление жизненным циклом остальных процессов. При старте он выполняет строго определенную последовательность действий:

  • Читает конфигурационные файлы (postgresql.conf, pg_hba.conf).
  • Запрашивает у операционной системы выделение блока разделяемой памяти (Shared Memory). Именно здесь будут располагаться shared_buffers (кэш данных), таблицы блокировок и структуры для межпроцессного взаимодействия.
  • Открывает сетевые сокеты на портах и IP-адресах, указанных в параметре listen_addresses, и начинает слушать входящие подключения.
  • Запускает набор служебных фоновых процессов (Background processes), таких как Checkpointer, Background Writer, Autovacuum Launcher, WAL Writer.
  • После завершения инициализации Postmaster переходит в бесконечный цикл ожидания (event loop). Он спит, пока на открытом сетевом порту (по умолчанию 5432) или в Unix-сокете не появится сигнал о новом входящем соединении от клиента.

    Жизненный цикл клиентского соединения

    Когда приложение (например, ORM вашего бэкенда или консольный клиент psql) пытается подключиться к базе данных, запускается сложный механизм делегирования полномочий. Postmaster не может сам обрабатывать запросы клиента, иначе он был бы заблокирован и не смог бы принимать другие подключения.

    !Механизм fork при новом соединении

    Процесс установки соединения выглядит следующим образом:

  • TCP Handshake: Клиент устанавливает TCP-соединение с портом 5432. Postmaster принимает это соединение.
  • Fork: Postmaster вызывает системный вызов fork(). Операционная система создает точную копию процесса Postmaster. Этот новый дочерний процесс называется Backend-процессом (или серверным процессом).
  • Делегирование: Postmaster передает файловый дескриптор установленного сетевого соединения своему новому потомку и немедленно возвращается к прослушиванию порта. С этого момента Postmaster не участвует в общении с клиентом.
  • Аутентификация: Backend-процесс принимает управление. Он читает стартовый пакет от клиента, содержащий имя пользователя и название базы данных. Затем Backend обращается к правилам, загруженным из pg_hba.conf, чтобы определить метод аутентификации (например, scram-sha-256). Он проводит криптографический обмен с клиентом.
  • Инициализация сессии: Если пароль верен, Backend-процесс инициализирует локальную память для сессии, применяет параметры конфигурации, специфичные для данного пользователя или базы данных, и отправляет клиенту сигнал готовности принимать SQL-запросы.
  • Цикл запросов: Backend читает SQL-запрос из сокета, парсит его, строит план выполнения, обращается к разделяемой памяти или диску за данными, формирует результат и отправляет его обратно клиенту.
  • Завершение: Когда клиент отправляет команду закрытия соединения (или обрывает TCP-сессию), Backend-процесс завершает текущую транзакцию, освобождает локальную память и выполняет системный вызов exit(), уничтожая себя.
  • Важно понимать, что системный вызов fork() — это относительно дорогая операция для операционной системы. Создание нового процесса занимает миллисекунды, но в масштабах высоконагруженной системы, где приложения могут открывать и закрывать сотни соединений в секунду, постоянный fork() становится узким местом (bottleneck), сжигающим ресурсы CPU.

    Анатомия Backend-процесса и системный мониторинг

    Каждый Backend-процесс жестко привязан к одной клиентской сессии и к одной конкретной базе данных (той, которая была указана при подключении). Переключиться на другую базу данных в рамках того же соединения невозможно — для этого клиент должен разорвать соединение и инициировать новое, что приведет к созданию нового Backend-процесса.

    Администратор Linux может наблюдать за состоянием этих процессов напрямую через системные утилиты. Если выполнить команду ps -ef | grep postgres, вывод продемонстрирует текущее состояние кластера.

    Типичный вывод содержит строки следующего вида: postgres 12345 1234 0 10:00 ? 00:00:00 postgres: user app_db 192.168.1.10(45678) idle

    Эта строка — не просто имя процесса. PostgreSQL динамически перезаписывает аргументы командной строки (argv) для своих Backend-процессов, чтобы администратор видел их статус без необходимости заходить в СУБД. В этой строке:

  • 12345 — PID (Process ID) Backend-процесса.
  • 1234 — PPID (Parent PID), указывающий на Postmaster.
  • user — роль, под которой выполнено подключение.
  • app_db — база данных, к которой подключен клиент.
  • 192.168.1.10(45678) — IP-адрес и порт клиента.
  • idle — текущее состояние процесса.
  • Состояние (status tag) — критически важная метрика. Процесс может находиться в нескольких базовых состояниях:

  • active: процесс прямо сейчас выполняет SQL-запрос (потребляет CPU и читает данные).
  • idle: процесс простаивает. Предыдущая транзакция завершена, процесс ждет следующей команды от клиента. При этом процесс продолжает занимать оперативную память.
  • idle in transaction: процесс начал транзакцию (например, выполнил BEGIN; и пару UPDATE), но сейчас ничего не делает, ожидая дальнейших команд от клиента. Это самое опасное состояние: такой процесс удерживает блокировки на строки таблиц и блокирует механизмы очистки старых данных (Autovacuum), что может привести к деградации производительности всего кластера.
  • Цена соединения и пределы масштабирования

    Понимание процессной модели дает четкий ответ на вопрос, почему параметр max_connections (максимальное число одновременных подключений) нельзя просто установить в значение 10000.

    Допустим, каждое соединение потребляет в среднем 10 мегабайт оперативной памяти только на базовые структуры процесса. При 10000 соединений сервер потратит 100 гигабайт RAM исключительно на поддержание этих процессов, даже если 99% из них будут находиться в состоянии idle. Эта память будет отнята у операционной системы и у внутреннего кэша PostgreSQL (shared_buffers), что приведет к катастрофическому падению скорости чтения с диска.

    Вторая проблема — планировщик задач ядра Linux (CPU Scheduler). Когда тысячи процессов одновременно просыпаются, чтобы выполнить короткий запрос, ядро начинает тратить больше процессорного времени на переключение контекста между процессами (context switching), чем на полезную работу (выполнение SQL). Нагрузка на CPU уходит в System (sys), а не в User (usr).

    Именно поэтому в профессиональной эксплуатации PostgreSQL перед базой данных всегда ставят пулер соединений (Connection Pooler), такой как PgBouncer. Пулер держит тысячи легковесных соединений от клиентов, но к самому PostgreSQL открывает лишь несколько десятков реальных Backend-процессов, мультиплексируя клиентские запросы через них.

    Управление сбоями: OOM Killer и защитный рестарт

    Изоляция процессов защищает от многих проблем, но разделяемая память (Shared Memory) создает уязвимость. Все Backend-процессы читают и пишут в один и тот же участок памяти (shared_buffers) и используют механизмы межпроцессных блокировок (LWLocks, спинлоки) для синхронизации доступа.

    Что произойдет, если Backend-процесс, выполняющий тяжелый запрос, исчерпает всю доступную оперативную память на сервере? Ядро Linux вызовет механизм OOM Killer (Out-Of-Memory Killer), который найдет процесс, потребляющий больше всего памяти, и принудительно убьет его сигналом SIGKILL.

    С точки зрения ОС убит всего один процесс. Но с точки зрения PostgreSQL произошла катастрофа. Убитый Backend-процесс в момент смерти мог находиться в середине операции записи в разделяемую память. Он мог удерживать критическую блокировку на страницу данных или оставить структуры данных в памяти в несогласованном состоянии. Если другие Backend-процессы продолжат работу, они прочитают поврежденные данные или зависнут навсегда, ожидая снятия блокировки от мертвого процесса.

    Postmaster, будучи супервизором, мгновенно обнаруживает нештатное завершение своего дочернего процесса (через сигнал SIGCHLD от ядра ОС). Понимая, что целостность разделяемой памяти скомпрометирована, Postmaster переходит в режим паники:

  • Он отправляет сигнал SIGQUIT (немедленное завершение) всем остальным работающим Backend-процессам. Все клиентские соединения обрываются с ошибкой the database system is in recovery mode.
  • Postmaster уничтожает текущий блок разделяемой памяти.
  • Postmaster заново выделяет чистую разделяемую память.
  • Запускается процесс восстановления из журнала транзакций (WAL), чтобы вернуть данные в консистентное состояние.
  • После успешного восстановления кластер снова начинает принимать подключения.
  • Этот механизм называется Crash Recovery. Он гарантирует, что логическая ошибка в одном процессе или агрессивное вмешательство ОС не приведут к физическому разрушению базы данных на диске. Однако для бизнеса это означает даун-тайм (от секунд до минут), пока кластер восстанавливается. Поэтому администраторы баз данных жестко контролируют локальную память процессов (параметр work_mem), чтобы не провоцировать OOM Killer, и настраивают ядро Linux (vm.overcommit_memory) на более предсказуемое поведение.

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

    2. Архитектура разделяемой памяти: устройство и управление shared_buffers

    Архитектура разделяемой памяти: устройство и управление shared_buffers

    Обращение к данным в оперативной памяти занимает десятки наносекунд. Чтение тех же данных с твердотельного накопителя (NVMe SSD) требует десятков микросекунд, а с классического жесткого диска — единиц миллисекунд. Разница в скорости между RAM и диском составляет от трех до пяти порядков. Если бы СУБД читала данные с диска при каждом запросе, производительность системы измерялась бы десятками транзакций в секунду даже на самом мощном оборудовании. Чтобы нивелировать эту пропасть, PostgreSQL использует агрессивное кэширование, ядром которого выступает shared_buffers.

    Анатомия разделяемой памяти

    При запуске кластера процесс Postmaster запрашивает у операционной системы единый монолитный кусок разделяемой памяти (Shared Memory). Размер этого сегмента жестко задается параметром shared_buffers в конфигурационном файле. В отличие от локальной памяти процессов, которая выделяется и освобождается динамически, shared_buffers резервируется целиком на старте. Именно поэтому изменение этого параметра требует полного перезапуска СУБД (restart), а не просто перечитывания конфигурации (reload).

    Внутри этот монолитный сегмент не является свалкой сырых данных. Он строго структурирован и разбит на блоки фиксированного размера — страницы (pages). По умолчанию размер одной страницы в PostgreSQL составляет ( байта). Это фундаментальная единица измерения ввода-вывода: PostgreSQL никогда не читает с диска и не пишет на диск отдельные строки таблицы (tuples). Даже если вы запрашиваете одну строку размером байт, СУБД загрузит в память всю 8-килобайтную страницу, на которой эта строка физически расположена.

    Пул разделяемых буферов состоит из трех ключевых компонентов, образующих архитектуру Buffer Manager:

  • Хэш-таблица (Buffer Mapping Table): Выполняет роль оглавления. Она связывает физические координаты страницы на диске с ее местоположением в оперативной памяти.
  • Массив дескрипторов буферов (Buffer Descriptors): Метаданные о каждой странице. Здесь хранится информация о том, кто сейчас читает страницу, изменялись ли на ней данные и насколько часто к ней обращаются.
  • Пул буферов (Buffer Pool): Непосредственно массив 8-килобайтных слотов, где лежат сами данные таблиц и индексов.
  • !Архитектура Buffer Manager в PostgreSQL

    Как Backend-процесс ищет данные

    Когда клиент отправляет запрос, обслуживающий его Backend-процесс должен получить доступ к нужным страницам. Допустим, планировщик определил, что для выполнения SELECT нужно прочитать блок №42 из файла таблицы с relfilenode 12345.

    Процесс поиска выглядит следующим образом. Сначала Backend формирует уникальный ключ страницы (Buffer Tag), который состоит из OID табличного пространства, OID базы данных, relfilenode таблицы, типа слоя (основные данные, карта видимости или карта свободного пространства) и номера блока.

    С этим ключом Backend обращается к хэш-таблице. Хэш-функция за времени указывает на конкретную корзину (bucket). Если страница найдена в хэш-таблице (произошел Cache Hit), Backend получает ID буфера — индекс в массиве дескрипторов. Если страницы в памяти нет (Cache Miss), Backend должен найти свободный слот в пуле буферов, прочитать 8-килобайтный блок с диска, поместить его в этот слот и добавить новую запись в хэш-таблицу.

    Механизмы защиты: Pin и Dirty

    Многопроцессная архитектура PostgreSQL означает, что сотни Backend-процессов могут одновременно искать, читать и изменять одни и те же страницы в shared_buffers. Чтобы система не развалилась от состояния гонки (race condition), используются два важнейших флага в дескрипторе буфера.

    Pin count (счетчик закреплений). Прежде чем прочитать данные из страницы, Backend-процесс «прикалывает» ее (pin). Он увеличивает значение pin_count в дескрипторе на единицу. Это сигнал для всех остальных процессов: «Я сейчас работаю с этой страницей, ее нельзя вытеснять из памяти или перезаписывать». Когда чтение завершено, процесс снимает закрепление (pin_count уменьшается). Если 10 процессов одновременно читают одну страницу, ее pin_count будет равен 10.

    Dirty flag (флаг загрязнения). Если Backend-процесс выполняет UPDATE, INSERT или DELETE, он изменяет данные прямо в оперативной памяти, внутри пула буферов. Сразу после изменения дескриптор страницы помечается флагом dirty (грязная). Это означает, что версия страницы в shared_buffers теперь новее, чем версия на диске. «Грязная» страница обязана оставаться в памяти до тех пор, пока фоновые процессы не сбросят ее на диск.

    Алгоритм Clock Sweep: вытеснение данных

    Рано или поздно все слоты в shared_buffers заполняются. Когда Backend-процесс получает Cache Miss и хочет загрузить новую страницу с диска, ему нужно найти свободное место. Если свободных слотов нет, нужно кого-то выгнать — выбрать страницу-жертву (victim page) и перезаписать ее.

    Вместо классического, но ресурсоемкого алгоритма LRU (Least Recently Used), требующего постоянного перемещения элементов в связанных списках при каждом чтении, PostgreSQL использует элегантный и легкий алгоритм Clock Sweep (метод часов).

    Представьте массив дескрипторов буферов как циферблат часов. По этому циферблату движется «стрелка» — внутренний указатель NextVictimBuffer.

    У каждого дескриптора есть параметр usage_count (счетчик использования), который может принимать значения от 0 до 5. Когда страница впервые загружается в память, ее usage_count равен 1. При каждом последующем обращении к странице (при каждом pin) счетчик увеличивается, вплоть до максимума в 5. Это защищает часто используемые данные от вытеснения.

    Когда Backend-процессу нужен свободный буфер, он начинает двигать стрелку NextVictimBuffer по кругу, проверяя дескрипторы один за другим:

  • Если страница закреплена (pin_count > 0), стрелка идет дальше. Вытеснять используемую прямо сейчас страницу нельзя.
  • Если usage_count > 0, алгоритм уменьшает его на 1 и двигает стрелку дальше. Это дает странице «второй шанс».
  • Если pin_count == 0 и usage_count == 0, страница становится жертвой.
  • !Алгоритм Clock Sweep в поиске страницы-жертвы

    Если выбранная жертва чистая (флаг dirty не установлен), Backend просто перезаписывает этот слот новыми данными с диска. Но если жертва «грязная», Backend-процесс вынужден сам записать эту страницу на диск, прежде чем положить туда новые данные. Это катастрофически сказывается на производительности: клиентский запрос, ожидавший быстрого чтения, внезапно блокируется на время выполнения синхронного дискового ввода-вывода. Чтобы предотвратить такие ситуации, в PostgreSQL существуют специальные фоновые процессы, которые асинхронно сбрасывают грязные страницы на диск, стараясь всегда держать в пуле запас чистых буферов.

    Защита от вымывания кэша: Ring Buffer

    Алгоритм Clock Sweep отлично работает для OLTP-нагрузок с частыми обращениями к одним и тем же горячим данным. Но что произойдет, если кто-то выполнит pg_dump или запустит аналитический запрос SELECT * FROM massive_table, который читает таблицу размером 100 ГБ при размере shared_buffers в 16 ГБ?

    По логике обычного кэширования, этот запрос загрузил бы в память все страницы огромной таблицы, вытеснив из shared_buffers абсолютно все полезные горячие данные других приложений. Произошло бы полное «вымывание» кэша (cache trashing).

    Чтобы этого не допустить, PostgreSQL использует стратегию Ring Buffer (кольцевой буфер). Если планировщик видит, что запрос собирается прочитать таблицу целиком (Sequential Scan), и размер таблицы превышает четверть от shared_buffers, Backend-процесс не использует общий пул на общих основаниях. Вместо этого он выделяет крошечный локальный кольцевой буфер размером всего (32 страницы) внутри shared_buffers. Он читает данные с диска в эти 32 страницы, перезаписывая их по кругу. Таким образом, даже чтение терабайтной таблицы затронет лишь разделяемой памяти, сохранив горячий кэш для остальных транзакций. Аналогичные кольцевые буферы применяются для операций VACUUM и массового импорта данных (COPY).

    Проблема двойного кэширования

    В отличие от таких СУБД, как Oracle или MySQL (InnoDB), которые стараются обходить кэши операционной системы (используя Direct I/O) и полностью управлять памятью самостоятельно, PostgreSQL исторически тесно интегрирован с ядром Linux.

    Когда PostgreSQL читает страницу с диска, запрос сначала попадает в ядро Linux. Ядро читает данные с физического накопителя и сохраняет их в своем собственном кэше — Page Cache (файловый кэш ОС). И только оттуда данные копируются в shared_buffers PostgreSQL. То же самое происходит при записи: PostgreSQL пишет грязную страницу в файл, но физически данные оседают в Page Cache ОС, и лишь позже ядро Linux сбрасывает их на диск (через механизм pdflush/writeback).

    Это архитектурное решение приводит к эффекту двойного кэширования (Double Buffering). Одна и та же 8-килобайтная страница горячих данных хранится дважды: один раз в памяти ядра Linux, второй раз — в shared_buffers PostgreSQL.

    На первый взгляд, это пустая трата оперативной памяти. Однако у такого подхода есть весомые преимущества. Если страница вытесняется из shared_buffers алгоритмом Clock Sweep, но вскоре снова требуется СУБД, PostgreSQL не идет за ней на физический диск. Он запрашивает ее у ОС, и Linux мгновенно отдает ее из своего Page Cache. Файловый кэш ОС выступает в роли огромного буфера второго уровня (L2 cache) для базы данных.

    Настройка размера shared_buffers: правило 25%

    Из-за архитектуры двойного кэширования логика выделения памяти в PostgreSQL кардинально отличается от других СУБД. Если на сервере MySQL администратор может отдать под innodb_buffer_pool_size 70-80% всей доступной оперативной памяти, то в PostgreSQL такой подход приведет к деградации производительности.

    Классическое, проверенное временем правило гласит: shared_buffers должен составлять около от общего объема RAM сервера.

    Почему именно столько?

  • Место для Page Cache. Оставшаяся память не простаивает. Львиную ее долю заберет ядро Linux под файловый кэш. Этот кэш будет хранить страницы, не поместившиеся в shared_buffers, а также обслуживать индексы и временные файлы.
  • Локальная память процессов. Каждому Backend-процессу нужна память для выполнения сортировок, хэш-соединений и агрегаций (параметр work_mem). При сотнях активных соединений суммарное потребление локальной памяти может достигать десятков гигабайт.
  • Защита от OOM Killer. Если отдать под shared_buffers 80% памяти, то при малейшем всплеске сложных запросов ядро Linux столкнется с нехваткой RAM и активирует OOM Killer, который с высокой вероятностью убьет процесс Postmaster, отправив весь кластер в Crash Recovery (как мы разбирали в предыдущей главе).
  • Граничные случаи и большие объемы RAM

    Правило 25% отлично работает на серверах с памятью от 16 до 128 ГБ. Однако на современных машинах с 512 ГБ, 1 ТБ или 2 ТБ оперативной памяти слепое следование этому правилу может навредить.

    Если установить shared_buffers = 256GB на терабайтном сервере, возникнет проблема управления грязными страницами. При интенсивной записи (write-heavy нагрузка) в памяти могут скопиться десятки гигабайт измененных данных. Когда наступит момент контрольной точки (Checkpoint), фоновым процессам придется синхронизировать весь этот огромный объем с диском. Это вызовет колоссальный всплеск I/O (I/O spike), который приведет к резкому замедлению всех клиентских запросов — так называемому фризу базы данных.

    Кроме того, управление гигантским массивом дескрипторов буферов увеличивает накладные расходы на поиск и блокировки. Поэтому на серверах с объемом RAM более 128 ГБ значение shared_buffers редко поднимают выше 32-64 ГБ. Остальная память оставляется операционной системе, которая благодаря современным алгоритмам управления Page Cache справляется с кэшированием терабайтных баз данных зачастую эффективнее, чем внутренний Buffer Manager.

    Конкурентный доступ и легковесные блокировки

    Поскольку shared_buffers — это разделяемый ресурс, необходимо синхронизировать доступ к нему. Когда Backend-процесс находит нужный дескриптор, он не может просто начать читать или писать данные в страницу. Другой процесс в эту же микросекунду может попытаться изменить ту же самую строку.

    Для защиты содержимого страниц на микроуровне PostgreSQL использует легковесные блокировки (LWLocks — Lightweight Locks). Это механизмы синхронизации, работающие на уровне оперативной памяти, которые удерживаются доли миллисекунд.

    Существует два режима блокировки содержимого буфера:

  • Shared (разделяемая): берется для чтения. Несколько процессов могут одновременно удерживать Shared-блокировку на одну страницу, читая данные параллельно.
  • Exclusive (эксклюзивная): берется для изменения данных (записи). Если процесс хочет изменить строку, он должен получить Exclusive-блокировку. В этот момент все остальные процессы, пытающиеся прочитать или изменить эту страницу, выстраиваются в очередь и ждут.
  • Если горячая страница (например, корень B-Tree индекса или таблица со счетчиками) постоянно обновляется множеством конкурентных сессий, возникает эффект Buffer Content Contention. Процессы начинают тратить процессорное время в циклах ожидания (spinlocks), пытаясь захватить LWLocks. В мониторинге ОС это выглядит как 100% утилизация CPU при почти нулевом дисковом вводе-выводе, а в системных представлениях PostgreSQL сессии зависают в состояниях ожидания LWLock:buffer_content. Архитектура единого разделяемого пула делает такие узкие места неизбежными при неоптимальном проектировании схемы данных.

    Понимание того, как страницы мигрируют с диска в Page Cache, затем в shared_buffers, как они закрепляются, загрязняются и вытесняются стрелкой Clock Sweep, дает полный контроль над профилем ввода-вывода кластера. Это фундамент, на котором строится вся дальнейшая диагностика производительности. Но оперативная память СУБД не ограничивается только кэшированием таблиц. Для выполнения сложных планов запросов — сортировок, группировок и обслуживания индексов — процессам требуется собственное, изолированное рабочее пространство, законы управления которым кардинально отличаются от логики разделяемых буферов.

    3. Локальная память процесса: work_mem, maintenance_work_mem и риски OOM Killer

    В журналах операционной системы появляется запись Out of memory: Killed process 12345 (postgres). Через секунду в логах самой СУБД возникает фатальное сообщение terminating any other active server processes, и база данных уходит в глухой отказ, начиная процедуру Crash Recovery. При разборе инцидента выясняется парадокс: глобальный кэш shared_buffers был настроен консервативно и занимал всего 25% оперативной памяти сервера. Причина падения кроется в невидимой на первый взгляд части архитектуры — локальной памяти процессов, которая при неправильной настройке способна бесконтрольно разрастаться, уничтожая стабильность всего кластера.

    Анатомия рабочего пространства процесса

    Многопроцессная архитектура PostgreSQL диктует жесткие правила работы с памятью. Когда Postmaster принимает новое клиентское соединение и вызывает системную функцию fork(), созданный Backend-процесс получает доступ к глобальному сегменту shared_buffers. Однако для выполнения самой работы — сортировки строк, объединения таблиц, вычисления агрегатов — процессу требуется собственное, изолированное рабочее пространство.

    Эта локальная память не выделяется единым монолитным куском при старте сессии. Она запрашивается у операционной системы динамически, по мере усложнения выполняемого SQL-запроса, и освобождается после его завершения. Главная проблема заключается в том, что операционная система (особенно Linux) по умолчанию склонна выдавать процессам больше памяти, чем физически существует на сервере, рассчитывая на то, что процессы не будут использовать её всю одновременно.

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

    work_mem: Умножение сущностей

    Параметр work_mem часто понимают неверно, считая его максимальным объемом памяти, который может потребить один запрос или одна клиентская сессия. На самом деле work_mem — это лимит памяти для одного узла выполнения (execution node) внутри плана запроса.

    Когда планировщик PostgreSQL строит план выполнения сложного запроса, он разбивает его на базовые операции. Если запрос требует отсортировать данные (узел Sort) и затем соединить их с другой таблицей методом хэширования (узел Hash Join), каждый из этих узлов получит право выделить память вплоть до значения work_mem.

    Формула максимального потребления памяти одним запросом выглядит так:

    где — количество узлов сортировки, хэширования или агрегации в плане выполнения.

    Если к серверу подключено 500 активных клиентов, и каждый выполняет сложный аналитический запрос, содержащий по 4 узла, требующих памяти, пиковое потребление локальной памяти вычислится следующим образом:

    При стандартном, казалось бы, значении work_mem = 32MB, кластер в пике может потребовать (около 62 ГБ) только под локальные операции, не считая shared_buffers и нужд самой операционной системы.

    Механика работы узлов и сброс на диск (Spilling)

    Что происходит, если узлу выполнения не хватает объема, заданного в work_mem? PostgreSQL не прерывает запрос ошибкой. Вместо этого он начинает использовать временные файлы на диске (temp files).

    Рассмотрим алгоритм хэш-соединения (Hash Join). Для быстрого поиска совпадений база данных берет меньшую из двух таблиц и строит из нее хэш-таблицу в локальной памяти. Если размер этой хэш-таблицы превышает work_mem, PostgreSQL разбивает данные на пакеты (batches). Один пакет остается в памяти, а остальные сбрасываются во временные файлы в директорию base/pgsql_tmp. Затем база данных читает вторую таблицу и проверяет совпадения, периодически подгружая пакеты с диска.

    !Зависимость скорости сортировки от параметра work_mem

    Переход от вычислений в оперативной памяти (in-memory) к дисковым операциям (disk spill) вызывает катастрофическое падение производительности. Запрос, который выполнялся 50 миллисекунд в RAM, может деградировать до 5 секунд из-за медленного I/O. Индикатором этой проблемы служат строки в логах: temporary file: path "base/pgsql_tmp/...", size ..., если включен параметр log_temp_files.

    Риски OOM Killer и стратегия защиты

    Linux управляет памятью с помощью механизма Overcommit. По умолчанию ядро разрешает процессам запрашивать больше памяти, чем есть в наличии (эвристический overcommit). Память фактически выделяется только в момент первого обращения к ней (page fault).

    Когда множество Backend-процессов PostgreSQL одновременно начинают активно использовать выделенные им лимиты work_mem, физическая оперативная память сервера исчерпывается. Ядро Linux, защищая себя от зависания, вызывает Out Of Memory (OOM) Killer. Этот системный снайпер анализирует процессы, вычисляет их «очки плохости» (badness score) на основе объема потребляемой памяти и убивает самый «тяжелый» процесс.

    Чаще всего жертвой становится один из Backend-процессов PostgreSQL, выполняющий тяжелый запрос.

    !Архитектура памяти кластера и прицел OOM Killer

    Убийство одного дочернего процесса операционной системой — это критическая нештатная ситуация для СУБД. Postmaster не знает, в каком состоянии находился убитый процесс. Возможно, в момент получения сигнала SIGKILL этот Backend-процесс удерживал легковесную блокировку (LWLock) на страницу в shared_buffers или находился в процессе изменения структуры разделяемой памяти.

    Чтобы гарантировать отсутствие повреждений данных (corruption), Postmaster принимает радикальное решение: он немедленно обрывает все остальные клиентские соединения, уничтожает все текущие Backend-процессы, очищает разделяемую память и запускает процедуру Crash Recovery, воспроизводя данные из журнала транзакций (WAL). С точки зрения бизнеса это выглядит как внезапная перезагрузка базы данных и потеря всех текущих транзакций.

    Настройка vm.overcommit_memory

    Для production-серверов баз данных полагаться на эвристический overcommit недопустимо. На уровне ядра Linux необходимо перевести управление памятью в строгий режим: sysctl -w vm.overcommit_memory=2

    В этом режиме ядро запрещает процессам запрашивать память сверх жесткого лимита, который вычисляется по формуле:

    Если PostgreSQL попытается выделить память, превышающую этот лимит, системный вызов вернет ошибку. База данных перехватит её, отменит конкретный проблемный запрос с ошибкой out of memory, но сам кластер продолжит работу. OOM Killer не будет вызван, и массового обрыва соединений не произойдет.

    Динамическое управление work_mem

    Устанавливать глобально высокое значение work_mem в файле postgresql.conf (например, 256MB) опасно из-за риска мультипликации при большом числе соединений. Устанавливать слишком низкое (например, 4MB) — значит облечь все аналитические запросы на медленную работу через дисковые временные файлы.

    Профессиональный подход заключается в динамическом управлении. Глобальный work_mem устанавливается на консервативном уровне (обычно 8-32MB), достаточном для простых OLTP-запросов. Для тяжелых аналитических выборок или построения сложных отчетов параметр меняется на лету внутри конкретной транзакции:

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

    maintenance_work_mem: Тяжелая артиллерия

    В то время как work_mem обслуживает клиентские запросы, параметр maintenance_work_mem контролирует объем локальной памяти для сервисных операций: VACUUM, CREATE INDEX, ALTER TABLE ADD FOREIGN KEY и REINDEX.

    Эти операции кардинально отличаются от обычных SELECT-запросов. Во-первых, они выполняются редко. Во-вторых, они не мультиплицируются: команда CREATE INDEX использует ровно один блок памяти размером maintenance_work_mem, независимо от сложности таблицы. В-третьих, скорость их выполнения критически важна для здоровья базы данных.

    При создании B-Tree индекса PostgreSQL считывает данные таблицы и строит дерево в локальной памяти. Если дерево помещается в maintenance_work_mem, операция проходит исключительно в RAM и завершается быстро. Если лимит исчерпан, СУБД начинает сбрасывать частично отсортированные ветви индекса во временные файлы на диск, а затем выполняет многопроходное слияние (merge). Это замедляет создание индекса в разы.

    Поскольку сервисных операций, выполняемых одновременно, обычно мало, maintenance_work_mem можно и нужно устанавливать значительно выше, чем work_mem. Значения в 1GB, 2GB или даже 4GB на серверах с достаточным объемом RAM являются нормой.

    Нюанс с Autovacuum

    Процесс автоматической очистки мертвых строк (Autovacuum) также использует память для хранения массива идентификаторов мертвых кортежей. По умолчанию он использует значение maintenance_work_mem.

    Однако, если на сервере разрешено одновременное выполнение нескольких рабочих процессов очистки (параметр autovacuum_max_workers, по умолчанию 3), то каждый из них заберет себе по целому maintenance_work_mem. Если вы установили этот параметр в 4GB для ускорения ручного создания индексов, три фоновых процесса Autovacuum могут незаметно зарезервировать 12GB оперативной памяти.

    Для разделения этих потоков существует параметр autovacuum_work_mem. Если он задан (значение отлично от -1), процессы автоматической очистки будут использовать его, а maintenance_work_mem останется эксклюзивным лимитом для ручных административных команд. Обычно autovacuum_work_mem устанавливают на уровне 256MB-512MB, что позволяет процессу собрать достаточное количество мертвых строк за один проход по таблице, не перегружая память сервера.

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

    4. Журнал упреждающей записи (WAL): физическая структура и алгоритм записи

    Вы отправляете команду COMMIT для критически важной транзакции перевода средств. База данных отвечает UPDATE 1, и ровно через миллисекунду сервер полностью обесточивается. Оперативная память очищается, содержимое shared_buffers исчезает. Как PostgreSQL гарантирует, что при следующем запуске ваш перевод не пропадет, хотя сами измененные страницы таблиц не успели записаться на диск? Ответ кроется в механизме Write-Ahead Logging (WAL) — журнале упреждающей записи, который является истинным сердцем транзакционной надежности СУБД.

    Концепция Write-Ahead Logging опирается на одно нерушимое правило: ни одна измененная (грязная) страница данных не может быть записана на диск до тех пор, пока запись о том, что именно было изменено, не окажется надежно сохраненной в журнале на физическом носителе.

    Файлы таблиц и индексов — это лишь проекция, отражение истории. Настоящая база данных — это WAL. Если у вас есть резервная копия файлов таблиц недельной давности и непрерывный поток WAL-файлов за эту неделю, вы можете восстановить базу до любой секунды.

    Физическая структура WAL: от LSN до сегментов

    Журнал транзакций не является единым бесконечным файлом. Физически он представляет собой набор файлов (сегментов), которые по умолчанию хранятся в директории pg_wal (в старых версиях до PostgreSQL 10 она называлась pg_xlog) внутри каталога PGDATA.

    Каждое изменение в базе данных получает свою уникальную координату — Log Sequence Number (LSN). LSN — это 64-битное целое число, которое указывает байтовое смещение записи от самого начала существования кластера. В системных представлениях и логах LSN обычно отображается в шестнадцатеричном формате, разделенном косой чертой, например: 0/16B374D8. Первая часть — это логический идентификатор файла, вторая — смещение внутри него.

    Поскольку держать один гигантский файл неудобно для ротации и архивации, PostgreSQL нарезает журнал на сегменты. По умолчанию размер одного WAL-сегмента составляет 16 МБ.

    !Структура имени WAL-файла и LSN

    Имя каждого файла в директории pg_wal состоит ровно из 24 шестнадцатеричных символов и формируется по строгой логике, разбиваясь на три блока по 8 символов: TTTTTTTTLLLLLLLLSSSSSSSS.

  • TTTTTTTT — Timeline ID (идентификатор линии времени). При обычной работе он равен 00000001. Линия времени меняется при восстановлении из резервной копии, чтобы предотвратить конфликт новых записей со старыми.
  • LLLLLLLL — Логический идентификатор (Log ID).
  • SSSSSSSS — Физический идентификатор сегмента (Segment ID).
  • Например, файл 00000001000000000000000A означает первую линию времени, нулевой логический лог и десятый (в hex — A) сегмент. Когда сегмент заполняется на свои 16 МБ, PostgreSQL создает следующий: 00000001000000000000000B. Когда физический идентификатор достигает 000000FF, логический идентификатор увеличивается на единицу.

    Связь между LSN и конкретным файлом вычисляется математически. Если мы знаем LSN, мы можем точно сказать, в каком файле и по какому байтовому смещению лежит эта запись. Формула определения файла опирается на размер сегмента:

    Где — это смещение внутри конкретного 16-мегабайтного файла.

    Анатомия WAL-записи

    Что именно пишется в журнал? PostgreSQL не пишет SQL-запросы (UPDATE users SET balance = 100). SQL — это логический уровень, а WAL оперирует физическим. Если записать SQL-запрос, то при восстановлении базе пришлось бы заново строить планы выполнения, искать нужные страницы в индексах, проверять блокировки. Это медленно и недетерминированно.

    Вместо этого WAL-запись содержит физические инструкции для Resource Managers (менеджеров ресурсов). Каждая запись состоит из заголовка (где указан LSN, идентификатор транзакции XID, длина записи и контрольная сумма CRC) и полезной нагрузки.

    Полезная нагрузка описывает дельту изменений: «В файле с relfilenode 12345, на странице 42, начиная с байта 1024, заменить следующие 50 байт на вот эти данные». Такая архитектура делает процесс восстановления (Crash Recovery) невероятно быстрым и прямолинейным: СУБД просто читает WAL и слепо применяет байтовые патчи к страницам на диске.

    Алгоритм записи: от shared_buffers до диска

    Процесс фиксации транзакции — это сложный танец между локальной памятью процесса, глобальной разделяемой памятью и дисковой подсистемой. Разберем его по шагам.

    !Пошаговый алгоритм фиксации транзакции и записи WAL

  • Модификация в памяти. Backend-процесс, обслуживающий клиента, находит нужную страницу таблицы в shared_buffers. Он накладывает на нее эксклюзивную блокировку (LWLock), изменяет нужные байты (например, обновляет баланс) и помечает страницу как «грязную» (dirty).
  • Формирование WAL-записи. До того как снять блокировку со страницы, Backend-процесс формирует WAL-запись об этом изменении.
  • Копирование в wal_buffers. Сформированная запись копируется в специальную область разделяемой памяти — wal_buffers. Это небольшой кольцевой буфер в RAM (по умолчанию его размер вычисляется автоматически, обычно около 16 МБ, что равно одному WAL-сегменту). На этом этапе запись имеет свой LSN, но физически находится только в оперативной памяти.
  • Ожидание фиксации. Backend-процесс может выполнить множество операций UPDATE или INSERT в рамках одной транзакции. Все они будут генерировать WAL-записи, которые оседают в wal_buffers.
  • Команда COMMIT. Когда клиент отправляет COMMIT, Backend-процесс обязан гарантировать надежность. Он обращается к операционной системе с системным вызовом (обычно fdatasync или fsync), принудительно сбрасывая содержимое wal_buffers на физический диск в файл pg_wal.
  • Подтверждение клиенту. Только после того, как дисковый контроллер подтвердит, что данные WAL физически записаны на магнитную пластину или чип SSD, Backend-процесс возвращает клиенту ответ об успешном завершении транзакции.
  • Сами измененные страницы данных в shared_buffers в этот момент на диск не сбрасываются. Они остаются в оперативной памяти. Их записью позже займутся фоновые процессы. В этом и заключается гениальность архитектуры: вместо того чтобы делать сотни случайных (random) записей разрозненных страниц таблиц по всему диску, PostgreSQL делает одну последовательную (sequential) запись в конец WAL-файла. Последовательная запись на порядки быстрее случайной даже на современных NVMe-накопителях.

    Проблема разорванных страниц (Torn Pages) и Full Page Writes

    Здесь мы сталкиваемся с фундаментальным конфликтом между архитектурой PostgreSQL и ядром Linux. Как мы знаем из устройства Buffer Manager, PostgreSQL оперирует страницами размером 8 КБ. Однако операционная система и файловая система (например, ext4 или xfs) обычно оперируют блоками по 4 КБ.

    Представьте ситуацию: фоновый процесс решает сбросить грязную 8-килобайтную страницу на диск. Операционная система разбивает эту операцию на две записи по 4 КБ. Первая половина страницы успешно записывается на диск. В этот самый момент происходит отключение питания. Вторая половина страницы не успела записаться.

    После перезапуска сервера на диске оказывается «химера»: первые 4 КБ страницы содержат новые данные, а вторые 4 КБ — старые. Это состояние называется Torn Page (разорванная страница). Контрольная сумма страницы не сойдется, и при попытке ее прочитать PostgreSQL выдаст фатальную ошибку PANIC: invalid page in block....

    !Механизм образования Torn Page

    Обычные WAL-записи, содержащие только дельту изменений (байтовые патчи), не могут починить разорванную страницу. Если применить патч к наполовину старой, наполовину новой странице, результат будет представлять собой поврежденный мусор.

    Для решения этой проблемы в PostgreSQL существует механизм Full Page Writes (FPW), управляемый параметром full_page_writes = on.

    Логика его работы следующая: при первом изменении любой страницы после контрольной точки (Checkpoint), PostgreSQL записывает в WAL не просто дельту изменений, а целиком всю 8-килобайтную страницу.

    Если произойдет сбой и страница на диске окажется разорванной, при восстановлении (Crash Recovery) PostgreSQL найдет в WAL полную копию этой страницы, целиком перезапишет ею поврежденный блок на диске, и только затем накатит поверх нее последующие дельта-изменения из более поздних WAL-записей.

    Это гарантирует абсолютную устойчивость к сбоям по питанию, но имеет цену: сразу после прохождения Checkpoint объем генерируемого WAL-трафика резко возрастает, так как в журнал летят полные 8-килобайтные копии страниц. Это явление часто называют «штормом WAL» (WAL storm).

    Тонкая настройка конвейера записи WAL

    Производительность любой транзакционной системы в конечном итоге упирается в скорость диска, на который пишется WAL. PostgreSQL предоставляет несколько рычагов для управления этим процессом, позволяя DevOps-инженерам находить баланс между параноидальной надежностью и максимальной пропускной способностью.

    #### Уровни synchronous_commit

    Ключевой параметр, определяющий поведение при COMMIT — это synchronous_commit. Он контролирует, насколько сильно Backend-процесс должен убедиться в сохранности WAL перед тем, как ответить клиенту.

  • on (по умолчанию): Backend-процесс ждет, пока WAL-записи транзакции не будут физически сброшены на локальный диск (выполнен fsync). Гарантирует отсутствие потерь при падении ОС или отключении питания.
  • off: Backend-процесс не ждет сброса на диск. Он лишь помещает запись в wal_buffers в оперативной памяти и сразу отвечает клиенту COMMIT. Сбросом на диск в фоновом режиме занимается процесс wal_writer (обычно каждые 200 миллисекунд). Это дает колоссальный прирост производительности (TPS может вырасти в десятки раз), но создает риск: если сервер обесточится, транзакции за последние доли секунды будут потеряны, хотя клиент получил подтверждение об их успехе. Важно: база данных при этом не повредится, она просто откатится на долю секунды назад.
  • local: Аналогичен on, но имеет значение только при настроенной репликации (ожидает записи только на локальный диск, игнорируя реплики).
  • Существуют также уровни remote_write и remote_apply, которые заставляют транзакцию ждать подтверждения от удаленных серверов-реплик, но их механика относится к архитектуре High Availability.

    #### Групповая фиксация (Group Commit)

    Если 1000 клиентов одновременно отправляют COMMIT, делать 1000 системных вызовов fsync к диску крайне неэффективно. PostgreSQL умеет объединять фиксации нескольких транзакций в одну дисковую операцию.

    Этим управляют параметры commit_delay и commit_siblings. Если Backend-процесс готов сделать COMMIT, он проверяет количество активных транзакций в системе. Если их больше или равно commit_siblings (по умолчанию 5), процесс засыпает на commit_delay микросекунд (по умолчанию 0, то есть отключено). Идея в том, чтобы дать другим транзакциям время тоже подойти к этапу фиксации. Когда микро-пауза заканчивается, один процесс выполняет fsync, сбрасывая на диск содержимое wal_buffers, которое накопилось от всех транзакций за это время. Для высоконагруженных систем с HDD или медленными SSD установка commit_delay = 10000 (10 миллисекунд) может существенно снизить нагрузку на I/O, хотя и ценой небольшого увеличения latency для отдельных запросов. На современных быстрых NVMe-накопителях групповой коммит часто теряет смысл, так как диск обрабатывает мелкие синхронные записи быстрее, чем длится искусственная задержка.

    Архитектура Write-Ahead Logging элегантно решает главную дилемму баз данных: как обеспечить персистентность данных без деградации производительности из-за случайного дискового ввода-вывода. Разделяя во времени быструю последовательную фиксацию намерений (WAL) и медленную асинхронную запись самих данных (Data Files), PostgreSQL достигает баланса, который делает его пригодным для самых требовательных enterprise-нагрузок. Понимание структуры сегментов, LSN и механики Full Page Writes является фундаментом, без которого невозможно грамотно планировать резервное копирование и настраивать отказоустойчивые кластеры.