Часть V: Высоконагруженные системы
Глава 20

Database Scaling — когда одной базы недостаточно

#Шардинг, репликация, partitioning — без страха и боли

Представьте: вы запустили стартап. Поначалу всё прекрасно — ваша PostgreSQL справляется с сотнями запросов в секунду, база данных умещается на одном сервере, бэкапы делаются за десять минут. Вы трогательно назвали сервер db-1 и даже не задумываетесь о масштабировании.

Проходит полгода. У вас уже десятки тысяч пользователей. Один из них пишет в твиттере: «Ваш сервис тормозит». Вы замечаете, что db-1 начал задыхаться — CPU стабильно под 80%, диски читают с бешеной скоростью, а некоторые запросы выполняются секундами.

Что вы делаете?

Если ваш первый инстинкт — «увеличить сервер», вы не одиноки. Vertical scaling — это соблазнительно: купить машину побольше, накинуть RAM, воткнуть NVMe. Это работает. До поры.

Но в какой-то момент вы упираетесь в потолок. Самый большой инстанс AWS — 24 ТБ RAM и 448 vCPU. Он стоит как квартира в Москве. И даже он не резиновый.

В этой главе мы разберём, что делать, когда одной базы данных перестаёт хватать. Мы пойдём от простого к сложному: от реплик для чтения до глобального шардирования. И да, мы не просто расскажем «как», но и «когда это вообще нужно».


#1. Read scaling: читать может кто угодно, писать — только один

Самое смешное в масштабировании баз данных — что в 80% случаев проблема именно в чтении. Пользователи читают посты, смотрят ленту, грузят профили. Запись при этом составляет ничтожный процент.

Логичное решение: read replicas (реплики для чтения).

#Как это работает

У вас есть один primary (мастер) — он принимает запись. И несколько read replicas (реплик) — они только читают. Данные реплицируются с мастера асинхронно или синхронно.

          ┌──────────┐
          │  Primary  │ ← запись (INSERT/UPDATE/DELETE)
          └────┬─────┘
               │
      ┌────────┼────────┐
      │        │        │
   ┌──▼───┐ ┌──▼───┐ ┌──▼───┐
   │ Rep1 │ │ Rep2 │ │ Rep3 │ ← только чтение (SELECT)
   └──────┘ └──────┘ └──────┘

#Connection pooling и query routing

Теперь вопрос: как наши приложения узнают, куда слать запрос?

Вот тут появляются специализированные инструменты-прокси:

Pgpool-II — старый, добрый, проверенный. Работает с PostgreSQL. Умеет:

  • Read/write splitting — SELECT на реплики, всё остальное на мастер
  • Connection pooling — экономит соединения
  • Load balancing — распределяет чтение по репликам
-- pgpool.conf
backend_hostname0 = 'primary-host'
backend_port0 = 5432
backend_weight0 = 0   -- мастер не участвует в read load balancing

backend_hostname1 = 'replica-1'
backend_port1 = 5432
backend_weight1 = 1

backend_hostname2 = 'replica-2'
backend_port2 = 5432
backend_weight2 = 1

ProxySQL — более современный, из мира MySQL. Тот же принцип:

-- mysql> INSERT INTO mysql_servers
INSERT INTO mysql_servers (hostgroup_id, hostname, port)
VALUES (0, 'primary', 3306),    -- hostgroup 0 = write
       (1, 'replica-1', 3306),  -- hostgroup 1 = read
       (1, 'replica-2', 3306);

Эти прокси работают на уровне протокола базы данных. Они прозрачны для приложения — вы просто подключаетесь к прокси, а он уже решает, куда направить запрос.

#CQRS — когда прокси недостаточно

Если логика чтения настолько сложна, что прокси не справляется, пора переходить к CQRS (Command Query Responsibility Segregation).

Идея: разделить модели данных для чтения и записи. У вас может быть нормализованная структура для записи и денормализованная, оптимизированная для чтения.

# Команда (запись) — просто сохраняем событие
async def create_order(command: CreateOrder):
    event = OrderCreated(
        user_id=command.user_id,
        items=command.items,
        total=command.total
    )
    await event_store.append(event)

