Справочник DBA: базы данных изнутри читается по порядку

Статья 12.8 · раздел 12 «Архитектуры популярных СУБД» · 94 из 109

Распределённые SQL: диапазоны и Raft в CockroachDB, транзакция на двух ranges, TiKV, YugabyteDB, Citus и когда хватит одного PostgreSQL

опубликовано
читать
≈ 11 мин
автор
среда и версия
PostgreSQL 18.6 в контейнере postgres:18 и CockroachDB v26.2.9 в трёх контейнерах cockroachdb/cockroach:v26.2.9, каждому --memory 2g, диск в томах контейнеров, 20 ядер хоста общие для всех; pgbench 18.6; таблица accounts 100 000 строк; все узлы на одной машине, сеть между ними локальная; опыт выполнен 28 октября 2026
содержание решать задачи SQL на собеседовании

Три узла CockroachDB на той же машине, что и один PostgreSQL 18, дали на тех же SQL-скриптах в 3–10 раз меньше TPS, хотя узлов больше. Цена в том, что каждая запись идёт через Raft-группу своего диапазона ключей, а транзакция на двух диапазонах ещё и согласует коммит между ними. Raft разобран в статье про консенсус, секционирование PostgreSQL — в статье про секционирование; здесь — диапазоны и цена записи в CockroachDB, а YugabyteDB, TiDB/TiKV и Citus разобраны по коду их релизных тегов, без запуска.

DBA / 01

Транзакция на двух диапазонах в CockroachDB

Транзакция из двух UPDATE в CockroachDB: шлюз n1, диапазоны r83 и r85, parallel commit Шлюз n1 принимает BEGIN, UPDATE по aid=10, UPDATE по aid=60000 и COMMIT. Строка aid=10 лежит в диапазоне r83 с leaseholder n2, строка aid=60000 в диапазоне r85 с leaseholder n1. Обе записи имеют реплики на узлах 1, 2 и 3. При COMMIT в r83 уходят Put и EndTxn с parallel commit, в r85 уходит Put. Запись транзакции получает статус STAGING и затем становится явно закоммиченной. узел n1 принимает SQL и отправляет запросы по ключам BEGIN; UPDATE aid=10; UPDATE aid=60000; COMMIT; диапазон r83 ключ /Table/108/1/10 lease n2 · реплики {1,2,3} Put + EndTxn → запись STAGING диапазон r85 ключ /Table/108/1/60000 lease n1 · реплики {1,2,3} Put (intent) parallel commit: Put и EndTxn уходят одним шагом STAGING → «making txn commit explicit» → COMMITTED Узлы на одной машине: сетевые раунды Raft почти бесплатны, число не переносится на зоны.
Один COMMIT разослал записи в два диапазона с разными leaseholder (n2 и n1) за один шаг; статус STAGING у записи в r83 делает транзакцию закоммиченной неявно, пока все её записи на месте.

Стенд и нагрузка

Кластер — три контейнера CockroachDB в сети Docker и рядом один PostgreSQL. Диск у всех в томах контейнеров, --memory 2g у каждого, хост общий (20 ядер), поэтому сетевые раунды между узлами идут через локальный мост и почти ничего не стоят.

docker network create dba-distsql-net
docker run -d --name dba-distsql-pg --network dba-distsql-net --memory 2g -e POSTGRES_PASSWORD=pg postgres:18
for i in 1 2 3; do
  docker run -d --name dba-distsql-crdb$i --network dba-distsql-net --memory 2g \
    -v /cockroach/cockroach-data -h dba-distsql-crdb$i cockroachdb/cockroach:v26.2.9 \
    start --insecure --listen-addr=dba-distsql-crdb$i:26257 \
    --advertise-addr=dba-distsql-crdb$i:26257 --http-addr=dba-distsql-crdb$i:8080 \
    --join=dba-distsql-crdb1:26257,dba-distsql-crdb2:26257,dba-distsql-crdb3:26257 \
    --cache=512MiB --max-sql-memory=512MiB
done
docker exec dba-distsql-crdb1 cockroach init --insecure --host=dba-distsql-crdb1:26257
вывод
Cluster successfully initialized

