В предыдущих уроках мы партиционировали таблицу внутри одного сервера — для управления размером, ускорения VACUUM и удаления старых данных через DROP. Но что если один сервер перестал справляться с нагрузкой? Если CPU держит запросы 100%, диск близок к лимиту, репликация буксует — это сигнал, что данных физически слишком много для одной машины. Нужен scale-out: разнести данные по нескольким серверам.
Постгрес из коробки не умеет настоящий sharding — нет single point of truth, который координирует запросы между нодами. Но есть обходной путь: declarative partitioning + postgres_fdw, где каждая partition — это foreign table, ссылающаяся на отдельный физический сервер. Этот урок про то, как это работает, какие у этого подхода честные границы, и почему в продакшене обычно выбирают Citus.
Foreign Data Wrapper: основы
foreign table, который выглядит как обычная таблица, но при SELECT Postgres отправляет запрос на целевой источник, получает строки и вставляет их в план.
postgres_fdw — самый распространённый: целевой источник — другой PostgreSQL-сервер.
Координирующий сервер (coordinator) хранит схему и роутит запросы; данные лежат на data nodes. SELECT прозрачен для приложения.
Главные понятия:
- Foreign server — описание удалённого сервера: host, port, dbname.
- User mapping — соответствие локального пользователя удалённому (логин/пароль).
- Foreign table — таблица-«заглушка», представляющая удалённый объект.
Синтаксис: как выглядит установка
Pglite в браузере не поддерживает postgres_fdw — для FDW нужно настоящее network-соединение к другому Postgres, чего в WASM нет. Поэтому в этом уроке мы покажем синтаксис и логику, но без <SqlSandbox> для запуска. Изучай и применяй на реальной БД.
Подготовка одного FDW:
-- 1. На coordinator: установить extension
CREATE EXTENSION postgres_fdw;
-- 2. Описать удалённый сервер
CREATE SERVER pgsrv_eu
FOREIGN DATA WRAPPER postgres_fdw
OPTIONS (host 'pg-eu.internal', port '5432', dbname 'orders_db');
-- 3. Прописать кто как туда ходит
CREATE USER MAPPING FOR app_user
SERVER pgsrv_eu
OPTIONS (user 'app_user', password 'secret');
-- 4. Создать foreign table
CREATE FOREIGN TABLE orders_eu (
id BIGINT,
customer_id INT,
region TEXT,
placed_at TIMESTAMPTZ,
total_cents INT
)
SERVER pgsrv_eu
OPTIONS (schema_name 'public', table_name 'orders');
После этого SELECT * FROM orders_eu WHERE customer_id = 42 на coordinator превращается в SELECT ... WHERE customer_id = 42 на pgsrv-eu и возвращает только подходящие строки. Это удобно само по себе — у тебя cross-server queries. Но интересное начинается, когда мы поверх этого построим partitioned table.
Sharding через partitioning + FDW
Идея: создаём partitioned table на coordinator, а partitions — это foreign tables, каждая на своём data node. INSERT в parent роутится в нужную foreign partition по partition key.
Симуляция sharding локально (без FDW): partitioned orders по LIST (region) с 3 partition, каждая на отдельной locally-эмулированной таблице. В реальном setup partitions будут foreign tables на 3 серверах.
В продакшене вместо CREATE TABLE orders_eu PARTITION OF orders FOR VALUES IN (...) ты пишешь:
CREATE FOREIGN TABLE orders_eu PARTITION OF orders
FOR VALUES IN ('DE', 'FR', 'IT', 'NL')
SERVER pgsrv_eu
OPTIONS (schema_name 'public', table_name 'orders_eu_data');
Это работает: pglite не позволяет проверить, но синтаксис стандартен с Postgres 11+. SELECT с фильтром по region pushdown’ится в нужный shard и читает только его. Pruning работает между foreign partitions точно так же, как между локальными.
Что pushdown работает, а что нет
Поведение postgres_fdw в плане pushdown’а — это самая болезненная часть. Идеальный план — когда вся «тяжёлая работа» (filter, join, aggregate) делается на data node, а coordinator только склеивает результат. На практике pushdown срабатывает не всегда.
WHERE с константами и операторами над колонками — pushdown. Join, aggregate, ORDER BY, LIMIT — частично. Функции и cast — почти никогда.
EXPLAIN (VERBOSE) покажет точный SQL, который Postgres отправит на data node — это главный инструмент дебага FDW. Если ты видишь, что вместо WHERE customer_id = 42 отправляется SELECT * FROM orders и фильтрация делается локально — это убитая производительность.
Включить агрессивный pushdown для конкретного сервера:
ALTER SERVER pgsrv_eu OPTIONS (
use_remote_estimate 'true', -- ходить за статистикой на remote
fetch_size '10000', -- сколько строк тянуть за один RTT
async_capable 'true' -- PG14+: parallel fetch с разных shards
);
Cross-shard JOIN: главная проблема
Идеальный случай sharding — когда каждый запрос трогает только один shard. На LIST по region запрос «дай все заказы из EU» — это single shard query, идёт на pgsrv-eu и возвращает результат за нормальное время.
Проблема — cross-shard queries. Запрос «дай топ-10 customers по обороту за год» нельзя выполнить на одном shard: пользователи могут быть из разных регионов. Coordinator должен либо собрать данные со всех shards и сделать aggregation у себя, либо как-то распределить вычисление.
postgres_fdw + partitioning делает это неуклюже: он скачает партиции на coordinator и сделает локальный JOIN. Если данных много — медленно и память забивается.
Когда уже Citus?
- Распределяет JOIN и aggregate по shards параллельно (не тянет всё на coordinator).
- Поддерживает reference tables — таблицы, реплицированные на каждый shard (lookup-таблицы, словари).
- Делает distributed transactions через 2PC между shards.
- Имеет coordinator с per-tenant routing.
Чек-лист: если у тебя
-
1 TB данных, не помещающихся в один сервер,
- большая часть запросов укладывается в один shard по естественному ключу (tenant_id, customer_id),
- редкие cross-shard аналитические запросы — приемлемо медленнее,
→ Citus. Если у тебя простая регионально-разнесённая система с одним типом cross-shard запроса (e.g. админский dashboard на coordinator), и больше нужно изолировать данные по регулятору, чем масштабироваться — postgres_fdw + partitioning норм.
Альтернативы:
- YugabyteDB — Postgres-совместимая distributed база с шардированием на storage layer; не extension, а отдельный fork.
- CockroachDB — wire-compatible с Postgres, но переписан с нуля, distributed by design.
- Vitess — для MySQL, не релевантно, но как пример sharding-слоя.
Honest tradeoffs
Чтобы было трезво:
Кажется, что sharding решает всё. На практике он переносит проблему: даёт горизонтальный scale в обмен на сложность всех cross-shard операций.
Правило большого пальца: пока влезаешь в одну машину — не шардируй. Современный сервер с 1 TB NVMe и 256 GB RAM выдерживает базы в десятки TB и сотни тысяч TPS. Гораздо чаще проблема не в железе, а в плохих запросах, отсутствии индексов или дрянной схеме. Sharding — это последний шаг, к которому идут после всех остальных оптимизаций.
Чек-лист
- postgres_fdw + declarative partitioning даёт «poor man’s sharding» — partitions живут как foreign tables на разных серверах.
- Pushdown работает для простых WHERE и aggregate; функции, cast и cross-shard JOIN — нет. Проверяй через
EXPLAIN (VERBOSE). - Single-shard queries работают отлично; cross-shard тянут данные на coordinator и медленные.
- Citus — реальная distributed база поверх Postgres, с pushdown JOIN, reference tables, 2PC и rebalancer.
- Не шарди раньше времени: один современный сервер выдерживает десятки TB. Сначала индексы, planning, schema.
- Алгоритм решения: regulatory + region-isolated → FDW; high-scale OLTP + analytics → Citus; распределённый ACID → YugabyteDB/CockroachDB.