Налаштування шардування бази даних для веб-застосунку

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.

Розробка та обслуговування будь-яких видів сайтів:

Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Налаштування шардування бази даних для веб-застосунку
Складна
~1-2 тижні
Часті питання

Наші компетенції:

Етапи розробки

Останні роботи

  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

Настройка шардування бази даних для веб-додатків

Шардування потрібно, коли один сервер 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 днів.