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

Статья 7.7 · раздел 7 «Проектирование схемы и данных» · 53 из 109

Моделирование под ClickHouse, MongoDB и Cassandra: порядок ORDER BY, вложенный документ против $lookup, partition key

опубликовано
читать
≈ 10 мин
автор
среда и версия
ClickHouse 25.3.14.14 (clickhouse/clickhouse-server:25.3), MongoDB 8.3.13 (mongo:8), Cassandra 5.0.9 (cassandra:5, MAX_HEAP_SIZE=1G), по одному контейнеру с --memory 2g на систему; опыт выполнен 28 октября 2026 на 1 000 000 заказов и 20 000 клиентах
содержание решать задачи SQL на собеседовании

Нормальные формы и JSONB в PostgreSQL решают один вопрос: сколько стоят запись и соединение. В другом движке те же «заказы и клиенты» ложатся иначе, потому что движок по-другому читает диск. ClickHouse хранит столбцы отдельными файлами и читает диапазоны гранул, как в статье про ключ MergeTree; MongoDB хранит заказ одним документом; Cassandra, построенная на LSM-дереве, находит данные по хэшу ключа партиции. Раскладка строки в каждом из них разобрана в сравнении движков; здесь один и тот же набор в трёх моделях: порядок столбцов ORDER BY решает размер таблицы, вложенный документ заменяет соединение, а partition key решает, сколько данных окажется в одном месте.

Один набор в трёх контейнерах

Каждая система в своём контейнере с --memory 2g, сеть не нужна. Cassandra стартует не сразу, поэтому готовность проверяется циклом с таймаутом, а не sleep:

P=dba-modeling-columnar-and-document
docker run -d --name $P-ch --memory 2g -e CLICKHOUSE_SKIP_USER_SETUP=1 clickhouse/clickhouse-server:25.3
docker run -d --name $P-mongo --memory 2g mongo:8
docker run -d --name $P-cass --memory 2g -e MAX_HEAP_SIZE=1G -e HEAP_NEWSIZE=200M cassandra:5
timeout 300 sh -c "until docker exec $P-cass nodetool status 2>/dev/null | grep -q '^UN'; do sleep 5; done"
docker exec $P-ch clickhouse-client -q "SELECT version()"
docker exec $P-mongo mongosh --quiet --eval 'db.version()'
docker exec $P-cass nodetool version
вывод
25.3.14.14
8.3.13
ReleaseVersion: 5.0.9

Cassandra при запуске пишет, что HEAP_NEWSIZE не учитывается вместе с G1 (HEAP_NEWSIZE has erroneously been set and will be ignored in combination with G1). Набор: 20 000 клиентов (страна и тариф вычисляются из номера) и 1 000 000 заказов за 90 дней, статус и сумма случайные. Один скрипт пишет файлы для всех трёх систем: плоский CSV, заказы с вложенным клиентом и заказы со ссылкой.

import random, datetime, json
random.seed(42)
C = ['DE', 'FR', 'US', 'BR', 'IN', 'JP', 'PL', 'ES', 'IT', 'TR']
T = ['free', 'pro', 'team']
S = ['paid', 'shipped', 'refunded', 'cancelled']
base = datetime.datetime(2026, 7, 1)
cust = [(i, 'user%d' % i, C[i % 10], T[i % 3]) for i in range(1, 20001)]
with open('customers.ndjson', 'w') as k:
    for i, name, country, tier in cust:
        k.write(json.dumps({'_id': i, 'name': name, 'country': country, 'tier': tier}) + '\n')
with open('orders.csv', 'w') as f, open('orders_ref.ndjson', 'w') as r, open('orders_embedded.ndjson', 'w') as e:
    for n in range(1, 1000001):
        i, name, country, tier = cust[random.randrange(20000)]
        ts = base + datetime.timedelta(seconds=random.randrange(86400 * 90))
        status = random.choices(S, [70, 20, 5, 5])[0]
        amount = random.randrange(100, 50001)
        f.write('%d,%d,%s,%s,%d,%s,%s,%s\n' % (n, i, ts.strftime('%Y-%m-%d %H:%M:%S'), status, amount, country, tier, name))
        doc = {'_id': n, 'customer_id': i, 'ts': {'$date': ts.isoformat() + 'Z'}, 'status': status, 'amount': amount}
        r.write(json.dumps(doc) + '\n')
        doc['customer'] = {'id': i, 'name': name, 'country': country, 'tier': tier}
        e.write(json.dumps(doc) + '\n')
