Партиционировать и шардировать — два разных решения, которые путают, потому что оба «про разделение данных». Стоимость ошибки в выборе ключа — кварталы работы команды. В статье три блока: как считать размер БД, когда применять партиционирование, когда — шардирование. Примеры — на тех же двух таблицах, что и в статье про ACID: category (3 строки) и product (7 строк, в реальности подразумеваем миллионы).
Как считать размер БД
PostgreSQL даёт несколько системных функций — каждая отвечает на свой вопрос.
-- размер всей базы вместе с системными каталогами
SELECT pg_size_pretty(pg_database_size('shop'));
-- → 42 GB
-- размер таблицы вместе с TOAST, индексами и FSM
SELECT pg_size_pretty(pg_total_relation_size('product'));
-- → 12 GB
-- только heap-страницы таблицы (без индексов)
SELECT pg_size_pretty(pg_relation_size('product'));
-- → 7 GB
-- суммарный размер всех индексов таблицы
SELECT pg_size_pretty(pg_indexes_size('product'));
-- → 5 GB
«Что съело место» — типичный запрос для разбора:
SELECT
schemaname,
relname,
pg_size_pretty(pg_total_relation_size(schemaname || '.' || relname)) AS total,
pg_size_pretty(pg_relation_size(schemaname || '.' || relname)) AS table,
pg_size_pretty(pg_indexes_size(schemaname || '.' || relname)) AS indexes,
n_live_tup AS live_rows,
n_dead_tup AS dead_rows
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(schemaname || '.' || relname) DESC
LIMIT 20;
Чем смотреть:
totalсильно большеtable+indexes— много toast-данных (длинные тексты, jsonb). Проверить TOAST-таблицу:pg_total_relation_sizeминусpg_relation_sizeминусpg_indexes_size.dead_rowsсравним сlive_rows— bloat: VACUUM не успевает чистить. Проверитьpg_stat_user_tables.last_autovacuum, поднятьautovacuum_vacuum_scale_factor.indexesбольшеtable— на таблице слишком много индексов или есть огромный индекс на широкое поле. Проверить черезpg_stat_user_indexes, какие индексы реально используются (idx_scan).
Прогноз роста
Грубая оценка размера таблицы:
size ≈ rows × (avg_row_size + 24 bytes header + ~8 bytes line pointer)
+ indexes (для нашего product с PK + FK + datetime — около 50% от size таблицы)
+ 20–30% запаса на bloat и FSM
Для нашей product со средней строкой ~200 байт и 100 млн записей:
100_000_000 × (200 + 32) = ~23 GB heap
+ ~12 GB на индексы
+ ~7 GB запаса
≈ 42 GB
Когда прогноз показывает, что одна таблица уйдёт за 50–100 GB, пора задумываться о партиционировании. Когда вся база уходит за пределы одного физического сервера (RAM, диск, IOPS) — примерно 500 GB активных данных или >10k TPS на запись — обсуждаем шардирование.
Партиционирование
Одна логическая таблица — несколько физических. Запросы по-прежнему пишутся к «родительской» таблице (product), но физически данные лежат в партициях (product_2024_q1, product_2024_q2 и так далее). PostgreSQL сам выбирает нужную партицию по условию запроса — это называется partition pruning.
Это всё ещё одна база на одном сервере. Партиционирование решает проблемы внутри инстанса:
- огромная таблица не влезает в индекс / кэш страниц;
- VACUUM на ней идёт сутками;
- удаление старых данных по
DELETEнагружает диск (аDROP PARTITIONмгновенный); - запросы по диапазону читают всю таблицу, хотя нужны 5%.
Три типа партиционирования
RANGE — по диапазону. Чаще всего — по дате.
CREATE TABLE product (
id BIGSERIAL,
category_id BIGINT REFERENCES category(id),
price NUMERIC(10, 2) NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (id, created_at) -- partition key ОБЯЗАН быть в PK
) PARTITION BY RANGE (created_at);
CREATE TABLE product_2026_q1 PARTITION OF product
FOR VALUES FROM ('2026-01-01') TO ('2026-04-01');
CREATE TABLE product_2026_q2 PARTITION OF product
FOR VALUES FROM ('2026-04-01') TO ('2026-07-01');
Запрос SELECT * FROM product WHERE created_at >= '2026-04-01' прочитает только product_2026_q2 — остальные партиции даже не открываются.
LIST — по дискретному набору значений. Подходит для нашей product, если хотим разрезать по category_id:
CREATE TABLE product (
id BIGSERIAL,
category_id BIGINT NOT NULL,
price NUMERIC(10, 2) NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (id, category_id)
) PARTITION BY LIST (category_id);
CREATE TABLE product_sweets PARTITION OF product FOR VALUES IN (1); -- Сладости
CREATE TABLE product_meat PARTITION OF product FOR VALUES IN (2); -- Мясо
CREATE TABLE product_dairy PARTITION OF product FOR VALUES IN (3); -- Молочные
CREATE TABLE product_other PARTITION OF product DEFAULT; -- всё прочее
Запрос WHERE category_id = 1 читает только product_sweets. Минус: новые категории требуют CREATE TABLE ... PARTITION OF руками или скриптом.
HASH — по хешу значения. Применяется, когда нужна равномерная раскладка и нет естественной шкалы:
CREATE TABLE product (
id BIGSERIAL,
category_id BIGINT,
price NUMERIC(10, 2) NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (id)
) PARTITION BY HASH (id);
CREATE TABLE product_p0 PARTITION OF product FOR VALUES WITH (MODULUS 8, REMAINDER 0);
CREATE TABLE product_p1 PARTITION OF product FOR VALUES WITH (MODULUS 8, REMAINDER 1);
-- ... product_p7
Запрос WHERE id = 12345 читает только нужную партицию. Запрос без фильтра по id читает все — для таких запросов HASH-партиционирование пользы не даёт.
Как выбирать partition key
Три критерия — выполнение всех трёх обязательно:
- Ключ присутствует в подавляющем большинстве запросов к таблице — иначе planner не сможет применить partition pruning и каждый запрос будет ходить по всем партициям.
- Распределение значений равномерное — иначе одна партиция будет в 1000 раз больше остальных и весь смысл теряется. Для нашей
product, если 95% товаров в категории «Сладости», LIST-партиционирование поcategory_idбесполезно. - Ключ почти не меняется — UPDATE, который меняет partition key, физически перемещает строку из одной партиции в другую (с PostgreSQL 11). На горячих апдейтах это дорого.
Антипример из нашего домена: партиционировать product по category_id, когда товары часто перемещаются между категориями маркетингом. Будет работать, но каждый такой UPDATE — DELETE + INSERT в системе.
И ещё ограничение, которое всегда удивляет: partition key обязан входить в первичный ключ и в каждый UNIQUE constraint. Поэтому PRIMARY KEY (id, created_at), а не просто PRIMARY KEY (id) — id сам по себе уже не может гарантировать уникальность через партиции.
Плюсы и минусы партиционирования
Плюсы:
- Маленькие индексы на партицию — быстрее, лучше влезают в
shared_buffers. DROP PARTITIONудаляет старые данные мгновенно (вместо часовогоDELETE FROM ... WHERE created_at < ...).- VACUUM идёт на партиции, а не на всей таблице — короче, легче.
- Partition pruning делает запросы по ключу драматически быстрее.
- Прозрачно для приложения — приложение видит одну таблицу
product, физика скрыта.
Минусы:
- Запросы без фильтра по partition key — медленнее, чем на нераздельной таблице (планировщик ходит по всем партициям).
- Управление партициями — нужна автоматизация (создавать новые партиции каждый месяц, удалять старые). В индустрии —
pg_partman. - Уникальные индексы по полям, не содержащим partition key, невозможны — это бьёт по моделям, где
nameилиslugглобально уникальны. - Сложнее с миграциями —
ALTER TABLEна родительской таблице иногда требует ребилда всех партиций.
Шардирование
Шардирование — это горизонтальное масштабирование на уровне нескольких физических серверов. Данные разносятся по разным инстансам PostgreSQL, каждый видит только свою долю. Приложение (или прокси перед ним) знает, на какой шард идти.
Это разговор про другой класс задач, чем партиционирование. Партиционирование помогает, когда одна машина ещё справляется, но таблица велика. Шардирование — когда одна машина уже не справляется: ни RAM, ни диск, ни IOPS не масштабируются вертикально достаточно.
Стратегии шардирования
По хешу — shard = hash(id) % N. Самая простая, даёт равномерную нагрузку. Минус: при добавлении узла приходится переносить огромную долю данных (решается через consistent hashing или диапазоны с поддержкой перебалансировки).
По диапазону — shard = lookup(id). product с id от 0 до 10M идёт на шард A, от 10M до 20M — на шард B. Плюс: легко добавлять узлы. Минус: горячий шард — последний диапазон, куда идут все новые INSERT'ы.
По арендатору (tenant) — shard = lookup(tenant_id). Один маркетплейс = один шард, у крупного клиента — собственный сервер. Подходит для SaaS. Удобно изолировать клиентов, но требует таблицы маршрутизации.
По географии — shard = lookup(region). Европа, Азия, США — на свои серверы для соблюдения регуляторики и снижения латентности.
Как выбирать shard key
Те же три критерия, что и для partition key, плюс ещё пара специфичных для шардирования:
- Ключ есть в большинстве запросов — иначе каждый запрос идёт веером по всем шардам (scatter-gather), агрегация в приложении, ужасная латентность.
- Распределение равномерное — иначе один shard перегружен, остальные простаивают.
- Ключ не меняется — UPDATE shard key = физический перенос строки между серверами с распределённой транзакцией. На практике — обычно запрещают и заставляют DELETE + INSERT с новым
id. - JOIN-ы внутри одного шарда — связанные данные должны лежать вместе. Если
categoryшардируется поcategory_id, аproduct— поcategory_id, JOIN по этому полю остаётся локальным внутри шарда. - Транзакции внутри одного шарда — распределённые транзакции через 2PC дороги, медленны и плохо переживают сетевые сбои. Лучше избегать через дизайн.
Антипример: шардировать product по id, а category оставить нешардированной. Любой запрос с JOIN к category идёт на все шарды + lookup в шардированной category. Лучше: шардируем обе таблицы по category_id — тогда category и все её product всегда на одном инстансе.
Плюсы и минусы шардирования
Плюсы:
- Горизонтальное масштабирование без потолка — добавлением серверов наращиваем RAM, диск, IOPS, CPU.
- Изоляция нагрузки — нагруженный клиент или регион не топит остальных.
- Можно держать данные географически рядом с пользователем.
Минусы:
- Распределённые транзакции — медленные и хрупкие. Большинство шардированных систем их избегают (одна транзакция = один шард).
- Cross-shard JOIN — почти всегда плохой запах архитектуры. Денормализация, copy data, async sync — отдельная боль.
- Глобальная уникальность —
idчерезBIGSERIALуже не работает (счётчики разъехались). Нужны UUID, snowflake-id или внешний sequencer. - Перебалансировка — при добавлении узла надо переносить данные. Долго, дорого, требует окна обслуживания или специальной инфраструктуры (Vitess, Citus).
- Приложение должно знать про шарды — либо через прокси (PgBouncer + кастомная логика, PgCat, Citus coordinator), либо через библиотеку на стороне приложения.
Партиционирование как путь к шардированию
Один полезный приём: сначала партиционируем по тому ключу, по которому потом хотим шардировать. Партиции живут в одной БД, всё работает. Когда упираемся в потолок одного сервера — переносим часть партиций на отдельные инстансы (например, через postgres_fdw или Citus). Архитектура приложения остаётся той же, меняется только физическое размещение партиций.
Чем partition key отличается от shard key
Оба ключа определяют, куда физически попадёт строка, и оба требуют от приложения, чтобы ключ присутствовал в большинстве запросов. Технически — это два разных механизма с разной стоимостью:
| Partition key | Shard key | |
|---|---|---|
| Где находятся данные | Разные таблицы в одной БД | Разные БД на разных серверах |
| Изменение значения | UPDATE перемещает строку между партициями в одной БД (дорого, но возможно) | Практически невозможно: распределённый перенос между серверами |
| JOIN по другому полю | Прозрачный, planner просто читает все нужные партиции | Cross-shard JOIN — отдельная инфраструктура (FDW, Citus) |
| Транзакция через несколько ключей | Обычная локальная транзакция | Распределённая транзакция (2PC) или дизайн без неё |
| Глобальная уникальность по другому полю | Только если поле входит в partition key | Невозможна стандартным UNIQUE, нужны UUID / snowflake |
Практический вывод: если есть выбор, делайте partition key и shard key одним и тем же полем. Тогда переход от партиционирования к шардированию — это «перенос партиций на другие серверы», без переписывания запросов и инвариантов.
Партиционирование vs шардирование
| Критерий | Партиционирование | Шардирование |
|---|---|---|
| Что разрезаем | Одну таблицу внутри одной БД | Данные между несколькими серверами |
| Когда применять | Таблица > 50–100 GB, тормоз индексов, VACUUM не успевает | Не хватает одного физического инстанса |
| Прозрачность для приложения | Полная (одна логическая таблица) | Требует поддержку маршрутизации |
| Распределённые транзакции | Не нужны (всё в одной БД) | Дорогие, избегают через дизайн |
| Cross-segment JOIN | Дешёвый | Дорогой / запрещённый |
| Изменение ключа | Дорого, но возможно | Практически невозможно |
| Управляемость | pg_partman или скрипты | Citus, Vitess, ручная инфраструктура |
| Стоимость ошибки в ключе | Можно перепартиционировать | Требует resharding всей системы |
Прагматичное правило: сначала партиционируй, шардируй когда упёрся в железо. Партиционирование — почти бесплатное по сравнению с шардированием, и снимает 80% проблем больших таблиц до того, как они становятся проблемами производительности.
Что почитать дальше
- PostgreSQL: Table Partitioning — официальная документация.
pg_partman— расширение для автоматического создания и удаления партиций.- Citus — шардирование PostgreSQL «из коробки», теперь часть Microsoft.
- ACID и уровни изоляции в PostgreSQL — почему шардирование ломает Serializable между шардами.
- PG Style Guide — правила работы с PostgreSQL в Java/Spring-сервисах.
- Distributed Patterns Style Guide — saga, idempotency, outbox: как жить без распределённых транзакций.