Базы данных NewSQL и распределённые БД: CockroachDB, YugabyteDB, TiDB, Spanner
0%

NewSQL и распределённые БД: CockroachDB, YugabyteDB, TiDB, Spanner

NewSQL и распределённые БД: CockroachDB, YugabyteDB, TiDB, Spanner

К 2010 году индустрия пришла к неприятной развилке. Данных стало больше, чем помещается в один сервер, и выбор был из двух вариантов, оба плохие.

Вариант первый: шардировать вручную. Оставить MySQL или PostgreSQL, разложить пользователей по 64 базам по user_id % 64, а всю логику маршрутизации, ребалансировки и кросс-шардовых запросов написать самим. Это работает — так жили Facebook, YouTube, Uber и половина отрасли. Но вы теряете ровно то, за что любили реляционку: внешние ключи между шардами, транзакцию поверх двух пользователей, JOIN без ограничений, уникальный индекс на весь датасет. И вы навсегда нанимаете команду, которая занимается только шардами.

Вариант второй: отказаться от SQL. Взять Cassandra или DynamoDB, получить линейное масштабирование и выживание при падении датацентра — но переписать приложение под денормализованные таблицы-под-запрос и eventual consistency (см. https://courses.digitable.life/post/databases/12-cassandra-and-wide-column/ и https://courses.digitable.life/post/databases/09-nosql-landscape/).

NewSQL — это попытка отказаться от выбора. Тезис: шардирование, репликация и отказоустойчивость должны быть свойством самой СУБД, а не приложения; при этом снаружи она должна выглядеть как обычная SQL-база с сериализуемыми транзакциями. Термин ввёл Мэтью Аслетт в 2011-м, а научную легитимность классу дала статья Spanner: Google’s Globally-Distributed Database (OSDI 2012), которая показала, что «распределённо и при этом строго консистентно» физически возможно, если решить проблему времени.

Эта статья — о том, как эти системы устроены внутри, какие законы физики они не могут обойти, сколько это стоит в миллисекундах и долларах, и — что важнее всего — когда их брать не надо.

Родословная: откуда что взялось

Два наследственных ствола видно сразу. Spanner-ветка (CockroachDB, YugabyteDB, TiDB) строит SQL поверх реплицированного упорядоченного KV с консенсусом на каждый диапазон. Calvin-ветка (FaunaDB, отчасти Aurora DSQL) идёт другим путём: сначала глобально упорядочить транзакции, потом исполнять их детерминированно, тем самым избавившись от 2PC. Первая ветка победила по распространённости, но вторая интереснее и возвращается в 2024–2025 годах.

Общая анатомия: три слоя, которые есть у всех

Несмотря на разные языки реализации и маркетинг, CockroachDB, YugabyteDB и TiDB устроены почти одинаково.

Ключевая идея, из которой следует всё остальное: таблица кодируется в один непрерывный отсортированный диапазон байтовых ключей, а этот диапазон механически режется на куски, каждый из которых живёт своей жизнью.

Кодирование строки в диапазоны и репликация Raft-группой

Из этой картинки вытекают все практические последствия, о которых пойдёт речь дальше:

  • Порядок PK = физический порядок на диске. Монотонно возрастающий первичный ключ означает, что все записи идут в один и тот же последний диапазон, то есть на один узел. Это главная причина «почему мой 30-узловой кластер пишет как один сервер».
  • Точечный запрос по PK — одно сетевое обращение к leaseholder-у. Запрос без индекса — обращение ко всем диапазонам таблицы, то есть ко всем узлам.
  • Вторичный индекс живёт в других диапазонах, а значит на других узлах. Запись в таблицу с тремя индексами — это четыре независимые Raft-группы, то есть распределённая транзакция даже для одного INSERT.
  • Отказоустойчивость поштучная. Падение узла не роняет ничего целиком: в каждой Raft-группе просто становится на одну реплику меньше, кворум сохраняется, лидерство переезжает за секунды.

Как устроен диапазон

-- CockroachDB: посмотреть, во что реально превратилась таблица
SHOW RANGES FROM TABLE orders WITH DETAILS;
-- start_key | end_key | range_id | replicas | lease_holder | range_size_mb | ...
--  …/42/"09" | …/71    |   19     | {1,2,3}  |      3       |     498       |

-- TiDB: то же самое в терминах Region
SHOW TABLE orders REGIONS;

-- YugabyteDB: таблеты
SELECT * FROM yb_local_tablets;

Размеры по умолчанию различаются на порядок и это осмысленно:

Система Единица Размер по умолчанию Автосплит Стратегия шардирования по умолчанию
CockroachDB Range ~512 MiB да, по размеру и по нагрузке range (лексикографический)
TiKV/TiDB Region ~96 MiB да, по размеру и по числу ключей range
YugabyteDB Tablet задаётся при создании (SPLIT INTO) да (auto-splitting) hash по первому столбцу PK
Spanner Split управляется системой да, в т.ч. по нагрузке range

Отдельно отметьте YugabyteDB: он по умолчанию хеширует первый столбец первичного ключа (PRIMARY KEY (id HASH, ts ASC)), поэтому проблема «горячего хвоста» у него из коробки решена — ценой того, что WHERE id BETWEEN ... перестаёт быть диапазонным сканом. Остальные требуют, чтобы вы думали об этом сами.

Время: почему это вообще сложная задача

Распределённая транзакция должна получить метку времени, и эта метка должна быть согласована с реальным порядком событий. Если транзакция T1 закоммитилась, клиент увидел ответ и запустил T2 на другом узле, то T2 обязана увидеть результат T1. Иначе рушится любая интуиция про «прочитал то, что записал».

Проблема в том, что часы на узлах расходятся. NTP даёт погрешность в десятки миллисекунд, а виртуализация в облаке умеет замораживать VM на секунды. Есть три разных ответа.

Неопределённость часов: commit wait против перезапуска чтения

Ответ Spanner — потратить деньги на железо. GPS-приёмники и атомные часы в каждом датацентре сжимают неопределённость ε до единиц миллисекунд. API возвращает не время, а интервал TT.now() = [earliest, latest], гарантированно содержащий истинное время. Транзакция берёт метку s = latest и ждёт, пока TT.after(s) не станет истинным, — это и есть commit wait, примерно . В статье 2012 года средний ε был около 4 мс с пилообразным ростом до ~7 мс между синхронизациями. Итог: внешняя консистентность (линеаризуемость всей базы), оплаченная фиксированной надбавкой к каждой записи.

Ответ CockroachDB и YugabyteDB — гибридные логические часы (HLC). Метка = (физическое время, логический счётчик); каждое сообщение между узлами переносит метку и подтягивает часы получателя вперёд. Специального железа не нужно, но ε приходится задавать консервативно: --max-offset=500ms по умолчанию у CockroachDB. Ждать 500 мс на каждой записи невозможно, поэтому платит читатель: если чтение с меткой ts натыкается на значение с меткой из интервала (ts, ts + max_offset], оно не может решить, было ли то значение записано «до» или «после», и транзакция перезапускается с более поздней меткой — ошибка ReadWithinUncertaintyIntervalError. Гарантия при этом слабее спаннеровской: без commit wait система даёт сериализуемость, но не строгую линеаризуемость для несвязанных транзакций.

Ответ TiDB — централизованный оракул времени. Компонент PD (Placement Driver) раздаёт монотонные метки TSO. Никаких часов, никакой неопределённости, идеальный порядок — ценой одного сетевого round-trip к PD на каждую транзакцию и того, что PD становится точкой, чью латентность вы чувствуете во всём кластере. В одном регионе это отлично (0.2–1 мс), между континентами — приговор: TiDB честно не позиционируется как глобально-распределённая база.

Важный практический вывод. Если вы разворачиваете CockroachDB или YugabyteDB — синхронизация часов не «желательна», а является условием корректности. CockroachDB принудительно убивает узел, если тот обнаруживает расхождение больше 80% от max-offset, и это правильное поведение: продолжать работу означало бы молча нарушать изоляцию. В AWS используйте Amazon Time Sync Service, в GCP — внутренний NTP, но никогда не публичный pool.ntp.org на всех узлах вразнобой.

Распределённые транзакции: как коммитить в трёх местах сразу

Классический двухфазный коммит (2PC) стоит два последовательных обхода: prepare-раунд и commit-раунд. В распределённой базе каждый «обход» — это ещё и Raft-консенсус внутри каждой группы. Наивно получается 4 последовательных консенсуса — неприемлемо.

Percolator-подход (TiDB)

TiDB реализует схему из статьи Percolator: среди всех ключей транзакции произвольно выбирается primary key, и атомарность коммита сводится к атомарности единственной записи в этот ключ.

Изящество в том, что второй фазе не нужно быть синхронной: любой читатель, встретивший неразрешённый лок, идёт по указателю на primary и достраивает истину. Цена — читатели иногда делают лишнюю работу, а при падении координатора локи висят до истечения TTL и блокируют конфликтующие чтения.

Parallel Commits (CockroachDB)

CockroachDB c версии 19.2 применяет другой трюк. Транзакция считается закоммиченной, если её запись состояния имеет статус STAGING и все её intent-ы успешно записаны. Это проверяемое условие, поэтому не нужно ждать явной записи COMMITTED — она делается асинхронно.

Практический эффект: коммит распределённой транзакции стоит примерно столько же, сколько запись одного ключа. Это разница между 4 мс и 8 мс на кросс-AZ кластере — то есть двукратная пропускная способность на транзакционно-тяжёлых нагрузках.

Аномалия, которую все обязаны предотвратить

Все четыре системы поддерживают сериализуемость, но по умолчанию дают разное — и вот это важнее всего для прикладного кода:

Система Изоляция по умолчанию Что доступно ещё Как реализовано
CockroachDB SERIALIZABLE READ COMMITTED (GA c 24.1) SSI: timestamp ordering + refresh spans, без блокировок на чтении
YugabyteDB SNAPSHOT (REPEATABLE READ) SERIALIZABLE, READ COMMITTED (через флаг) Слой PostgreSQL поверх DocDB; SI + предикатные локи для SERIALIZABLE
TiDB SNAPSHOT (маскируется под REPEATABLE READ) READ COMMITTED Percolator; пессимистичные транзакции по умолчанию с 4.0
Spanner SERIALIZABLE + external consistency read-only снапшоты, stale reads 2PL + Paxos + TrueTime commit wait
Aurora DSQL SNAPSHOT (REPEATABLE READ) OCC: конфликты проверяются на коммите, без локов

Ловушка номер один при миграции: snapshot isolation допускает write skew (подробно — в https://courses.digitable.life/post/databases/07-transactions-and-isolation/). Классический пример — правило «хотя бы один врач должен быть на дежурстве»: две параллельные транзакции читают, что дежурных двое, каждая снимает своего, обе коммитятся, дежурных ноль. В CockroachDB это невозможно. В TiDB и YugabyteDB с настройками по умолчанию — вполне возможно. Если вы переносите на TiDB код, который на PostgreSQL полагался на SERIALIZABLE, — вы получите тихую порчу данных.

Лечится либо явными блокировками (SELECT ... FOR UPDATE — работает во всех), либо переключением уровня изоляции.

Практика: схема, которая масштабируется, и схема, которая нет

Возьмём мультитенантный сервис заказов.

Ключевой приём распределённого SQL — интерливинг по общему префиксу. Если order_items имеет тот же префикс PK, что и orders, все строки одного заказа лексикографически соседствуют и почти наверняка окажутся в одном диапазоне. Тогда JOIN заказа с его позициями выполняется локально на одном узле, без сетевого обмена.

-- CockroachDB
CREATE TABLE orders (
    tenant_id    UUID NOT NULL,
    order_id     UUID NOT NULL DEFAULT gen_random_uuid(),
    status       STRING NOT NULL,
    amount_cents INT8 NOT NULL,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (tenant_id, order_id)
);

CREATE TABLE order_items (
    tenant_id  UUID NOT NULL,
    order_id   UUID NOT NULL,
    line_no    INT4 NOT NULL,
    product_id UUID NOT NULL,
    qty        INT4 NOT NULL,
    PRIMARY KEY (tenant_id, order_id, line_no),
    CONSTRAINT fk_order FOREIGN KEY (tenant_id, order_id)
        REFERENCES orders (tenant_id, order_id) ON DELETE CASCADE
);

-- Аналитический доступ «свежие заказы арендатора» — покрывающий индекс,
-- чтобы не ходить в основную таблицу за amount_cents.
CREATE INDEX idx_orders_recent
    ON orders (tenant_id, created_at DESC)
    STORING (status, amount_cents);

Обратите внимание, что мы не написали order_id SERIAL. Вот почему:

-- АНТИПАТТЕРН: монотонный ключ
CREATE TABLE events_bad (
    id         SERIAL PRIMARY KEY,   -- или BIGSERIAL, или timestamp
    payload    JSONB
);
-- Все вставки идут в конец пространства ключей → один диапазон →
-- один leaseholder → одно ядро одного узла. Кластер из 30 узлов
-- пишет с производительностью примерно одного.

Лекарства по системам:

-- CockroachDB: хеш-шардированный индекс — виртуальный столбец crdb_internal_hash
CREATE TABLE events_ok (
    id  INT8 PRIMARY KEY USING HASH WITH (bucket_count = 16),
    payload JSONB
);
-- либо просто UUID / UUIDv7-подобный, но с ведущей энтропией

-- TiDB: рассеять неявные row_id или явный AUTO_RANDOM
CREATE TABLE events_ok (
    id BIGINT PRIMARY KEY AUTO_RANDOM,
    payload JSON
);
-- для таблиц без явного PK: SHARD_ROW_ID_BITS = 4 PRE_SPLIT_REGIONS = 4

-- YugabyteDB: явное указание hash-шардирования
CREATE TABLE events_ok (
    id UUID DEFAULT gen_random_uuid(),
    payload JSONB,
    PRIMARY KEY (id HASH)
) SPLIT INTO 24 TABLETS;

Отдельная ловушка: UUIDv7 и ULID, которые все полюбили ради локальности в PostgreSQL, в распределённой базе становятся антипаттерном — они специально монотонны по времени, то есть создают ровно ту горячую точку, от которой мы уходим. Для B-tree на одном узле монотонность хороша, для range-шардированного кластера — вредна. Это ровно тот случай, когда «best practice» из https://courses.digitable.life/post/databases/06-indexes-and-query-plans/ меняет знак.

Планы выполнения: что здесь читать по-другому

План распределённого SQL содержит слой, которого нет в PostgreSQL: как запрос раскладывается по узлам.

EXPLAIN ANALYZE
SELECT o.order_id, o.amount_cents, count(i.line_no) AS lines
FROM orders o JOIN order_items i USING (tenant_id, order_id)
WHERE o.tenant_id = '3f7a…' AND o.created_at > now() - INTERVAL '7 days'
GROUP BY o.order_id, o.amount_cents;
planning time: 0.7ms      execution time: 3.4ms
distribution: local                      ← ГЛАВНАЯ СТРОКА
vectorized: true
rows read from KV: 1 842 (218 KiB)
cumulative time spent in KV: 2.1ms
maximum memory usage: 320 KiB
network usage: 0 B (0 messages)          ← ноль сетевого обмена между узлами

  • group
  │ nodes: n3
  │ actual row count: 91
  │ group by: order_id, amount_cents
  └── • lookup join
      │ nodes: n3
      │ KV rows decoded: 1 842
      │ table: order_items@order_items_pkey
      │ equality: (tenant_id, order_id) = (tenant_id, order_id)
      └── • index join / scan
            table: orders@idx_orders_recent
            spans: 1 span                 ← один диапазон ключей, один узел

Что смотреть в первую очередь:

  1. distribution: local против full. full означает, что план разослан на все узлы — законно для аналитики, катастрофично для OLTP-запроса, который вы зовёте 5000 раз в секунду.
  2. network usage. Ненулевой сетевой обмен в точечном запросе — почти всегда признак того, что предикат не содержит префикса PK и произошёл кросс-узловой join.
  3. spans. ALL вместо конкретного диапазона — это распределённый full scan. В CockroachDB его можно запретить: SET disallow_full_table_scans = on;
  4. cumulative time spent in KV. Если оно близко к общему времени — узкое место в хранилище/сети, а не в SQL-слое; если далеко — виноват планировщик или сортировка/агрегация.

Для TiDB эквивалент выглядит так:

EXPLAIN ANALYZE SELECT ...;
-- id                        task       estRows  actRows  execution info
-- HashAgg_11                root       91       91       time:3.9ms
-- └─IndexLookUp_18          root       1842     1842     time:3.7ms, ...
--   ├─IndexRangeScan_15     cop[tikv]  1842     1842     rpc num: 2, proc keys: 1842
--   └─TableRowIDScan_17     cop[tikv]  1842     1842     rpc num: 3

task = cop[tikv] значит «выполняется на узле хранения» (coprocessor pushdown) — это хорошо, фильтрация происходит рядом с данными. task = root — данные едут в SQL-узел. Чем больше работы в cop[tikv], тем меньше трафика. Если вы видите TableFullScan с task: cop[tikv] и десятки миллионов proc keys — у вас нет нужного индекса. Отдельно у TiDB есть cop[tiflash] — та же таблица, но её колоночная реплика: HTAP в одном запросе (концептуально это ClickHouse-подобное хранение, см. https://courses.digitable.life/post/databases/13-clickhouse-and-olap/).

Геораспределение: где действительно окупается

Главный аргумент в пользу этих систем — не «много данных», а «данные в нескольких регионах с сохранением транзакционности». Здесь вступает арифметика, которую нельзя обойти никакой инженерией.

Стоимость кворумной записи = RTT до ВТОРОЙ по близости реплики (при RF=3).

в пределах одной зоны AZ    : 0.2–0.5 мс  → коммит ~1–2 мс
между AZ одного региона     : 0.5–2 мс    → коммит ~2–5 мс
между регионами континента  : 20–40 мс    → коммит ~25–45 мс
между континентами          : 70–150 мс   → коммит ~80–160 мс

Отсюда прямое следствие, которое обесценивает половину маркетинга: если три реплики раскиданы по трём континентам, каждая запись стоит ~100 мс, и никакой Raft этого не исправит. Поэтому все зрелые системы дают механизмы привязки данных к географии.

-- CockroachDB multi-region: три уровня гранулярности
ALTER DATABASE shop SET PRIMARY REGION "eu-central-1";
ALTER DATABASE shop ADD REGION "us-east-1";
ALTER DATABASE shop ADD REGION "ap-southeast-1";

-- 1. Строка живёт в регионе своего арендатора: локальная запись 2–5 мс,
--    чтение из других регионов — дороже.
ALTER TABLE orders SET LOCALITY REGIONAL BY ROW;   -- добавляет столбец crdb_region

-- 2. Справочник, который читают все и почти не пишут:
--    чтение локальное везде, запись дорогая (~ RTT до самого дальнего региона).
ALTER TABLE products SET LOCALITY GLOBAL;

-- 3. Таблица целиком принадлежит одному региону.
ALTER TABLE eu_only_audit SET LOCALITY REGIONAL BY TABLE IN "eu-central-1";

GLOBAL-таблицы — красивый трюк: они используют «неблокирующие диапазоны», где записи получают метку времени в будущем, а читатели могут читать локально из любой реплики без обращения к leaseholder-у. Идеально для валют, тарифов, фичефлагов; катастрофа для чего-либо часто изменяемого.

Второй универсальный инструмент — чтение с реплик с контролируемой устареваемостью:

-- CockroachDB: точная устареваемость (~4.8 с) — читает ближайшая реплика
SELECT * FROM products AS OF SYSTEM TIME follower_read_timestamp();

-- ограниченная устареваемость: свежее, если можно, иначе до 10 с назад
SELECT * FROM products
  AS OF SYSTEM TIME with_max_staleness('10s');
-- Spanner (GoogleSQL): read-only транзакция не берёт локов вообще
SET TRANSACTION READ ONLY;  -- + опции exact_staleness / max_staleness в клиенте

Правило простое: любой отчёт, дашборд и экспорт должен читать устаревшие данные. Это снимает нагрузку с leaseholder-ов, убирает конфликты с OLTP-записями и стоит в разы дешевле по латентности. Если бизнес не готов на 5 секунд отставания в отчёте — обычно он просто не задумывался, что альтернатива стоит денег.

Честное сравнение

CockroachDB YugabyteDB TiDB Cloud Spanner Vitess / Citus
Совместимость PostgreSQL wire, свой диалект реальный код PostgreSQL (YSQL) MySQL 8.0 wire GoogleSQL + PG-интерфейс ~полный MySQL / PostgreSQL
Консенсус Raft на диапазон Raft на таблет Raft на Region Paxos на split нет (асинхронная репликация)
Время HLC + max_offset HLC централизованный TSO (PD) TrueTime (GPS/атомные) обычные часы
Изоляция по умолчанию SERIALIZABLE SNAPSHOT SNAPSHOT SERIALIZABLE + external зависит от движка
Гео-распределение сильное (REGIONAL BY ROW) сильное (tablespaces по регионам) слабое (TSO — узкое место) сильнейшее ручное
HTAP нет (колоночного слоя нет) нет да, TiFlash ограниченно нет
Онлайн DDL да, без блокировок да да да да, с оговорками
Лицензия Enterprise-лицензия; бесплатно при выручке < 10M $ Apache 2.0 Apache 2.0 проприетарная (managed) Apache 2.0
Язык реализации Go + Pebble C++ (DocDB) + код PG Go (SQL) + Rust (TiKV) C++ Go / C
Минимум узлов в проде 3 3 ~5 (TiDB + PD + TiKV) 1 узел / от 100 PU 1 + прокси
Когда НЕ брать нужен полный PG-функционал; одиночный регион с умеренной нагрузкой нужна максимальная зрелость оптимизатора; тяжёлые кросс-таблетные джойны нужна геораспределённость; нужен PostgreSQL нужна портируемость и отсутствие vendor lock-in нужны кросс-шардовые транзакции и джойны

Отдельно про Vitess и Citus: это не NewSQL, а «шардирующий слой поверх настоящей СУБД», и это часто более разумный выбор. Citus — расширение PostgreSQL (см. https://courses.digitable.life/post/databases/02-postgresql/), которое даёт вам распределённые таблицы, оставаясь настоящим PostgreSQL со всеми расширениями и зрелым планировщиком. Vitess даёт шардированный MySQL и обслуживает YouTube и Slack. Оба честно говорят: кросс-шардовые транзакции и джойны либо ограничены, либо медленны. Взамен вы получаете отсутствие консенсуса в критическом пути записи — то есть запись со скоростью обычного MySQL/PostgreSQL, а не «RTT до кворума».

И Aurora DSQL (GA 2025) — самая интересная новинка: active-active в нескольких регионах, PostgreSQL-совместимость, но без 2PC вообще. Оптимистичный контроль параллелизма: транзакция выполняется локально, а на коммите проверяется на конфликты против глобального журнала, упорядоченного через Amazon Time Sync Service с микросекундной точностью. Цена очевидна из природы OCC: при высокой конкуренции за одни и те же строки растёт доля откатов, а изоляция — snapshot, не сериализуемая. Плюс на старте не было внешних ключей, последовательностей и триггеров. Смотреть стоит, закладываться в архитектуру — с осторожностью.

Замеры: чего реально ожидать

Опубликованные вендорские числа полезны как верхняя граница возможного, но не как прогноз для вашей нагрузки. Полезнее считать самому. Ниже — бюджет латентности для типичной OLTP-транзакции на кластере RF=3, три AZ одного региона:

Операция «создать заказ с 3 позициями» на CockroachDB, 3 AZ, RF=3

  разбор SQL + планирование                            0.3 мс
  чтение остатка товара (точечный, локальный range)    0.8 мс
  запись orders  → Raft-кворум                         2.2 мс  ┐
  запись order_items x3 → тот же range (интерливинг)   0.4 мс  │ параллельно
  обновление вторичного индекса → другой range         2.4 мс  ┘ ≈ 2.6 мс
  Parallel Commit (совмещён с записями)                0.0 мс
  ─────────────────────────────────────────────────────────────
  итого p50                                          ≈ 4 мс
  p99 (компакция LSM, ребалансировка, GC)            ≈ 25–60 мс

Три вывода, которые обычно всех удивляют:

  1. p50 приемлем, p99 — нет, если вы к нему не готовы. Хвост латентности у LSM-хранилища с фоновой компакцией и ребалансировкой принципиально толще, чем у PostgreSQL на локальном NVMe. Планируйте таймауты и retry исходя из p99, а не p50.
  2. Одиночный INSERT с автокоммитом почти всегда дороже, чем на PostgreSQL. Выигрыш появляется на пропускной способности, а не на латентности одной операции. Если ваша нагрузка — 500 tps и не растёт, распределённая база даст вам худшую латентность за бо́льшие деньги.
  3. Батчинг решает. INSERT ... VALUES (...), (...), (...) из 100 строк дешевле, чем 100 отдельных INSERT, не на 10%, а в десятки раз — потому что консенсус амортизируется. Это самая эффективная оптимизация в распределённом SQL, и она же самая игнорируемая.

Для калибровки порядков величин: Cockroach Labs публиковала результат 1.68 млн tpmC на 81 узле (TPC-C, 100 000 складов, 2020), а PingCAP регулярно публикует TPC-C и sysbench-числа для TiDB. Читайте их как «система умеет линейно масштабироваться на правильно спроектированной схеме», а не как «столько будет у меня».

Эксплуатация: чем это отличается от привычного

Резервные копии. Логический дамп 5-терабайтного распределённого кластера бессмыслен. Используйте встроенные механизмы: BACKUP INTO 's3://...' AS OF SYSTEM TIME '-10s' в CockroachDB (инкрементальные бэкапы + revision history для PITR), BR в TiDB, yb-admin create_snapshot / ysql_dump в YugabyteDB. Все они опираются на MVCC-снапшот, то есть бэкап не блокирует запись — но удерживает GC, поэтому долгий бэкап раздувает хранилище.

Сборка мусора MVCC. Старые версии удаляются не сразу: gc.ttlseconds по умолчанию 4 часа в CockroachDB, tidb_gc_life_time — 10 минут в TiDB. Отсюда два правила: (а) AS OF SYSTEM TIME дальше GC TTL вернёт ошибку; (б) долгая транзакция или незакрытая backup-джоба удерживают GC и приводят к росту диска и деградации чтений — прямой аналог xmin horizon в PostgreSQL.

Изменения схемы. Онлайн-DDL здесь — не бонус, а необходимость, и реализован он через протокол F1 с промежуточными состояниями схемы (DELETE_ONLYWRITE_ONLYPUBLIC), чтобы узлы с разными версиями схемы не портили данные. Практический эффект: ADD COLUMN ... DEFAULT и создание индекса не блокируют таблицу, но выполняются минутами-часами и создают заметный фон нагрузки. Откат DROP COLUMN невозможен после того, как схема стала PUBLIC и прошёл GC.

Ребалансировка и вывод узла. Никогда не выключайте узел «просто так»: сначала cockroach node decommission (или pd-ctl store delete в TiDB), дождитесь переезда реплик, потом гасите. Иначе кластер полчаса будет реплицировать данные под нагрузкой. И следите за тем, чтобы число зон отказа было не меньше RF — три реплики в двух AZ означают, что падение «неправильной» AZ убивает кворум.

Мониторинг: что смотреть.

Метрика Смысл Тревога
ranges.underreplicated диапазоны без полного RF > 0 дольше минуты
txn.restarts.* перезапуски транзакций по причинам рост serializable или writetooold
liveness.heartbeatlatency здоровье узлов p99 > 100 мс
clock-offset.meannanos расхождение часов > 20% от max-offset
queue.replicate.pending очередь ребалансировки устойчивый рост
hot ranges / hot regions горячие диапазоны одна запись доминирует по QPS

Ретраи — обязанность приложения, а не опция. При SERIALIZABLE конфликты сериализации нормальны и возвращаются как SQLSTATE 40001. Драйвер обязан их повторять.

import time, random
import psycopg

RETRYABLE = {"40001"}          # serialization_failure
MAX_ATTEMPTS = 10

def run_txn(conn, fn):
    """Выполнить транзакцию с экспоненциальным backoff и джиттером.
    Тело fn ДОЛЖНО быть идемпотентным: оно может выполниться несколько раз."""
    for attempt in range(MAX_ATTEMPTS):
        try:
            with conn.transaction():
                return fn(conn)
        except psycopg.errors.Error as e:
            if e.sqlstate not in RETRYABLE or attempt == MAX_ATTEMPTS - 1:
                raise
            # backoff растёт экспоненциально: 2ms, 4ms, 8ms, ... плюс джиттер,
            # иначе конкурирующие клиенты будут синхронно бить в одну строку
            delay = (2 ** attempt) * 0.002 * (0.5 + random.random())
            time.sleep(min(delay, 1.0))
    raise RuntimeError("недостижимо")

def transfer(conn, src, dst, cents):
    with conn.cursor() as cur:
        cur.execute("UPDATE accounts SET balance = balance - %s "
                    "WHERE id = %s AND balance >= %s RETURNING id", (cents, src, cents))
        if cur.fetchone() is None:
            raise ValueError("недостаточно средств")
        cur.execute("UPDATE accounts SET balance = balance + %s WHERE id = %s", (cents, dst))

Если доля ретраев превышает несколько процентов — это не проблема настройки, а проблема схемы: у вас есть строка, за которую дерутся все. Классика — счётчик в единственной строке. Лечится шардированием счётчика на N строк с периодическим суммированием.

Типичные катастрофы в проде

«Мы взяли распределённую базу, чтобы не думать о шардировании». Самая дорогая ошибка. Думать надо ровно столько же, только другими словами: вместо «в какой шард пойдёт запись» — «какой префикс PK, попадёт ли join в один диапазон, где горячие точки». База избавляет от инфраструктурной работы, но не от проектирования данных.

Мелкие транзакции по одной строке в цикле. На PostgreSQL это 0.1 мс на итерацию, здесь — 3–5 мс. Цикл на 10 000 итераций превращается из секунды в минуту. Всегда батчите.

SELECT count(*) FROM big_table. В распределённой базе это обход всех диапазонов на всех узлах. На таблице в миллиард строк — минуты и заметная деградация всего кластера. Держите счётчики отдельно или читайте оценку из статистики.

Внешние ключи между «далёкими» таблицами. Каждый FK — это дополнительное чтение (а при ON DELETE CASCADE — дополнительная запись) в другом диапазоне, скорее всего на другом узле. FK внутри общего префикса PK почти бесплатны; FK на глобальный справочник — по 2 мс каждый. Это не повод отказываться от FK, но повод их считать.

Долгие аналитические запросы поверх OLTP-кластера. Они удерживают GC, забивают память узлов и конфликтуют с записями. Выносите в follower reads или в отдельный колоночный контур (https://courses.digitable.life/post/databases/13-clickhouse-and-olap/); TiDB здесь исключение благодаря TiFlash.

Часы. Дрейф часов ломает корректность и роняет узлы. Отдельный сорт боли — VM с «замерзанием» на секунды (перегруженный гипервизор, снапшот, живая миграция): CockroachDB это увидит и убьёт узел, что правильно, но выглядит как случайные падения.

Планирование ёмкости от объёма данных, а не от IOPS. RF=3 означает тройной объём хранения плюс запас на компакцию LSM: рассчитывайте, что диск должен быть примерно в 4 раза больше логического размера данных, и что заполненность выше 70–80% ведёт к проблемам компакции.

Стоимость: считаем честно

Минимальный продакшн-кластер — три узла в трёх зонах доступности. Порядок цен (AWS, 2026, on-demand, без резервирования):

Самостоятельное развёртывание CockroachDB / YugabyteDB / TiDB
  3 x m6i.2xlarge (8 vCPU, 32 GiB)        ~ $0.384/ч x 3 x 730  ≈ $840/мес
  3 x 500 GiB gp3 + provisioned IOPS      ≈ $150–250/мес
  межзональный трафик (репликация!)       ≈ $30–150/мес — легко недооценить
  ─────────────────────────────────────────────────────────────
  инфраструктура                          ≈ $1000–1200/мес
  + инженер, который это эксплуатирует     ≈ основная статья расходов

Сравните: одна RDS PostgreSQL db.m6i.2xlarge Multi-AZ
                                          ≈ $700–900/мес, ноль distributed-специфики

Три момента, которые ломают бюджеты:

  1. Межзональный трафик. Кворумная репликация означает, что каждый записанный байт пересекает границу зоны минимум дважды. На нагрузке в 50 МБ/с записи это десятки терабайт в месяц и вполне ощутимый счёт. У облаков это ~0.01 $–0.02 за ГБ в каждую сторону.
  2. Лицензия CockroachDB. С версии 24.3 Cockroach Labs перешла на единую Enterprise-лицензию: бесплатно для компаний с годовой выручкой менее 10 $ млн, платно выше. Модель «Core бесплатен навсегда» закончилась. Если это принципиально — YugabyteDB и TiDB остаются под Apache 2.0.
  3. Spanner. Тарифицируется по вычислительной ёмкости (processing units, 1000 PU = 1 узел) и хранилищу; порядок — сотни долларов в месяц за узел в regional-конфигурации и заметно больше за multi-region, плюс сетевой трафик. Взамен вы вообще не занимаетесь эксплуатацией. Точные ставки смотрите в актуальном прайсе — они меняются.

Итог по деньгам прямой: распределённая SQL-база дороже одиночного PostgreSQL примерно в 1.5–3 раза при том же объёме данных. Она окупается, когда вы уже платите за ручное шардирование или за простои — то есть когда альтернатива тоже дорогая.

Как решать, брать или нет

Простой тест из четырёх вопросов. Если хотя бы на один ответ «да» — распределённая SQL-база оправдана. Если на все «нет» — вы, скорее всего, покупаете сложность впустую.

Отдельно проговорю то, что вендоры не любят: «у нас будет 100 миллионов пользователей» — не аргумент. Один современный сервер с NVMe и PostgreSQL спокойно держит терабайты данных и десятки тысяч транзакций в секунду. Распределённая база нужна тогда, когда у вас уже есть проблема, которую она решает: реальный объём, реальное требование к региональной отказоустойчивости, реальные требования регуляторов к резидентности данных. Проектировать «на вырост» здесь дорого — вы платите за латентность каждой транзакции, начиная с первого дня.

Мини-итог

  • Все распределённые SQL-базы строятся по одной схеме: таблица → упорядоченное KV → диапазоны → Raft/Paxos-группа на диапазон. Из этого механически выводятся и сильные стороны, и все грабли.
  • Главная нерешаемая проблема — время. Spanner покупает точность железом и платит commit wait на записи; CockroachDB и YugabyteDB используют HLC и платят перезапусками чтений; TiDB использует централизованный TSO и платит невозможностью геораспределения.
  • Изоляция по умолчанию различается: SERIALIZABLE у CockroachDB и Spanner, snapshot у TiDB, YugabyteDB и Aurora DSQL. Write skew при миграции — реальный риск.
  • Проектирование схемы важнее, чем в обычной СУБД: префикс первичного ключа определяет локальность, монотонный ключ создаёт горячую точку, батчинг даёт кратный выигрыш.
  • Ретраи на 40001 — обязательная часть приложения, а не защита «на всякий случай».
  • Цена — в 1.5–3 раза дороже одиночного PostgreSQL и заметно хуже по латентности одиночной операции. Берите, когда есть конкретная проблема, которую это решает.

Источники

Что дальше

Мы прошли все основные классы хранилищ — от реляционных и документных до колоночных, векторных и распределённых. Осталось собрать это в решение: Как выбрать БД и как мигрировать: сравнительная сводка по всем классам — сводная таблица по всем классам, процедура выбора под конкретную задачу и практика миграции без простоя.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов