Масштабирование PostgreSQL

1 октября 2023

PostgreSQL не масштабируется одной кнопкой - он упирается в три разных потолка: вычислительный, по записи и по числу соединений. Каждый потолок снимается своим набором решений, и большинство ошибок в проектировании БД - попытка решить проблему «не того» уровня: команда уходит в шардирование, когда у неё упёрлось число backend-процессов, или ставит реплики, когда тормозит конкретный неиндексированный запрос. Эта статья - не каталог механизмов, а карта, которая помогает понять, где именно у вас потолок и какое следующее решение реально передвинет узкое место, а какое только добавит операционной сложности.

Примеры YAML и SQL ниже - тестовые стенды на docker-compose, не production-конфигурации. Они нужны как иллюстрация к разговору, а не как готовый рецепт. Перед применением в проде - тестирование под реальной нагрузкой.

Где PostgreSQL упирается в потолок

Разговор о масштабировании СУБД часто начинается с перечисления техник: "у нас есть репликация, партиционирование, шардирование, пулинг". Это словарь, но не карта. Карта начинается с вопроса: какой именно ресурс заканчивается у вас прямо сейчас? PostgreSQL ломается по трём независимым осям, и техника, которая снимает один потолок, никак не помогает с двумя другими.

Потолок по соединениям. Каждый клиент PostgreSQL получает отдельный backend-процесс. Это решение архитектуры PostgreSQL, восходящее к ранним 1990-м, и оно не меняется в обозримом будущем. Каждый процесс - это десятки мегабайт RSS, контекст-свитч, доля shared_buffers. На современном железе сотни активных backend-ов уже создают заметный CPU overhead на context switching и lock contention; тысячи - почти всегда означают деградацию. Бенчмарк PgBouncer и многократные публикации Brandur Leach (детальные технические разборы Postgres-внутренностей со времён Heroku и Stripe, сейчас - Crunchy Data) дают одну и ту же цифру: при примерно 300-500 активных соединениях производительность начинает падать вне зависимости от железа, потому что узкое место - не CPU и не диск, а внутренние блокировки на ProcArrayLock.

Потолок по записи (write amplification). Любая запись в PostgreSQL - это запись в WAL, потом в shared_buffers, потом checkpoint выносит грязные страницы на диск, а MVCC создаёт новую версию строки и оставляет старую для autovacuum. На write-heavy нагрузке узкое место - не "сколько IOPS у диска", а "успевает ли autovacuum за порождением dead tuples и не превращается ли WAL в бутылочное горло на одной physical-replication ссылке". Андрес Фройнд (Andres Freund, core PostgreSQL committer) в серии докладов на PGCon последних лет подробно показывал, как именно WAL fsync и replay становятся первыми ограничителями на современном NVMe-железе: однопоточный walsender, однопоточный walreceiver, и одна реплика не может опередить мастер.

Потолок по объёму данных и по запросам к ним. Это уже другое: миллиардная таблица, на которой index-only scan занимает секунды, autovacuum висит часами, а pg_dump практически невозможен. Вертикальное железо здесь помогает плохо - дисковый объём растёт, но индексы не помещаются в RAM, а каждое обновление статистики занимает реальное время. Здесь работают партиционирование (если есть естественный ключ для разрезания) и шардирование (если данные физически перестают помещаться на одну машину).

Bruce Momjian, co-founder PostgreSQL Global Development Group и один из старейших активных committer-ов проекта (с 1996 года), в публичных презентациях формулирует это просто: "Vertical scaling solves most problems for most people; horizontal scaling solves a smaller set of problems for fewer people, at much higher cost". Эта позиция - не консерватизм, а реалистичная оценка того, во что обходится распределённая транзакция, отсутствие глобальных foreign key и руководство шардированным кластером в three a.m. инциденте. Перед каждой техникой имеет смысл сначала спросить: какой потолок я снимаю и точно ли он у меня - именно этот?

