Шардирование PostgreSQL в 2026 году: горизонтальное масштабирование без Citus и облачных решений
Введение: когда вертикальное масштабирование перестаёт справляться
PostgreSQL — один из самых надёжных и функциональных движков реляционных баз данных. Но даже он упирается в потолок при росте нагрузки. Вертикальное масштабирование (добавление CPU, RAM, NVMe) работает до определённого предела: примерно 32–64 ядра и несколько терабайт данных. Дальше начинаются проблемы: рост latency на write-запросах, перегрев WAL, неприемлемое время VACUUM, деградация индексов.
В 2026 году типичная высоконагруженная система — это микросервисная архитектура с десятками миллионов событий в сутки, мультитенантные SaaS-платформы и IoT-коллекторы данных. Горизонтальное масштабирование через шардирование становится не опцией, а необходимостью. При этом Citus требует лицензирования и специфической операционной модели, а managed-решения (Aurora, AlloyDB, Neon) создают вендорную зависимость и увеличивают стоимость. Статья посвящена self-hosted шардированию PostgreSQL силами собственной инфраструктуры.
Основные стратегии шардирования
Hash Sharding
Данные распределяются по шардам на основе хэша ключа. Например, shard_id = hash(user_id) % N. Это обеспечивает равномерное распределение записей и отсутствие hotspot-шардов при случайном распределении ключей.
Плюсы: равномерная нагрузка, простая логика маршрутизации, хорошо масштабируется при добавлении шардов с решардингом.
Минусы: range-запросы по ключу шардирования неэффективны — придётся обходить все шарды. Resharding при изменении числа шардов требует миграции данных.
Сценарий применения: user-oriented workloads, где запросы всегда содержат user_id, и range-запросы по этому полю не нужны.
Range Sharding
Каждый шард отвечает за диапазон значений: например, user_id от 1 до 1 000 000 — шард 1, от 1 000 001 до 2 000 000 — шард 2 и т.д. Или шардирование по дате: события за январь — шард 1, за февраль — шард 2.
Плюсы: эффективные range-запросы, возможность точечной маршрутизации, удобно для time-series данных.
Минусы: риск hotspot — новые записи всегда попадают в последний шард. Требует перебалансировки при неравномерном росте.
Сценарий применения: аналитические таблицы событий с запросами по временным диапазонам, архивирование старых данных путём отключения шардов.
Directory-Based Sharding
Отдельная таблица метаданных хранит маппинг: какой tenant или entity размещён на каком шарде. Маршрутизатор обращается к этой таблице перед каждым запросом.
Плюсы: максимальная гибкость, возможность ручного перемещения tenants между шардами, изоляция данных крупных клиентов.
Минусы: дополнительный lookup на каждый запрос (решается кэшированием), единая точка отказа для shard map (решается репликацией).
Сценарий применения: мультитенантные SaaS, где нужна изоляция данных и возможность переноса клиента на выделенный шард.
Реализация шардирования на уровне приложения
Application-level sharding — самый распространённый подход в production-системах без Citus. Логика маршрутизации живёт в приложении или в отдельном proxy-слое. Ключевые компоненты:
- Shard Map — структура данных (хэш-таблица, конфиг-файл или отдельная БД), которая отображает ключ шардирования на connection string конкретного PostgreSQL-узла.
- Router — компонент, принимающий запрос и возвращающий нужный connection pool.
- Metadata Store — хранилище схемы шардирования, версии миграций и состояния шардов.
Пример shard map на Go (псевдокод):
type ShardMap struct {
Shards []ShardNode
TotalShards int
}
type ShardNode struct {
ID int
DSN string
Pool *pgxpool.Pool
}
func (sm *ShardMap) GetShard(userID int64) *ShardNode {
idx := int(userID) % sm.TotalShards
return &sm.Shards[idx]
}
Для directory-based sharding shard map хранится в отдельном PostgreSQL-инстансе с репликой. Кэш маппинга обновляется через Redis pub/sub при изменении схемы.
Важно версионировать схему шардирования: если вы меняете число шардов или алгоритм хэширования, старые данные должны быть перемещены, иначе маршрутизация сломается. Используйте отдельную таблицу shard_schema_version с историей изменений.
Foreign Data Wrappers и postgres_fdw для федеративных запросов
Когда необходимо выполнять запросы, затрагивающие несколько шардов, можно использовать postgres_fdw — стандартное расширение PostgreSQL для обращения к удалённым PostgreSQL-серверам. Это позволяет создать «координатор» — один PostgreSQL-узел, через который выполняются cross-shard запросы.
-- На координаторе: подключаем шарды как foreign servers
CREATE EXTENSION postgres_fdw;
CREATE SERVER shard1
FOREIGN DATA WRAPPER postgres_fdw
OPTIONS (host 'shard1.internal', port '5432', dbname 'events_db');
CREATE SERVER shard2
FOREIGN DATA WRAPPER postgres_fdw
OPTIONS (host 'shard2.internal', port '5432', dbname 'events_db');
CREATE USER MAPPING FOR app_user
SERVER shard1
OPTIONS (user 'app_user', password 'secret');
CREATE USER MAPPING FOR app_user
SERVER shard2
OPTIONS (user 'app_user', password 'secret');
-- Создаём foreign tables
CREATE FOREIGN TABLE events_shard1 (
id BIGINT,
user_id BIGINT,
event_type TEXT,
payload JSONB,
created_at TIMESTAMPTZ
) SERVER shard1
OPTIONS (table_name 'events');
CREATE FOREIGN TABLE events_shard2 (
id BIGINT,
user_id BIGINT,
event_type TEXT,
payload JSONB,
created_at TIMESTAMPTZ
) SERVER shard2
OPTIONS (table_name 'events');
-- Объединяем через VIEW
CREATE VIEW events_all AS
SELECT * FROM events_shard1
UNION ALL
SELECT * FROM events_shard2;
Теперь запрос SELECT * FROM events_all WHERE user_id = 42 будет выполнен на координаторе с pushdown предикатов к обоим шардам. PostgreSQL достаточно умён, чтобы передать условие фильтрации на удалённые серверы, минимизируя трафик.
Ограничения postgres_fdw: агрегации и сортировки с LIMIT выполняются локально на координаторе после получения данных с шардов. При больших объёмах данных это создаёт нагрузку на координатор. Для тяжёлой аналитики лучше использовать параллельный запуск запросов на каждом шарде в приложении и merge-сортировку результатов.
Управление транзакциями и согласованностью данных
Главная боль шардирования — distributed transactions. PostgreSQL поддерживает двухфазные транзакции (2PC) через PREPARE TRANSACTION / COMMIT PREPARED, но это сложный в операционном плане механизм: зависшие prepared transactions блокируют vacuum и занимают слоты.
-- Координатор начинает распределённую транзакцию
BEGIN;
-- На шарде 1
INSERT INTO events (user_id, event_type, created_at)
VALUES (42, 'purchase', now());
PREPARE TRANSACTION 'txn_20260101_001';
-- На шарде 2
INSERT INTO user_stats (user_id, total_purchases)
VALUES (42, 1)
ON CONFLICT (user_id)
DO UPDATE SET total_purchases = user_stats.total_purchases + 1;
PREPARE TRANSACTION 'txn_20260101_001';
-- Если оба шарда ответили OK:
COMMIT PREPARED 'txn_20260101_001'; -- на обоих шардах
На практике в микросервисных системах 2PC используют редко. Вместо этого применяют паттерн Saga: последовательность локальных транзакций, каждая из которых публикует событие для следующего шага. При сбое выполняются компенсирующие транзакции.
Пример Saga для операции перевода средств между шардами:
- Шард A: списать средства, статус операции —
PENDING, опубликовать событиеdebit_completed. - Шард B: зачислить средства, статус —
COMPLETED, опубликовать событиеcredit_completed. - Шард A: обновить статус операции на
SUCCESS. - При сбое на шаге 2: опубликовать
credit_failed, Шард A компенсирует — возвращает средства, статус —ROLLED_BACK.
Для eventual consistency между шардами используйте outbox-паттерн: транзакционная запись в локальную таблицу outbox_events и отдельный процесс доставки событий. Это гарантирует at-least-once доставку без координирующего менеджера транзакций.
Практический пример: шардирование таблицы событий по user_id
Рассмотрим реальный сценарий: таблица events с 500 млн записей, рост 5 млн в сутки. Шардируем по user_id с использованием hash sharding на 4 шарда.
Схема на каждом шарде:
-- Выполняется на каждом из 4 PostgreSQL-серверов
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
event_type VARCHAR(64) NOT NULL,
payload JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_events_user_id ON events (user_id);
CREATE INDEX idx_events_created_at ON events (created_at DESC);
CREATE INDEX idx_events_type_user ON events (event_type, user_id);
-- Партиционирование внутри шарда по месяцам (range по created_at)
CREATE TABLE events_2026_01
PARTITION OF events
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE events_2026_02
PARTITION OF events
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
Логика маршрутизации в приложении:
-- Определяем шард для user_id = 123456
-- shard_index = 123456 % 4 = 0 → shard_0
-- Запрос на нужном шарде:
SELECT id, event_type, payload, created_at
FROM events
WHERE user_id = 123456
AND created_at >= now() - INTERVAL '30 days'
ORDER BY created_at DESC
LIMIT 50;
Cross-shard агрегация через FDW-координатор:
-- Статистика по типам событий за последние 7 дней
-- Выполняется на координаторе, предикаты pushdown на все шарды
SELECT
event_type,
count(*) AS total,
count(DISTINCT user_id) AS unique_users
FROM events_all
WHERE created_at >= now() - INTERVAL '7 days'
GROUP BY event_type
ORDER BY total DESC;
Для получения данных пользователя 123456 приложение обращается напрямую к шарду 0 — без координатора, с минимальной latency. Для аналитических отчётов используется координатор с FDW, нагрузка от которого изолирована от OLTP-трафика.
Мониторинг и observability шардированного кластера
Мониторинг шардированного PostgreSQL сложнее монолитного: нужно агрегировать метрики со всех узлов и коррелировать их. Ключевые метрики и инструменты:
- pg_stat_statements на каждом шарде — топ медленных запросов, нормализованные по параметрам. Собирайте через Prometheus + postgres_exporter.
- pg_stat_replication — lag репликации на каждом шарде. Критично для read replicas: при lag > 5 секунд read-реплика исключается из ротации.
- Shard balance метрики — размер таблиц и количество строк на каждом шарде. Отклонение > 20% от среднего — сигнал к решардингу.
- Cross-shard query latency — время выполнения запросов через FDW-координатор. Трассируйте через OpenTelemetry с аннотацией shard_id.
- Connection pool utilization — PgBouncer на каждом шарде. Мониторинг через
SHOW POOLSиSHOW STATS.
-- Полезный запрос для мониторинга баланса шардов
-- Выполняйте на координаторе через FDW
SELECT
'shard1' AS shard,
count(*) AS row_count,
pg_size_pretty(pg_total_relation_size('events')) AS table_size
FROM events_shard1
UNION ALL
SELECT
'shard2',
count(*),
pg_size_pretty(pg_total_relation_size('events'))
FROM events_shard2;
Для distributed tracing используйте application_name в connection string с указанием shard_id и request_id. Это позволяет коррелировать логи PostgreSQL с трейсами приложения в Jaeger или Tempo.
Подводные камни и типичные ошибки
- Выбор неправильного ключа шардирования. Ключ должен присутствовать в большинстве запросов. Шардирование по
created_atдля событий выглядит логично, но если запросы всегда поuser_id— вы будете фанаутить каждый запрос на все шарды. - Отсутствие решардинга в дизайне. Hardcode числа шардов в коде — путь к болезненной миграции. Закладывайте resharding с самого начала: consistent hashing или виртуальные шарды (1024 виртуальных → 4 физических).
- Global sequences.
BIGSERIALна каждом шарде генерирует независимые последовательности — возможны коллизииidпри cross-shard JOIN. Используйте UUIDv7 или Snowflake ID с встроенным shard_id. - Cross-shard JOIN в горячем пути. JOIN между таблицами на разных шардах — это сетевой roundtrip плюс merge на координаторе. Денормализуйте данные или используйте co-location: связанные данные одного пользователя всегда на одном шарде.
- Игнорирование VACUUM на шардах. При высокой write-нагрузке на каждом шарде нужен агрессивный autovacuum. Настройте
autovacuum_vacuum_cost_delay = 2msиautovacuum_max_workers = 6на каждом узле независимо. - Нет circuit breaker для шардов. Если один шард деградирует, без circuit breaker вы получите каскадный сбой. Используйте паттерн circuit breaker в router-слое с fallback на read-реплику.
Заключение: как выбрать стратегию
Шардирование PostgreSQL без Citus и облачных managed-сервисов — это реально и оправданно при правильном проектировании. Выбор стратегии зависит от паттернов доступа к данным:
- Hash sharding — если все запросы содержат один ключ (user_id, tenant_id) и range-запросы по этому ключу не нужны. Подходит для большинства OLTP-систем.
- Range sharding — если данные имеют временну́ю природу и регулярно архивируются. Удобно для time-series и log-storage.
- Directory-based sharding — для мультитенантных SaaS с требованием изоляции данных и возможностью миграции клиентов между шардами.
Начинайте с application-level sharding и минимальным числом шардов (4–8). Используйте postgres_fdw для аналитических cross-shard запросов, изолируя их от OLTP-нагрузки. Закладывайте resharding в архитектуру с первого дня. Применяйте паттерн Saga вместо 2PC для распределённых операций. И обязательно инвестируйте в observability — без метрик со всех шардов вы будете работать вслепую.
Горизонтальное масштабирование PostgreSQL — это не магия и не серебряная пуля. Это инженерный компромисс между сложностью системы и её масштабируемостью. Принимайте его осознанно.
Технологии
Теги
Руслан Исмаилов
Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →