Три узла CockroachDB на той же машине, что и один PostgreSQL 18, дали на тех же SQL-скриптах в 3–10 раз меньше TPS, хотя узлов больше. Цена в том, что каждая запись идёт через Raft-группу своего диапазона ключей, а транзакция на двух диапазонах ещё и согласует коммит между ними. Raft разобран в статье про консенсус, секционирование PostgreSQL — в статье про секционирование; здесь — диапазоны и цена записи в CockroachDB, а YugabyteDB, TiDB/TiKV и Citus разобраны по коду их релизных тегов, без запуска.
Транзакция на двух диапазонах в CockroachDB
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.sql | PostgreSQL 18.6 | 7 531 | 2,124 | нет |
simple.sql | CockroachDB, по 1 range на таблицу | 2 530 | 6,325 | 0 |
simple.sql | CockroachDB, accounts разрезана и раздана | 2 074 | 7,714 | 0 |
tpcb.sql | PostgreSQL 18.6 | 3 301 | 4,847 | нет |
tpcb.sql | CockroachDB, по 1 range на таблицу | 340 | 47,062 | 397 транзакций (3,88 %), 486 повторов |
tpcb.sql | CockroachDB, accounts разрезана и раздана | 258 | 62,035 | 142 транзакции (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.
Чек-лист
- Перед выбором распределённой СУБД измерь свою нагрузку на одном PostgreSQL на тех же скриптах (
pgbench -f) и запиши TPS, латентность и долю горячих строк. - Для CockroachDB проверь
SHOW RANGES FROM TABLE ... WITH DETAILS: сколько диапазонов, где leaseholder, на каких узлах реплики. - Горячие таблицы из десятков строк (счётчики, остатки по филиалам) вынеси из транзакции или разнеси по ключам.
- Сетевые числа замеряй между реальными зонами: опыт на одной машине показывает цену Raft без сетевых задержек зон.
- Контейнеры и сеть удали вместе с данными:
docker rm -f -v dba-distsql-pg dba-distsql-crdb1 dba-distsql-crdb2 dba-distsql-crdb3 && docker network rm dba-distsql-net.