python3 gen.py
docker cp orders.csv $P-ch:/tmp/orders.csv
docker cp orders.csv $P-cass:/tmp/orders.csv
for f in orders_embedded orders_ref customers; do docker cp $f.ndjson $P-mongo:/tmp/$f.ndjson; done

Запросы опыта: A — сумма заказов страны DE со статусом paid; B — сумма заказов недели с 1 по 8 августа по странам; в Cassandra — заказы одного клиента.

DBA / 01

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

Миллион заказов в ClickHouse, MongoDB и Cassandra: по две модели в каждойТри панели. ClickHouse: ключ (status, country) занимает 12,5 МиБ и читает 10 гранул из 123 для запроса по стране и статусу; ключ (ts, customer_id) занимает 20,9 МиБ и читает 123 гранулы из 123; столбец country сжат до 5 247 байт против 955 129, столбец customer_id в десять раз меньше: 355 764 против 3 626 154. MongoDB: вложенный клиент весит 55,8 МБ и считает неделю за 95-102 мс; $lookup в customers весит 30,7 МБ плюс 0,3 МБ и считает ту же неделю за 652-689 мс; смена страны клиента правит 1 документ против 50. Cassandra: ключ customer_id даёт 19 975 партиций, наибольшая 4 768 байт; ключ country даёт 10 партиций, наименьшая 4 866 324 байт; запрос по неделе без ключа отвергается с требованием ALLOW FILTERING.ClickHouse · ORDER BYMongoDB · документCassandra · partition key(status, country, …)сжато 12,5 МиБзапрос A: 10 из 123 гранул(ts, customer_id, …)сжато 20,9 МиБзапрос A: 123 из 123 гранулвложенный клиент55,8 МБ, 146 Б на заказнеделя: 95–102 мс$lookup в customers30,7 + 0,3 МБ, 73 Б на заказта же неделя: 652–689 мс((customer_id), ts)19 975 партицийнаибольшая 4 768 Б((country), …)10 партицийнаименьшая 4 866 324 Бcountry: 5 247 Бпротив 955 129 Бcustomer_id: в 10 раз меньшесмена страны клиента:1 документ против 50сумма за неделю без ключа:отказ, нужен ALLOW FILTERING
Зелёная модель в каждой системе построена под запрос, ради которого заведена таблица, пунктирная читает тот же набор хуже. ClickHouse выигрывает размером и числом гранул, MongoDB — временем запроса, Cassandra — размером партиции.

ClickHouse: что стоит первым в ORDER BY

Плоская таблица без соединений: страна, тариф и имя клиента лежат в каждой строке. Две таблицы с одними данными и разным порядком ключа: от низкой кардинальности к высокой (status 4 значения, country 10, customer_id 20 000, ts) и наоборот.

docker exec -i $P-ch clickhouse-client -mn <<'EOF'
CREATE TABLE orders_low_first (order_id UInt32, customer_id UInt32, ts DateTime, status LowCardinality(String), amount UInt32, country LowCardinality(String), tier LowCardinality(String), name String)
ENGINE = MergeTree ORDER BY (status, country, customer_id, ts);
CREATE TABLE orders_high_first (order_id UInt32, customer_id UInt32, ts DateTime, status LowCardinality(String), amount UInt32, country LowCardinality(String), tier LowCardinality(String), name String)
ENGINE = MergeTree ORDER BY (ts, customer_id, country, status);
EOF
for t in orders_low_first orders_high_first; do
  docker exec $P-ch sh -c "clickhouse-client -q 'INSERT INTO $t FORMAT CSV' < /tmp/orders.csv"
  docker exec $P-ch clickhouse-client -q "OPTIMIZE TABLE $t FINAL"
done

Порядок физический: каждый вставляемый блок сортируется по ключу до записи в part, и столбцы пишутся уже в этом порядке.

    if (!sort_description.empty())
    {
        ProfileEventTimeIncrement<Microseconds> watch(ProfileEvents::MergeTreeDataWriterSortingBlocksMicroseconds);

        if (!isAlreadySorted(block, sort_description))
        {
            stableGetPermutation(block, sort_description, perm);
            perm_ptr = &perm;
        }
        else
            ProfileEvents::increment(ProfileEvents::MergeTreeDataWriterBlocksAlreadySorted);
    }

MergeTreeDataWriter::writeTempPartImpl, MergeTreeDataWriter.cpp (v25.3.14.14-lts, строки 591–602): перестановка строк считается один раз по sort_description, а блок, который уже отсортирован, не трогается.

Размер каждого столбца в байтах после сжатия, из system.columns:

SELECT name,
  sumIf(data_compressed_bytes, table = 'orders_low_first') AS low_first,
  sumIf(data_compressed_bytes, table = 'orders_high_first') AS high_first
FROM system.columns WHERE database = 'default' AND table IN ('orders_low_first', 'orders_high_first')
GROUP BY name WITH TOTALS ORDER BY low_first;
SELECT table, formatReadableSize(bytes_on_disk), marks FROM system.parts WHERE active AND table LIKE 'orders_%' ORDER BY table;
вывод
status       4850      488613
country      5247      955129
tier         244111    549942
customer_id  355764    3626154
name         598737    4549043
amount       3874579   3877135
order_id     4017277   4017275
ts           4017288   3865373

             13117853  21928664
orders_high_first  20.92 MiB  124
orders_low_first   12.51 MiB  124

Итог: 13 117 853 байта против 21 928 664, на диске 12,51 против 20,92 МиБ. Выиграли столбцы, которые повторяются или зависят от ведущих столбцов ключа: country сжался с 955 129 до 5 247 байт, status с 488 613 до 4 850, customer_id вдесятеро, name (функция customer_id) с 4 549 043 до 598 737. Столбцы без повторов внутри групп ключа не изменились: order_id уникален, amount случаен, а ts даже в первом положении сжался лишь до 3 865 373 из 4 000 000 байт, потому что миллион заказов размазан по 7,8 млн секунд.

Тот же ключ решает и чтение. Запросы A (страна и статус) и B (неделя, группировка по странам) по пять раз; число гранул и прочитанных строк берётся из system.query_log (SelectedMarks, read_rows):

for t in orders_low_first orders_high_first; do
  for i in 1 2 3 4 5; do
    docker exec $P-ch clickhouse-client --log_comment "A_$t" -q "SELECT sum(amount) FROM $t WHERE country = 'DE' AND status = 'paid' FORMAT Null"
    docker exec $P-ch clickhouse-client --log_comment "B_$t" -q "SELECT country, sum(amount) FROM $t WHERE ts >= '2026-08-01' AND ts < '2026-08-08' GROUP BY country FORMAT Null"
  done
done
docker exec $P-ch clickhouse-client -q "SYSTEM FLUSH LOGS"
docker exec $P-ch clickhouse-client -q "SELECT log_comment, any(ProfileEvents['SelectedMarks']) AS marks, any(read_rows) AS rows, median(query_duration_ms) AS ms FROM system.query_log WHERE type = 'QueryFinish' AND log_comment LIKE '%\_orders\_%' GROUP BY log_comment ORDER BY log_comment"
вывод
A_orders_high_first	123	1000000	3.5
A_orders_low_first	10	81920	2.5
B_orders_high_first	10	81920	3
B_orders_low_first	123	1000000	4

Колонки вывода: метка запроса, гранул, прочитано строк, медиана в миллисекундах. С ключом (status, country, …) запрос A читает 10 гранул из 123, а B — все 123; с ключом (ts, customer_id, …) наоборот. Время (2,5–4 мс на миллионе строк в горячем кэше) ничего не различает, различают строки: каждая таблица читает либо 81 920 строк, либо все 1 000 000. Один порядок ключа обслуживает один порядок вопросов; нужны оба — нужна вторая таблица или сводка.

MongoDB: вложить клиента или ссылаться на него

Два варианта тех же заказов. В orders_embedded клиент лежит внутри каждого заказа, в orders_ref в заказе только customer_id, а клиенты в customers. Индекс по времени нужен обоим.

docker exec $P-mongo sh -c 'for c in orders_embedded orders_ref customers; do mongoimport --quiet --db shop --collection $c --file /tmp/$c.ndjson; done'
docker exec $P-mongo mongosh --quiet --eval '
  db = db.getSiblingDB("shop");
  db.orders_embedded.createIndex({ts: 1}); db.orders_ref.createIndex({ts: 1});
  db.getCollectionNames().sort().forEach(n => { const s = db[n].stats(); print(n, s.count, "avgObjSize", s.avgObjSize, "storageSize", s.storageSize, "size", s.size) });
  print(db.hello().maxBsonObjectSize)'
вывод
customers 20000 avgObjSize 64 storageSize 315392 size 1282227
orders_embedded 1000000 avgObjSize 146 storageSize 55783424 size 146159913
orders_ref 1000000 avgObjSize 73 storageSize 30707712 size 73048300
16777216