# Запрос (чтение) — тянем из read model
async def get_order_summary(order_id: str) -> OrderSummary:
    return await read_db.fetch_one(
        "SELECT * FROM order_summaries WHERE id = ?",
        order_id
    )

Read model может агрегировать данные из разных источников, вычислять производные поля, хранить готовые view. Это жертвует консистентностью и свежестью данных, но выигрывает в скорости чтения.

#Materialized Views — когда данные не обязаны быть идеально свежими

Materialized view — это физическая таблица, которая хранит результат запроса и обновляется по расписанию или триггеру.

CREATE MATERIALIZED VIEW daily_sales_mv AS
SELECT
    DATE(created_at) as day,
    product_id,
    SUM(amount) as total_sales,
    COUNT(*) as order_count
FROM orders
GROUP BY DATE(created_at), product_id;

-- Обновляем раз в час
REFRESH MATERIALIZED VIEW CONCURRENTLY daily_sales_mv;

Без CONCURRENTLY материализованный view блокирует чтение на время обновления. С CONCURRENTLY — нужен уникальный индекс, но зато читать можно даже во время обновления.

#Компромиссы read scaling

Подход Плюсы Минусы
Read replicas Просто, прозрачно Replication lag, нет гибкости моделей
CQRS Полная гибкость, оптимизация под сценарии Сложность, eventual consistency
Materialized views Быстрый доступ к агрегатам Несвежесть данных, оверхед на обновление

Когда всё это нужно? Когда у вас больше 70-80% запросов — чтение. Если у вас наоборот — пишут много, а читают редко (логи, метрики) — read replicas вам не помогут.


#2. Write scaling: а теперь давайте писать

Read scaling мы починили. Но что, если расти продолжает и запись?

Допустим, у вас мессенджер. Каждую секунду приходит 10 000 сообщений. Все они должны записаться в messages таблицу. Одна машинка это физически не вывозит.

Здесь в игру вступает шардирование (sharding) — или, как его называют в PostgreSQL мире, partitioning (но с нюансами, о которых ниже).

#Что такое шард

Шард — это самостоятельная база данных, которая хранит часть данных. Все шарды вместе образуют логическую единую базу.

         ┌──────────────────────────┐
         │       Application        │
         └──────────┬───────────────┘
                    │
         ┌──────────┴───────────────┐
         │          Router          │ ← решает, на какой шард идти
         └──┬──────────┬──────────┬─┘
            │          │          │
        ┌───▼───┐  ┌───▼───┐  ┌───▼───┐
        │Shard 1│  │Shard 2│  │Shard N│
        │users  │  │users  │  │users  │
        │A-M    │  │N-Z    │  │...    │
        └───────┘  └───────┘  └───────┘

Ключевое отличие от репликации: каждый шард хранит свой уникальный кусок данных. Шард 1 не знает про данные шарда 2, и наоборот.

#Shard key — самый важный выбор в вашей жизни

Shard key — это правило, по которому мы определяем, на каком шарде хранить запись. От правильного выбора зависит всё.

Вариант 1: Range-based sharding

Данные делятся по диапазону значений.

-- Shard 1: user_id 1 - 1000000
-- Shard 2: user_id 1000001 - 2000000
-- Shard 3: user_id 2000001 - 3000000

Плюс: простой, можно добавлять шарды, перераспределяя диапазоны. Минус: риск hotspot — если все новые пользователи попадают в последний диапазон, последний шард получает всю нагрузку.

Вариант 2: Hash-based sharding

Берём хеш от shard key и берём остаток от деления на количество шардов.

def get_shard(user_id: str, total_shards: int) -> int:
    return hash(user_id) % total_shards

Плюс: равномерное распределение данных (если хеш-функция хорошая). Минус: при добавлении шарда почти все данные нужно перераспределять (re-sharding).

Вариант 3: Directory-based sharding

Держим отдельную таблицу-словарь, которая говорит, где что лежит.

lookup_table:
  user_42 → shard_3
  user_99 → shard_1
  user_101 → shard_2

Плюс: гибкость, можно перемещать данные между шардами без изменения логики хеша. Минус: дополнительный lookup, точка отказа, узкое место.

Вариант 4: Geographic sharding

Делим данные по региону пользователя.

  Europe → shard eu-west-1
  Asia   → shard ap-southeast-1
  US     → shard us-east-1