Прежде чем выбирать механизм масштабирования, посмотрите на конкретные метрики. pg_stat_activity покажет, сколько backend-ов реально активно. pg_stat_statements - какие запросы съедают время. pg_stat_bgwriter и pg_stat_wal - как идут чекпоинты и сколько генерится WAL. Без этих цифр любое решение про "переходить ли на шарды" - угадывание.

AKF Scale Cube Мартина Эбботта, Майкла Фишера и Тома Кивена ("The Art of Scalability", Addison-Wesley, 2-е издание, 2015) даёт удобную таксономию: ось X - дублирование (читай: реплики и пулы стейтлесс-приложений), ось Y - функциональная декомпозиция (микросервисы, выделение отдельных БД для отдельных bounded contexts), ось Z - шардирование по ключу (один и тот же тип данных, разделённый по tenant_id, region_id, hash). Большинство компаний живут на оси X неограниченно долго, проходят на ось Y по мере выделения сервисов, и только малая часть когда-либо доходит до оси Z. Эта пропорция важнее, чем любые технические детали отдельных решений.

Пулинг соединений как первый шаг

Если по симптомам похоже, что у вас "БД не справляется", и вы при этом ещё не поставили пулер, дальнейшие архитектурные решения почти всегда преждевременны. Пулинг соединений - не оптимизация и не "продвинутая техника"; это базовая гигиена, без которой PostgreSQL ведёт себя предсказуемо плохо в любом сценарии с десятками воркеров на стороне приложения.

Причина, как уже было выше - архитектура процессов. PostgreSQL форкает отдельный backend на каждое соединение, и эта операция стоит десятки миллисекунд плюс затраты по памяти на жизненном цикле. Современное Rails/Django/Node-приложение с Puma/Gunicorn/PM2 на десяти инстансах по 5 воркеров уже создаёт 50 потенциальных соединений; добавьте Sidekiq/Celery/BullMQ с пулом 25 воркеров на инстанс - и вы у 300 соединений из приложения, которое логически делает одну операцию в секунду. Без пулера каждый воркер держит свой backend, даже если 95% времени тот idle. С PgBouncer в transaction mode все 300 воркеров делят, скажем, 25 реальных backend-ов, и потолок ProcArrayLock сдвигается далеко за горизонт реальной нагрузки.

PgBouncer (pgbouncer.org/usage.html) поддерживает три режима пулинга, и выбор между ними - не вкусовщина, а функциональный компромисс. Session pooling выделяет backend на всю клиентскую сессию: безопасно для prepared statements и SET переменных, но бесполезно с точки зрения экономии соединений. Statement pooling возвращает соединение в пул после каждого statement: даёт максимальную плотность, но ломает почти всё, что использует транзакции. Transaction pooling - типичный выбор для веб-приложений (хотя дефолт самого PgBouncer - session): соединение закрепляется на время одной транзакции, что совместимо с обычным CRUD-кодом, но требует отказа от server-side prepared statements (или явной поддержки на стороне драйвера) и сессионных переменных.

Пример минимальной конфигурации PgBouncer (pgbouncer.ini) - не как production-рецепт, а как иллюстрация того, насколько маленьким может быть конфиг для большой выгоды:

[databases]
mydatabase = host=localhost port=5432 dbname=mydatabase

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
default_pool_size = 25
max_client_conn = 1000
min_pool_size = 5

В этом конфиге PgBouncer держит ровно 25 backend-соединений к PostgreSQL и обслуживает до 1000 клиентских соединений из приложения. Узкое место сдвигается с числа процессов в PostgreSQL на длительность транзакций: пока транзакции короткие, 25 соединений хватает на сотни RPS. Длинные транзакции (отчётный SELECT, который держит соединение секунды) сразу видны в SHOW POOLS - они забивают пул и приложение начинает ждать.

Альтернатива на тот же случай - PgPool-II с балансировкой между репликами и автоматическим failover. Настраивается сложнее, документация местами противоречива, и большинство команд ставит его не за пулинг, а за чтение-балансировку и failover. Для одного только пулинга PgBouncer почти всегда правильный выбор - он делает одну вещь и делает её предсказуемо.

