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 = 1ProxySQL — более современный, из мира 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
Универсального рецепта нет, но есть несколько правил:
-
Shard key должен быть в каждом запросе. Если вы шардируете по
user_id, то каждый запрос должен содержатьuser_id. Иначе вы не знаете, на какой шард слать запрос, и придётся слать на все (scatter-gather). -
Выбирайте ключ с высокой кардинальностью (cardinality).
is_active(true/false) — ужасный shard key.user_id— хороший. -
Учитывайте паттерны доступа. Если большинство запросов идёт по
org_id— шардируйте поorg_id. Шарды должны быть «дружественными» к приложению. -
Готовьтесь к 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 CreateConnection 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
-
Не шардируйте, пока не нужно. Notion продержался на одной PostgreSQL + partitioning очень долго. Шардирование — это сложность, которую лучше отложить.
-
Выбирайте shard key по паттернам доступа, а не по структуре данных.
workspace_id— не про данные, а про то, как к ним обращаются. -
Готовьтесь к outliers. Всегда будет один пользователь, который генерирует 10% трафика. Предусмотрите механизм изоляции.
-
Partitioning и шардирование — разные вещи. Notion делает и то, и другое. Partitioning — внутри одного шарда (по времени), шардирование — между машинами.
#Не делай так: анти-паттерны масштабирования баз данных
-
«Поставлю-ка я ProxySQL и забуду». ProxySQL не решит проблему медленных запросов. Если ваш SELECT выполняется 10 секунд — реплика не поможет. Нужно оптимизировать запрос.
-
«Шардирование решит все проблемы». Нет, шардирование создаст новые проблемы: распределённые транзакции, cross-shard JOIN, re-sharding. Шардируйтесь, только когда другие методы исчерпаны.
-
«Выберу shard key раз и навсегда». Менять shard key — это как перекладывать фундамент под стоящим домом. Выбирайте с запасом на будущее.
-
«Поставлю Active-Active репликацию, это же просто». Active-Active — самый сложный паттерн из существующих. Конфликты, расходящиеся данные, потерянные обновления. Если вы не Google — не делайте Active-Active без острой необходимости.
-
«База данных — не моя забота, вот 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) без даунтайма?
#Ключевые выводы
-
80% проблем с масштабированием БД — чтение. Read replicas + CQRS часто решают 90% проблем без шардирования.
-
Шардирование — это крайняя мера, а не первый шаг. Сначала индексы, потом партиционирование, потом read replicas, и только потом шарды.
-
Выбор shard key — самое важное архитектурное решение при шардировании. Он определяет, как приложение будет общаться с базой. Менять его дорого.
-
Hotspots неизбежны. Не стройте иллюзий, что равномерное распределение будет вечным. Закладывайте механизмы изоляции и перебалансировки.
-
Multi-tenant vs Single-tenant — не технический, а продуктовый выбор. Стоимость, сложность и изоляция данных балансируются в зависимости от того, кто ваши клиенты.
-
Vitess и Citus — зрелые инструменты, но не серебряные пули. Каждый решает свою задачу: Vitess — шардинг MySQL на уровне прокси, Citus — распределённый PostgreSQL как расширение.
-
Глобальная база — это не про технологии, а про компромиссы. Strong consistency vs availability vs latency — выбирайте два (на самом деле, одно, если вы не Spanner).