Плюс: низкая задержка для пользователей, соблюдение data residency (GDPR и прочее). Минус: неравномерное распределение (в одном регионе может быть больше пользователей).

#Как выбрать shard key

Универсального рецепта нет, но есть несколько правил:

  1. Shard key должен быть в каждом запросе. Если вы шардируете по user_id, то каждый запрос должен содержать user_id. Иначе вы не знаете, на какой шард слать запрос, и придётся слать на все (scatter-gather).

  2. Выбирайте ключ с высокой кардинальностью (cardinality). is_active (true/false) — ужасный shard key. user_id — хороший.

  3. Учитывайте паттерны доступа. Если большинство запросов идёт по org_id — шардируйте по org_id. Шарды должны быть «дружественными» к приложению.

  4. Готовьтесь к re-sharding заранее. Предусмотрите механизм перераспределения данных с самого начала, даже если сейчас кажется, что шардов хватит навсегда.

#Re-sharding — момент истины

Добавление нового шарда в хеш-схему — это боль. Допустим, у вас было 4 шарда, и вы добавляете 5-й:

  • hash(user_id) % 4 — больше не работает
  • Нужно hash(user_id) % 5
  • Это значит, что почти все данные нужно переместить

Решения:

Consistent Hashing — хитрый алгоритм, при котором добавление/удаление шарда затрагивает только 1/N данных (где N — количество шардов). Используется в Cassandra, Amazon DynamoDB, Discord.

# Упрощённая идея consistent hashing
# Вместо map(key) → shard, мы map(key) → position on ring
# И map(shard) → position on ring
# Ключ попадает на ближайший шард по часовой стрелке

Vitess VReplication — таблицы заменяются на виртуальные, за которыми стоит механизм непрерывной репликации между шардами. Re-sharding происходит без даунтайма.

PostgreSQL FDW (Foreign Data Wrappers) — можно использовать для постепенной миграции, но лучше не надо для продакшна.


#3. Vitess — шардинг на уровне прокси

Vitess — это система, изначально разработанная в YouTube (сейчас CNCF-проект). Она решает проблему шардирования MySQL на уровне прокси.

#Как это устроено

У вас есть:

  • VTGate — прокси-уровень. Приложение подключается к нему как к обычной MySQL-базе.
  • VTTablet — процесс, который управляет одним экземпляром MySQL.
  • Topology Service (etcd / ZK) — хранит метаданные: какие шарды существуют, где они, какой клюш за что отвечает.
   App → VTGate → VTTablet → MySQL
                → VTTablet → MySQL
                → VTTablet → MySQL

#Что даёт Vitess

Прозрачное шардирование. Приложение работает с Vitess как с единой базой MySQL. VTGate сам решает, на какой шард отправить запрос, на основе shard key.

-- Создаём таблицу с шардингом по user_id
CREATE TABLE messages (
    user_id BIGINT,
    message_id BIGINT,
    content TEXT,
    created_at TIMESTAMP
) VITESS_SHARD_BY = user_id;

-- Приложение просто делает:
SELECT * FROM messages WHERE user_id = 42;
-- VTGate сам идёт на нужный шард

-- А вот это scatter-gather:
SELECT COUNT(*) FROM messages WHERE created_at > NOW() - INTERVAL 1 DAY;
-- VTGate отправляет на ВСЕ шарды и агрегирует результат

Online DDL. В Vitess можно менять схему базы данных без даунтайма. Изменения применяются постепенно, через механизм VReplication — создаётся новая версия таблицы, данные реплицируются из старой, затем переключение. Всё прозрачно.

Re-sharding без даунтайма. Vitess умеет расщеплять шарды (split shard) без остановки сервиса. Это одна из главных фич.

# Split shard 0 into shard -80 and shard 80-
vtctl Reshard -- --source_shards 0 --target_shards -80,80 Create

Connection pooling. Vitess управляет пулом соединений к MySQL, что решает проблему «1000 приложений открывают 1000 соединений каждое».

#Когда использовать Vitess

  • У вас уже MySQL, и вы выросли из одной машины
  • Нужно шардирование без переписывания всего приложения
  • Нужна высокая доступность (failover, backup)