Transaction pooling несовместим с server-side prepared statements в их классическом виде. Современные библиотеки (psycopg 3, pgx, recent versions of node-postgres) умеют согласовывать prepared statements с пулером через named statement reuse, но многие старые ORM держат prepared statement по handle, который может попасть на разный backend. Если после включения transaction mode вы видите ошибки "prepared statement does not exist" - это именно тот случай, и решается либо отключением prepared, либо переходом на драйвер с поддержкой PgBouncer.

Репликация для чтения: компромиссы консистентности и сложности

Если пулер стоит, а нагрузка по чтению продолжает упираться в один сервер, следующий шаг - реплики. Это второй после пулинга по применимости приём: его эксплуатирует, наверное, каждый продакшен PostgreSQL крупнее одного nano-инстанса. Реплика снимает с мастера часть SELECT-нагрузки, даёт hot standby для failover и страхует от полной потери данных. Цена - eventual consistency и операционная сложность маршрутизации запросов.

PostgreSQL поддерживает два вида репликации, и они решают разные задачи. Физическая (streaming) репликация копирует WAL-блоки побайтово: реплика - точная копия мастера на уровне страниц, без выбора, что реплицировать. Запись на реплику невозможна; чтение - сколько угодно, но с лагом, обычно миллисекунды на здоровой ссылке и до часов в инцидентах. Логическая репликация работает на уровне строк через decoding WAL: можно реплицировать отдельные таблицы, в обе стороны, между разными мажорными версиями PostgreSQL и даже в другие СУБД. Цена - отсутствие реплицирования DDL, ограничения по типам данных и значительно более тонкая работа в случае конфликтов.

Маршрутизация запросов - отдельная задача, которую PostgreSQL не решает. Приложение должно само понимать, какой запрос идёт на мастер, а какой может пойти на реплику. Rails/ActiveRecord 7.x предлагает connects_to с именами ролей writing/reading и автоматическое переключение в блоке after-write. Django имеет database routers. В большинстве микросервисных архитектур это решается на уровне DSN: два разных URL, два пула, явная аннотация на репозитории. Самая частая ошибка - читать после записи "из той же транзакции бизнес-логики" с реплики и получать чужой стейл; защита - sticky session на мастер на время после записи.

Для тестового стенда логической репликации достаточно двух контейнеров с PostgreSQL 16, которым выставлен wal_level = logical:

services:
  db_master:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: example
      POSTGRES_DB: mydatabase
    ports:
      - "5432:5432"
  db_replica:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: example
      POSTGRES_DB: mydatabase
    depends_on:
      - db_master

На мастере создаётся PUBLICATION, на реплике - SUBSCRIPTION, схема (DDL) предварительно повторяется на обеих сторонах вручную. С этого момента INSERT/UPDATE/DELETE на таблицах публикации применяются на реплике с задержкой в десятки миллисекунд. Запись на реплике технически возможна (логическая репликация её не блокирует), и это - известный источник аварий: если на реплике случайно поменяли строку, а потом мастер прислал свою версию, изменения с реплики молча перетираются. Поэтому для большинства "просто чтобы был read replica" сценариев лучше брать физическую репликацию: она read-only by design.

Серьёзная сложность с репликами - что они не дают линеаризуемости. Кайл Кингсбури (Aphyr) в Jepsen-тестах подробно показал, в каких именно сценариях PostgreSQL-репликация даёт стейл-чтения и lost updates: краткое содержание - default isolation Read Committed позволяет видеть на реплике строку, которая на мастере уже обновлена, и любое решение приложения "сначала прочитать с реплики, потом записать на мастер" уязвимо к race condition. Полные отчёты Jepsen по PostgreSQL и его деривативам (Aurora, YugabyteDB, CockroachDB) опубликованы на https://jepsen.io/analyses и обязательны к прочтению, если вы строите критичную для денег логику поверх реплик.

Дефолтный совет: физическая репликация - первый выбор, когда нужны read replicas. Логическая - когда нужно реплицировать подмножество таблиц, или между разными мажорными версиями (часто в апгрейдах с минимальным downtime), или в другую СУБД (BI, ETL). Для upgrade-strategy логическая репликация - подход, который Bruce Momjian рекомендует в публичных презентациях о near-zero-downtime upgrades; для read-scaling - почти всегда физическая.

