Когда нужна ScyllaDB и как думать данными: моделирование под wide-column

Чем wide-column-модель отличается от реляционной, как проектировать партиции «запрос-первым», почему плохой ключ партиции убивает производительность — на живых числах ScyllaDB

В реляционной модели сначала рисуют схему — таблицы, внешние ключи, нормальные формы — а запросы пишут потом, под уже готовую структуру. В wide-column-модели порядок обратный: сначала формулируют запросы, которые база будет обслуживать в проде, и только под них проектируют таблицы и ключи партиций. Один и тот же датасет, разложенный по «неправильному» ключу, превращается из витрины производительности в источник таймаутов — не через недели эксплуатации, а сразу же, на первом плане EXPLAIN-подобной диагностики. Ниже — почему так и сколько это стоит в реальных байтах и миллисекундах.

Это первая, обзорная статья серии «ScyllaDB: глубокое погружение». Дальше будут архитектура shard-per-core, compaction, tombstones и repair, consistency levels и LWT, от token ring к tablets и multi-DC, shard-aware драйверы на Go и Java и эксплуатация и итоговый выбор. Здесь — фундамент: что такое wide-column-модель и почему выбор ключа партиции решает исход задачи ещё до того, как речь заходит о шардах, репликации или консистентности.

Числа ниже — с живого трёхузлового кластера scylladb/scylla:2026.2.0: один и тот же детерминированный датасет телеметрии (672 000 строк — 500 устройств × 14 суток × 96 замеров в сутки), загруженный в три таблицы с разными ключами партиции. Стенд целиком — в публичном репозитории примеров (digital-cookbook, каталог scylladb/).

Моделирование данных в ScyllaDB: партиции good/bad/hot — правильный ключ даёт маленькую партицию, плохой ключ делает скан в 45 раз дороже

В статье

Модель данных wide-column

ScyllaDB (как и Cassandra, чей CQL-протокол она разделяет — на этом кластере release_version равен 3.0.8, той же протокольной версии, что использует классическая Cassandra) устроена вокруг двух понятий: ключ партиции (partition key) и ключ кластеризации (clustering key). Вместе они образуют первичный ключ строки, но играют совершенно разные роли.

Ключ партиции определяет, на каком физическом узле и в каком месте кластера окажутся строки — это единица распределения данных и единица атомарности записи. Все строки с одним и тем же значением ключа партиции физически хранятся вместе, отсортированные по ключу кластеризации, и читаются одним обращением без сетевого fan-out по нескольким узлам. Ключ кластеризации задаёт порядок строк внутри партиции — благодаря ему диапазонный скан «дай мне данные за такой-то интервал» превращается в последовательное чтение уже отсортированных данных, без дополнительной сортировки на лету.

Отсюда и главное отличие от реляционной модели: JOIN между таблицами в CQL нет. Если приложению нужны данные из двух сущностей одним запросом, их либо денормализуют в одну таблицу заранее, либо читают отдельными запросами и соединяют на стороне приложения. Денормализация здесь не компромисс «раз нет JOIN, придётся» — это осознанный приём: одни и те же данные сознательно кладут в несколько таблиц с разными ключами партиции под разные паттерны доступа, платя за это местом на диске, а не временем ответа.

Практический пример — таблица телеметрии readings в датасете этого стенда:

CREATE TABLE telemetry.readings (
  device_id text, day date, event_time timestamp,
  metric text, value double, region text, status text,
  PRIMARY KEY ((device_id, day), event_time)
) WITH CLUSTERING ORDER BY (event_time DESC);

Составной ключ партиции (device_id, day) в скобках — это осознанное проектное решение, а не синтаксическая деталь: «дай мне все замеры одного устройства за одни сутки» превращается в чтение ровно одной партиции. Именно этот выбор и разбирается ниже — на этом же датасете, но с двумя другими ключами партиции, дающими совершенно другой результат на тех же 672 000 строках.

Ключ партиции решает всё

Один и тот же датасет — 672 000 строк, 500 устройств, 14 суток, 96 замеров в сутки — загружен в три таблицы с идентичной шириной строки (~50 байт), но с тремя разными ключами партиции:

-- good: партиция ограничена сутками устройства
PRIMARY KEY ((device_id, day), event_time)