#Когда НЕ использовать Vitess

  • У вас PostgreSQL (Citus для вас)
  • У вас маленькая база (Vitess добавляет operational complexity)
  • Вам нужны сложные JOIN между разными шардами (Vitess поддерживает, но это дорого)

#4. Citus — распределённый PostgreSQL

Citus — это расширение PostgreSQL, которое превращает его в распределённую базу данных. В отличие от Vitess (который работает на уровне прокси), Citus встраивается прямо в PostgreSQL.

#Как это устроено

У вас есть coordinator node (координатор) и worker nodes (воркеры). Coordinator выглядит как обычный PostgreSQL — приложение подключается к нему стандартным клиентом.

-- Выбираем shard key
SELECT citus_set_coordinator_host('coordinator-host', 5432);

-- Создаём распределённую таблицу
CREATE TABLE events (
    event_id BIGSERIAL,
    user_id BIGINT,
    payload JSONB,
    created_at TIMESTAMPTZ
);

-- Говорим Citus шардировать по user_id
SELECT create_distributed_table('events', 'user_id');

После этого Citus автоматически распределяет данные по worker nodes. Coordinator знает метаданные — какой шард на каком воркере.

#Reference tables

Одна из сильных сторон Citus — reference tables. Это маленькие таблицы, которые реплицируются на все воркеры: справочники, статусы, категории.

-- Эта таблица будет на каждом воркере целиком
SELECT create_reference_table('product_categories');

Теперь JOIN распределённой таблицы с reference table работает локально на каждом воркере, без пересылки данных.

#Коллокация (Colocation)

Если две таблицы шардированы по одному ключу, Citus может хранить связанные строки на одном воркере. Это позволяет делать JOIN без пересылки данных между узлами.

-- Обе таблицы шардированы по tenant_id
SELECT create_distributed_table('orders', 'tenant_id');
SELECT create_distributed_table('payments', 'tenant_id');
-- Citus автоматически colocate их, если совпадают:
-- - количество шардов
-- - тип шардирования
-- - репликационный фактор

#Когда Citus побеждает

  • У вас PostgreSQL. Citus — это расширение, а не замена. Весь ваш код, драйверы, ORM продолжают работать.
  • Multi-tenant архитектура. Citus идеально подходит для SaaS, где каждый запрос содержит tenant_id. Это его killer feature.
  • Real-time analytics. Citus умеет распределённо выполнять агрегаты, и это быстро.

#Когда Citus проигрывает

  • Вам нужно перешардировать данные с изменением ключа (это сложно, почти как переезд)
  • Количество шардов фиксируется при создании таблицы (хотя можно менять)
  • Очень сложные транзакции, затрагивающие много шардов (two-phase commit — медленно)

#5. Hotspots — обнаружить и обезвредить

Горячая точка (hotspot) — это ситуация, когда один шард или одна партиция получает непропорционально большую долю нагрузки.

#Как выглядят hotspots

Метрика Норма Hotspot
CPU по шардам Все ~30% Один 95%, остальные 20%
Latency p99 10ms На одном шарде 500ms
Disk IOPS Равномерно Один шард упирается в лимит

#Типичные причины

1. Shard key выбран плохо

-- Если shard key = created_at, то последняя партиция всегда горячая
-- Все пишут в неё, никто не читает из старых

Диагностика: смотрим распределение записей и запросов по шардам.

SELECT
    shard_id,
    count(*) as records,
    pg_size_pretty(sum(pg_column_size(*))) as size
FROM citus_shards
GROUP BY shard_id
ORDER BY records DESC;

2. Один пользователь-гигант

У вас 1000 tenant-ов, и у одного из них 5 миллионов записей, а у остальных — по 10 тысяч. Весь его шард — hotspot.

3. Временные паттерны

Черная пятница. Новогодняя распродажа. Хакерская атака. Внезапный виральный пост.

#Лечение

Для range-based partitioning: увеличиваем количество партиций или меняем границу. Если шардирование по дате — переходим на комбинированный ключ: tenant_id + created_at.

Для хеш-шардирования: увеличиваем количество виртуальных шардов (vnodes). Cassandra и ScyllaDB используют этот подход — один физический шард содержит много виртуальных, их можно перемещать между нодами для балансировки.

