PostgreSQL под enterprise-нагрузкой. Часть 1: тюнинг, WAL и мониторинг — на примере СЭД

Контекст: откуда взяты цифры
Материал собран из опыта внедрения и эксплуатации ECM-систем на базе Directum RX в связке с PostgreSQL. Профиль инсталляций, на который опираются все рекомендации ниже:
| Параметр | Малая | Средняя | Крупная |
| Активных пользователей | До 50 | До 500 | От 500 |
| Размер БД (среднее расчетное на 5 лет) | До 100Гб | До 300Гб | До 1Тб |
| Документов в системе | ~500Гб | ~1Тб | ~3Тб |
| Пик RPS к БД (можно задать в настройках по плану) | 50 | 300 | 500 |
| Прирост БД в месяц | 1Гб | 3Гб | 10Гб |
Стек, который сейчас встречается чаще всего: ОС семейства Debian, СУБД — PostgreSQL, Tantor или Postgres Pro. Дальше по тексту «PostgreSQL» означает любой из них, отличия я отмечаю отдельно.
Сразу оговорка, без которой весь текст читается неправильно. Готовых конфигураций «под СЭД» не существует. Вендор публикует рекомендованные (читай «минимальные») требования для стандартных сценариев, и это ровно то, чем они являются: точка старта. Дальше начинается вилка между двумя реальностями:
- заказчику нужна всесторонне отказоустойчивая система даже при скромной нагрузке — потому что простой документооборота останавливает согласование договоров;
- заказчик выбивает каждый гигабайт RAM с боем, и «просто добавьте памяти» здесь не работает как ответ.
Между этими полюсами и живет вся инженерная работа. Единственная универсальная формула — сверхизбыточное закладывание ресурсов, но тогда экономический эффект от системы съедается стоимостью железа.
Чем ECM-нагрузка отличается от обычного приложения
Если вы настраивали PostgreSQL под веб-бэкенд или под 1С, интуиция вас частично подведет. Отличия, которые реально меняют конфигурацию:
Транзакции живут долго, и виноват в этом человек
В веб-приложении транзакция — это десятки миллисекунд. В СЭД пользователь открывает карточку документа, ставит на ней блокировку и уходит на совещание. Система при обработке задачи упирается в заблокированный объект, откладывает выполнение, задача выглядит «зависшей», хотя формально все работает штатно. Для PostgreSQL это означает удерживаемый xmin, из-за которого autovacuum не может почистить мертвые строки во всей базе. Одна забытая вкладка в браузере деградирует производительность всего кластера.
Смешанная нагрузка OLTP + отчеты
Одновременно с потоком мелких запросов (открыть папку, взять задачу в работу, подписать) в базу приходят отчеты за период. Классический промах — попытка сформировать отчет за 10 лет. Такой запрос выгребает половину базы, вытесняет полезные данные из shared_buffers, пишет гигабайты во временные файлы и роняет отклик для всех остальных.
Иерархии и рекурсия
Папки, оргструктура, маршруты согласования — это рекурсивные CTE и самосоединения. Планировщик на них ошибается охотнее, чем на плоских выборках, а ошибка в оценке кардинальности на верхнем уровне рекурсии умножается вниз по дереву.
Большие выборки в UI
Папка «Входящие», открытая без фильтров, — это SELECT по таблице с сотнями тысяч строк, отсортированный по дате, с проверкой прав доступа на каждой строке. В логах Directum RX это всплывает как предупреждение Large Fetches. Разберу такой инцидент во второй части.
Права доступа как часть каждого запроса
В ECM почти нет запросов «просто выбрать данные» — почти всегда это «выбрать данные, которые видит данный пользователь». Условия по ACL добавляются в предикаты и ломают селективность, которую планировщик считал по базовой таблице.
Заказная разработка
В любой живой инсталляции есть код, написанный под конкретного заказчика. Часть таких запросов тяжелые по своей природе: они обращаются к десятку таблиц и агрегируют результат, и другая реализация невозможна исходя из требований. Про это честнее предупреждать еще на этапе оценки, а не на этапе «почему тормозит».
Тюнинг: рабочие значения и почему именно такие
Ниже стартовые конфигурации для трех масштабов. Это не «правильные значения», это точка, с которой можно начинать и от которой измерять.
Память
# --- Малая: 4 vCPU / 8 ГБ RAM --- shared_buffers = 1500MB effective_cache_size = 5GB work_mem = 12MB maintenance_work_mem = 384MB huge_pages = off
# --- Средняя: 8 vCPU / 32 ГБ RAM --- shared_buffers = 8GB effective_cache_size = 24GB work_mem = 32MB maintenance_work_mem = 2GB huge_pages = try
# --- Крупная: 32 vCPU / 64 ГБ RAM --- shared_buffers = 16GB effective_cache_size = 48GB work_mem = 64MB maintenance_work_mem = 4GB huge_pages = on
shared_buffers — 25% RAM, но не безгранично
Правило «25%» держится примерно до 96–128 ГБ, хоть у нас таких значений практически не бывает, дальше растут накладные расходы на управление буферным кешем и на checkpoint, а выигрыш почти не растет, потому что страничный кеш ОС продолжает работать. Для ECM это особенно верно: рабочее множество (актуальные задачи, недавние документы, справочники) обычно куда меньше базы. Смысла кешировать архив пятилетней давности нет.
work_mem — самый опасный параметр
Он выделяется не на сессию, а на каждую операцию сортировки/хеша в плане, помноженную на число parallel workers. Запрос со сложным планом и max_parallel_workers_per_gather = 4 может съесть 10–20× work_mem. При 400 соединениях агрессивное значение — это гарантированный OOM killer. Практический подход: держать глобальное значение умеренным, а для отчетных сессий поднимать точечно:
-- отдельная роль под отчеты ALTER ROLE rx_reports SET work_mem = '256MB'; ALTER ROLE rx_reports SET statement_timeout = '15min';
Признак того, что work_mem мал: рост temp_bytes в pg_stat_database и записи в логе при log_temp_files = 0.
huge_pages
При shared_buffers от 32 ГБ отказ от huge pages стоит заметного процента CPU на обслуживание таблиц страниц. Не забудьте посчитать и зафиксировать vm.nr_hugepages в ОС, иначе huge_pages = on не даст СУБД стартовать.
Соединения
max_connections = 50 # малая max_connections = 300 # средняя max_connections = 500 # крупная
Ключевой момент: max_connections считается не по числу пользователей, а по числу сервисов и размерам их пулов. Directum RX — это набор сервисов, каждый со своим пулом соединений, плюс интеграции, плюс сервисные учетки мониторинга и бэкапа, плюс DBA-сессии. Сложите фактические лимиты пулов всех потребителей, добавьте 20–30% запаса — получите нижнюю границу.
Классические грабли из практики: система рассчитана на одно количество подключений, по факту их оказывается в полтора раза больше. Лечится перенастройкой за пять минут, а вот путь до выявления причины бывает долгим, потому что симптом (FATAL: sorry, too many clients already) появляется в пиковые часы и у разных пользователей.
Если суммарно получается больше ~500 активных соединений — ставьте пулер (PgBouncer в режиме transaction) и заранее проверьте, что используемые сервисы не полагаются на сессионные объекты вроде временных таблиц и SET LOCAL за пределами транзакции.
Диск и планировщик
random_page_cost = 1.1 # NVMe/SSD; для HDD оставить 4.0 seq_page_cost = 1.0 effective_io_concurrency = 256 # NVMe maintenance_io_concurrency = 256 default_statistics_target = 200 jit = off
jit = off — не опечатка
JIT дает выигрыш на длинных аналитических запросах и стабильно вредит в OLTP-профиле: компиляция плана добавляет десятки-сотни миллисекунд к запросу, который сам по себе выполняется 5 мс. В ECM подавляющее большинство запросов короткие. Если отчеты действительно выигрывают от JIT — включайте его точечно для отчетной роли.
Autovacuum
Дефолтный autovacuum рассчитан на базы 2010 года. На таблицах рабочих процессов (sungero_wf_*), где строки обновляются постоянно, autovacuum_vacuum_scale_factor = 0.2 означает «чистим, когда протухло 20% таблицы» — при миллионе строк это 200 тысяч мертвых версий, накопленных до начала уборки.
autovacuum_max_workers = 4 autovacuum_naptime = 120s autovacuum_vacuum_cost_limit = 400 autovacuum_vacuum_cost_delay = 20ms autovacuum_vacuum_scale_factor = 0.1 autovacuum_analyze_scale_factor = 0.2 autovacuum_vacuum_insert_scale_factor = 0.05 log_autovacuum_min_duration = 500
Важно: autovacuum_max_workers увеличивает параллелизм, но общий бюджет cost_limit делится между воркерами. Поднимая число воркеров, поднимайте и лимит, иначе вы просто размажете ту же скорость уборки на большее число процессов.
Защитные таймауты
Этот блок я считаю обязательным для СЭД. Он напрямую закрывает проблему «пользователь ушел на обед с открытым документом».
idle_in_transaction_session_timeout = 5min statement_timeout = 0 # глобально не ставим, только на ролях lock_timeout = 30s deadlock_timeout = 1s log_lock_waits = on log_temp_files = 0 log_min_duration_statement = 3000 # рекомендация вендора log_checkpoints = on log_autovacuum_min_duration = 1000 track_io_timing = on track_activities = on track_counts = on track_functions = pl
idle_in_transaction_session_timeout — это не грубость по отношению к пользователю. Это защита от того, что одна брошенная транзакция заблокирует autovacuum по всей базе и через сутки вы получите распухшие таблицы и деградацию всех запросов. Пять минут — адекватный старт; согласуйте значение с поведением сервисов, чтобы не рвать длинные легитимные операции.
Отдельно проговорите с заказчиком организационную часть: рекомендация закрывать документы и карточки, если работать с ними сейчас не планируете. Технические таймауты снижают ущерб, но не отменяют дисциплину.
Контрольные точки и WAL-запись
checkpoint_timeout = 15min checkpoint_completion_target = 0.9 max_wal_size = 32GB # средняя инсталляция min_wal_size = 8GB wal_compression = zstd # pg16+; для более старых версий - on wal_buffers = 64MB
Ориентир по max_wal_size: столько WAL, сколько система генерирует за 2–3 интервала checkpoint_timeout под пиковой нагрузкой. Если в логах при log_checkpoints = on видно checkpoint starting: wal вместо checkpoint starting: time — max_wal_size мал, контрольные точки запускаются по переполнению и бьют по I/O в самый неподходящий момент.
WAL, архивация и восстановление
Базовая конфигурация
wal_level = replica # logical - только если нужна логическая репликация archive_mode = on archive_timeout = 300 # гарантия RPO даже при простое archive_command = 'pgbackrest --stanza=rx archive-push %p' max_wal_senders = 10 max_replication_slots = 10 max_slot_wal_keep_size = 128GB # критично, см. вторую часть synchronous_commit = on full_page_writes = on
Про инструменты: pgBackRest, WAL-G и Barman все решают задачу. Я предпочитаю pgBackRest за инкрементальные бэкапы на уровне блоков, встроенную проверку целостности и внятную работу с несколькими репозиториями. Ключевое требование к любому из них — retention должен быть согласован с реальным RPO/RTO, записанным в SLA, а не выбран по остатку места на диске.
Пример конфигурации:
# /etc/pgbackrest/pgbackrest.conf [rx] pg1-path=/var/lib/postgresql/16/main [global] repo1-path=/backup/pgbackrest repo1-retention-full=2 repo1-retention-diff=6 repo1-bundle=y repo1-block=y compress-type=zst compress-level=6 process-max=8 start-fast=y archive-async=y spool-path=/var/spool/pgbackrest log-level-console=info log-level-file=detail
Расписание: полный бэкап раз в неделю, дифференциальный — ежедневно, WAL — непрерывно. archive-async=y обязателен для нагруженных систем, иначе archive_command становится узким местом и WAL начинает копиться в pg_wal.
PITR: чего не пишут в инструкциях
Механика восстановления на точку тривиальна:
pgbackrest --stanza=rx --type=time --target="2026-07-20 14:30:00+03" --target-action=promote restore
Нетривиально другое. В ECM база данных — не единственное хранилище состояния. Если вы откатили БД на 14:30, то у вас теперь:
- файловое хранилище тел документов, которое живет по своему графику. Метаданные говорят о версии документа, которой в хранилище уже/еще нет. Или наоборот — в хранилище лежат тела документов, о которых БД не знает, и они превращаются в невидимый мусор;
- поисковый индекс (Elasticsearch), который после отката БД содержит документы из будущего;
- очереди и внешние интеграции — обмен с 1С, ЭДО-оператором, порталом. Документ, отправленный контрагенту в 14:45, после отката в базе снова «в согласовании», но у контрагента он уже есть.
Отсюда практическое правило: точка восстановления должна быть согласованной для всего контура, а не для БД. Минимум — синхронизировать снапшоты файлового хранилища с базовыми бэкапами БД и иметь процедуру переиндексации поиска после восстановления. И описать её раньше, чем она понадобится.
Проверка бэкапов
Бэкап, который ни разу не восстанавливали, — это не бэкап, а надежда. Минимальный регламент:
- pgbackrest check — ежедневно, автоматически, с алертом при ненулевом коде возврата;
- полное тестовое восстановление на отдельный стенд — ежемесячно, с замером фактического RTO;
- после восстановления — amcheck по ключевым индексам и контрольные бизнес-запросы (число документов за период, число активных задач), сверенные с продом.
Фактический RTO почти всегда оказывается больше расчетного, и лучше узнать об этом на стенде.
Мониторинг: что и с какими порогами
Сбор метрик
Схема, которая себя оправдала: postgres_exporter → VictoriaMetrics → Grafana, плюс Zabbix для инфраструктурного слоя (диски, сеть, состояние сервиса), плюс отдельный контур логов. Вендорское решение мониторинга Directum RX собрано из тех же кирпичей — Grafana, Zabbix, Prometheus, Kibana — и дает коробочный дашборд PostgreSQL Database.
Для сбора метрик из БД потребуется отдельный пользователь с минимальными правами:
CREATE USER drx_monitor WITH PASSWORD '<PASSWORD>' INHERIT; GRANT pg_monitor TO drx_monitor;
Роль pg_monitor даёт доступ к статистике без права читать данные — не давайте экспортеру суперпользователя, это лишний вектор.
pg_stat_statements и pg_profile
Мгновенный срез дает pg_stat_statements, ретроспективу — pg_profile (для Postgres Pro — pgpro_pwr). Рабочие параметры postgresql.conf:
shared_preload_libraries = 'pg_stat_statements' # далее не обязательные параметры pg_stat_statements.max = 20000 pg_stat_statements.track = 'top' pg_stat_statements.save = off pg_profile.topn = 100 pg_profile.max_sample_age = 2 pg_profile.track_sample_timings = on
Важное замечание по Postgres Pro: не меняйте pg_stat_statements на pgpro_stat — отчет разрастается в размере, а само расширение создает избыточную нагрузку.
Как читать отчет
В pg_profile смотрим три раздела:
- Top SQL by execution time — суммарная длительность. Приоритет анализа — строки с пиковыми %Total и Mean;
- Top SQL by executions — количество вызовов. Здесь важно не ловить ложные срабатывания: для ряда запросов сервисов Directum RX большое число выполнений — ожидаемое поведение (DISCARD ALL, получение содержимого «Входящих» и «Избранного»);
- Top SQL by I/O wait time — чтение с диска. Смотрим на пиковый %Total и на завышенную пропорцию W(s) к Executions. Если Executions низкий, а время I/O высокое — проверьте, не сервисный ли это запрос: публикация заказной разработки или инициализация модулей выполняются разово и на производительность не влияют.
Отдельная тонкость: запросы с разными параметрами попадают в разные строки отчета. Запрос на открытие списка сотрудников с фильтром по состоянию и без него — формально два разных queryid, но анализировать и оптимизировать их надо вместе, суммируя вызовы и время.
Связка «медленный запрос → бизнес-операция»
Самое ценное в этой конфигурации — возможность за 10 минут пройти путь от строчки в отчете до конкретной функциональности. Включите в config.yml Directum RX:
SQL_COMMENT_ENABLED: true
После этого каждый SQL-запрос содержит комментарий с именем сервиса-источника и ИД трассы (tr). Алгоритм разбора:
1. В отчете pg_profile открыть текст запроса по ссылке с queryid;
2. Из комментария в начале текста взять имя сервиса и ИД трассы;
3. В лог-файле этого сервиса найти запись по ИД трассы;
4. Определить сущности в записи. Сущности заказной разработки узнаются по виду имени: <Код компании>.<Имя решения>.<Сущность> — например, Company.MySolution.Override.IncomingLetter.
Если комментария нет, ориентируйтесь по префиксам таблиц:
| Префикс | Что это |
| sungero_system_* | данные ядра Sungero |
| sungero_core_* | администрирование: пользователи, сертификаты, подписи |
| sungero_content_*, sungero_wf_*, sungero_reports_* | предметные модули: документы, бизнес-процессы, отчёты |
| sungero_docflow_*, sungero_company_* | прикладные модули: документооборот, компания |
Это же определяет маршрутизацию проблемы: по стандартным сущностям — в поддержку вендора, по сущностям заказной разработки — их разработчику. Экономит недели пинг-понга.
Пороги алертов
Пороги — предмет калибровки под конкретную инсталляцию, но начинать удобно с этих:
| Метрика | Warning | Critical |
| Использование max_connections | 70% | 85% |
| Возраст самой долгой транзакции | 5 мин | 30 мин |
| Сессии в idle in transaction | 5 мин | 15 мин |
| Свободное место на томе pg_wal | 30% | 15% |
| Свободное место под данными | 25% | 10% |
| age(datfrozenxid) | 500 млн | 1 млрд |
| Лаг реплики | 60 с | 300 с |
| WAL, удерживаемый слотом | 32 ГБ | 64 ГБ |
| Ошибки archive_command подряд | 3 | 10 |
| Доля checkpoints_req от всех checkpoint | 30% | 50% |
| Рост temp_bytes | +50% к базовой линии | ×2 |
| Deadlocks | 1/час | 10/час |
| p95 sqlTimeMs из логов RX | ×1.5 к базовой линии | ×3 |
| Предупреждения Large Fetches | появление | рост тренда |
Пример правила для Prometheus/VictoriaMetrics:
- alert: PgLongRunningTransaction expr: pg_stat_activity_max_tx_duration{datname="rx"} > 1800 for: 2m labels: { severity: critical } annotations: summary: "Транзакция живет более 30 минут - блокируется autovacuum" - alert: PgConnectionsSaturation expr: sum(pg_stat_database_numbackends) / on() pg_settings_max_connections > 0.85 for: 5m labels: { severity: critical }
Часть метрик (удержание WAL слотами, bloat, возраст datfrozenxid) требует пользовательских запросов в конфиге postgres_exporter — из коробки их нет, это стоит заложить в работы по настройке.
Про SLA на запросы — без догматизма
Частый вопрос от заказчика еще до внедрения: «а как мы будем оценивать качество работы?» После внедрения он превращается в «а почему так медленно?» — обычно на основании одного случая, а не поведения системы в целом.
Здоровый подход выглядит так:
- Стандартные операции получают фиксированный порог. Открытие папки «Входящие» — до 3 секунд. На него влияют в основном скорость канала, качество железа и ограничения сети. Если не укладывается — есть что чинить.
- Заказная функциональность, которая по проекту собирает большой объем данных, фиксированного порога не получает. Требовать 3 секунды от запроса, который агрегирует данные из десятков таблиц, бессмысленно. Здесь оценивается динамика: замерили базовую линию после внедрения и следим за ней. Стабильное время — признак качественной разработки. Деградация со временем — сигнал к оптимизации. Это гораздо честнее и как инженерный критерий, и как пункт в SLA.
Что дальше
На этом заканчивается «настроечная» часть материала. Я разобрал, из чего может складываться нагрузка на СЭД, какие значения параметров имеют смысл для инсталляций разного масштаба, как настроить WAL и восстановление и что держать под наблюдением.
Во второй части опишу самое интересное: пять реальных отказов из практики (симптом – диагностика – корень – лечение – профилактика) и чек-лист из двух десятков пунктов, которые стоит проверить перед выводом кластера PostgreSQL в продакшен под enterprise-нагрузкой.
Сергей Дунаев, руководитель группы инженеров Directum, интегратор TANAiS