Партиционирование против шардирования: дерево решений

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

Партиционирование - это разделение одной большой таблицы на несколько физических таблиц-партиций в пределах одной БД. Декларативное партиционирование PostgreSQL (с версии 10, существенно улучшено в 11-15) поддерживает три стратегии: RANGE (по диапазону, типичный кейс - даты), LIST (по явному списку значений), HASH (равномерное распределение по hash). Запросы к родительской таблице автоматически направляются в нужные партиции через partition pruning; добавление и удаление партиций не требует переписывания основной таблицы. Robert Haas, один из ключевых разработчиков declarative partitioning в PostgreSQL, неоднократно подчёркивал в докладах на PGCon и PgConf.EU, что партиционирование решает три конкретные задачи: сокращение времени запросов, которые обращаются только к "свежим" данным; быстрое удаление старых данных через DROP PARTITION вместо DELETE с массовым autovacuum; уменьшение размера индексов на каждой партиции.

Шардирование - это разделение данных между несколькими физическими БД, обычно несколькими физическими серверами. Каждый шард - полноценный PostgreSQL со своими процессами, своим WAL, своим autovacuum. Координатор (или приложение) знает, какой ключ к какому шарду относится, и направляет запросы. Шардирование снимает потолок по объёму и по записи, но добавляет три типа сложности, которых нет в одиночном PostgreSQL: распределённые транзакции (требуют 2PC и страдают на write-amplification), глобальные ограничения целостности (foreign key между шардами недоступны без надстроек), и rebalancing при добавлении/удалении нод.

Практическое дерево решений выглядит так. Если данные логически разделимы по диапазону или категории (новости по дате, события по tenant_id), помещаются в один сервер по объёму, но запросы стали медленными или vacuum не успевает - партиционирование. Если объём данных физически перестал помещаться на самый большой разумный сервер, или write-нагрузка упёрлась в WAL/checkpoint, и при этом данные легко режутся по ключу с минимумом cross-shard запросов - шардирование. Если ничего из этого, но "вроде же надо горизонтально масштабироваться" - почти наверняка нет, и пора смотреть на пулер, реплики, индексы и план запросов.

Простой пример range-партиционирования в PostgreSQL для иллюстрации синтаксиса (стилизованный пример на основе типичных паттернов news-приложения):

CREATE TABLE news (
    id serial,
    title VARCHAR(255),
    type VARCHAR(50),
    content TEXT,
    publish_date DATE,
    PRIMARY KEY (id, publish_date)
) PARTITION BY RANGE (publish_date);

CREATE TABLE news_2023 PARTITION OF news
  FOR VALUES FROM ('2023-01-01') TO ('2024-01-01');
CREATE TABLE news_2024 PARTITION OF news
  FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');
CREATE TABLE news_2025 PARTITION OF news
  FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');

Главное здесь не синтаксис, а ключ партиционирования: publish_date попадает в составной первичный ключ, потому что PostgreSQL требует, чтобы ключ партиционирования входил в primary key и unique constraints. Это техническая деталь, но из-за неё многие existing-схемы нельзя партиционировать без изменения модели данных. Markus Winand в "SQL Performance Explained" и на use-the-index-luke.com подробно разбирает, как такие изменения в ключе влияют на план запросов и почему "просто добавить партиционирование на работающую таблицу" - почти всегда трёхмесячный проект, а не квартальный спринт.

Шардирование в PostgreSQL без сторонних расширений требует ручной разметки: приложение само знает, что user_id 1...1000000 живёт на shard0, 1000001...2000000 на shard1, и так далее. Это даёт максимум контроля, но и максимум кода в инфраструктурном слое: маршрутизация, fan-out для cross-shard запросов, rebalancing при росте. Citus (citusdata.com/blog; с 2019 года часть Microsoft, сейчас известен как Azure Cosmos DB for PostgreSQL) автоматизирует большую часть этого: SELECT create_distributed_table('news', 'tenant_id') превращает обычную таблицу в распределённую, а координатор берёт на себя планирование запросов. Документация Citus и блог инженерной команды - один из лучших источников по тому, как именно ведёт себя распределённый PostgreSQL под нагрузкой и где у него тонкие места.

