Настройка шардування бази даних для веб-додатків
Шардування потрібно, коли один сервер PostgreSQL не справляється з обсягом даних або write-навантаженням. Це не перший крок оптимізації — до нього мають бути вичерпані індекси, партиціонування, реплікація та кешування. Але коли таблиці ростуть до сотень мільйонів рядків, а конкурентних записів тисячі в секунду — шардування стає необхідністю.
Партиціонування vs шардування
Партиціонування — розбивка однієї таблиці на фізичні частини всередині одного екземпляра PostgreSQL. Шардування — розподіл даних по кількох незалежних серверах.
Партиціонування простіше та часто достатньо. Починаємо з нього:
-- Range партиціонування за датою (логи, eventos)
CREATE TABLE events (
id BIGSERIAL,
user_id BIGINT NOT NULL,
event_type VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
data JSONB
) PARTITION BY RANGE (created_at);
CREATE TABLE events_2024_q1 PARTITION OF events
FOR VALUES FROM ('2024-01-01') TO ('2024-04-01');
CREATE TABLE events_2024_q2 PARTITION OF events
FOR VALUES FROM ('2024-04-01') TO ('2024-07-01');
-- Hash партиціонування для рівномірного розподілу
CREATE TABLE user_sessions (
id BIGSERIAL,
user_id BIGINT NOT NULL,
token VARCHAR(255) NOT NULL,
data JSONB
) PARTITION BY HASH (user_id);
CREATE TABLE user_sessions_0 PARTITION OF user_sessions
FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE user_sessions_1 PARTITION OF user_sessions
FOR VALUES WITH (MODULUS 4, REMAINDER 1);
-- тощо до REMAINDER 3
Шардування через Citus
Citus — розширення PostgreSQL, що перетворює його в розподілену БД. Це найменш болісний шлях до шардування для PostgreSQL-проектів.
# Docker Compose для локального тестування
docker run -e POSTGRES_PASSWORD=pass -p 5432:5432 citusdata/citus:12.1
-- Додаємо воркери
SELECT citus_add_node('worker1', 5432);
SELECT citus_add_node('worker2', 5432);
-- Створюємо розподілену таблицю
CREATE TABLE orders (
id BIGSERIAL,
tenant_id INT NOT NULL,
user_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL,
total DECIMAL(12,2),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (id, tenant_id) -- partition key повинен бути в PK
);
SELECT create_distributed_table('orders', 'tenant_id', shard_count => 32);
-- Таблиця для colocation (JOIN по tenant_id буде локальним)
CREATE TABLE order_items (
id BIGSERIAL,
tenant_id INT NOT NULL,
order_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
quantity INT NOT NULL,
PRIMARY KEY (id, tenant_id)
);
SELECT create_distributed_table('order_items', 'tenant_id',
colocate_with => 'orders');
-- Reference table: реплицюється на все воркери
CREATE TABLE categories (id BIGSERIAL PRIMARY KEY, name VARCHAR(200));
SELECT create_reference_table('categories');
Після цього запити з фільтром по tenant_id маршрутизуються на конкретний шард. JOIN між orders та order_items по tenant_id виконується локально на воркері.
Шардування на рівні додатку
Коли немає Citus або потрібен повний контроль — реалізуємо шардування в додатку.
Вибір shard key — головне архітектурне рішення. Хороші shard keys:
-
user_id— для user-centric додатків -
tenant_id— для multi-tenant SaaS -
region— для географічно розподілених даних
Погані shard keys:
-
created_at— hot spot на останньому шарді -
status— нерівномірний розподіл - UUID v4 — немає локальності, поганий cache hit
Consistent hashing:
# sharding/router.py
import hashlib
from dataclasses import dataclass
from typing import Any
@dataclass
class ShardConfig:
host: str
port: int
database: str
SHARDS: dict[int, ShardConfig] = {
0: ShardConfig('db-shard-0', 5432, 'myapp_0'),
1: ShardConfig('db-shard-1', 5432, 'myapp_1'),
2: ShardConfig('db-shard-2', 5432, 'myapp_2'),
3: ShardConfig('db-shard-3', 5432, 'myapp_3'),
}
SHARD_COUNT = len(SHARDS)
def get_shard_id(shard_key: Any) -> int:
"""Детерміністичне визначення шарду за ключем."""
key_bytes = str(shard_key).encode('utf-8')
hash_value = int(hashlib.md5(key_bytes).hexdigest(), 16)
return hash_value % SHARD_COUNT
def get_shard_config(shard_key: Any) -> ShardConfig:
return SHARDS[get_shard_id(shard_key)]
Підключення до шардів:
from contextlib import contextmanager
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from functools import lru_cache
@lru_cache(maxsize=None)
def _get_engine(shard_id: int):
cfg = SHARDS[shard_id]
dsn = f"postgresql+psycopg2://user:pass@{cfg.host}:{cfg.port}/{cfg.database}"
return create_engine(dsn, pool_size=5, max_overflow=10)
@contextmanager
def get_shard_session(shard_key):
shard_id = get_shard_id(shard_key)
Session = sessionmaker(bind=_get_engine(shard_id))
session = Session()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
# Використання:
with get_shard_session(user_id=12345) as session:
orders = session.query(Order).filter_by(user_id=12345).all()
Cross-shard запити
Запити через кілька шардів — найскладніше місце. Два підходи:
Scatter-gather — паралельний запит до всіх шардів, слиття на рівні додатку:
import asyncio
import asyncpg
async def get_all_orders_by_status(status: str) -> list[dict]:
"""Scatter-gather по всіх шардах."""
async def query_shard(shard_id: int) -> list[dict]:
cfg = SHARDS[shard_id]
conn = await asyncpg.connect(
host=cfg.host, database=cfg.database,
user='app', password='pass'
)
rows = await conn.fetch(
"SELECT * FROM orders WHERE status = $1 ORDER BY created_at DESC LIMIT 100",
status
)
await conn.close()
return [dict(r) for r in rows]
results = await asyncio.gather(*[
query_shard(i) for i in range(SHARD_COUNT)
])
# Merge + sort
all_orders = [o for shard_result in results for o in shard_result]
all_orders.sort(key=lambda x: x['created_at'], reverse=True)
return all_orders[:100]
Global index — окрема таблиця маппінгу в виділеній БД:
-- У окремій "routing" базі даних
CREATE TABLE order_shard_map (
order_id BIGINT PRIMARY KEY,
shard_id INT NOT NULL,
user_id BIGINT NOT NULL
);
CREATE INDEX ON order_shard_map (user_id);
При створенні замовлення — записуємо маппінг. При пошуку по order_id — спочатку знаходимо шард, потім запитуємо конкретний.
Решардування
Додавання нового шарду — болюча операція без Citus. Consistent hashing з віртуальними вузлами (vnodes) мінімізує переміщення даних:
Замість hash(key) % N
Використовуємо: знайти найближчий vnode на кільці з 150 віртуальних вузлів
При додаванні шарду: переміщується ~1/N даних, не 1-(1/N)
Citus вирішує це автоматично через citus_rebalance_start().
Графіки
Настройка партиціонування PostgreSQL для існуючої таблиці: 1–2 дні. Установка та настройка Citus для нового проекту: 2–3 дні. Реалізація application-level шардування зі scatter-gather та global index: 3–5 днів.