Копия клиента удвоила заказ: 146 байт против 73, 55,8 МБ на диске против 30,7 МБ плюс 0,3 МБ на customers (storageSize сжат, size нет). Предел размера документа задан одной строкой в src/mongo/bson/util/builder.h (r8.3.13, строка 77): MONGO_MOD_PUBLIC const int BSONObjMaxUserSize = 16 * 1024 * 1024;, то есть 16 777 216 байт, как в выводе maxBsonObjectSize. Обратная вложенность, заказы внутри клиента, упрётся в предел на 229 тысячах заказов по 73 байта (16 777 216 / 73); у клиента 3649 их 50.

Запрос «сумма заказов недели по странам». Для ссылок нужен $lookup, для вложенных достаточно $group. Файл q.js печатает из explain("executionStats") число просмотренных документов и время пяти реальных запусков aggregate():

db = db.getSiblingDB('shop')
const rng = {ts: {$gte: ISODate('2026-08-01'), $lt: ISODate('2026-08-08')}}
const pRef = [{$match: rng}, {$lookup: {from: 'customers', localField: 'customer_id', foreignField: '_id', as: 'c'}},
              {$unwind: '$c'}, {$group: {_id: '$c.country', amount: {$sum: '$amount'}}}]
const pEmb = [{$match: rng}, {$group: {_id: '$customer.country', amount: {$sum: '$amount'}}}]
function run(coll, p) {
  const e = db[coll].explain('executionStats').aggregate(p)
  const x = (e.stages ? e.stages[0].$cursor : e).executionStats
  const l = (e.stages || []).find(s => s.$lookup)
  print(coll, 'docs', x.totalDocsExamined, l ? '$lookup docs ' + l.totalDocsExamined + ' ' + l.indexesUsed : '')
  const t = []
  for (let i = 0; i < 5; i++) { const s = Date.now(); db[coll].aggregate(p).toArray(); t.push(Date.now() - s) }
  print('  ms', t.sort((a, b) => a - b).join(' '))
}
run('orders_ref', pRef); run('orders_embedded', pEmb)
docker cp q.js $P-mongo:/tmp/q.js && docker exec $P-mongo mongosh --quiet /tmp/q.js
вывод
orders_ref docs 77554 $lookup docs 77555 _id_
  ms 652 657 674 686 689
orders_embedded docs 77554 
  ms 95 97 100 101 102

Оба варианта прочитали по индексу ts те же 77 554 документа. Вложенный вариант на этом заканчивает, а ссылочный на каждый заказ ищет клиента в customers по индексу _id_ (77 555 просмотренных документов): 652–689 мс против 95–102, примерно в семь раз. Абсолютные миллисекунды зависят от загрузки машины (в других прогонах было 637–693 и 88–94, 822–1016 и 133–156), отношение шесть-семь раз держалось.

Цена вложенности в другом месте: клиента надо менять во всех его заказах, а для поиска по нему нужен ещё индекс. Смена страны клиента 3649 в обоих вариантах:

db.orders_embedded.createIndex({'customer.id': 1})
db.customers.updateOne({_id: 3649}, {$set: {country: 'FR'}})
db.orders_embedded.updateMany({'customer.id': 3649}, {$set: {'customer.country': 'FR'}})
вывод
ref: matched 1 modified 1
embedded: matched 50 modified 50
customer.id_1 6471680

Правка затрагивает 50 документов вместо одного, а индекс customer.id_1 весит 6 471 680 байт (stats().indexSizes). Для поля, которое не меняется после записи (страна на момент заказа), копия подходит; для имени в профиле клиента нет.

Cassandra: таблица под один запрос

В Cassandra нет соединений, а читать эффективно можно только по partition key: хэш ключа даёт токен, токен определяет узел. Таблица строится под запрос «заказы клиента, новые первыми». Для сравнения вторая таблица отвечает на тот же запрос, но партиция в ней по стране:

docker exec -i $P-cass cqlsh <<'EOF'
CREATE KEYSPACE shop WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1};
CREATE TABLE shop.orders_by_customer (customer_id int, ts timestamp, order_id int, status text, amount int, country text, tier text, name text,
  PRIMARY KEY ((customer_id), ts, order_id)) WITH CLUSTERING ORDER BY (ts DESC, order_id ASC);
CREATE TABLE shop.orders_by_country (country text, customer_id int, ts timestamp, order_id int, status text, amount int, tier text, name text,
  PRIMARY KEY ((country), customer_id, ts, order_id)) WITH CLUSTERING ORDER BY (customer_id ASC, ts DESC, order_id ASC);
