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 устроены почти одинаково.
стоимостная оптимизация
распределённое исполнение"] end subgraph L2["Слой 2: транзакционное KV"] T["MVCC-версии, intent-ы, разрешение конфликтов
гибридные логические часы
распределённый commit"] end subgraph L3["Слой 3: репликация"] R["диапазоны ключей, Raft-группа на диапазон
ребалансировка, split/merge, leaseholder"] end subgraph L4["Слой 4: локальное хранилище"] S["LSM-дерево на узле: Pebble / RocksDB
WAL, memtable, SST, компакция"] end C["Клиент по wire-протоколу
PostgreSQL или MySQL"] --> P P -->|"KV-операции по диапазонам"| T T -->|"Raft-предложения"| R R -->|"батчи записей"| S M["Слой метаданных
PD / master / gossip"] -. "где какой диапазон,
кто leaseholder" .-> P M -. "команды на перемещение реплик" .-> R
Ключевая идея, из которой следует всё остальное: таблица кодируется в один непрерывный отсортированный диапазон байтовых ключей, а этот диапазон механически режется на куски, каждый из которых живёт своей жизнью.
Из этой картинки вытекают все практические последствия, о которых пойдёт речь дальше:
- Порядок 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 на секунды. Есть три разных ответа.
Ответ Spanner — потратить деньги на железо. GPS-приёмники и атомные часы в каждом датацентре сжимают неопределённость ε до единиц миллисекунд. API возвращает не время, а интервал TT.now() = [earliest, latest], гарантированно содержащий истинное время. Транзакция берёт метку s = latest и ждёт, пока TT.after(s) не станет истинным, — это и есть commit wait, примерно 2ε. В статье 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 и узнает исход
Изящество в том, что второй фазе не нужно быть синхронной: любой читатель, встретивший неразрешённый лок, идёт по указателю на primary и достраивает истину. Цена — читатели иногда делают лишнюю работу, а при падении координатора локи висят до истечения TTL и блокируют конфликтующие чтения.
Parallel Commits (CockroachDB)
CockroachDB c версии 19.2 применяет другой трюк. Транзакция считается закоммиченной, если её запись состояния имеет статус STAGING и все её intent-ы успешно записаны. Это проверяемое условие, поэтому не нужно ждать явной записи COMMITTED — она делается асинхронно.
отправлены ПАРАЛЛЕЛЬНО STAGING --> Implicit: все intent-ы подтверждены →
транзакция фактически закоммичена,
клиенту отвечают ЗДЕСЬ Implicit --> COMMITTED: асинхронная запись явного статуса STAGING --> PENDING: часть intent-ов отклонена
(push, конфликт меток) PENDING --> ABORTED: конфликт неразрешим / таймаут COMMITTED --> Resolved: intent-ы заменяются
обычными MVCC-версиями ABORTED --> Resolved: intent-ы удаляются Resolved --> [*] note right of Implicit Латентность коммита = один раунд консенсуса вместо двух. Наблюдатель, увидевший STAGING, обязан сам проверить intent-ы — это «recovery protocol». end note
Практический эффект: коммит распределённой транзакции стоит примерно столько же, сколько запись одного ключа. Это разница между 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 ← один диапазон ключей, один узел
Что смотреть в первую очередь:
distribution: localпротивfull.fullозначает, что план разослан на все узлы — законно для аналитики, катастрофично для OLTP-запроса, который вы зовёте 5000 раз в секунду.network usage. Ненулевой сетевой обмен в точечном запросе — почти всегда признак того, что предикат не содержит префикса PK и произошёл кросс-узловой join.spans.ALLвместо конкретного диапазона — это распределённый full scan. В CockroachDB его можно запретить:SET disallow_full_table_scans = on;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 мс
Три вывода, которые обычно всех удивляют:
- p50 приемлем, p99 — нет, если вы к нему не готовы. Хвост латентности у LSM-хранилища с фоновой компакцией и ребалансировкой принципиально толще, чем у PostgreSQL на локальном NVMe. Планируйте таймауты и retry исходя из p99, а не p50.
- Одиночный
INSERTс автокоммитом почти всегда дороже, чем на PostgreSQL. Выигрыш появляется на пропускной способности, а не на латентности одной операции. Если ваша нагрузка — 500 tps и не растёт, распределённая база даст вам худшую латентность за бо́льшие деньги. - Батчинг решает.
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_ONLY → WRITE_ONLY → PUBLIC), чтобы узлы с разными версиями схемы не портили данные. Практический эффект: 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-специфики
Три момента, которые ломают бюджеты:
- Межзональный трафик. Кворумная репликация означает, что каждый записанный байт пересекает границу зоны минимум дважды. На нагрузке в 50 МБ/с записи это десятки терабайт в месяц и вполне ощутимый счёт. У облаков это ~0.01 $–0.02 за ГБ в каждую сторону.
- Лицензия CockroachDB. С версии 24.3 Cockroach Labs перешла на единую Enterprise-лицензию: бесплатно для компаний с годовой выручкой менее 10 $ млн, платно выше. Модель «Core бесплатен навсегда» закончилась. Если это принципиально — YugabyteDB и TiDB остаются под Apache 2.0.
- Spanner. Тарифицируется по вычислительной ёмкости (processing units, 1000 PU = 1 узел) и хранилищу; порядок — сотни долларов в месяц за узел в regional-конфигурации и заметно больше за multi-region, плюс сетевой трафик. Взамен вы вообще не занимаетесь эксплуатацией. Точные ставки смотрите в актуальном прайсе — они меняются.
Итог по деньгам прямой: распределённая SQL-база дороже одиночного PostgreSQL примерно в 1.5–3 раза при том же объёме данных. Она окупается, когда вы уже платите за ручное шардирование или за простои — то есть когда альтернатива тоже дорогая.
Как решать, брать или нет
Простой тест из четырёх вопросов. Если хотя бы на один ответ «да» — распределённая SQL-база оправдана. Если на все «нет» — вы, скорее всего, покупаете сложность впустую.
с произвольными запросами?"] -->|нет| Z1["NoSQL по профилю нагрузки:
KV, документная, wide-column,
колоночная для аналитики"] A -->|да| B["Данные и нагрузка помещаются
в один сервер PostgreSQL
с репликами для чтения?"] B -->|да, с запасом 2–3x| Z2["PostgreSQL / MySQL.
Распределённая БД — преждевременная
оптимизация с реальной ценой"] B -->|нет| C["Требуется ли переживать
потерю целого региона
без ручного failover?"] C -->|да| D C -->|нет| E["Нужны ли кросс-шардовые
транзакции и джойны?"] E -->|нет| Z3["Vitess / Citus / ручное шардирование:
дешевле и быстрее по латентности"] E -->|да| D["Распределённый SQL"] D --> F{"Что важнее всего?"} F -->|"совместимость с PostgreSQL"| G["YugabyteDB
или Aurora DSQL"] F -->|"гео-распределение и SERIALIZABLE"| H["CockroachDB"] F -->|"MySQL + аналитика в той же БД"| I["TiDB + TiFlash"] F -->|"максимум гарантий, минимум эксплуатации,
согласны на GCP"| J["Cloud Spanner"]
Отдельно проговорю то, что вендоры не любят: «у нас будет 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 и заметно хуже по латентности одиночной операции. Берите, когда есть конкретная проблема, которую это решает.
Источники
- Spanner: Google’s Globally-Distributed Database — OSDI 2012, обязательное чтение
- Large-scale Incremental Processing Using Distributed Transactions and Notifications — Percolator, основа транзакций TiDB
- Spanner: Becoming a SQL System — SIGMOD 2017, как поверх KV вырос настоящий SQL
- CockroachDB: The Resilient Geo-Distributed SQL Database — SIGMOD 2020
- The Case for Determinism in Database Systems — Calvin, альтернатива 2PC
- In Search of an Understandable Consensus Algorithm — Raft, Ongaro & Ousterhout
- Документация CockroachDB, TiDB, YugabyteDB, Cloud Spanner
- Martin Kleppmann, Designing Data-Intensive Applications — главы 7–9 про транзакции, распределённые системы и консистентность
Что дальше
Мы прошли все основные классы хранилищ — от реляционных и документных до колоночных, векторных и распределённых. Осталось собрать это в решение: Как выбрать БД и как мигрировать: сравнительная сводка по всем классам — сводная таблица по всем классам, процедура выбора под конкретную задачу и практика миграции без простоя.