← назад к разделу

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

ключ раздела решает, в какой партиции строка; ключ кластеризации — её место внутри строки приходят вперемешкуA 10:07B 10:02A 10:02A 10:05B 10:09 hash(chat_id) → токен → партиция и её репликипартиция chat_id = AA 10:07A 10:02A 10:05партиция chat_id = BB 10:02B 10:09 внутри партиции A порядок задаёт sent_at — ключ кластеризацииA 10:02A 10:05A 10:07sent_at > 10:02 — две строки подряд WHERE author = 'ann' — ключа раздела в запросе нетадреса нет, значит читать надо все партиции кластера

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

Обязательно

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 проектируют не от данных, а от запросов. Порядок такой:

  1. выпишите все запросы, которые сервис будет делать;
  2. под каждый запрос спроектируйте свою таблицу так, чтобы запрос бил по ключу раздела;
  3. если один и тот же факт нужен в нескольких запросах под разными углами — храните его в нескольких таблицах.

То есть одни и те же данные сознательно дублируют — это денормализация, и в 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.

Что почитать дальше