Для одного большого пользователя: изолируем его в отдельный шард (dedicated shard). Да, это «привилегия» за то, что он платит больше всех.

def get_shard_for_tenant(tenant_id: str) -> int:
    # Если tenant в списке «гигантов» — выделенный шард
    if tenant_id in LARGE_TENANTS_SHARD_MAP:
        return LARGE_TENANTS_SHARD_MAP[tenant_id]
    # Для всех остальных — хеш
    return hash(tenant_id) % BASE_SHARD_COUNT

Ручное вмешательство: бывают ситуации, когда ничего умного не придумать, кроме как взять и переложить данные. Это нормально. Для этого и нужны операции, а не только код.

#Проактивная защита

Мониторинг — ваша единственная защита от hotspots, если вы не знаете о них заранее.

# Пример алерта для Prometheus + Grafana
groups:
  - name: shard_alerts
    rules:
      - alert: ShardHotspot
        expr: |
          stddev by (shard_id) (
            rate(mysql_global_status_questions{shard_id!=""}[5m])
          ) > 0.3
        for: 5m
        annotations:
          summary: "Hotspot detected on shard {{ $labels.shard_id }}"

#6. Multi-tenant vs Single-tenant

Когда мы говорим о шардировании в контексте SaaS, неизбежно встаёт вопрос: а как вообще организовывать данные разных клиентов (tenant-ов)?

#Single-tenant: база на каждого клиента

Tenant A → отдельная база (или даже отдельный инстанс)
Tenant B → отдельная база
Tenant C → отдельная база

Плюсы:

  • Полная изоляция: один tenant не может «зашуметь» соседа
  • Можно делать бэкапы и восстановление для каждого tenant-а независимо
  • Упрощённое compliance: для каждого tenant-а свой регион, свой encryption key
  • Простой шардинг (каждый tenant — это шард)

Минусы:

  • Operation overhead: тысячи баз нужно мониторить, обновлять, бэкапить
  • Сложно делать фичи, где нужны данные нескольких tenant-ов (аналитика «поверх»)
  • Дорого: каждая база резервирует ресурсы под свой пик (даже если обычно простаивает)

#Multi-tenant: все в одной базе

Одна база → Tenant A, Tenant B, Tenant C
             │         │         │
          rows с    rows с    rows с
          tenant_id  tenant_id  tenant_id

Плюсы:

  • Эффективное использование ресурсов (одна база, утилизация выше)
  • Проще деплоить и обновлять
  • Дешевле до определённого масштаба

Минусы:

  • Проблема «шумного соседа» (noisy neighbor) — один tenant может положить всю базу
  • Сложнее изолировать данные (нужно строго следить за WHERE tenant_id = ?)
  • Бэкапы — всё или ничего
  • Compliance сложнее (данные разных tenant-ов в одном физическом хранилище)

#Hybrid — разумный компромисс

Большинство зрелых SaaS-продуктов (Notion, Figma, Slack) используют гибридный подход:

  • 80% маленьких tenant-ов — в multi-tenant шардах
  • 20% крупных tenant-ов (enterprise) — в single-tenant инстансах
class TenantRouter:
    def get_database(self, tenant: Tenant) -> str:
        if tenant.plan == "enterprise":
            return f"db-{tenant.id}"  # Single-tenant
        shard = hash(tenant.id) % self.small_tenant_shards
        return f"shared-shard-{shard}"  # Multi-tenant

#Decision matrix

Фактор Single-tenant Multi-tenant
Количество tenant-ов < 50 энтерпрайзных > 1000
Размер данных на tenant > 500 GB < 50 GB
Требования к изоляции HIPAA, SOC2 Type 2 Стандартные
Цена для клиента Высокая Низкая
Операционная сложность Высокая Средняя
Эффективность ресурсов Низкая Высокая

#7. Global database: база на весь мир

Когда ваш сервис становится глобальным, возникает проблема: данные в США видны с задержкой 200-300ms для пользователя из Австралии. Что делать?

#Multi-region replication

Идея: данные реплицируются между регионами, чтение происходит из ближайшего.