Антипаттерны шардирования: где это ломается

Шардирование - не "следующий уровень" после реплик; это качественно другая система с другими failure modes. Большинству команд оно не нужно никогда, и попытка ввести его преждевременно стоит больше, чем позднее. Перечислим явно, где именно решение ломается на практике.

Антипаттерн 1: шардирование вместо оптимизации запросов. Команда видит медленные запросы, видит высокую нагрузку на CPU мастера и решает, что "пора шардить". Через полгода у них шесть шардов, каждый из которых тормозит на тех же запросах, потому что причина была не в одном узле, а в неиндексированном WHERE LOWER(email) = ? или sequential scan по 50-миллионной таблице. Шардирование умножает все существующие неэффективности и добавляет к ним сетевую задержку. Markus Winand в "SQL Performance Explained" формулирует это явно: 80% случаев проблем с производительностью PostgreSQL решаются индексом, который должен был быть с самого начала. Шардирование - дорогой способ обойти отсутствующий индекс.

Антипаттерн 2: распределённые транзакции в OLTP. Citus и любая другая шардирующая надстройка решают проблему cross-shard транзакций через двухфазную фиксацию (2PC). 2PC технически работает, но добавляет round-trips и блокировки на всех участниках. Если бизнес-операция типично затрагивает 2-3 шарда (классический случай "пользователь A переводит деньги пользователю B, а они на разных шардах"), 2PC становится узким местом, и latency p99 растёт в разы. Документация Citus прямо рекомендует выбирать ключ так, чтобы 95%+ операций были single-shard; если такой ключ не находится, шардирование - неправильный инструмент. Хороший задокументированный пример того же класса проблем - Notion (см. ниже): их выбор пал на шардирование по workspace_id именно потому, что 99% запросов локальны для одного workspace; без такой природы данных шардирование умножает round-trips, а не снимает их.

