Модуль: Базы данных · Уровень: 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)
Пример в PGstreaming replication, logicalPARTITION BY RANGE/LIST/HASHCitus, 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 — ошибка здесь стоит дороже всего, потому что меняется тяжелее всего. Критерии:

  1. Высокая кардинальность и равномерность. Ключ должен давать много значений, размазанных равномерно. country для глобального сервиса — плохо (несколько значений, перекос). user_id/tenant_id — обычно хорошо.

  2. Отсутствие hotspots. Монотонный ключ (auto-increment id, timestamp) при range-шардировании → весь трафик на последний шард. Решение: хэш или composite-ключ. Также «celebrity problem» — один тенант с непропорциональной нагрузкой делает шард горячим.

  3. Co-location связанных данных. Данные, которые часто читаются/джойнятся вместе, должны жить на одном шарде. Если шардируем по tenant_id, то orders, order_items, invoices одного тенанта — на одном шарде → JOIN локальный, транзакции локальные. Это и есть способ избежать cross-shard операций. В Citus это «colocation»: distributed-таблицы с одинаковым distribution column колоцируются.

  4. Совпадение с шаблоном доступа. Шард-ключ должен присутствовать в большинстве запросов (особенно 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:

  1. Подготовка. Поднимаем новые шарды. Обновляем shard map: для переезжающего диапазона ставим состояние migrating.
  2. Double-write. Приложение пишет и в старый, и в новый шард для затронутых ключей. Чтения пока идут со старого.
  3. Backfill. Фоном копируем исторические данные старый → новый (батчами, идемпотентно: INSERT ... ON CONFLICT DO UPDATE). Логические слоты репликации / CDC (Debezium) помогают захватить изменения, случившиеся во время копирования.
  4. Верификация. Сверяем контрольные суммы/счётчики между старым и новым. Догоняем дельту.
  5. Cutover. Переключаем чтения на новый шард (атомарно через shard map). Кратко переводим ключ в read_only, чтобы избежать гонки на переключении.
  6. Очистка. Останавливаем 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_id

Cross-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дада (строго)низкая (блокирующий)высокаяредкие, критичные к атомарности
Sagaeventualнетвысокаясредняядлинные бизнес-процессы

Решения для 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.