EOF
for t in orders_by_customer orders_by_country; do
  docker exec $P-cass cqlsh -e "COPY shop.$t (order_id, customer_id, ts, status, amount, country, tier, name) FROM '/tmp/orders.csv' WITH NUMPROCESSES=2 AND CHUNKSIZE=500" 2>&1 | tr '\r' '\n' | tail -1
done

Партиция — единица, которую Cassandra не делит. После flush и compact в каждой таблице по одному SSTable, и tablestats показывает размеры партиций:

docker exec $P-cass nodetool flush shop
docker exec $P-cass nodetool compact shop
docker exec $P-cass nodetool tablestats shop.orders_by_customer shop.orders_by_country | grep -E 'Table:|Space used \(live\)|Number of partitions|Compacted partition (min|max)'
вывод
		Table: orders_by_country
		Space used (live): 25001579
		Number of partitions (estimate): 10
		Compacted partition minimum bytes: 4866324
		Compacted partition maximum bytes: 5839588
		Table: orders_by_customer
		Space used (live): 24883918
		Number of partitions (estimate): 19975
		Compacted partition minimum bytes: 1110
		Compacted partition maximum bytes: 4768

Объём одинаков, около 25 МБ, но в orders_by_country весь миллион строк лежит в десяти партициях по 4,9–5,8 МБ, а в orders_by_customer — в 19 975 партициях по 1 110–4 768 байт. Ключ превращается в токен так:

    private long[] getHash(ByteBuffer key)
    {
        long[] hash = new long[2];
        MurmurHash.hash3_x64_128(key, key.position(), key.remaining(), 0, hash);
        return hash;
    }

Murmur3Partitioner.getHash (cassandra-5.0.9, строки 274–279): токен партиции — normalize(hash[0]) от этого хэша (строки 261–266 того же файла), то есть зависит только от байтов partition key. Страна даёт на всю таблицу десять токенов, поэтому её партиции можно разложить не больше чем на десять узлов; 19 975 клиентов дают 19 975 токенов. Плохой partition key не замедляет один запрос, а ломает равномерность и рост: десять партиций не распределить по большому кластеру, и каждая растёт вместе с данными.

Запрос вне ключа база отвергает: сумма за неделю (SELECT sum(amount) FROM shop.orders_by_customer WHERE ts >= '2026-08-01' AND ts < '2026-08-08') возвращает InvalidRequest ... Cannot execute this query as it might involve data filtering and thus may have unpredictable performance. Для такого вопроса в Cassandra заводят третью таблицу с partition key по дню или считают вне базы.

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

В ClickHouse ключ — это решение о запросах. Один порядок столбцов дал 12,5 МиБ и 10 гранул из 123 для запроса A, обратный — 20,9 МиБ и 123 гранулы; на запросе B то же наоборот. Ключ выбирают по самому частому условию, начиная с низкой кардинальности, а остальные разрезы отдают второй таблице или сводке.

В MongoDB вложенность — обмен записи на чтение. Неделя по странам в 6–7 раз быстрее без $lookup, но заказ вдвое тяжелее, смена страны клиента правит 50 документов, а поиск по клиенту требует индекса на 6,5 МБ. Вкладывают то, что не меняется после записи; профиль клиента остаётся ссылкой. В PostgreSQL ту же развилку разбирает статья про JSONB: документ там тоже платит размером и перезаписью.

В Cassandra таблицу рисуют после запроса. partition key по клиенту дал 19 975 партиций до 4 768 байт, по стране — десять от 4,9 МБ. Запрос вне ключа база отвергает.

Чек-лист

  1. В ClickHouse перед CREATE TABLE выпиши два-три самых частых условия и поставь первым столбцом ключа тот, что с равенством и низкой кардинальностью; сравни data_compressed_bytes и SelectedMarks на копии данных, как выше.
  2. В MongoDB вкладывай копию только того, что не меняется, и сравни explain("executionStats") для $lookup и для вложенного варианта; следи, чтобы самый большой документ был далеко от 16 777 216 байт.
  3. В Cassandra перед созданием таблицы запиши запрос, затем выбери partition key с тысячами значений и проверь nodetool tablestats: наибольшая партиция не должна расти вместе с таблицей.
  4. Все три системы измерены на миллионе заказов в горячем кэше; пересчитай на своих данных, прежде чем переносить выводы.

Каталоги данных после всех опытов (du -sm внутри контейнеров): ClickHouse 112 МБ, MongoDB 337 МБ, Cassandra 219 МБ, из них 49 МБ данных и 170 МБ журнала commitlog. Контейнеры и тома удаляются командой docker rm -f -v $P-ch $P-mongo $P-cass.

Источники

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