┌───────────── US East ──────────┐
│  Primary (активная запись)      │
│  Replica 1 (чтение)             │
└──────────────┬──────────────────┘
               │ Асинхронная репликация
     ┌─────────┴─────────┐
     │                   │
┌────▼─────┐      ┌──────▼────┐
│ EU West  │      │ AP Southeast│
│ Replica  │      │ Replica    │
│ (чтение) │      │ (чтение)   │
└──────────┘      └───────────┘

Проблема: асинхронная репликация между регионами вносит replication lag. Если пользователь написал пост в Токио, а читает его из Лондона через реплику — он может его не увидеть (если реплика ещё не догнала primary).

#Решения для multi-region

Active-Passive: один регион пишет, остальные читают. Просто, но пишут все в один регион (далеко).

Active-Active (Multi-master): можно писать в любой регион. Данные реплицируются между регионами в обоих направлениях.

-- В каждом регионе: свой master
-- Репликация двунаправленная
-- Нужен механизм разрешения конфликтов

Active-Active — это сложно. Нужно решать конфликты: что, если один пользователь изменил запись в Европе, а другой — в Азии одновременно?

  • CRDT (Conflict-free Replicated Data Types) — математическая основа для автоматического разрешения конфликтов (используется в Riak, Redis CRDT)
  • Last-Writer-Wins (LWW) — просто кто последний записал, того и правда (часто с timestamp)
  • Application-level conflict resolution — отдаём конфликт на уровень приложения (как в Google Docs)

#Google Spanner подход

Google Spanner и его open-source аналоги (CockroachDB, YugabyteDB) используют TrueTime (аппаратные часы + GPS) для синхронизации времени между датацентрами. Это позволяет делать глобальные транзакции с сильной консистентностью (strong consistency).

Но большинству это не нужно. Spanner — это уровень Google, а не ваш.

#Когда задумываться о multi-region

  • Пользователи в Европе, Азии и США
  • Задержка из одного региона для другого > 200ms
  • Требования data residency (GDPR: данные европейцев должны быть в Европе)
  • DR (Disaster Recovery) — хотим пережить падение целого региона AWS

#8. Кейс: Как Notion масштабирует миллиарды записей

Источник: конференции и инженерные блоги Notion, [Anecdotal] структурировано по публичным выступлениям команды.

Notion вырос из маленького стартапа до продукта с десятками миллионов пользователей, которые создали миллиарды записей. Как они справлялись?

#Этап 1: Монолитная PostgreSQL

На заре Notion всё было просто: одна PostgreSQL-база, один сервер. Schema была гибридная — часть данных в реляционных таблицах, часть в JSONB-колонках. Это работает, когда пользователей тысячи.

Проблема: рост. Каждый месяц — удвоение данных. PostgreSQL справлялась, но:

  • Бэкапы стали занимать часы
  • Некоторые запросы — десятки секунд
  • VACUUM не успевал чистить мёртвые строки

#Этап 2: Read replicas + partitioning

Первым делом Notion добавил read replicas. Это сняло нагрузку с primary для чтения. Но запись оставалась на одной машине.

Дальше — PostgreSQL partitioning (на уровне таблиц, встроенными средствами). Notion стал партиционировать большие таблицы по времени. Например, page_events — по месяцам.

CREATE TABLE page_events (
    id BIGSERIAL,
    page_id UUID NOT NULL,
    event_type TEXT,
    created_at TIMESTAMPTZ NOT NULL
) PARTITION BY RANGE (created_at);

CREATE TABLE page_events_2025_01
    PARTITION OF page_events
    FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');

Это ускорило запросы, которые фильтровали по времени (а таких большинство). VACUUM тоже стал успевать — каждая партиция меньше.

#Этап 3: Шардирование — и это больно

Когда данных стало по-настоящему много, Notion пошёл на шардирование. Выбор пал на Citus (у них был PostgreSQL, и Citus — расширение, а не замена).

Ключ шардирования — workspace_id (или space_id в терминах Notion). Почему? Потому что пользователь Notion всегда работает в контексте рабочего пространства. Все запросы содержат workspace_id.

Проблема: один workspace-гигант. У Notion есть workspace-ы с тысячами участников и миллионами страниц. Если посадить их на один шард — будет hotspot.