На загруженном диске узлы могут выйти с WARNING: disk slowness detected: unable to sync log files within 10s (так было при повторном запуске кластера для разделов о диапазонах); помог флаг --log-dir= в start, он отключает файловые логи.

Встроенный cockroach workload run kv на PostgreSQL не запустился: FATAL: unrecognized configuration parameter "allow_unsafe_internals". Поэтому нагрузка одна для обеих баз: pgbench 18.6 с собственными скриптами в обычном SQL. Схема — как у pgbench: 10 филиалов, 100 касс, 100 000 счетов и журнал. Для CockroachDB деление (g-1)/10 даёт decimal, поэтому в её копии схемы стоит //.

CREATE TABLE branches (bid int PRIMARY KEY, bbalance int NOT NULL DEFAULT 0);
CREATE TABLE tellers (tid int PRIMARY KEY, bid int NOT NULL, tbalance int NOT NULL DEFAULT 0);
CREATE TABLE accounts (aid int PRIMARY KEY, bid int NOT NULL, abalance int NOT NULL DEFAULT 0, filler char(84) NOT NULL DEFAULT '');
CREATE TABLE history (id bigserial PRIMARY KEY, tid int, bid int, aid int, delta int, mtime timestamp DEFAULT now());
INSERT INTO branches SELECT g, 0 FROM generate_series(1,10) g;
INSERT INTO tellers SELECT g, (g-1)/10+1, 0 FROM generate_series(1,100) g;
INSERT INTO accounts SELECT g, (g-1)/10000+1, 0 FROM generate_series(1,100000) g;

Скрипт tpcb.sql: транзакция из пяти запросов, два из которых бьют по 10 и 100 горячим строкам. Скрипт simple.sql — тот же без обновлений tellers и branches.

\set aid random(1, 100000)
\set tid random(1, 100)
\set bid random(1, 10)
\set delta random(-5000, 5000)
BEGIN;
UPDATE accounts SET abalance = abalance + :delta WHERE aid = :aid;
SELECT abalance FROM accounts WHERE aid = :aid;
UPDATE tellers SET tbalance = tbalance + :delta WHERE tid = :tid;
UPDATE branches SET bbalance = bbalance + :delta WHERE bid = :bid;
INSERT INTO history (tid, bid, aid, delta) VALUES (:tid, :bid, :aid, :delta);
END;

Диапазоны и реплики

Следующие четыре раздела — один прогон на одной свежей базе bench (таблица accounts, ключи /Table/108/..., в обычном выводе сокращены до …/1/<aid>). Сырой вывод каждой команды вставлен без правок; <before:...>, <after:...> и … в ключах ставит сам SHOW RANGES. Только что созданная таблица accounts в 3 МБ лежит в одном диапазоне:

SELECT start_key, end_key, range_id, range_size_mb, lease_holder, replicas
FROM [SHOW RANGES FROM TABLE accounts WITH DETAILS] ORDER BY start_key;
вывод
       start_key      |   end_key    | range_id |     range_size_mb     | lease_holder | replicas
----------------------+--------------+----------+-----------------------+--------------+-----------
  <before:/Table/106> | <after:/Max> |       83 | 3.0369410000000000000 |            1 | {1,2,3}
(1 row)

Один диапазон r83, один leaseholder (n1) и реплика на каждом из трёх узлов. Он начинается раньше ключей таблицы (<before:/Table/106>), то есть таблица не получила собственного диапазона. Чтобы нагрузка разошлась, диапазон нужно разрезать и раздать:

ALTER TABLE accounts SPLIT AT SELECT g FROM generate_series(25001,75001,25000) g;
ALTER TABLE accounts SCATTER;
SELECT range_id, start_key, end_key, lease_holder, replicas
FROM [SHOW RANGES FROM TABLE accounts WITH DETAILS] ORDER BY start_key;
вывод (SHOW RANGES после SPLIT и SCATTER)
  range_id |      start_key      |   end_key    | lease_holder | replicas
