С Cassandra чаще всего ошибаются не в эксплуатации, а в моделировании: проектируют таблицы, как в реляционной базе, а потом упираются в то, что нужный запрос невозможен. Модель данных Cassandra подчинена её архитектуре: данные распределены по узлам, JOIN нет, и это меняет сам порядок проектирования.
Ключ раздела — это адрес: его хеш даёт токен, токен указывает партицию и реплики, на которых она лежит. Ключ кластеризации — порядок внутри партиции: строки одного чата хранятся отсортированными по времени, поэтому диапазон читается подряд, без сортировки на лету. Запрос без ключа раздела адреса не имеет и вынужден обойти весь кластер.
Wide-column: строки с гибким набором колонок
Внешне таблица Cassandra похожа на реляционную — строки и колонки, есть язык запросов CQL с SQL-образным синтаксисом. Но устройство иное: физически данные хранятся как широкие строки — по ключу раздела лежит группа записей, отсортированных и сгруппированных. Отсюда название «wide-column»: одна логическая партиция может содержать от одной строки до многих тысяч упорядоченных строк.
Это не хранилище документов, как MongoDB, и не таблицы со связями, как PostgreSQL. Всё проектирование крутится вокруг того, как задать ключ раздела.
Первичный ключ: две разные роли
Хочу последние сообщения чата, и база должна сразу знать, на каком узле они лежат и в каком порядке их читать. Ради этого в Cassandra первичный ключ состоит из двух частей с разными задачами.
CREATE TABLE messages (
chat_id uuid, -- partition key: где лежат данные
sent_at timestamp, -- clustering column: порядок внутри партиции
message_id uuid,
author text,
body text,
PRIMARY KEY ((chat_id), sent_at, message_id)
);
Ключ раздела (partition key) — первая часть (здесь chat_id, в двойных скобках). Он определяет на каком узле лежат данные: его хеш даёт токен, а токен — узел (см. архитектуру). Все строки с одним chat_id физически лежат вместе, в одной партиции, на одних и тех же репликах.
Колонки кластеризации (clustering columns) — остальные части (sent_at, message_id). Они задают порядок строк внутри партиции. Здесь сообщения одного чата хранятся отсортированными по времени, и диапазон читается без сортировки на лету.
Сам порядок — по возрастанию или по убыванию — выбирают один раз, когда создают таблицу: WITH CLUSTERING ORDER BY (sent_at DESC, message_id ASC). Мелочь, о которой забывают, а потом удивляются: лента чата, где нужны последние сообщения, при порядке по возрастанию читается «с конца» и требует ORDER BY в каждом запросе. Поменять порядок у готовой таблицы нельзя — только пересоздать.
Ту же раскладку видно на маленькой программе: ключ раздела собирает строки в группы, ключ кластеризации сортирует внутри группы.
живой пример
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
public class PartitionLayout {
record Row(String chatId, String sentAt, String author) {}
public static void main(String[] args) {
List<Row> incoming = List.of(
new Row("A", "10:07", "ann"), new Row("B", "10:02", "bob"),
new Row("A", "10:02", "bob"), new Row("A", "10:05", "ann"),
new Row("B", "10:09", "ann"));
Map<String, List<Row>> partitions = new TreeMap<>();
for (Row row : incoming) {
partitions.computeIfAbsent(row.chatId(), k -> new ArrayList<>()).add(row);
}
partitions.forEach((key, rows) -> {
rows.sort((x, y) -> x.sentAt().compareTo(y.sentAt()));
System.out.println("партиция " + key + ": " + rows.stream().map(Row::sentAt).toList());
});
List<Row> chatA = partitions.get("A");
long slice = chatA.stream().filter(r -> r.sentAt().compareTo("10:02") > 0).count();
System.out.println("chat_id = A AND sent_at > 10:02 -> подряд " + slice + " из " + chatA.size());
int all = partitions.values().stream().mapToInt(List::size).sum();
System.out.println("author = 'ann' без ключа раздела -> читаем все " + all + " строк");
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
package main
import (
"fmt"
"maps"
"slices"
"strings"
)
type row struct{ chatID, sentAt, author string }
func main() {
incoming := []row{{"A", "10:07", "ann"}, {"B", "10:02", "bob"}, {"A", "10:02", "bob"}, {"A", "10:05", "ann"}, {"B", "10:09", "ann"}}
partitions := map[string][]row{}
for _, r := range incoming {
partitions[r.chatID] = append(partitions[r.chatID], r)
}
for _, key := range slices.Sorted(maps.Keys(partitions)) {
rows := partitions[key]
slices.SortFunc(rows, func(x, y row) int { return strings.Compare(x.sentAt, y.sentAt) })
var times []string
for _, r := range rows {
times = append(times, r.sentAt)
}
fmt.Printf("партиция %s: [%s]\n", key, strings.Join(times, ", "))
}
chatA := partitions["A"]
slice := 0
for _, r := range chatA {
if r.sentAt > "10:02" {
slice++
}
}
fmt.Printf("chat_id = A AND sent_at > 10:02 -> подряд %d из %d\n", slice, len(chatA))
all := 0
for _, rows := range partitions {
all += len(rows)
}
fmt.Printf("author = 'ann' без ключа раздела -> читаем все %d строк\n", all)
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
const incoming = [
{ chatId: 'A', sentAt: '10:07', author: 'ann' }, { chatId: 'B', sentAt: '10:02', author: 'bob' },
{ chatId: 'A', sentAt: '10:02', author: 'bob' }, { chatId: 'A', sentAt: '10:05', author: 'ann' },
{ chatId: 'B', sentAt: '10:09', author: 'ann' },
];
const partitions = new Map();
for (const row of incoming) {
if (!partitions.has(row.chatId)) partitions.set(row.chatId, []);
partitions.get(row.chatId).push(row);
}
for (const key of [...partitions.keys()].sort()) {
const rows = partitions.get(key);
rows.sort((x, y) => x.sentAt.localeCompare(y.sentAt));
console.log(`партиция ${key}: [${rows.map((r) => r.sentAt).join(', ')}]`);
}
const chatA = partitions.get('A');
const slice = chatA.filter((r) => r.sentAt > '10:02').length;
console.log(`chat_id = A AND sent_at > 10:02 -> подряд ${slice} из ${chatA.length}`);
const all = [...partitions.values()].reduce((n, rows) => n + rows.length, 0);
console.log(`author = 'ann' без ключа раздела -> читаем все ${all} строк`);
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
from collections import defaultdict
from dataclasses import dataclass
@dataclass(frozen=True)
class Row:
chat_id: str
sent_at: str
author: str
incoming = [Row("A", "10:07", "ann"), Row("B", "10:02", "bob"), Row("A", "10:02", "bob"),
Row("A", "10:05", "ann"), Row("B", "10:09", "ann")]
partitions: dict[str, list[Row]] = defaultdict(list)
for row in incoming:
partitions[row.chat_id].append(row)
for key in sorted(partitions):
rows = sorted(partitions[key], key=lambda r: r.sent_at)
partitions[key] = rows
print(f"партиция {key}: [{', '.join(r.sent_at for r in rows)}]")
chat_a = partitions["A"]
slice_ = sum(1 for r in chat_a if r.sent_at > "10:02")
print(f"chat_id = A AND sent_at > 10:02 -> подряд {slice_} из {len(chat_a)}")
all_rows = sum(len(rows) for rows in partitions.values())
print(f"author = 'ann' без ключа раздела -> читаем все {all_rows} строк")
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Две последние строки вывода — вся разница: диапазон внутри партиции берётся подряд, поиск по обычной колонке читает все партиции целиком.
Что можно и нельзя в запросе
Из устройства ключа вытекают жёсткие правила запросов, которые новичка удивляют, и причина у всех одна: у запроса без ключа раздела нет адреса, и Cassandra не знает, к какому узлу идти.
Запрос должен указывать ключ раздела. Без него Cassandra не знает, на каком узле искать, и пришлось бы обойти весь кластер. Поэтому WHERE chat_id = ? — обязателен.
По колонкам кластеризации можно фильтровать и брать диапазоны, но только слева направо, в порядке их объявления (нельзя фильтровать по второй, пропустив первую).
По обычным колонкам фильтровать нельзя (WHERE body = ? не сработает). Заставить можно через ALLOW FILTERING, и вот тут важно, что именно он разрешает. Если ключ раздела в запросе есть — WHERE chat_id = ? AND author = ? ALLOW FILTERING — база находит нужную партицию и отбирает строки уже внутри неё; это дёшево и вполне обычный приём. А если ключа раздела нет, тот же ALLOW FILTERING разрешает обойти весь кластер — вот так на проде почти никогда не делают.
Вторичные индексы есть, но правила не отменяют. В Cassandra можно завести индекс по обычной колонке (CREATE INDEX, а с версии 5.0 — более удачный SAI, storage-attached index). Запрос по такому индексу без ключа раздела всё равно опрашивает все узлы и собирает ответ по кусочкам, поэтому под частый запрос по-прежнему делают отдельную таблицу, а индекс оставляют для редких обращений.
Нет JOIN и нет подзапросов. GROUP BY в CQL есть, но группировать можно только по префиксу первичного ключа — произвольная аналитика этой модели недоступна.
-- Быстро: указан ключ раздела + диапазон по времени
SELECT * FROM messages
WHERE chat_id = ? AND sent_at > ?;
-- Плохо: нет ключа раздела — обход всего кластера
SELECT * FROM messages WHERE author = ?; -- потребует ALLOW FILTERING
Проектирование от запросов
Отсюда главный принцип, переворачивающий привычку реляционщика: в Cassandra проектируют не от данных, а от запросов. Порядок такой:
- выпишите все запросы, которые сервис будет делать;
- под каждый запрос спроектируйте свою таблицу так, чтобы запрос бил по ключу раздела;
- если один и тот же факт нужен в нескольких запросах под разными углами — храните его в нескольких таблицах.
То есть одни и те же данные сознательно дублируют — это денормализация, и в Cassandra она норма, а не грех. Диск дёшев, а вот запрос без ключа раздела — дорог. Согласованность копий между таблицами — забота приложения. Соблазн записать во все таблицы разом одним пакетом (BATCH) велик, но работает это только пока все записи ложатся в одну и ту же партицию. Пакет, растянутый на разные партиции, одновременности не даёт (читатель застанет половину изменений) и вдобавок валит всю работу на один узел-координатор — это известный способ испортить себе кластер. Обычный путь проще: пишем в каждую таблицу отдельно и закладываемся на то, что копии какое-то время расходятся.
Практический вывод: если заранее не известно, как будут спрашивать данные, или запросы часто меняются, — Cassandra противопоказана: она вознаграждает предсказуемые обращения по ключу и наказывает за произвольную аналитику.
Типы, которых нет в SQL
CQL похож на SQL синтаксисом и заметно отличается набором типов. Четыре из них меняют модель.
Коллекции: set, list, map. Небольшой список значений кладут прямо в колонку: emails set<text>, tags list<text>, attributes map<text, text>. Это удобно и ограничено: коллекция целиком читается и целиком перезаписывается, элементы внутри не индексируются (кроме особых случаев), а больше нескольких десятков элементов в коллекцию складывать нельзя — она не для этого. Большой список — это отдельные строки в разделе, а не коллекция.
Пользовательский тип (UDT). Составное значение как одна колонка: address frozen<address_type>. Слово frozen означает «менять по частям нельзя, только целиком» — так хранят адрес, деньги, координату.
Статическая колонка. Значение, общее для всего раздела: у чата это название и число участников, у показаний датчика — его модель и место установки.
CREATE TABLE messages_by_chat (
chat_id uuid,
created_at timeuuid,
author_id uuid,
body text,
chat_title text STATIC, -- одно значение на весь раздел
PRIMARY KEY ((chat_id), created_at)
) WITH CLUSTERING ORDER BY (created_at DESC);
Это и есть ответ на вопрос «куда девать атрибуты чата, чтобы не дублировать их в каждом сообщении»: статическая колонка хранится один раз на раздел, читается вместе с любой строкой раздела и обновляется одной записью.
Счётчик. Колонка типа counter умеет только увеличиваться и уменьшаться (SET views = views + 1), зато без чтения перед записью. Ограничения жёсткие: в таблице со счётчиками не может быть обычных колонок, значение не идемпотентно при повторе (повторная попытка после таймаута прибавит второй раз), и точным его считать нельзя. Для «примерно сколько просмотров» годится, для денег — нет.
TTL: данные, которые исчезают сами
Для сообщений, показаний датчиков и журналов почти всегда есть срок хранения, и Cassandra умеет его сама:
INSERT INTO sensor_readings (sensor_id, bucket, ts, value)
VALUES (?, ?, ?, ?) USING TTL 2592000; -- 30 суток
ALTER TABLE sensor_readings WITH default_time_to_live = 2592000;
USING TTL задаёт срок на конкретную запись, default_time_to_live — на всю таблицу. Это меняет и разговор про рост раздела: раздел с сроком жизни не растёт бесконечно, он живёт на скользящем окне.
Две оговорки, о которых узнают позже. Срок жизни ставится на колонку, а не на строку: обновили одну колонку без USING TTL — она останется вечной, а остальные исчезнут, и строка станет полупустой. И истёкшие данные не пропадают с диска сразу: они превращаются в метку удаления (tombstone) и убираются при уплотнении, поэтому вместе со сроком жизни выбирают стратегию уплотнения по временным окнам — про это в статье про устройство Cassandra.
Одна таблица на запрос: как это выглядит
«Проектируем от запросов» звучит абстрактно, пока не увидишь, что одни и те же данные пишутся в две таблицы.
Запрос первый: «последние сообщения чата». Запрос второй: «все сообщения этого автора». Один раздел не отвечает на оба — значит, таблиц две:
CREATE TABLE messages_by_chat (
chat_id uuid, created_at timeuuid, author_id uuid, body text,
PRIMARY KEY ((chat_id), created_at)
) WITH CLUSTERING ORDER BY (created_at DESC);
CREATE TABLE messages_by_author (
author_id uuid, created_at timeuuid, chat_id uuid, body text,
PRIMARY KEY ((author_id), created_at)
) WITH CLUSTERING ORDER BY (created_at DESC);
Приложение пишет сообщение в обе таблицы — обычно логической партией (BEGIN BATCH … APPLY BATCH), чтобы обе записи ушли вместе. Дублирование данных здесь не недостаток модели, а её механика: место дешевле, чем обход всего кольца в поисках сообщений автора.
Когда денормализация начинает ломаться
Цена у приёма та, о которой не пишут в первых главах.
Первое: обновление становится задачей приложения. Поправили текст сообщения — обновить надо во всех таблицах, где он лежит. Никакой базы, которая сделает это за вас, здесь нет; забыли одну копию — данные разъехались, и заметят это через месяц по жалобе. Отсюда правило: денормализуют то, что не меняется (событие, сообщение, показание), и избегают денормализации изменяемых полей. Если поле всё же меняется, его держат в одном месте и читают вторым запросом — да, это два обращения, зато нет расхождения.
Второе: расхождение надо чинить, и чинят его сверкой. Отдельное задание проходит по таблице-источнику и переписывает копии; работает оно порциями, по разделам, и пишет отчёт о найденных расхождениях. Такой сверке нужен источник правды — та таблица, чьи данные считаются верными.
Третье: поток обновлений умножается на число копий. Три таблицы означают три записи на каждое изменение — и по объёму журнала, и по нагрузке на уплотнение.
Отсюда второй стоп-сигнал для выбора Cassandra (первый — непредсказуемые запросы): данные, которые часто меняются по месту или удаляются пачками. Каждое обновление и каждое удаление здесь — это новая запись, а место освобождается только при уплотнении; таблица, из которой регулярно удаляют, накапливает метки удаления и начинает медленно читаться. Для изменяемых данных берут базу, которая умеет менять строку на месте.
Грабли размера партиции
Ещё одна частая ошибка — неудачный выбор ключа раздела, из-за которого партиция разрастается. Две крайности:
- слишком «горячий» ключ — если по одному значению ключа скапливается непропорционально много данных или запросов (например, ключ раздела — страна, и вся Россия в одной партиции), партиция целиком лежит на одних репликах, и они становятся узким местом;
- безграничный рост — если в партицию бесконечно дописываются строки (ключ раздела —
sensor_id, а показания идут годами), она пухнет до неуправляемого размера.
Лечится это составным ключом раздела с «корзиной»: например, ((sensor_id, day), reading_ts) — тогда данные каждого дня образуют отдельную партицию разумного размера.
А какой размер разумный? Ориентиры, на которые смотрят при проектировании: держать партицию примерно до 100 МБ и до сотни тысяч строк. Жёсткий предел базы выше — два миллиарда ячеек в партиции (ячейка — это значение одной колонки в одной строке), но до него доходить не стоит: задолго до предела партиция начинает тормозить и чтение, и фоновое уплотнение файлов.
Глубже: вторичные индексы: 2i, SASI и SAIрасширенное
Выше сказано «индексы есть, но правила не отменяют». Разберём, какие именно, потому что в Cassandra 5 они стали заметно полезнее, а вопросы про них решаются по старой памяти.
Первое поколение, обычный CREATE INDEX ON messages (author), называют 2i. Устроен он как скрытая таблица на каждом узле: узел индексирует только свои данные. Поэтому запрос WHERE author = ? без ключа раздела координатор рассылает на все узлы кольца, каждый ищет у себя, и ответ собирается по кусочкам. На кластере из шести узлов это шесть запросов вместо одного, а по колонке с малым числом значений (статус, страна) индекс ещё и раздувается. SASI, второе поколение с поддержкой LIKE и диапазонов, осталось экспериментальным и в новых кластерах его не включают.
Cassandra 5.0 привезла SAI, storage-attached index: индекс живёт внутри тех же SSTable, что и данные, занимает в разы меньше места, поддерживает несколько условий в одном запросе, диапазоны и AND по разным индексированным колонкам:
CREATE INDEX messages_author_sai ON messages (author) USING 'sai';
SELECT * FROM messages WHERE author = 'ivan' AND sent_at > '2026-09-01';
Что не изменилось: без ключа раздела запрос по-прежнему идёт на все узлы. SAI сделал такой запрос дешевле на каждом узле, но не убрал рассылку. Отсюда правило выбора. Частый запрос, на который завязан продукт, получает свою таблицу с нужным ключом раздела. Редкий запрос для админки, отчёта или расследования получает SAI, и это уже честный ответ, а не костыль. Запрос внутри известного раздела, WHERE chat_id = ? AND author = ?, с SAI работает отлично: узел один, индекс ускоряет отбор внутри партиции.
Материализованные представления, CREATE MATERIALIZED VIEW, обещали то же самое (вторую таблицу с другим ключом, которую база ведёт сама), но в 4.0 их выключили по умолчанию, materialized_views_enabled: false, из-за расхождений с базовой таблицей, которые нечем починить. Вторую таблицу пишут руками из приложения, и это осознанное дублирование, а не недоработка.
Глубже: пределы в цифрах: размер раздела, tombstone и батчрасширенное
Раздел про размер партиции говорит «держите разделы небольшими». Вот где именно ломается.
Раздел здорового размера это до 100 МБ и до ста тысяч строк; выше начинает страдать уплотнение, чтение с диска и ремонт, а с гигабайта узел рискует не пережить чтение раздела целиком. Проверяют по nodetool tablehistograms, где видна гистограмма размеров разделов, и по предупреждению Writing large partition в логе, порог которому задаёт compaction_large_partition_warning_threshold (100 МБ по умолчанию).
Пометки об удалении считаются на каждый запрос: прошёл мимо тысячи tombstone, в логе предупреждение (tombstone_warn_threshold), прошёл мимо ста тысяч, запрос прерван с TombstoneOverwhelmingException (tombstone_failure_threshold). Так падает «очередь на Cassandra»: вставки и удаления в одном разделе оставляют надгробия до gc_grace_seconds, и чтение головы очереди упирается в порог. Тот же эффект дают TTL на колонках и запись null в колонку, потому что null в Cassandra это тоже tombstone.
BATCH в CQL не ускоряет, а собирает записи в атомарный набор, и у него есть порог: 5 КБ предупреждение, 50 КБ отказ (batch_size_warn_threshold, batch_size_fail_threshold). Батч на тысячу строк в разные разделы заставляет координатора вести журнал батча и рассылать записи по всему кольцу; массовую загрузку ведут отдельными асинхронными вставками. Ячейка формально может весить до 2 ГБ, на практике всё крупнее мегабайта выносят в объектное хранилище, а в строке оставляют ссылку.
Коротко
- Физически Cassandra хранит широкие строки: по ключу раздела лежит группа отсортированных записей. Это не документы и не реляционные таблицы со связями.
- Первичный ключ = partition key (определяет узел, распределяет данные) + clustering columns (задают порядок внутри партиции).
- Запрос обязан указывать ключ раздела; по обычным колонкам фильтровать нельзя.
ALLOW FILTERINGс ключом раздела просто отбирает строки внутри партиции и стоит копейки, а без ключа раздела разрешает обход всего кластера — вот этого на проде избегают. Вторичные индексы (CREATE INDEX, SAI с версии 5.0) есть, но без ключа раздела тоже опрашивают все узлы. НетJOINи подзапросов, аGROUP BYработает только по префиксу первичного ключа. - Проектируют от запросов, сознательно денормализуя данные в несколько таблиц; согласованность копий — на приложении, и один
BATCHна разные партиции её не заменяет. - Ключ раздела выбирают так, чтобы партиции не были ни «горячими», ни безгранично растущими (приём «корзины» в составном ключе): ориентир — до 100 МБ и до сотни тысяч строк на партицию.
- Индексы: 2i и SAI (5.0) не отменяют рассылку по узлам без ключа раздела; частый запрос это своя таблица, редкий это SAI; материализованные представления выключены не зря.
- Пределы: раздел до 100 МБ и ста тысяч строк, tombstone на запрос до ста тысяч (
tombstone_failure_threshold), батч до 50 КБ, крупные значения в объектное хранилище. - Сверх SQL-типов есть коллекции (не больше десятков элементов), пользовательские типы, счётчики и статическая колонка — значение, общее для всего раздела.
- Срок жизни задают
USING TTLилиdefault_time_to_live, и он ставится на колонку: раздел со сроком жизни не растёт бесконечно, но истёкшее становится меткой удаления. - Один запрос — одна таблица: одни и те же данные пишут в две таблицы партией. Денормализуют неизменяемое, расхождения чинят сверкой, а часто изменяемые и удаляемые данные — стоп-сигнал для выбора Cassandra.
Что почитать дальше
- Согласованность и репликация в Cassandra — какие гарантии даёт запись и чтение поверх этой модели.
- Архитектура Cassandra: кольцо без главного узла — почему запрос обязан указывать ключ раздела и как данные распределены по кольцу.
- Cassandra, PostgreSQL или MongoDB: когда брать колоночную NoSQL — сравнение моделей данных трёх баз.
- Секционирование (шардинг) простыми словами - как выбирают ключ разбиения и откуда берётся горячая точка в любой распределённой базе, а не только в Cassandra.