Антипаттерн 3: шардирование без data locality. Хешированное распределение по случайному ключу (например, по hash(id)) равномерно нагружает все шарды, но любой запрос с фильтром по другому полю превращается в fan-out. Geospatial-данные, time-series, multi-tenant с большими тенантами - все требуют осознанного выбора ключа, который соответствует доступу. Notion в своём публичном посте о шардировании (https://www.notion.com/blog/sharding-postgres-at-notion, 2021) описал, как они выбрали workspace_id как ключ, потому что 99% запросов локальны для workspace - и это сразу определило, что шардирование вообще имеет смысл. Без такого свойства шардирование ничего не выигрывает.

Антипаттерн 4: шардирование "под рост", которого ещё нет. Команда читает блог Notion, делает выводы и шардирует базу с 200 GB данных и 500 RPS на запись. Через год у них всё та же база, но теперь с операционным накладным расходом на пять шардов: пять отдельных backup-цепочек, пять окон обслуживания, пять потенциально расходящихся в схеме инстансов, пять отдельных мониторингов. Notion подошли к шардированию, когда дисковый объём начал удваиваться каждые полгода и autovacuum перестал успевать - то есть когда у них уже были конкретные метрики, упирающиеся в потолок, а не план роста. GitLab в своей многолетней серии о database evolution (about.gitlab.com/blog) прошёл годы на одном PostgreSQL, прежде чем перейти к decomposition по доменам (CI, Registry и т.д.), и до сих пор основной БД остаётся одна, без шардирования.

Антипаттерн 5: rebalancing как retroactive проблема. Изначально шардов 4, нагрузка распределяется ровно. Через год добавили двух больших клиентов, и они оба попали на shard2; теперь shard2 в два раза горячее остальных. Rebalancing - перенос диапазона ключей с одного шарда на другой - всегда означает downtime или окно eventual consistency, и его нельзя сделать незаметным. В Citus есть shard_rebalancer, в собственной реализации это пишется руками. Команда, которая не подумала о rebalance в момент решения шардить, через два года имеет hot shards и многомесячный проект миграции - типичный путь, описанный в публичных постмортемах нескольких SaaS-компаний.

Антипаттерн 6: операционная сложность недооценивается на 3-5x. Один PostgreSQL - один backup, один pg_dump для аналитики, один pg_upgrade на следующую мажорную версию. Шесть шардов - шесть параллельных backup-окон, координированный pg_upgrade, схема, которая может разойтись между шардами, если кто-то накатил миграцию неправильно. Эту мысль регулярно повторяют core-разработчики PostgreSQL в публичных докладах: одиночный PostgreSQL - одна из самых простых в эксплуатации production-СУБД, шардированный PostgreSQL - одна из самых сложных в эксплуатации систем, какие вы можете собрать. Это не аргумент против шардирования - это аргумент за то, что выбор должен быть осознанным и не сделанным "на всякий случай".

Простой тест: если вы не можете назвать конкретное число (объём данных, write RPS, число активных backend-ов), которое сейчас упёрлось в один сервер, и не можете сказать, какой single-shard ключ покрывает 95%+ запросов - вы ещё не готовы шардировать. И почти наверняка вы скейлитесь не туда.

Реальные пути: как Notion, GitLab и Discord жили со своими потолками

Каждая из этих компаний прошла собственный путь масштабирования и публично его описала. Истории разные, но паттерн один: реальные команды доходят до шардирования или альтернативной СУБД годами после первого "нам надо горизонтально масштабироваться", и решение всегда привязано к конкретной модели данных, а не к абстрактному размеру нагрузки.

Notion. В 2021 опубликовали "Herding elephants: lessons learned from sharding Postgres at Notion" (notion.com/blog). К моменту начала шардирования у них была одна Postgres-база, дисковый объём которой удваивался каждые 6-12 месяцев, существенный write-amplification на блоках (Notion-документ хранится как дерево блоков, и одно редактирование может породить сотни UPDATE-ов), и autovacuum, который перестал успевать. Решение - шардирование по workspace_id на 480 логических шардов поверх 32 физических баз (стандартный паттерн: логических шардов кратно больше, чем физических, чтобы потом перебалансировать без изменения ключа). Ключевое слово - workspace_id: Notion-документ всегда живёт в одном workspace, 99% запросов локальны workspace, и поэтому шардирование оказалось работоспособным. Без такого свойства шардирование не было бы решением.

GitLab. Многолетняя публичная серия в инженерном блоге (https://about.gitlab.com/blog/) показывает другой паттерн: вместо горизонтального шардирования - функциональная декомпозиция (ось Y по Эбботту-Фишеру). GitLab выделил CI/CD-данные в отдельную физическую БД, потом контейнерный реестр, потом отдельные домены ещё. Основная БД остаётся одной (с pgBouncer, реплики, large multi-TB volumes), но cross-domain-нагрузка ушла. Это позволило прожить намного дольше без необходимости шардирования и сохранить операционную простоту - каждый из доменных PostgreSQL отдельно проще, чем один шардированный. Урок: горизонтальное шардирование одной таблицы - не единственный способ снять нагрузку, иногда декомпозиция доменов проще и эффективнее.

Discord. История с двумя главами и важным уточнением: для message storage Discord никогда не использовал PostgreSQL, и пример полезен именно как контраст. В 2017 опубликовали "How Discord stores billions of messages" - переход с MongoDB на Cassandra для сообщений, потому что MongoDB переставал держать read-heavy нагрузку на канал и hot partitions. В 2023 опубликовали "How Discord stores trillions of messages" (discord.com/blog) - переход с Cassandra на ScyllaDB из-за GC pauses и hot partitions уже на Cassandra. PostgreSQL у Discord продолжает работать там, где модель данных подходит: метаданные пользователей, серверов, каналов, аутентификация. Урок не «PostgreSQL плох для сообщений», а более общий: выбор хранилища зависит не от размера, а от формы доступа. Если 95% запросов - чтение последних N записей из одного «канала», который пишется тысячами клиентов одновременно, ни Postgres, ни Mongo архитектурно не оптимизированы; wide-column СУБД с time-window-партиционированием подходит лучше. Эту границу полезно увидеть до того, как вы начали шардировать «не туда».

Стилизованный пример на основе типичных паттернов. Типичная команда B2B SaaS в 2024-2025 проходит примерно такой путь. Старт - один Postgres на 8 vCPU / 32 GB RAM, один инстанс приложения, прямые соединения. На пороге 50-100 RPS появляется PgBouncer в transaction mode и сразу решает проблему "почему у нас 800 backend-ов на 50 RPS". На пороге 500 RPS добавляется physical read replica, и аналитические запросы уезжают на неё; основной мастер свободнеет. На пороге 5000 RPS железо мастера апгрейдится до 32 vCPU / 128 GB, добавляется вторая read replica, начинается работа над "топ-20 медленных запросов" по pg_stat_statements - это даёт ещё 2-3x запаса. На объёме 1-2 ТБ начинается партиционирование самых больших таблиц (события, аудит, логи) по дате с автоматическим DROP старых партиций. И только если всё это перестаёт хватать (что для большинства SaaS-продуктов не наступает никогда), начинается серьёзный разговор о шардировании или о выделении части данных в специализированное хранилище.

Рамка действий: от симптома к решению

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

Симптом Что обычно происходит Следующий шаг
"too many connections", сотни активных backend-ов Каждый воркер приложения держит свой backend, ProcArrayLock contention PgBouncer в transaction mode
Один SELECT тормозит, остальное - нормально Отсутствует или неэффективен индекс, либо план запроса плохой EXPLAIN ANALYZE, индекс, переписать запрос (Markus Winand)
Чтение в целом нагружает мастер, аналитика тяжёлая Read-heavy профиль с одной точкой обработки Physical read replica + маршрутизация в приложении
Vacuum не успевает, dead tuples растут Большая горячая таблица, autovacuum со стандартными настройками Тюнинг autovacuum, возможно партиционирование таблицы
WAL генерится быстрее, чем реплика успевает применить Write-amplification, single-threaded walreceiver на реплике Уменьшить write rate (батчинг), возможно отдельная Aurora-style СУБД
Объём одной таблицы > 1 ТБ, индексы не в RAM Доступ к "горячим" данным размывается по всему индексу Range partitioning по естественному ключу (обычно дата)
Объём БД физически не помещается на максимальный сервер Реальный потолок vertical scaling Шардирование - если есть single-shard ключ; иначе декомпозиция
"Нам надо как Notion / Discord / Stripe" Преждевременная оптимизация, копирование чужого финального состояния Вернуться к измерению реальных метрик

Сводный путь, который проходит большинство успешных команд: оптимизация запросов и индексов → connection pooling → vertical scaling → physical replication для чтения → partitioning больших таблиц → функциональная декомпозиция доменов. Шардирование не входит в этот путь как обязательный шаг и для большинства продуктов не наступает никогда. Если ваше архитектурное решение опирается на "когда-нибудь мы зашардим" как страховку - стоит честно проверить, какой ключ это будет, какие cross-shard операции у вас в коде сегодня и сколько лет реальной нагрузки нужно для оправданности этого шага.

Bruce Momjian в одном из своих публичных докладов формулировал общий принцип так: "PostgreSQL is a tool, not a religion. Use it where it fits, replace it where it doesn't, and don't pretend the operational cost of distributed PostgreSQL is zero." Это полезная рамка не только для шардирования: для любого решения о масштабировании имеет смысл явно посчитать стоимость операционной сложности (на год, на пять лет), сравнить с реальным выигрышем по метрикам и принять решение с открытыми глазами.

Рекомендация: прежде чем менять архитектуру, потратьте неделю на измерение. pg_stat_statements почти всегда показывает 5-10 запросов, которые съедают 80% CPU; добавление индекса или переписывание - один день работы и месяцы запаса по нагрузке. Если после этого вы всё ещё упираетесь, делайте следующий шаг - но уже зная, какой именно потолок вы снимаете.

Источники и смежные статьи