PostgreSQL Pro: Архитектура, оптимизация и масштабирование

Углубленный курс, охватывающий внутреннее устройство СУБД, продвинутый SQL и методы обеспечения отказоустойчивости. Программа составлена на основе материалов [postgrespro.ru](https://postgrespro.ru/education/books/internals) и [uproger.com](https://uproger.com/gajd-po-postgresql-dlya-prodvinutyh-razrabotchikov/).

1. Архитектура процессов, управление памятью (Shared Buffers) и принципы MVCC

Архитектура процессов, управление памятью (Shared Buffers) и принципы MVCC

PostgreSQL — это объектно-реляционная СУБД, построенная на классической клиент-серверной архитектуре. Понимание того, как база данных работает «под капотом», отличает простого пользователя SQL от инженера, способного проектировать высоконагруженные системы. Мы разберем три фундаментальных столпа: как устроены процессы, как распределяется память и как работает конкурентный доступ.

Архитектура процессов

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

!Взаимодействие процессов и общей памяти в PostgreSQL

Главный процесс (Postmaster)

При старте PostgreSQL запускается один главный процесс, исторически называемый postmaster (в списке процессов ОС он часто виден как postgres). Его задачи:

  • Инициализация выделенной памяти (Shared Memory).
  • Запуск фоновых процессов.
  • Прослушивание порта (по умолчанию 5432) для входящих соединений.
  • Когда клиентское приложение подключается к базе, postmaster порождает (fork) новый процесс — Backend Process (обслуживающий процесс). С этого момента клиент общается только со своим персональным бэкендом. Именно поэтому параметр max_connections так важен: каждый новый клиент потребляет ресурсы ОС на создание и поддержание отдельного процесса.

    Фоновые процессы (Background Processes)

    Для обслуживания системы работают служебные процессы, не связанные напрямую с клиентами:

    * Background Writer (BGWriter): Его задача — постепенно сбрасывать измененные («грязные») страницы из оперативной памяти на диск. Это делается для того, чтобы процесс Checkpointer не создавал гигантскую нагрузку ввода-вывода в момент контрольной точки. * Checkpointer: Периодически выполняет контрольную точку (checkpoint) — гарантированный сброс всех грязных данных на диск. Это сокращает время восстановления после сбоя. * WAL Writer: Сбрасывает журнал предзаписи (Write-Ahead Log) из буфера на диск, гарантируя, что транзакция зафиксирована надежно. * Autovacuum Launcher: Планировщик, который запускает процессы очистки мусора (о них поговорим в разделе MVCC).

    Управление памятью: Shared Buffers

    Память в PostgreSQL делится на две большие категории: локальная память (для каждого процесса) и разделяемая память (общая для всех).

    Разделяемые буферы (Shared Buffers)

    Это ключевой компонент производительности. PostgreSQL не читает и не пишет данные напрямую на диск байт за байтом. Все операции происходят страницами (по умолчанию размер страницы — 8 КБ). Чтобы прочитать строку, база данных должна загрузить всю страницу, содержащую эту строку, в оперативную память.

    Область памяти, отведенная под эти страницы, называется shared_buffers. Это кэш базы данных.

    > PostgreSQL хранит все данные в страницах, размер которых по умолчанию равен 8 Кб. Однако напрямую читать и писать страницы с/на диск было бы дороговато. Поэтому используется кэш в разделяемой памяти. eax.me

    Алгоритм работы:

  • Клиент запрашивает данные.
  • PostgreSQL ищет нужную страницу в shared_buffers.
  • Если страница там есть (cache hit), данные отдаются мгновенно.
  • Если страницы нет (cache miss), она считывается с диска в буфер, вытесняя старую страницу, если места нет.
  • Для вытеснения используется алгоритм Clock Sweep (разновидность LRU), который оценивает полезность страницы на основе частоты обращений к ней.

    Двойная буферизация

    Важно понимать, что PostgreSQL полагается и на кэш операционной системы. Когда Postgres пишет данные на «диск», он фактически передает их в страничный кэш ОС. Операционная система сама решает, когда физически записать биты на магнитный диск или SSD. Это приводит к эффекту двойной буферизации: данные могут храниться и в shared_buffers, и в кэше ОС одновременно. Поэтому не рекомендуется выделять под shared_buffers более 25-40% всей оперативной памяти сервера.

    Локальная память (Work Mem)

    Каждый backend-процесс также требует личной памяти для выполнения запросов. Параметр work_mem определяет объем памяти, который может быть использован для одной операции сортировки или хеширования (например, для ORDER BY, DISTINCT или Hash Join).

    Рассчитаем потенциальное потребление памяти:

    Где: * — общий объем оперативной памяти. * — размер общего буфера. * — максимальное число подключений. * — лимит памяти на одну операцию. * — среднее количество сложных операций в одном запросе (сортировок/хеширований).

    Пример: Если у вас 100 соединений, и вы выставили work_mem = 100 МБ, то теоретически при сложной нагрузке процессы могут попытаться занять 10 ГБ (100 * 100 МБ), что может привести к ошибке Out Of Memory (OOM), если у сервера всего 8 ГБ RAM.

    Принципы MVCC (Multi-Version Concurrency Control)

    Классическая проблема баз данных: что делать, если один пользователь читает таблицу, а второй в этот же момент пытается её изменить? Блокировать читателя? Блокировать писателя?

    PostgreSQL решает это через MVCC — многоверсионное управление конкурентным доступом. Главное правило MVCC:

    > Чтение не блокирует запись, а запись не блокирует чтение.

    Как это работает: xmin и xmax

    В PostgreSQL при обновлении строки (UPDATE) старая строка не удаляется физически и не перезаписывается. Вместо этого:

  • Создается новая версия строки с новыми данными.
  • Старая версия помечается как «устаревшая», но остается на диске, пока она может быть нужна другим транзакциям.
  • Каждая строка (кортеж) имеет скрытые системные поля: * xmin: ID транзакции, которая создала эту версию строки. * xmax: ID транзакции, которая удалила (или обновила) эту версию строки.

    !Механизм версионирования строк при операции UPDATE

    Рассмотрим пример на числах. Текущая транзакция имеет ID = 110.

  • INSERT: Мы вставляем строку. Ей присваивается xmin = 110, xmax = 0 (или NULL). Она видна всем транзакциям, начавшимся после фиксации транзакции 110.
  • DELETE: Мы удаляем строку. Данные не стираются. В поле xmax прописывается 110. Строка считается «мертвой» для всех будущих транзакций, но «живой» для тех, кто начал работу до транзакции 110.
  • UPDATE: Это комбинация DELETE + INSERT. Старая версия получает xmax = 110. Новая версия создается с xmin = 110 и xmax = 0.
  • Снимки данных (Snapshots)

    Когда транзакция начинает выполнение запроса, она делает «снимок» состояния базы. Она видит только те строки, у которых: * xmin меньше ID текущей транзакции (они были созданы в прошлом). * xmax либо пуст, либо больше ID текущей транзакции (они еще не были удалены на момент начала нашего взгляда).

    Это обеспечивает изоляцию транзакций. Вы видите базу такой, какой она была на момент начала вашего запроса, даже если параллельно кто-то удалил половину таблицы.

    > MVCC в PostgreSQL — это основополагающий механизм, обеспечивающий высокую производительность при параллельной работе с базой данных. В отличие от традиционных систем блокировок, MVCC позволяет транзакциям видеть свои собственные "снимки" данных. it-classic.ru

    Проблема Bloat и VACUUM

    Платой за MVCC является «мусор». Мертвые версии строк (у которых xmax указывает на завершенную транзакцию) занимают место на диске и в shared_buffers. Если их не чистить, таблица раздувается (bloat), и запросы начинают тормозить, так как им приходится перебирать гигабайты мертвых данных.

    Для решения этой проблемы существует процесс VACUUM (и его автоматическая версия Autovacuum). Он сканирует страницы, находит мертвые кортежи и помечает место, которое они занимали, как свободное для повторного использования новыми данными.

    Итоги

  • Процессы: PostgreSQL использует модель «один процесс на соединение». Postmaster управляет системой, а Background Writer и Checkpointer обеспечивают сохранность данных на диске.
  • Память: shared_buffers — это основной кэш страниц данных. work_mem — это локальная память для операций сортировки, которая выделяется каждому процессу отдельно и требует осторожной настройки.
  • MVCC: Конкурентный доступ реализован через создание версий строк. UPDATE создает новую версию, а не перезаписывает старую. Это позволяет читать данные без блокировок, но требует регулярной очистки мусора (VACUUM).
  • 2. Продвинутый SQL: CTE, оконные функции, JSONB и нормализация данных

    Продвинутый SQL: CTE, оконные функции, JSONB и нормализация данных

    В предыдущей лекции мы разобрали физическую архитектуру PostgreSQL: процессы, память и MVCC. Теперь, понимая, как база данных работает «под капотом», мы переходим к инструментам, которые позволяют писать эффективные, читаемые и мощные запросы. Знание архитектуры помогает понять почему запрос работает медленно, а продвинутый SQL дает инструменты, как это исправить.

    Мы рассмотрим четыре столпа современной разработки на PostgreSQL: общие табличные выражения (CTE), оконные функции, работу с неструктурированными данными (JSONB) и принципы нормализации.

    Общие табличные выражения (CTE)

    Часто SQL-запросы превращаются в нечитаемую «лапшу» из вложенных подзапросов. CTE (Common Table Expressions) — это именованные временные наборы данных, существующие только в рамках одного запроса. Они делают код чище и позволяют переиспользовать логику.

    Синтаксис начинается с ключевого слова WITH:

    Материализация CTE

    До версии PostgreSQL 12 CTE всегда материализовывались. Это значит, что база данных выполняла подзапрос, сохраняла результат во временную область памяти (как временную таблицу), и только потом основной запрос читал эти данные. Это было «черным ящиком» для оптимизатора: он не мог протолкнуть условия (predicates) внутрь CTE.

    Начиная с PostgreSQL 12, оптимизатор стал умнее. Если CTE используется один раз и не имеет побочных эффектов (например, INSERT/UPDATE), планировщик может «развернуть» (inline) его в основной запрос, как обычный подзапрос, что позволяет использовать индексы эффективнее.

    > CTE упрощают написание сложных запросов, ускоряют работу программ, когда надо обращаться к одной и той же части базы. specialist.ru

    Рекурсивные CTE

    Это «киллер-фича» для работы с иерархическими данными (деревья категорий, оргструктура, графы). Рекурсивный запрос состоит из двух частей, объединенных UNION ALL: стартовой части (anchor) и рекурсивной части, которая ссылается сама на себя.

    !Итеративный процесс обхода иерархической структуры

    Пример обхода дерева сотрудников:

    Оконные функции

    Если GROUP BY схлопывает строки в одну (например, сумму по отделу), то оконные функции позволяют выполнять вычисления по набору строк, сохраняя при этом сами строки. Это незаменимо для создания рейтингов, скользящих средних и нарастающих итогов.

    Синтаксис всегда включает конструкцию OVER:

    Где: * — результат вычисления для текущей строки. * — оконная функция (например, sum, rank, lead). * — колонка группировки (окно разбивается на независимые секции). * — порядок сортировки внутри окна.

    Основные функции

  • ROW_NUMBER(): Просто нумерует строки внутри партиции.
  • RANK() и DENSE_RANK(): Присваивают ранг. Если значения равны, RANK пропускает следующие номера (1, 1, 3), а DENSE_RANK — нет (1, 1, 2).
  • LAG() и LEAD(): Доступ к предыдущей или следующей строке без использования JOIN. Идеально для поиска изменений («как изменилась цена по сравнению со вчера»).
  • Пример нарастающего итога:

    !GROUP BY объединяет строки, Window Functions сохраняют исходные строки, добавляя вычисляемую колонку

    JSONB: NoSQL внутри SQL

    PostgreSQL предлагает два типа данных для JSON: json и jsonb.

    * json: Хранит данные как текст. При каждом чтении требуется парсинг. Сохраняет пробелы и порядок ключей. * jsonb (binary): Хранит данные в декомпозированном двоичном формате. Вставка чуть медленнее (из-за конвертации), но чтение и поиск на порядки быстрее. Поддерживает индексацию.

    Всегда используйте jsonb, если вам не нужно сохранять исходное форматирование текста.

    Индексация JSONB

    Главная мощь jsonb — GIN-индексы (Generalized Inverted Index). Они позволяют мгновенно находить строки, где JSON-документ содержит определенную пару ключ-значение.

    Оператор @> («содержит») использует индекс, тогда как обычное извлечение поля data->>'product_id' может потребовать полного сканирования таблицы, если не создать функциональный индекс.

    Нормализация и денормализация

    Проектирование схемы — это баланс между чистотой данных и скоростью чтения.

    Нормальные формы (кратко)

  • 1NF: Атомарность. В одной ячейке — одно значение (никаких списков через запятую).
  • 2NF: Все неключевые атрибуты зависят от всего первичного ключа.
  • 3NF: Нет транзитивных зависимостей (атрибут зависит от ключа, а не от другого неключевого атрибута).
  • > Большинство катастроф в базах данных это не сбой железа... Это решения, принятые на этапе проектирования схемы. habr.com

    Когда нужна денормализация?

    Нормализация устраняет избыточность и аномалии (обновления, вставки, удаления). Однако она заставляет делать много JOIN. В высоконагруженных системах JOIN больших таблиц — это дорого.

    Оценим стоимость соединения таблиц через Nested Loop Join:

    Где: * — примерная вычислительная сложность операции. * — количество строк в внешней таблице. * — количество строк во внутренней таблице.

    Если и — миллионы, результат будет неприемлемым. Индексы снижают сложность до , но это все равно накладно при огромных объемах.

    Стратегия денормализации: Иногда выгодно хранить user_name прямо в таблице orders, чтобы при отображении списка заказов не делать JOIN с таблицей users. Плата за это — сложность поддержки (при смене имени нужно обновить его во всех заказах) и увеличение размера диска.

    !Влияние на количество JOIN операций при выборке данных

    Практические советы по проектированию

  • Суррогатные ключи: Всегда используйте BIGSERIAL или UUID как Primary Key. Натуральные ключи (email, паспорт) могут меняться, что вызовет каскадный ад при обновлении.
  • Timestamps: Каждая таблица обязана иметь created_at и updated_at типа TIMESTAMPTZ. Обычный TIMESTAMP не хранит часовой пояс, что приведет к проблемам в распределенных системах.
  • Избегайте NULL: Где возможно, ставьте NOT NULL и значение по умолчанию. Трехзначная логика SQL (True, False, Null) — частый источник ошибок в отчетах.
  • Итоги

  • CTE делают код читаемым и позволяют строить рекурсивные запросы для иерархий. В новых версиях PostgreSQL они не создают барьер для оптимизатора.
  • Оконные функции позволяют проводить аналитику (ранги, скользящие агрегаты) без группировки строк, сохраняя детальность выборки.
  • JSONB — это эффективный бинарный формат для полуструктурированных данных. Используйте GIN-индексы для ускорения поиска по ключам JSON.
  • Нормализация важна для целостности, но денормализация может быть необходима для производительности на чтение. Всегда начинайте с 3NF и денормализуйте только при доказанной необходимости.