-----------+---------------------+--------------+--------------+-----------
        83 | <before:/Table/106> | …/1/25001    |            2 | {1,2,3}
        84 | …/1/25001           | …/1/50001    |            3 | {1,2,3}
        85 | …/1/50001           | …/1/75001    |            1 | {1,2,3}
        86 | …/1/75001           | <after:/Max> |            1 | {1,2,3}
(4 rows)

Четыре диапазона по заданным границам 25001, 50001 и 75001, реплики на всех трёх узлах, leaseholder разошлись: n2, n3, n1, n1. Других границ в выводе нет: нагрузка в этом прогоне не запускалась.

Транзакция на двух диапазонах

Строка aid=10 лежит в r83 (leaseholder n2), строка aid=60000 — в r85 (leaseholder n1):

SELECT 10 AS aid, range_id, lease_holder FROM [SHOW RANGE FROM TABLE accounts FOR ROW (10)]
UNION ALL
SELECT 60000, range_id, lease_holder FROM [SHOW RANGE FROM TABLE accounts FOR ROW (60000)];
вывод
   aid  | range_id | lease_holder
--------+----------+---------------
     10 |       83 |            2
  60000 |       85 |            1
(2 rows)

План одного оператора по обоим ключам показывает узлы, на которых прошёл KV-слой:

EXPLAIN ANALYZE UPDATE accounts SET abalance = abalance + CASE aid WHEN 10 THEN -100 ELSE 100 END
WHERE aid IN (10, 60000);
вывод (фрагмент: узлы update и scan, остальные строки пропущены)
  • update
  │ sql nodes: n1
  …
      └── • scan
            sql nodes: n1
            kv nodes: n1, n2
            spans: [/10 - /10] [/60000 - /60000]
            locking strength: for update

SQL выполнил n1, а данные пришли с n1 и n2: leaseholder r85 — сам шлюз. Теперь явная транзакция с включённой трассировкой сессии; из трассы выбраны строки по шаблону sending, flushing, executing, making txn commit explicit (последняя обрезана запросом substr(message,1,150)):

SET tracing = on;
BEGIN;
UPDATE accounts SET abalance = abalance - 100 WHERE aid = 10;
UPDATE accounts SET abalance = abalance + 100 WHERE aid = 60000;
COMMIT;
SET tracing = off;
SELECT substr(message,1,150) AS m FROM [SHOW TRACE FOR SESSION]
WHERE message ~ '^(r[0-9]+: sending|flushing|executing|making txn commit explicit)';
вывод
                                                                            m