Решение: изоляция крупных workspace-ов. Notion выделяет самые большие workspace-ы в отдельные шарды (dedicated shards). Остальные равномерно распределяются по хешу.

#Этап 4: Multi-tenant architecture

Notion — классический multi-tenant SaaS. Но их архитектура гибридная:

  • Большинство пользователей сидят в общих шардах (multi-tenant)
  • Enterprise-клиенты с требованиями к изоляции получают выделенные инстансы (single-tenant)
  • Workspace-гиганты — тоже выделенные шарды (хотя и в общем кластере)

#Уроки Notion

  1. Не шардируйте, пока не нужно. Notion продержался на одной PostgreSQL + partitioning очень долго. Шардирование — это сложность, которую лучше отложить.

  2. Выбирайте shard key по паттернам доступа, а не по структуре данных. workspace_id — не про данные, а про то, как к ним обращаются.

  3. Готовьтесь к outliers. Всегда будет один пользователь, который генерирует 10% трафика. Предусмотрите механизм изоляции.

  4. Partitioning и шардирование — разные вещи. Notion делает и то, и другое. Partitioning — внутри одного шарда (по времени), шардирование — между машинами.


#Не делай так: анти-паттерны масштабирования баз данных

  1. «Поставлю-ка я ProxySQL и забуду». ProxySQL не решит проблему медленных запросов. Если ваш SELECT выполняется 10 секунд — реплика не поможет. Нужно оптимизировать запрос.

  2. «Шардирование решит все проблемы». Нет, шардирование создаст новые проблемы: распределённые транзакции, cross-shard JOIN, re-sharding. Шардируйтесь, только когда другие методы исчерпаны.

  3. «Выберу shard key раз и навсегда». Менять shard key — это как перекладывать фундамент под стоящим домом. Выбирайте с запасом на будущее.

  4. «Поставлю Active-Active репликацию, это же просто». Active-Active — самый сложный паттерн из существующих. Конфликты, расходящиеся данные, потерянные обновления. Если вы не Google — не делайте Active-Active без острой необходимости.

  5. «База данных — не моя забота, вот AWS RDS всё сделает». RDS managed — не значит «бесконечный». Read replicas в RDS имеют лимиты. Multi-AZ — не multi-region. Вы всё ещё отвечаете за архитектуру.


#Чек-лист для самопроверки

  • Мы измерили текущую нагрузку на БД и понимаем, что именно bottleneck?
  • Мы попробовали все «бесплатные» оптимизации: индексы, VACUUM, медленные запросы?
  • Нам нужно масштабировать чтение или запись? (Это разные стратегии)
  • Если чтение: готова ли наша архитектура к eventual consistency (replication lag)?
  • Если шардирование: выбран ли shard key, который будет в каждом запросе?
  • Предусмотрен ли механизм re-sharding?
  • Есть ли мониторинг hotspots по шардам?
  • Multi-tenant или Single-tenant архитектура выбрана осознанно, а не «само так вышло»?
  • Для глобальных систем: продумана ли multi-region репликация и compliance?
  • Есть ли playbook для расщепления шарда (split shard) без даунтайма?

#Ключевые выводы

  1. 80% проблем с масштабированием БД — чтение. Read replicas + CQRS часто решают 90% проблем без шардирования.

  2. Шардирование — это крайняя мера, а не первый шаг. Сначала индексы, потом партиционирование, потом read replicas, и только потом шарды.

  3. Выбор shard key — самое важное архитектурное решение при шардировании. Он определяет, как приложение будет общаться с базой. Менять его дорого.

  4. Hotspots неизбежны. Не стройте иллюзий, что равномерное распределение будет вечным. Закладывайте механизмы изоляции и перебалансировки.

  5. Multi-tenant vs Single-tenant — не технический, а продуктовый выбор. Стоимость, сложность и изоляция данных балансируются в зависимости от того, кто ваши клиенты.

  6. Vitess и Citus — зрелые инструменты, но не серебряные пули. Каждый решает свою задачу: Vitess — шардинг MySQL на уровне прокси, Citus — распределённый PostgreSQL как расширение.

  7. Глобальная база — это не про технологии, а про компромиссы. Strong consistency vs availability vs latency — выбирайте два (на самом деле, одно, если вы не Spanner).