Модуль: Базы данных · Уровень: Senior+
TL;DR#
- Шардирование — разбиение данных на независимые узлы (шарды), каждый из которых хранит свой непересекающийся поднабор строк. Это способ масштабировать запись и объём данных за пределы одной машины.
- Не путать: репликация = копии тех же данных (масштаб чтения и HA), партиционирование = разбиение таблицы внутри одного инстанса, шардирование = разбиение по разным инстансам.
- Стратегии: hash-based (равномерность, но плохие range-запросы), range-based (хорошие диапазоны, но hotspots), lookup/directory (гибкость ценой лишнего хопа), geo (локальность и data residency).
- Shard key — главное архитектурное решение: равномерность распределения, отсутствие hotspots, co-location связанных данных. Изменить его потом крайне дорого.
- Consistent hashing + виртуальные узлы минимизируют перемещение данных при resharding: при добавлении N+1-го шарда переезжает ~1/(N+1) ключей, а не «почти всё».
- Cross-shard запросы (scatter-gather, JOIN, агрегации) и транзакции (2PC, saga) дороги — хорошая схема шардирования их избегает.
- PostgreSQL: Citus (расширение, distributed tables), Vitess (это про MySQL), либо application-level sharding с роутингом в коде.
Теория#
Шардирование vs репликация vs партиционирование#
Три разных оси масштабирования, которые часто путают на собеседовании.
| Свойство | Репликация | Партиционирование | Шардирование |
|---|---|---|---|
| Что делает | копирует одни и те же данные | бьёт таблицу на куски в одном инстансе | бьёт данные на куски по разным инстансам |
| Масштабирует | чтение, HA, гео-чтение | управляемость, vacuum, pruning | запись, объём, throughput |
| Объём на узел | не уменьшается | не уменьшается (тот же диск) | уменьшается (1/N) |
| Single point of write | да (primary) | да | нет (запись распределена) |
| Сложность приложения | низкая | низкая (прозрачно) | высокая (роутинг, cross-shard) |
| Пример в PG | streaming replication, logical | PARTITION BY RANGE/LIST/HASH | Citus, app-level |
Ключевая мысль: эти техники ортогональны и комбинируются. Реальный продакшен обычно: шардирование (N шардов) × репликация (каждый шард = primary + реплики) × партиционирование (внутри шарда таблица партиционирована по времени).
┌─ shard 1 ─ primary ── replica ── replica
router ───────┼─ shard 2 ─ primary ── replica ── replica (каждый primary
└─ shard 3 ─ primary ── replica ── replica партиционирован
по месяцам)Почему вертикального масштабирования и репликации не хватает:
- Репликация не масштабирует запись — все writes идут в один primary.
- Один primary ограничен RAM/IOPS/CPU одной машины; таблица в десятки ТБ → распухший индекс не влезает в shared_buffers, autovacuum не успевает.
- Партиционирование в одном инстансе помогает с pruning и обслуживанием, но диск и WAL — общие.
Партиционирование как «шардирование внутри узла»#
Полезно понимать связь: декларативное партиционирование PostgreSQL — это локальный аналог шардирования, и часто оно предшествует шардированию.
-- Партиционирование по диапазону (range) — типично для time-series
CREATE TABLE events (
id bigint GENERATED ALWAYS AS IDENTITY,
tenant_id bigint NOT NULL,
created_at timestamptz NOT NULL,
payload jsonb
) PARTITION BY RANGE (created_at);
CREATE TABLE events_2026_06 PARTITION OF events
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
-- Партиционирование по хэшу — для равномерного распределения по N бакетов
CREATE TABLE accounts (id bigint, ...) PARTITION BY HASH (id);
CREATE TABLE accounts_p0 PARTITION OF accounts FOR VALUES WITH (MODULUS 4, REMAINDER 0);
-- ... p1, p2, p3Когда партиций в одном инстансе становится недостаточно (диск/IOPS упёрлись) — те же бакеты раскидывают по физически разным серверам. Citus, по сути, автоматизирует этот шаг.
Стратегии шардирования#
1. Hash-based (по ключу)#
shard = hash(shard_key) % N. Данные распределяются равномерно (при хорошей хэш-функции), исключая hotspots по «горячим» диапазонам.
- Плюсы: равномерность, простой роутинг, точечные lookup по ключу очень быстрые.
- Минусы: range-запросы (
WHERE created_at BETWEEN ...) превращаются в scatter-gather по всем шардам; смежные значения раскиданы. Наивный% Nломается при изменении N (см. consistent hashing).
2. Range-based (по диапазону)#
Каждому шарду назначен диапазон ключа: shard A = [0, 1M), shard B = [1M, 2M), и т.д. Часто по времени или по алфавиту.
- Плюсы: эффективные range-сканы и пагинация; легко добавлять «следующий» шард для свежих данных.
- Минусы: hotspots — последний шард принимает все свежие записи (монотонно растущий ключ = «горячий хвост»); неравномерность при перекошенном распределении (много пользователей на «A», мало на «Z»).
3. Lookup / directory (таблица соответствия)#
Отдельный сервис/таблица хранит явное отображение shard_key -> shard_id.
- Плюсы: максимальная гибкость — можно вручную переселять тяжёлых тенантов, балансировать, изолировать; легко добавлять шарды.
- Минусы: лишний хоп и точка отказа (нужно кэшировать и реплицировать directory); согласованность directory при миграциях.
-- directory-таблица (обычно в отдельной координирующей БД, агрессивно кэшируется)
CREATE TABLE shard_map (
tenant_id bigint PRIMARY KEY,
shard_id int NOT NULL,
state text NOT NULL DEFAULT 'active' -- active | migrating | read_only
);4. Geo-sharding#
Шард выбирается по географии (региону пользователя). Частный случай list/directory, но мотивация другая: локальность задержек и data residency (GDPR — данные EU-граждан физически в EU).
- Плюсы: низкая latency, юридическое соответствие, отказоустойчивость по регионам.
- Минусы: перекос нагрузки между регионами; пользователь, сменивший регион, требует миграции; глобальные отчёты — это cross-region scatter-gather.
Сводка#
| Стратегия | Равномерность | Range-запросы | Resharding | Когда выбирать |
|---|---|---|---|---|
| Hash | отличная | плохо (scatter) | плохо без CH | равномерная точечная нагрузка по ключу |
| Range | средняя (hotspots) | отлично | легко (новый диапазон) | time-series, последовательный доступ |
| Lookup | управляемая вручную | зависит | очень гибко | multi-tenant, нужна изоляция тенантов |
| Geo | по регионам | внутри региона | по миграции | low-latency, data residency |
Выбор shard key#
Это решение №1 — ошибка здесь стоит дороже всего, потому что меняется тяжелее всего. Критерии:
Высокая кардинальность и равномерность. Ключ должен давать много значений, размазанных равномерно.
countryдля глобального сервиса — плохо (несколько значений, перекос).user_id/tenant_id— обычно хорошо.Отсутствие hotspots. Монотонный ключ (auto-increment id, timestamp) при range-шардировании → весь трафик на последний шард. Решение: хэш или composite-ключ. Также «celebrity problem» — один тенант с непропорциональной нагрузкой делает шард горячим.
Co-location связанных данных. Данные, которые часто читаются/джойнятся вместе, должны жить на одном шарде. Если шардируем по
tenant_id, тоorders,order_items,invoicesодного тенанта — на одном шарде → JOIN локальный, транзакции локальные. Это и есть способ избежать cross-shard операций. В Citus это «colocation»: distributed-таблицы с одинаковым distribution column колоцируются.Совпадение с шаблоном доступа. Шард-ключ должен присутствовать в большинстве запросов (особенно WHERE). Если шардировали по
user_id, а основной запрос —WHERE product_id = ?, то каждый запрос превращается в scatter-gather.
-- Citus: co-location нескольких таблиц по одному distribution column
SELECT create_distributed_table('orders', 'tenant_id');
SELECT create_distributed_table('order_items', 'tenant_id', colocate_with => 'orders');
-- Теперь JOIN orders ⋈ order_items по tenant_id выполняется локально на каждом шарде.
-- Reference-таблица (мелкий справочник) реплицируется на ВСЕ шарды,
-- чтобы JOIN с ней был локальным и не требовал scatter-gather:
SELECT create_reference_table('countries');Антипаттерны выбора ключа: boolean/status (низкая кардинальность), временная метка как единственный ключ (hotspot), естественный ключ, который может меняться (смена ключа = миграция строки).
Resharding без даунтайма#
Резон: шардов стало мало (исчерпали ёмкость) или нужно перебалансировать. Главная боль — перенести данные, не теряя записи и без остановки.
Общая стратегия double-write + backfill + cutover:
- Подготовка. Поднимаем новые шарды. Обновляем shard map: для переезжающего диапазона ставим состояние
migrating. - Double-write. Приложение пишет и в старый, и в новый шард для затронутых ключей. Чтения пока идут со старого.
- Backfill. Фоном копируем исторические данные старый → новый (батчами, идемпотентно:
INSERT ... ON CONFLICT DO UPDATE). Логические слоты репликации / CDC (Debezium) помогают захватить изменения, случившиеся во время копирования. - Верификация. Сверяем контрольные суммы/счётчики между старым и новым. Догоняем дельту.
- Cutover. Переключаем чтения на новый шард (атомарно через shard map). Кратко переводим ключ в
read_only, чтобы избежать гонки на переключении. - Очистка. Останавливаем double-write, удаляем данные со старого шарда, переводим состояние в
active.
Минимизировать объём переезда помогают: (а) consistent hashing вместо % N; (б) изначально создавать много мелких логических шардов (например 1024) и мапить их на меньшее число физических узлов — тогда resharding = перемещение целых логических шардов между узлами, без перехэширования строк (подход Vitess/Slack/Notion).
Логических шардов (бакетов): 1024 (фиксировано навсегда)
Физических узлов: 4 → каждый держит 256 бакетов
Добавили 5-й узел → перемещаем ~205 бакетов целиком, ключи не перехэшируютсяConsistent hashing#
Проблема наивного hash(key) % N: при изменении N меняется остаток почти для всех ключей → массовая миграция.
Идея consistent hashing: и ключи, и узлы хэшируются в одно кольцо [0, 2^k). Ключ принадлежит первому узлу по часовой стрелке. Добавление/удаление узла затрагивает только ключи между новым узлом и его предшественником → переезжает в среднем K/N ключей.
Проблема «голого» кольца — неравномерность (узлы попадают в кольцо неравномерно). Решение — виртуальные узлы (vnodes): каждый физический узел представлен V точками на кольце (node#1, node#2, …). Чем больше V, тем равномернее распределение и тем плавнее перебалансировка при выходе узла (его нагрузка размазывается по многим соседям, а не валится на одного).
package shardring
import (
"hash/crc32"
"sort"
"strconv"
"sync"
)
// HashRing — consistent hashing с виртуальными узлами.
type HashRing struct {
mu sync.RWMutex
replicas int // число vnodes на физический узел
ring []uint32 // отсортированные хэши vnodes (точки на кольце)
hashToNode map[uint32]string
}
func New(replicas int) *HashRing {
return &HashRing{replicas: replicas, hashToNode: make(map[uint32]string)}
}
func (h *HashRing) hash(s string) uint32 { return crc32.ChecksumIEEE([]byte(s)) }
// Add добавляет физический узел как replicas виртуальных точек.
func (h *HashRing) Add(node string) {
h.mu.Lock()
defer h.mu.Unlock()
for i := 0; i < h.replicas; i++ {
hk := h.hash(node + "#" + strconv.Itoa(i))
h.ring = append(h.ring, hk)
h.hashToNode[hk] = node
}
sort.Slice(h.ring, func(a, b int) bool { return h.ring[a] < h.ring[b] })
}
func (h *HashRing) Remove(node string) {
h.mu.Lock()
defer h.mu.Unlock()
for i := 0; i < h.replicas; i++ {
hk := h.hash(node + "#" + strconv.Itoa(i))
delete(h.hashToNode, hk)
idx := sort.Search(len(h.ring), func(j int) bool { return h.ring[j] >= hk })
if idx < len(h.ring) && h.ring[idx] == hk {
h.ring = append(h.ring[:idx], h.ring[idx+1:]...)
}
}
}
// Get возвращает узел, ответственный за ключ (первый по часовой стрелке).
func (h *HashRing) Get(key string) string {
h.mu.RLock()
defer h.mu.RUnlock()
if len(h.ring) == 0 {
return ""
}
hk := h.hash(key)
idx := sort.Search(len(h.ring), func(i int) bool { return h.ring[i] >= hk })
if idx == len(h.ring) { // обход кольца
idx = 0
}
return h.hashToNode[h.ring[idx]]
}Замечание senior-уровня: для шардирования БД consistent hashing в чистом виде применяют реже, чем для кэшей/распределённых KV (Cassandra, Dynamo, Redis Cluster со слотами). Для SQL-шардов чаще берут «много логических бакетов → мало физических узлов», т.к. это упрощает атомарный переезд диапазона и контроль co-location. Но понимать механику и асимптотику CH обязательно.
Cross-shard запросы#
Когда запрос не содержит shard key (или содержит несколько разных), его нельзя направить в один шард.
- Scatter-gather: запрос рассылается на все шарды, результаты собираются и мержатся координатором. Latency = latency самого медленного шарда (хвостовые задержки), нагрузка ×N, плохо масштабируется по фану-ауту.
- Агрегации (
COUNT,SUM,AVG): нужна двухфазность.SUM/COUNTаддитивны (суммируем частичные).AVG=SUM/COUNT(нельзя усреднять средние).DISTINCT/COUNT(DISTINCT)требует пересылки множеств или приближений (HyperLogLog).ORDER BY ... LIMIT k— каждый шард отдаёт top-k, координатор делает merge. - Cross-shard JOIN — самое дорогое: если джойнимые строки на разных шардах, нужно либо тянуть данные на координатор (broadcast), либо repartition/shuffle join (пересылка между шардами по ключу джойна). Объём сетевого трафика и память координатора растут. Поэтому правильный ответ — проектировать так, чтобы джойнить только co-located данные или реплицировать мелкие справочники (reference tables).
-- Дёшево: WHERE по shard key → один шард
SELECT * FROM orders WHERE tenant_id = 42 AND id = 1001;
-- Дорого: нет shard key → scatter-gather по всем шардам
SELECT count(*) FROM orders WHERE status = 'pending';
-- Дорого: JOIN по НЕ-distribution column → repartition join (shuffle между шардами)
SELECT * FROM orders o JOIN customers c ON o.email = c.email; -- email != tenant_idCross-shard транзакции#
ACID-транзакция в пределах одного шарда — обычная локальная транзакция PostgreSQL. Как только она затрагивает 2+ шарда, появляется проблема атомарности между независимыми СУБД.
1. Two-Phase Commit (2PC). Координатор: PREPARE на всех участниках → если все ответили OK, COMMIT, иначе ABORT. PostgreSQL поддерживает PREPARE TRANSACTION / COMMIT PREPARED.
- Даёт атомарность, но: блокирующий протокол (если координатор падает между фазами — участники держат locks и зависают
in-doubt); двойной round-trip → высокая latency; брошенные prepared-транзакции держат WAL и блокировки (мониторитьpg_prepared_xacts). Плохо масштабируется и снижает доступность (CAP: жертвуем availability).
-- На каждом участнике:
BEGIN;
INSERT INTO ...;
PREPARE TRANSACTION 'txn-7f3a'; -- фаза 1
-- координатор собрал OK со всех → фаза 2 на каждом:
COMMIT PREPARED 'txn-7f3a'; -- или ROLLBACK PREPARED при отказе2. Saga. Бизнес-транзакция разбивается на цепочку локальных транзакций, каждая на своём шарде, с компенсирующими действиями на случай отката. Без глобальных блокировок.
- Плюсы: высокая доступность, нет distributed locks, хорошо масштабируется.
- Минусы: только eventual consistency (нет изоляции — промежуточные состояния видны); нужно проектировать идемпотентность и компенсации; сложнее рассуждать о корректности. Оркестрация (центральный координатор саги) vs хореография (события).
3. Отказ от распределённых транзакций (предпочтительно). Лучшая cross-shard транзакция — та, которой нет. Достигается co-location: если всё, что меняется атомарно, лежит на одном шарде (выбран правильный shard key), транзакция локальна. Поэтому co-location — это не только про JOIN, но и про транзакции.
| Подход | Атомарность | Изоляция | Доступность | Latency | Когда |
|---|---|---|---|---|---|
| Локальная (co-location) | да | да | высокая | низкая | по возможности всегда |
| 2PC | да | да (строго) | низкая (блокирующий) | высокая | редкие, критичные к атомарности |
| Saga | eventual | нет | высокая | средняя | длинные бизнес-процессы |
Решения для PostgreSQL#
- Citus (расширение PostgreSQL, ныне часть Microsoft / Azure Cosmos DB for PostgreSQL). Превращает PG в распределённый: coordinator + worker-узлы,
create_distributed_table, distribution column, colocation, reference tables, параллельный scatter-gather и repartition joins «из коробки», поддержка 2PC между воркерами. Хорош для multi-tenant SaaS (шардирование по tenant_id) и real-time analytics. Минусы: ограничения на cross-shard constraints/уникальность без distribution column, не все фичи vanilla PG работают прозрачно. - Vitess — это про MySQL, не PostgreSQL (частый подвох на собеседовании). Идейный аналог для PG в части «много логических шардов»: нет полного эквивалента, но архитектуру VTGate/VSchema полезно знать как референс.
- Application-level sharding — роутинг в приложении (см. Go ниже). Максимальный контроль, нулевая магия, но всё (cross-shard, resharding, агрегации) пишется руками. Часто старт для команд, не желающих новых инфра-зависимостей; так начинали Instagram, Notion, Figma.
- Прочее: PgCat / PgBouncer (пулинг + базовый sharding-роутинг на уровне прокси), pg_shard (устарел, влился в Citus), Spanner/CockroachDB/YugabyteDB — distributed SQL, где шардирование (range-based, авто-rebalance) встроено в саму СУБД и скрыто от приложения.
Go: роутинг по shard key#
Application-level sharding: пул соединений на каждый физический шард + функция выбора шарда по ключу.
package shards
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// Router держит пулы соединений и стратегию выбора шарда.
type Router struct {
pools []*pgxpool.Pool // индекс = номер логического шарда
}
// shardFor отображает tenantID в индекс шарда.
// Здесь — простой hash % N; в проде заменяется на consistent hashing
// или lookup в shard_map (для гибкости и resharding).
func (r *Router) shardFor(tenantID int64) int {
return int(fnv64(tenantID) % uint64(len(r.pools)))
}
func fnv64(x int64) uint64 {
h := uint64(1469598103934665603)
for i := 0; i < 8; i++ {
h ^= uint64(byte(x >> (i * 8)))
h *= 1099511628211
}
return h
}
// Pool возвращает пул для тенанта — все запросы должны идти через него,
// чтобы гарантировать, что в WHERE присутствует shard key.
func (r *Router) Pool(tenantID int64) *pgxpool.Pool {
return r.pools[r.shardFor(tenantID)]
}
// GetOrder — пример точечного запроса (один шард, дёшево).
func (r *Router) GetOrder(ctx context.Context, tenantID, orderID int64) (string, error) {
pool := r.Pool(tenantID)
var status string
err := pool.QueryRow(ctx,
`SELECT status FROM orders WHERE tenant_id = $1 AND id = $2`,
tenantID, orderID,
).Scan(&status)
return status, err
}
// CountPendingAllShards — пример scatter-gather (дорого, по всем шардам).
// Агрегируем COUNT (аддитивно) на стороне приложения.
func (r *Router) CountPendingAllShards(ctx context.Context) (int64, error) {
type res struct {
n int64
err error
}
ch := make(chan res, len(r.pools))
for _, p := range r.pools { // параллельный fan-out
go func(p *pgxpool.Pool) {
var n int64
err := p.QueryRow(ctx,
`SELECT count(*) FROM orders WHERE status = 'pending'`).Scan(&n)
ch <- res{n, err}
}(p)
}
var total int64
for range r.pools {
x := <-ch
if x.err != nil {
return 0, fmt.Errorf("scatter-gather shard failed: %w", x.err)
}
total += x.n // gather: COUNT суммируется
}
return total, nil
}Продакшен-нюансы роутера: на каждый запрос требовать shard key (защита от случайного scatter-gather), отдельный «admin»-пул для cross-shard, поддержка состояния шарда (migrating/read_only) для double-write во время resharding, контекст с таймаутом на самый медленный шард, метрики per-shard для выявления hotspots.
Подводные камни / gotchas#
hash % Nнамертво привязывает к N. Добавление шарда → перехэширование почти всех ключей. Сразу проектируй consistent hashing или «много логических бакетов».- Монотонный shard key = hotspot.
BIGSERIAL/timestampпри range-шардировании отправляют весь свежий трафик на последний шард. Хэшируй или добавляй prefix. - Глобально уникальные id. Внутри шарда
BIGSERIALдаст коллизии между шардами. Нужны UUID/ULID, Snowflake-id (с битами шарда), или per-shard диапазоны последовательностей. - Cross-shard уникальность невозможна дёшево.
UNIQUE(email)глобально требует либо включения email в shard key, либо отдельного индекс-сервиса. В Citus уникальные ограничения должны включать distribution column. - Foreign keys через шарды не работают. FK живут только внутри шарда; межшардовая ссылочная целостность — забота приложения.
- AVG/DISTINCT в scatter-gather считаются неправильно при наивной агрегации. AVG = SUM/COUNT, COUNT(DISTINCT) требует HLL или пересылки множеств.
- 2PC: брошенные prepared-транзакции. Падение координатора оставляет
in-doubtтранзакции, держащие locks и WAL. Без мониторингаpg_prepared_xactsи таймаутов это деградирует кластер. - Saga ≠ ACID. Нет изоляции: чужой код увидит промежуточные состояния. Нужны идемпотентность, компенсации, обработка частичных отказов.
- Pagination через шарды.
OFFSET ... LIMITпо всем шардам некорректен и дорог; нужен merge top-k или keyset-пагинация по сортируемому ключу. - Перекошенные тенанты (celebrity problem). Один тенант перегружает шард. Решение: directory-шардирование с возможностью выделить его на отдельный шард, или sub-sharding.
- Schema migrations на N шардах. DDL надо применять атомарно/согласованно по всем шардам; нужна оркестрация и обратная совместимость на время раскатки.
- Vitess — это MySQL. Не предлагать Vitess как решение для PostgreSQL; для PG это Citus/Spanner-like/app-level.
Вопросы на собеседовании#
В: В чём разница между шардированием, репликацией и партиционированием? О: Репликация копирует одни и те же данные (масштаб чтения и HA, запись остаётся на одном primary). Партиционирование бьёт таблицу на куски внутри одного инстанса (управляемость, pruning; диск общий). Шардирование распределяет непересекающиеся поднаборы данных по разным инстансам — это единственное из трёх, что масштабирует запись и уменьшает объём на узел. На практике комбинируются.
В: Как выбрать shard key?
О: Высокая кардинальность и равномерность распределения; отсутствие hotspots (не монотонный ключ); co-location связанных данных (чтобы JOIN и транзакции были локальными); присутствие ключа в большинстве запросов (иначе scatter-gather). Для multi-tenant обычно tenant_id. Помнить, что сменить ключ потом крайне дорого.
В: Зачем нужен consistent hashing и при чём тут виртуальные узлы?
О: Наивный hash % N при изменении N перехэширует почти все ключи. Consistent hashing кладёт узлы и ключи на кольцо; при добавлении/удалении узла переезжает только ~K/N ключей. Виртуальные узлы (каждый физ. узел = V точек на кольце) убирают неравномерность и сглаживают перебалансировку при выходе узла — его нагрузка размазывается по многим соседям.
В: Как добавить шард без даунтайма?
О: Double-write + backfill + cutover: поднять новый шард, помечать переезжающий ключ migrating, писать одновременно в старый и новый, фоном идемпотентно копировать историю (+ CDC для дельты), сверить контрольные суммы, атомарно переключить чтения через shard map (краткий read_only на момент cutover), затем убрать double-write и почистить старое. Объём переезда минимизируют consistent hashing или схема «много логических бакетов».
В: Почему cross-shard JOIN дорогой и как его избегать? О: Если джойнимые строки на разных шардах, нужно либо тащить данные на координатор (broadcast), либо делать repartition/shuffle join с пересылкой по ключу джойна — растут сеть и память координатора, latency определяется самым медленным шардом. Избегают через co-location (джойнить только данные с одним shard key) и reference-таблицы (мелкие справочники реплицируются на все шарды).
В: Какие есть варианты cross-shard транзакций и их компромиссы? О: (1) 2PC — атомарно и изолированно, но блокирующий протокол, высокая latency, риск in-doubt транзакций при падении координатора, бьёт по доступности. (2) Saga — цепочка локальных транзакций с компенсациями; высокая доступность и масштабируемость, но только eventual consistency без изоляции. (3) Лучшее — вообще избегать через co-location, чтобы транзакция была локальной.
В: Как правильно посчитать агрегаты при scatter-gather? О: SUM и COUNT аддитивны — суммируем частичные результаты. AVG нельзя усреднять как среднее средних — считаем как SUM/COUNT. MIN/MAX — берём экстремум по шардам. COUNT(DISTINCT) требует либо пересылки множеств, либо приближения (HyperLogLog). ORDER BY … LIMIT k — каждый шард отдаёт свой top-k, координатор делает merge.
В: Какие решения для шардирования PostgreSQL вы знаете? О: Citus (расширение: coordinator + workers, distributed/reference tables, colocation, параллельный scatter-gather, 2PC) — для multi-tenant и аналитики. Application-level sharding с роутингом в коде — максимум контроля, всё руками. Distributed SQL (CockroachDB/YugabyteDB/Spanner) — шардирование встроено и скрыто. Важно: Vitess — это про MySQL, не PostgreSQL.
В: Как генерировать глобально уникальные id при шардировании?
О: Не использовать per-shard BIGSERIAL (коллизии между шардами). Варианты: UUIDv4/v7, ULID; Snowflake-id (timestamp + биты shard/worker + sequence) — сортируемые и кодируют шард; либо нарезать диапазоны последовательностей по шардам. UUIDv7/ULID/Snowflake предпочтительнее случайного UUIDv4 из-за локальности по индексу.
На что копают на senior+#
- Trade-off CAP/PACELC в выборе cross-shard стратегии: 2PC жертвует доступностью ради consistency, saga — наоборот; уметь привязать к конкретному бизнес-требованию.
- Почему «много логических бакетов → мало физических узлов» на практике часто бьёт чистый consistent hashing для SQL: атомарный переезд целого бакета, контроль co-location, отсутствие перехэширования строк (Vitess/Slack/Notion-style).
- Влияние шардирования на схему: потеря глобальных FK, глобальной уникальности, глобальных авто-инкрементов; как это перепроектировать.
- Хвостовые задержки scatter-gather: latency = max по шардам; обсуждение fan-out, hedged requests, таймаутов и деградации.
- Операционная зрелость: schema migrations по N шардам, мониторинг per-shard (выявление hotspots), rebalancing, in-doubt 2PC, наблюдаемость shard map.
- Когда НЕ шардировать: сначала исчерпать партиционирование, read-реплики, вертикальный скейл, кэш — шардирование добавляет огромную операционную сложность; уметь обосновать момент перехода.
- Эволюция shard key: что делать, когда исходный ключ перестал подходить (например, перекос); стратегии re-sharding на новый ключ через double-write.
- Идемпотентность и exactly-once в double-write и saga: дедупликация, outbox-паттерн, CDC (Debezium) для согласованного backfill.