----------------------------------------------------------------------------------------------------------------------------------------------------------
  executing: BEGIN TRANSACTION in state: NoTxn
  executing: UPDATE accounts SET abalance = abalance - 100 WHERE aid = 10 in state: Open
  r83: sending batch 1 Get to (n2,s2):3
  executing Get(Exclusive,Unreplicated) [/Table/108/1/10/0], [txn: f067e01b], [can-forward-ts], [max_span_request_keys: 0], [target_bytes: 10485760]
  executing read-only batch
  executing: UPDATE accounts SET abalance = abalance + 100 WHERE aid = 60000 in state: Open
  r85: sending batch 1 Get to (n1,s1):1
  executing Get(Exclusive,Unreplicated) [/Table/108/1/60000/0], [txn: f067e01b], [max_span_request_keys: 0], [target_bytes: 10485760]
  executing read-only batch
  executing: COMMIT TRANSACTION in state: Open
  flushing 2 buffered requests in batch: Put [/Table/108/1/10/0], Put [/Table/108/1/60000/0], EndTxn(commit) [/Min], [txn: f067e01b]
  r85: sending batch 1 Put to (n1,s1):1
  r83: sending batch 1 Put, 1 EndTxn to (n2,s2):3
  executing Put [/Table/108/1/60000/0], [txn: f067e01b]
  executing read-write batch
  executing Put [/Table/108/1/10/0], EndTxn(parallel commit) [/Table/108/1/10/0], [txn: f067e01b]
  flushing proposal to Raft
  executing read-write batch
  flushing proposal to Raft
  making txn commit explicit: "sql txn" meta={id=f067e01b key=/Table/108/1/10/0 iso=Serializable pri=0.00760723 epo=0 ts=1793188828.814448796,0 min=1793
  executing: SET TRACING = off in state: NoTxn
(21 rows)

Каждый UPDATE сначала читает свой ключ запросом Get в свой диапазон (r83 на n2, r85 на n1, как в SHOW RANGES). При COMMIT накопленные Put уходят вместе: в r85 один Put, в r83 Put и EndTxn(parallel commit), то есть запись транзакции лежит в r83. Клиентская часть описана в комментарии файла txn_interceptor_committer.go (L50–L57, тег v26.2.9):

// Parallel commits works by defining a committed transaction as a transaction
// that meets one of the two following commit conditions:
//  1. a transaction is *explicitly committed* if it has a transaction record with
//     a COMMITTED status
//  2. a transaction is *implicitly committed* if it has a transaction record with
//     a STAGING status and intents written for all writes declared as "in-flight"
//     on the transaction record at equal or lower timestamps than the transaction
//     record's commit timestamp

Строка making txn commit explicit в трассе — шаг от условия 2 к условию 1: после EndTxn(parallel commit) транзакция уже закоммичена неявно (запись транзакции в STAGING, записи в обоих диапазонах на месте), а явный COMMITTED ей выставляют следом. Две строки flushing proposal to Raft — по одному предложению в каждой из двух Raft-групп на один COMMIT.

Цена в TPS и латентности

Каждый запуск — pgbench -n -r -c 16 -j 4 -T 30: 16 клиентов, 4 потока, 30 секунд. К CockroachDB клиент подключён к одному узлу n1 с --max-tries=50, чтобы повторять транзакции после ошибок сериализации. Один запуск на конфигурацию, разброс между запусками не мерили.

СкриптСУБДTPSЛатентность, мсПовторы транзакций
simple.sqlPostgreSQL 18.67 5312,124нет
simple.sqlCockroachDB, по 1 range на таблицу2 5306,3250
simple.sqlCockroachDB, accounts разрезана и раздана2 0747,7140
tpcb.sqlPostgreSQL 18.63 3014,847нет
tpcb.sqlCockroachDB, по 1 range на таблицу34047,062397 транзакций (3,88 %), 486 повторов
tpcb.sqlCockroachDB, accounts разрезана и раздана25862,035142 транзакции (1,82 %)

На simple.sql PostgreSQL быстрее в 2,98 раза (7 531 против 2 530 TPS), на tpcb.sql — в 9,7 раза (3 301 против 340). Разница — в двух горячих таблицах. Из построчной статистики pgbench: UPDATE branches стоил 2,131 мс у PostgreSQL и 16,625 мс у CockroachDB; из 486 повторов у CockroachDB 464 пришлись на UPDATE branches и 21 на UPDATE tellers. Десять строк на шестнадцать клиентов — это конфликты записи. Разрезание и раздача accounts TPS не подняли: 2 074 против 2 530. Причина в опыте не выяснялась: все узлы, pgbench и хост делят одни 20 ядер и один диск, масштабироваться в другие машины им некуда.

Цифры не переносятся на кластер в нескольких зонах: здесь сетевые раунды Raft идут через локальный мост Docker и почти бесплатны, а задержки между зонами этот опыт не измерял. Ещё две находки: в bigserial CockroachDB при создании таблицы выдал NOTICE: using sequential values in a primary key does not perform as well as using random UUIDs; монотонный ключ журнала history в этом опыте не разбирался. И CockroachDB без лицензии пишет в каждое подключение NOTICE: No license is installed. Throttling will begin after 2026-11-04 11:41:30 +0000 UTC unless a license is installed before then.

Код остальных систем

Здесь только релизные теги, без запуска. Файлы открыты через raw.githubusercontent.com по тегу.

TiDB и TiKV (v8.5.8). SQL-слой TiDB без состояния: по документации, TiDB server разбирает и оптимизирует запрос и строит распределённый план. В pkg/planner/optimize.go (L577–L581) выбор оптимизатора зависит от сессионной переменной: GetEnableCascadesPlanner() ведёт в cascades.DefaultOptimizer.FindBestPlan, иначе работает core.DoOptimize. Транзакции хранит TiKV: структура TransactionProperties в prewrite.rs (L289–L293) хранит primary, первичный ключ транзакции, а способ коммита выбирает CommitKind (L328–L334):

pub enum CommitKind {
    TwoPc,
    /// max_commit_ts
    OnePc(TimeStamp),
    /// max_commit_ts
    Async(TimeStamp),
}

В перечислении три способа коммита: TwoPc, OnePc и Async; у последних двух параметр помечен комментарием max_commit_ts. Отдельная функция commit в соседнем файле actions/commit.rs читает блокировку ключа (reader.load_lock) и отказывает, если commit_ts меньше min_commit_ts блокировки.

YugabyteDB (v2026.1.2.0). SQL-слой — форк PostgreSQL: src/postgres/configure.ac (L20) объявляет AC_INIT([PostgreSQL], [15.12], ...), то есть YSQL этого тега построен на PostgreSQL 15.12, тогда как актуальная версия в этом опыте — 18.6. Конфликты транзакций решает src/yb/docdb/conflict_resolution.h (L44–L74): три политики WAIT_ON_CONFLICT, SKIP_ON_CONFLICT и FAIL_ON_CONFLICT; при последней транзакциям назначаются случайные приоритеты, и более приоритетная прерывает конфликтующих.

Citus (v14.3.0). Citus делит таблицы на шарды; подсказка планировщика в multi_router_planner.c (L1117–L1119) советует «equality filter on partition column … to target a single shard», то есть равенство по колонке распределения. В том же файле (L2879–L2886) router-планировщик, который отправляет запрос на один набор шардов, отказывает запросу с таблицами не в одной колокации:

	if (!EnableNonColocatedRouterQueryPushdown &&
		!AllShardsColocated(*relationShardList))
	{
		return DeferredError(ERRCODE_FEATURE_NOT_SUPPORTED,
							 "router planner does not support queries that "
							 "reference non-colocated distributed tables",
							 NULL, NULL);
	}

Это отказ именно router-планировщика; как Citus выполняет такой запрос дальше, в файле не определено и в этой статье не проверялось.

Что из этого следует

  • В этом опыте распределённость не дала выигрыша по TPS. На одной машине PostgreSQL выдал 7 531 против 2 530 TPS у CockroachDB на simple.sql; задержки между зонами не измерены.
  • Горячая строка в распределённой базе дороже, чем в PostgreSQL. Десять филиалов на шестнадцать клиентов дали 3,88 % транзакций с повторами, и PostgreSQL обогнал CockroachDB в 9,7 раза; схему нужно проектировать так, чтобы записи расходились по ключам.
  • Ranges надо смотреть, а не предполагать. Таблица в 3 МБ лежит в одном диапазоне, и пока он один, у него один leaseholder; разрезание и раздача на общем хосте TPS не подняли (2 074 против 2 530).
  • Транзакция на нескольких диапазонах — это несколько Raft-групп. В трассе: два flushing proposal to Raft на один COMMIT с двумя ключами.
  • Совместимость с PostgreSQL проверяй по версии. YugabyteDB v2026.1.2.0 встраивает PostgreSQL 15.12, а в этом опыте PostgreSQL 18.6; CockroachDB сообщает server 13.0.0 в шапке pgbench.

Чек-лист

  1. Перед выбором распределённой СУБД измерь свою нагрузку на одном PostgreSQL на тех же скриптах (pgbench -f) и запиши TPS, латентность и долю горячих строк.
  2. Для CockroachDB проверь SHOW RANGES FROM TABLE ... WITH DETAILS: сколько диапазонов, где leaseholder, на каких узлах реплики.
  3. Горячие таблицы из десятков строк (счётчики, остатки по филиалам) вынеси из транзакции или разнеси по ключам.
  4. Сетевые числа замеряй между реальными зонами: опыт на одной машине показывает цену Raft без сетевых задержек зон.
  5. Контейнеры и сеть удали вместе с данными: docker rm -f -v dba-distsql-pg dba-distsql-crdb1 dba-distsql-crdb2 dba-distsql-crdb3 && docker network rm dba-distsql-net.

Источники

Выжимки статей в Telegram: одна ловушка, код с выводом и ссылка на полный разбор. t.me/koddo_ru