-- bad: партиция растёт неограниченно вместе с историей устройства
PRIMARY KEY (device_id, event_time)

-- hot: партиция — это целый регион, их всего 4
PRIMARY KEY (region, event_time, device_id)

Реальные размеры партиций после nodetool tablestats (снято на живом кластере, после flush):

Модель Ключ партиции Партиций Max строк/партиция Max байт/партиция
good (device_id, day) 7000 96 4768
bad device_id 500 1344 73 457
hot region 4 168 000 8 409 007

Разница — не в устройстве таблицы (ширина строки во всех трёх ~50 байт, структура одна и та же), а исключительно в том, сколько строк физически ложится в одну партицию под данным ключом. bad-модель растёт с каждым новым днём в истории устройства и на этом датасете уже даёт партицию в 15,4 раза больше, чем good (73 457 против 4768 байт). hot-модель — крайний случай: партиция на весь регион, всего 4 партиции на кластер вместо тысяч, и она обгоняет bad ещё в 114,5 раза (8 409 007 против 73 457 байт).

flowchart LR Q["Один и тот же запрос:
«устройство X за сутки Y»"] subgraph GOOD["good: PRIMARY KEY (device_id, day)"] direction TB G1["7000 партиций"] G2["max 96 строк / 4768 байт"] G1 --> G2 end subgraph BAD["bad: PRIMARY KEY (device_id)"] direction TB B1["500 партиций"] B2["max 1344 строки / 73 457 байт"] B1 --> B2 end subgraph HOT["hot: PRIMARY KEY (region)"] direction TB H1["4 партиции = число регионов"] H2["max 168 000 строк / 8 409 007 байт"] H1 --> H2 end Q -->|3.6ms, без ALLOW FILTERING| G1 Q -->|5.1ms, без ALLOW FILTERING| B1 Q -->|28.7ms, только с ALLOW FILTERING| H1 style GOOD fill:#c9e4c5,stroke:#5b8a5e style BAD fill:#f4d9c6,stroke:#c67a4a style HOT fill:#f4c6c6,stroke:#b4552f

flowchart LR
  Q["Один и тот же запрос:
«устройство X за сутки Y»"] subgraph GOOD["good: PRIMARY KEY (device_id, day)"] direction TB G1["7000 партиций"] G2["max 96 строк / 4768 байт"] G1 --> G2 end subgraph BAD["bad: PRIMARY KEY (device_id)"] direction TB B1["500 партиций"] B2["max 1344 строки / 73 457 байт"] B1 --> B2 end subgraph HOT["hot: PRIMARY KEY (region)"] direction TB H1["4 партиции = число регионов"] H2["max 168 000 строк / 8 409 007 байт"] H1 --> H2 end Q -->|3.6ms, без ALLOW FILTERING| G1 Q -->|5.1ms, без ALLOW FILTERING| B1 Q -->|28.7ms, только с ALLOW FILTERING| H1 style GOOD fill:#c9e4c5,stroke:#5b8a5e style BAD fill:#f4d9c6,stroke:#c67a4a style HOT fill:#f4c6c6,stroke:#b4552f
Один и тот же датасет (672 000 строк, ~50 байт/строку), три ключа партиции — три разных профиля

Цена гигантской партиции видна не только в статике, но и в реальном чтении. Полный скан партиции: good — 96 строк за 4,85 мс, hot — 168 000 строк за 218,6 мс. Скан hot-партиции дороже good-скана в 45 раз — дольше держится cache партиционного индекса, дольше идёт компакция, дольше выполняется каждый обход.

Показательнее всего — типовой запрос «одно устройство за одни сутки», который в проде выполняется постоянно:

readings (good): 96 строк за 3.6ms, ALLOW FILTERING не потребовался
readings_bad:    96 строк за 5.1ms, ALLOW FILTERING не потребовался
readings_hot без ALLOW FILTERING: ошибка CQL —
  Clustering column "device_id" cannot be restricted
  (preceding column "event_time" is restricted by a non-EQ relation)
readings_hot с ALLOW FILTERING:   96 строк за 28.7ms

good и bad отвечают на этот запрос без ALLOW FILTERINGdevice_id стоит в ключе в нужной позиции. hot-модель партиционирована по region, и её ключ кластеризации устроен как (event_time, device_id) — CQL не разрешает сузить device_id, пока event_time ограничен диапазоном, а не точным равенством: получится сканирование не по индексу, а по кускам живой партиции, и драйвер честно отказывает с этой ошибкой, если не разрешить его явно. С ALLOW FILTERING запрос отработает — но это сигнал «партиция спроектирована не под этот запрос», а не штатный путь: 28,7 мс против 3,6 мс на good — это не про удобство API, а про то, что база вынуждена перебирать чужие строки внутри гигантской партиции в поисках нужных.

Вывод простой и без исключений: партиция — это единица атомарности и единица физического размещения одновременно, и оба свойства нужно держать в голове при выборе ключа. Как партиции распределяются по узлам — от token ring к tablets — тема отдельной статьи серии; общий принцип консистентного хеширования, на котором строится распределение партиций по узлам в любой системе такого рода, разобран в статье про согласованное хешированиеСкоро.

Materialized views и вторичные индексы

Иногда партиция, спроектированная под основной паттерн доступа, не покрывает второй нужный запрос — например, данные читаются и по device_id, и отдельно по region. У ScyllaDB (как и у Cassandra) для этого случая есть два инструмента, но оба — компромисс, а не бесплатный второй индекс в смысле SQL.

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

Вторичный индекс устроен иначе и годится для другого профиля: он полезен на низкокардинальных колонках (статус, категория, регион), но физически размазан по всему кластеру — точечный запрос по значению индексируемой колонки не попадает в одну партицию, а рассылается на все узлы, которые потенциально могут её содержать. Это работает как административный или редкий диагностический запрос, но не как замена продуманного ключа партиции на горячем пути: тот же fan-out, который делает вторичный индекс удобным («найти по любому полю»), делает его дорогим при высокой частоте обращений.

Оба инструмента стоит воспринимать как отдушину для второстепенных запросов, а не как страховку от плохо спроектированной партиции — если вторичный паттерн доступа частый и предсказуемый, честнее сразу завести под него отдельную денормализованную таблицу с собственным ключом партиции, как good-модель выше.

Когда ScyllaDB не нужна

Wide-column-модель — не универсальный ответ, и есть три класса задач, где её выбирают ошибочно.

Аналитика: GROUP BY по всей таблице, произвольные агрегаты, отчёты. ScyllaDB оптимизирована под чтение по известному ключу партиции, а не под полный скан с группировкой по не-ключевым колонкам — для этого нет ни колоночного хранения, ни векторного исполнения. Если основной паттерн — «просканировать много, вернуть агрегат», это профиль OLAP-движка вроде ClickHouse, а не wide-column-хранилища.

Сложные многошаговые транзакции с ACID между таблицами. У ScyllaDB есть compare-and-swap через LWT (Paxos-раунд поверх обычной записи — заметно дороже обычной операции, разбор с числами — в статье про consistency levels и LWT), но это точечный примитив «прочитать-сравнить-записать одну строку», а не транзакция с произвольным числом таблиц и откатом. Если бизнес-логике нужны именно такие транзакции, честнее сравнение моделей — в статье про транзакции в KV/документных/wide-column хранилищах.

Небольшой объём данных и низкая нагрузка. ScyllaDB рассчитана на кластер из нескольких узлов и заточена под то, чтобы каждый шард каждого узла (подробнее — в статье про архитектуру shard-per-core) обслуживал свою долю большого потока запросов. Если данные умещаются на одном сервере, а нагрузка невелика, операционная цена кластера (мониторинг, backup, repair, эксплуатация нескольких узлов вместо одного) не окупается — обычная одноузловая СУБД проще и дешевле в поддержке.

Общая карта альтернатив хранения данных, если нужен более широкий взгляд, чем одна серия про ScyllaDB, — в хабе «Карта данных»готовится, с 22 сентября. Дальше в этой серии — почему у ScyllaDB нет JVM-style GC-пауз и что это даёт в задержке на реальном кластере.

Версии на стенде: образ scylladb/scylla:2026.2.0, CQL/release_version3.0.8, датасет — детерминированный генератор с -seed 42 (672 000 строк: 500 устройств × 14 суток × 96 замеров в сутки).

Обсуждение в Telegram

Присоединиться →

Комментарии