Перейти к содержимому

Apache Cassandra: первый взгляд системного архитектора

Константин Потапов
40 мин

Экспертный разбор Cassandra для Python/Backend-разработчиков: от архитектуры без master-узлов до production-ready кластера с мониторингом.

Apache Cassandra: первый взгляд системного архитектора

Когда PostgreSQL начинает сдаваться

На прошлом проекте мониторинг показал 500ms p95 на запросах к таблице с 200M записей. Добавили индексы — стало 400ms. Партиционирование дало ещё минус 100ms. Потом рост пользователей съел весь прогресс за месяц. Vertical scaling упёрся в лимиты инстанса, horizontal sharding PostgreSQL превращался в кошмар поддержки.

Проблема была не в PostgreSQL — он честно делал свою работу. Проблема в том, что реляционная модель с ACID гарантиями имеет свою цену при линейном масштабировании. Когда ваши требования:

  • Линейное масштабирование write-нагрузки (добавил узел → вырос пропорционально)
  • Высокая доступность без single point of failure (выпал узел → система работает)
  • Географическое распределение данных (данные в нескольких дата-центрах)
  • Миллионы записей в секунду с предсказуемой latency

...тогда Cassandra — не альтернатива, а решение другого класса задач.

Cassandra НЕ заменяет PostgreSQL. Это инструмент для задач, где реляционная модель становится узким горлом. Если у вас нет write-интенсивной нагрузки на 100k+ записей в секунду или требований к multi-datacenter репликации — оставайтесь с PostgreSQL.

Что такое Cassandra простыми словами

Архитектура без хозяина

Представьте PostgreSQL: один master пишет, N replicas читают. Master упал — система в read-only до failover. Это классическая master-slave архитектура с единой точкой отказа.

Cassandra работает иначе — peer-to-peer, каждый узел равноправен:

  • Нет master узла — каждый узел может принимать и записи, и чтения
  • Нет single point of failure — выпало 2 узла из 10, остальные 8 работают
  • Линейное масштабирование — добавили узел, вырос пропорционально
  • Географическое распределение — узлы в разных дата-центрах без сложной настройки

Аналогия: торрент-сеть

PostgreSQL — это централизованный файловый сервер: один источник, все качают с него. Сервер упал → файл недоступен.

Cassandra — это торрент: файл разбит на куски, каждый пир хранит несколько кусков. Выпало 30% пиров → файл всё равно доступен, качаем с оставшихся.

Ключевые концепции

Keyspace (пространство ключей) — аналог базы данных в PostgreSQL. Содержит таблицы и определяет стратегию репликации.

Table (таблица) — структура данных, но НЕ реляционная. Таблица в Cassandra хранит данные по ключу, а не связи между сущностями.

Partition Key (ключ партиции) — определяет, на каких узлах хранятся данные. Все записи с одним partition key гарантированно на одних и тех же узлах кластера.

CREATE TABLE users (
    user_id UUID,
    email TEXT,
    created_at TIMESTAMP,
    PRIMARY KEY (user_id)  -- user_idpartition key
);
 
-- Все записи для user_id=123 будут на одних и тех же узлах
-- Cassandra хеширует user_id и решает, какие узлы хранят эти данные

Clustering Key (ключ кластеризации) — определяет порядок записей внутри партиции. Используется для сортировки и эффективных range-запросов.

CREATE TABLE user_events (
    user_id UUID,
    event_time TIMESTAMP,
    event_type TEXT,
    payload TEXT,
    PRIMARY KEY (user_id, event_time)
    -- user_idpartition key (на каких узлах хранить)
    -- event_timeclustering key (как сортировать внутри партиции)
) WITH CLUSTERING ORDER BY (event_time DESC);
 
-- Все события user_id=123 хранятся вместе (одна партиция)
-- Внутри партиции отсортированы по event_time от новых к старым
-- Запрос последних 100 событийO(1) чтение, без сканирования всей таблицы

Replication Factor (фактор репликации) — сколько копий данных хранится. RF=3 означает, что каждая запись физически хранится на 3 разных узлах.

Consistency Level (уровень консистентности) — сколько узлов должны подтвердить операцию.

# Запись с CL=QUORUM: ждём подтверждения от большинства реплик
session.execute(
    insert_query,
    consistency_level=ConsistencyLevel.QUORUM  # 2 из 3 для RF=3
)
 
# Запись с CL=ONE: ждём подтверждения от любой одной реплики
session.execute(
    insert_query,
    consistency_level=ConsistencyLevel.ONE  # Быстро, но риск неконсистентности
)
 
# Чтение с CL=ALL: ждём ответа от всех реплик
session.execute(
    select_query,
    consistency_level=ConsistencyLevel.ALL  # Медленно, но самые свежие данные
)

Гарантии консистентности

Cassandra — AP-система по теореме CAP:

  • Availability (доступность) — система отвечает всегда, даже при падении узлов
  • Partition tolerance (устойчивость к разделению) — работает при проблемах сети между узлами
  • Consistency (консистентность) — eventual consistency: данные рано или поздно станут одинаковыми везде, но НЕ мгновенно

Это означает, что Cassandra выбирает доступность вместо строгой консистентности. При падении узла или проблемах сети система продолжает работать, но разные узлы могут временно видеть разные версии данных.

Сравнение с PostgreSQL:

PostgreSQL (CP)Cassandra (AP)
Строгая консистентностьEventual consistency
Master упал → read-onlyВыпало N узлов → работа продолжается
ACID транзакцииНет транзакций между партициями
Vertical scaling (до 96TB)Horizontal scaling (сотни узлов)
Сложный shardingАвтоматическое распределение данных
Joins, foreign keysДенормализация, дублирование данных
Хорош для: OLTP, аналитикаХорош для: time-series, логи, IoT, метрики

Теорема CAP простыми словами:

Невозможно одновременно гарантировать:

  1. Consistency — все узлы видят одни и те же данные
  2. Availability — система всегда отвечает
  3. Partition tolerance — работа при проблемах сети

Cassandra выбирает AP: система всегда доступна, но консистентность — eventual (со временем). PostgreSQL выбирает CP: консистентность строгая, но если master упал — доступность нарушена до восстановления.

Зачем Cassandra нужен Backend-разработчику

Типичные сценарии

1. Time-series данные (метрики, логи, события):

Проблема: PostgreSQL хранит метрики миллионов устройств. Каждую секунду 100k записей, за день 8.6B записей. Таблица раздулась до 5TB, запросы по старым данным медленные, партиционирование вручную превратилось в кошмар.

-- PostgreSQL: плохо масштабируется для time-series
CREATE TABLE device_metrics (
    device_id UUID,
    metric_time TIMESTAMP,
    cpu_usage FLOAT,
    memory_usage FLOAT,
    PRIMARY KEY (device_id, metric_time)
);
 
-- Проблемы:
-- 1. INSERT нагрузка упирается в один master
-- 2. Партиционирование по времени нужно создавать вручную
-- 3. Запросы к старым партициям медленные
-- 4. Шардинг по device_id требует прокси-слой (Citus, Vitess)

С Cassandra это естественный паттерн:

-- Cassandra: идеально для time-series
CREATE TABLE device_metrics (
    device_id UUID,
    metric_time TIMESTAMP,
    cpu_usage FLOAT,
    memory_usage FLOAT,
    PRIMARY KEY (device_id, metric_time)
) WITH CLUSTERING ORDER BY (metric_time DESC);
 
-- Преимущества:
-- 1. INSERT распределяются по всем узлам автоматически
-- 2. Данные каждого device_id хранятся вместе (одна партиция)
-- 3. Внутри партиции метрики отсортированы по времени
-- 4. Запрос последних 1000 метрик устройстваO(1) чтение
-- 5. Добавили узелwrite throughput вырос пропорционально

Практический пример:

from cassandra.cluster import Cluster
from datetime import datetime
import uuid
 
# Подключение к кластеру
cluster = Cluster(['192.168.1.10', '192.168.1.11', '192.168.1.12'])
session = cluster.connect('monitoring')
 
# Запись метрик — распределяется по узлам автоматически
device_id = uuid.uuid4()
for i in range(1000):
    session.execute(
        """
        INSERT INTO device_metrics (device_id, metric_time, cpu_usage, memory_usage)
        VALUES (%s, %s, %s, %s)
        """,
        (device_id, datetime.utcnow(), 45.2 + i * 0.1, 62.8 + i * 0.05)
    )
 
# Чтение последних 100 метрик — O(1) благодаря clustering key
rows = session.execute(
    """
    SELECT metric_time, cpu_usage, memory_usage
    FROM device_metrics
    WHERE device_id = %s
    LIMIT 100
    """,
    (device_id,)
)
 
# Все 100 записей находятся на одних узлах, отсортированы — быстрое чтение
for row in rows:
    print(f"{row.metric_time}: CPU {row.cpu_usage}%, MEM {row.memory_usage}%")

2. Логирование и аудит:

Проблема: централизованный лог-сервер пишет 500k строк в секунду. PostgreSQL упирается в IOPS диска, master узел перегружен, репликация отстаёт на часы.

# Cassandra для логирования
CREATE TABLE application_logs (
    app_name TEXT,
    log_date DATE,
    log_time TIMESTAMP,
    level TEXT,
    message TEXT,
    PRIMARY KEY ((app_name, log_date), log_time)
) WITH CLUSTERING ORDER BY (log_time DESC);
 
# Composite partition key (app_name, log_date):
# - Логи каждого приложения за день хранятся вместе
# - Автоматическое "партиционирование по дням" без ручного создания партиций
# - Запросы по приложению и дате — O(1) чтение с одной партиции

Преимущества:

  • Логи разных приложений и разных дней — на разных узлах (равномерная нагрузка)
  • Запись 500k строк/сек распределяется по всему кластеру
  • Старые логи (прошлый месяц) не влияют на скорость записи новых
  • Можно настроить TTL — логи старше 30 дней удаляются автоматически

3. Счётчики и метрики в реальном времени:

Проблема: нужно считать клики на миллионы статей в реальном времени. PostgreSQL требует UPDATE ... SET clicks = clicks + 1 — каждый UPDATE блокирует строку, при высокой конкуренции throughput падает.

-- Cassandra имеет нативные counter-колонки
CREATE TABLE article_stats (
    article_id UUID PRIMARY KEY,
    views COUNTER,
    clicks COUNTER,
    shares COUNTER
);
 
-- Инкремент без блокировок
UPDATE article_stats
SET views = views + 1
WHERE article_id = 550e8400-e29b-41d4-a716-446655440000;

Как это работает:

Cassandra использует специальный тип данных COUNTER, который поддерживает атомарные инкременты без блокировок. Несколько узлов могут одновременно увеличивать счётчик, Cassandra разрешит конфликты через CRDT (Conflict-free Replicated Data Type).

Важно: COUNTER колонки имеют ограничения:

  • Нельзя смешивать с обычными колонками (только COUNTER в таблице)
  • Нельзя читать и инкрементить в одной транзакции (нет транзакций)
  • При конфликтах выбирается максимальное значение (eventual consistency)

4. Сессии и кэширование:

Проблема: Redis хранит сессии пользователей, но при росте нагрузки упирается в memory limit одного инстанса. Cluster mode Redis сложен в настройке и не даёт персистентности из коробки.

-- Cassandra для сессий
CREATE TABLE user_sessions (
    session_id UUID PRIMARY KEY,
    user_id UUID,
    data TEXT,  -- JSON с данными сессии
    expires_at TIMESTAMP
) WITH default_time_to_live = 86400;  -- TTL 24 часа
 
-- Преимущества:
-- 1. Персистентность на диск (не потеряем сессии при рестарте)
-- 2. Автоматическое удаление через TTL
-- 3. Линейное масштабированиедобавили узел, больше памяти для сессий
-- 4. Географическое распределениесессии реплицируются между дата-центрами

5. Очереди и message passing:

Проблема: нужна очередь задач, но Kafka избыточен (просто enqueue/dequeue), а Redis Streams не даёт гарантий персистентности при падении узла.

-- Cassandra для простых очередей
CREATE TABLE task_queue (
    queue_name TEXT,
    task_id TIMEUUID,
    payload TEXT,
    status TEXT,
    PRIMARY KEY (queue_name, task_id)
) WITH CLUSTERING ORDER BY (task_id ASC);
 
-- Enqueue
INSERT INTO task_queue (queue_name, task_id, payload, status)
VALUES ('email_notifications', now(), '{"to": "user@example.com"}', 'pending');
 
-- Dequeue (atomic update)
UPDATE task_queue
SET status = 'processing'
WHERE queue_name = 'email_notifications'
  AND task_id = <минимальный pending task_id>
IF status = 'pending';  -- LWT для атомарности

Важно: Легковесные транзакции (LWT) медленные — используйте только когда критична атомарность. Для высоконагруженных очередей лучше Kafka или RabbitMQ.

Когда Cassandra избыточен

НЕ используйте Cassandra, если:

  • Нагрузка < 10k записей в секунду (хватит PostgreSQL + индексы)
  • Нужны JOIN'ы и сложные аналитические запросы (используйте PostgreSQL или ClickHouse)
  • Критична строгая консистентность (банковские транзакции → PostgreSQL с ACID)
  • Команда не готова мыслить денормализацией (Cassandra требует другого подхода к моделированию)
  • Бюджет инфраструктуры ограничен (минимальный production кластер — 3 узла)

Используйте Cassandra, если:

  • Нужна линейная масштабируемость write-нагрузки
  • Критична высокая доступность (no single point of failure)
  • Географическое распределение данных (multi-datacenter)
  • Time-series данные: метрики, логи, события
  • Денормализация допустима (дублирование данных не проблема)

Установка и запуск Cassandra

Docker Compose (для dev/testing)

Создайте docker-compose.yml:

services:
  cassandra:
    image: cassandra:5.0
    container_name: cassandra-node1
    ports:
      - "9042:9042" # CQL порт
      - "7199:7199" # JMX мониторинг
    environment:
      - CASSANDRA_CLUSTER_NAME=DevCluster
      - CASSANDRA_DC=datacenter1
      - CASSANDRA_RACK=rack1
      - CASSANDRA_ENDPOINT_SNITCH=GossipingPropertyFileSnitch
      - MAX_HEAP_SIZE=512M
      - HEAP_NEWSIZE=128M
    volumes:
      - cassandra_data:/var/lib/cassandra
    healthcheck:
      test: ["CMD-SHELL", "cqlsh -e 'describe cluster'"]
      interval: 30s
      timeout: 10s
      retries: 5
 
volumes:
  cassandra_data:

Запуск:

# Поднимаем Cassandra
docker compose up -d
 
# Проверяем статус
docker compose ps
 
# Логи
docker compose logs -f cassandra
 
# Подключаемся через cqlsh (CQL shell)
docker exec -it cassandra-node1 cqlsh
 
# В cqlsh проверяем кластер
cqlsh> DESCRIBE CLUSTER;
cqlsh> SELECT * FROM system.local;

Docker Compose подходит только для dev/testing. В production используйте минимум 3 узла в разных availability zones для отказоустойчивости.

Создание Keyspace и таблицы

Keyspace в Cassandra — аналог базы данных в PostgreSQL. Определяет стратегию репликации.

-- Создаём keyspace с SimpleStrategy (для dev)
CREATE KEYSPACE IF NOT EXISTS demo
WITH replication = {
    'class': 'SimpleStrategy',
    'replication_factor': 1
};
 
-- В production используйте NetworkTopologyStrategy
CREATE KEYSPACE IF NOT EXISTS production_app
WITH replication = {
    'class': 'NetworkTopologyStrategy',
    'datacenter1': 3  -- 3 копии данных в datacenter1
};
 
-- Используем keyspace
USE demo;
 
-- Создаём таблицу
CREATE TABLE users (
    user_id UUID PRIMARY KEY,
    email TEXT,
    full_name TEXT,
    created_at TIMESTAMP,
    last_login TIMESTAMP
);
 
-- Создаём таблицу с composite primary key
CREATE TABLE user_sessions (
    user_id UUID,
    session_id TIMEUUID,
    ip_address TEXT,
    user_agent TEXT,
    created_at TIMESTAMP,
    PRIMARY KEY (user_id, session_id)
) WITH CLUSTERING ORDER BY (session_id DESC);

Что означают параметры:

  • SimpleStrategy — простая репликация, игнорирует топологию дата-центров (только для dev)
  • NetworkTopologyStrategy — учитывает дата-центры и racks (для production)
  • replication_factor: 1 — одна копия данных (dev), в production минимум 3
  • PRIMARY KEY (user_id, session_id) — user_id = partition key, session_id = clustering key

Базовые операции (CQL)

CQL (Cassandra Query Language) — язык запросов Cassandra, синтаксически похожий на SQL, но с ключевыми отличиями. CQL скрывает внутреннюю структуру хранения данных (wide rows, column families) за привычным табличным интерфейсом.

Ключевые отличия от SQL:

  • Нет JOIN'ов — нужна денормализация данных
  • WHERE работает только с partition key и clustering key (или ALLOW FILTERING)
  • Нет транзакций между партициями
  • Нет foreign keys и constraints
  • GROUP BY и агрегации ограничены

Основные операции:

-- INSERT
INSERT INTO users (user_id, email, full_name, created_at)
VALUES (uuid(), 'constantin@potapov.me', 'Constantin Potapov', toTimestamp(now()));
 
-- SELECT
SELECT * FROM users WHERE user_id = 550e8400-e29b-41d4-a716-446655440000;
 
-- UPDATE
UPDATE users
SET last_login = toTimestamp(now())
WHERE user_id = 550e8400-e29b-41d4-a716-446655440000;
 
-- DELETE
DELETE FROM users WHERE user_id = 550e8400-e29b-41d4-a716-446655440000;
 
-- Batch операции (в пределах одной партиции!)
BEGIN BATCH
    INSERT INTO users (user_id, email, full_name) VALUES (uuid(), 'user1@ex.com', 'User 1');
    INSERT INTO users (user_id, email, full_name) VALUES (uuid(), 'user2@ex.com', 'User 2');
APPLY BATCH;

BATCH в Cassandra НЕ транзакция! Batch гарантирует атомарность только для записей в одну партицию (одинаковый partition key). Для разных партиций это просто группировка запросов без ACID гарантий.

Python клиенты для Cassandra

Официальный DataStax Driver

Стандартный выбор для production:

pip install cassandra-driver

Основной пример:

from cassandra.cluster import Cluster, ExecutionProfile, EXEC_PROFILE_DEFAULT
from cassandra.policies import DCAwareRoundRobinPolicy, TokenAwarePolicy
from cassandra.query import dict_factory
import uuid
 
# Настройка профиля выполнения
profile = ExecutionProfile(
    load_balancing_policy=TokenAwarePolicy(
        DCAwareRoundRobinPolicy(local_dc='datacenter1')
    ),
    row_factory=dict_factory  # Возвращать строки как dict
)
 
# Подключение к кластеру
cluster = Cluster(
    contact_points=['192.168.1.10', '192.168.1.11', '192.168.1.12'],
    execution_profiles={EXEC_PROFILE_DEFAULT: profile},
    protocol_version=4,
)
 
session = cluster.connect('demo')
 
# Prepared statement для производительности
insert_user = session.prepare(
    """
    INSERT INTO users (user_id, email, full_name, created_at)
    VALUES (?, ?, ?, toTimestamp(now()))
    """
)
 
# Вставка данных
user_id = uuid.uuid4()
session.execute(insert_user, (user_id, 'test@example.com', 'Test User'))
 
# Чтение данных
rows = session.execute(
    "SELECT * FROM users WHERE user_id = %s",
    (user_id,)
)
 
for row in rows:
    print(f"User: {row['full_name']}, Email: {row['email']}")
 
# Закрываем соединение
cluster.shutdown()

Async версия (aiocassandra)

Для интеграции с FastAPI и другими async фреймворками:

pip install aiocassandra

Пример с FastAPI:

from fastapi import FastAPI, HTTPException
from cassandra.cluster import Cluster
from cassandra.query import dict_factory
from aiocassandra import aiosession
from pydantic import BaseModel
from typing import Optional
import uuid
 
app = FastAPI()
 
# Инициализация Cassandra при старте
cluster = Cluster(['192.168.1.10'])
session = cluster.connect('demo')
session.row_factory = dict_factory
 
# Делаем session асинхронным
async_session = aiosession(session)
 
class User(BaseModel):
    email: str
    full_name: str
 
class UserResponse(User):
    user_id: uuid.UUID
    created_at: Optional[str]
 
@app.on_event("shutdown")
def shutdown_event():
    cluster.shutdown()
 
@app.post("/users", response_model=UserResponse)
async def create_user(user: User):
    """Создание пользователя"""
    user_id = uuid.uuid4()
 
    query = """
    INSERT INTO users (user_id, email, full_name, created_at)
    VALUES (?, ?, ?, toTimestamp(now()))
    """
 
    await async_session.execute(query, (user_id, user.email, user.full_name))
 
    return UserResponse(
        user_id=user_id,
        email=user.email,
        full_name=user.full_name,
        created_at=None
    )
 
@app.get("/users/{user_id}", response_model=UserResponse)
async def get_user(user_id: uuid.UUID):
    """Получение пользователя"""
    rows = await async_session.execute(
        "SELECT * FROM users WHERE user_id = %s",
        (user_id,)
    )
 
    users = list(rows)
    if not users:
        raise HTTPException(status_code=404, detail="User not found")
 
    return UserResponse(**users[0])

Prepared Statements: производительность и безопасность

Prepared statements — обязательный паттерн для production:

# ❌ Плохо: парсинг CQL на каждый запрос
session.execute(
    f"INSERT INTO users (user_id, email) VALUES ({user_id}, '{email}')"
)
 
# ✅ Хорошо: подготовили один раз, используем многократно
insert_stmt = session.prepare(
    "INSERT INTO users (user_id, email, full_name) VALUES (?, ?, ?)"
)
 
# Prepared statement парсится один раз, затем Cassandra знает структуру запроса
# Вместо парсинга SQL на каждую вставку — подстановка параметров
for user in users:
    session.execute(insert_stmt, (user.id, user.email, user.name))

Преимущества:

  • Парсинг CQL происходит один раз (экономия CPU на сервере)
  • Защита от CQL-injection
  • Cassandra кэширует execution план

Prepared statements особенно важны в циклах. Если вставляете 10k записей, prepared statement сэкономит 10k парсингов CQL — ощутимая разница в latency.

Моделирование данных в Cassandra

Главный принцип: Query First Design

В PostgreSQL моделируем сущности и связи (ER-диаграммы), затем строим запросы.

В Cassandra наоборот: сначала определяем запросы, затем моделируем таблицы под эти запросы.

Алгоритм моделирования:

  1. Список всех запросов приложения (например: "получить последние 100 событий пользователя")
  2. Определить partition key (что будет в WHERE без ALLOW FILTERING)
  3. Определить clustering key (сортировка внутри партиции)
  4. Денормализовать данные (дублирование допустимо)
  5. Создать отдельные таблицы для разных паттернов запросов

Пример: блог-платформа

Запросы приложения:

  1. Получить статью по ID
  2. Получить все статьи автора, отсортированные по дате
  3. Получить последние 20 статей (главная страница)
  4. Получить все комментарии к статье

Плохая модель (в стиле PostgreSQL):

--Антипаттерн: пытаемся делать JOIN через WHERE
CREATE TABLE articles (
    article_id UUID PRIMARY KEY,
    author_id UUID,
    title TEXT,
    content TEXT,
    published_at TIMESTAMP
);
 
CREATE TABLE comments (
    comment_id UUID PRIMARY KEY,
    article_id UUID,
    author_id UUID,
    text TEXT
);
 
-- Проблема 1: Нельзя эффективно получить статьи автора
-- SELECT * FROM articles WHERE author_id = ? ALLOW FILTERING;
-- ALLOW FILTERING сканирует ВСЕ партициикатастрофа для производительности!
 
-- Проблема 2: Нет сортировки по published_at без ALLOW FILTERING

Правильная модель (Query First):

-- Запрос 1: статья по ID
CREATE TABLE articles_by_id (
    article_id UUID PRIMARY KEY,
    author_id UUID,
    title TEXT,
    content TEXT,
    published_at TIMESTAMP
);
 
-- Запрос 2: статьи автора, отсортированные по дате
CREATE TABLE articles_by_author (
    author_id UUID,
    published_at TIMESTAMP,
    article_id UUID,
    title TEXT,
    content TEXT,  -- Денормализация: дублируем content
    PRIMARY KEY (author_id, published_at)
) WITH CLUSTERING ORDER BY (published_at DESC);
 
-- Запрос 3: последние статьи (главная страница)
CREATE TABLE articles_recent (
    bucket TEXT,  -- Например 'home_page'
    published_at TIMESTAMP,
    article_id UUID,
    author_id UUID,
    title TEXT,
    PRIMARY KEY (bucket, published_at)
) WITH CLUSTERING ORDER BY (published_at DESC);
 
-- Запрос 4: комментарии к статье
CREATE TABLE comments_by_article (
    article_id UUID,
    comment_id TIMEUUID,  -- TIMEUUID для сортировки по времени создания
    author_id UUID,
    text TEXT,
    PRIMARY KEY (article_id, comment_id)
) WITH CLUSTERING ORDER BY (comment_id DESC);

Код приложения:

from cassandra.cluster import Cluster
import uuid
 
cluster = Cluster(['192.168.1.10'])
session = cluster.connect('blog')
 
# Публикация статьи — пишем в ТРИ таблицы (денормализация)
article_id = uuid.uuid4()
author_id = uuid.uuid4()
title = "Apache Cassandra: первый взгляд"
content = "Экспертный разбор Cassandra..."
published_at = datetime.utcnow()
 
# Таблица 1: для запроса по ID
session.execute(
    "INSERT INTO articles_by_id (article_id, author_id, title, content, published_at) VALUES (?, ?, ?, ?, ?)",
    (article_id, author_id, title, content, published_at)
)
 
# Таблица 2: для запроса статей автора
session.execute(
    "INSERT INTO articles_by_author (author_id, published_at, article_id, title, content) VALUES (?, ?, ?, ?, ?)",
    (author_id, published_at, article_id, title, content)
)
 
# Таблица 3: для главной страницы
session.execute(
    "INSERT INTO articles_recent (bucket, published_at, article_id, author_id, title) VALUES (?, ?, ?, ?, ?)",
    ('home_page', published_at, article_id, author_id, title)
)
 
# Чтение статей автора — O(1) по partition key
rows = session.execute(
    "SELECT * FROM articles_by_author WHERE author_id = ? LIMIT 20",
    (author_id,)
)
 
# Чтение комментариев к статье — O(1) по partition key
rows = session.execute(
    "SELECT * FROM comments_by_article WHERE article_id = ?",
    (article_id,)
)

Что здесь происходит:

  • Одна запись (статья) физически хранится в трёх таблицах с разными ключами
  • Это денормализация — дублирование данных ради производительности чтения
  • Каждый паттерн запроса имеет свою таблицу с оптимальным ключом
  • Нет ALLOW FILTERING — каждый запрос использует partition key

Правила хорошего моделирования

1. Один запрос = одна таблица

Если для одних и тех же данных нужны разные паттерны доступа (по ID, по автору, по дате) — создавайте разные таблицы.

2. Денормализация — норма

Дублирование данных в Cassandra не проблема. Это осознанный trade-off: дисковое пространство обменивается на производительность чтения.

3. Избегайте больших партиций

Партиция — все записи с одинаковым partition key. Рекомендация: партиция < 100MB, < 100k строк.

--Плохо: все события всех пользователей в одной партиции
CREATE TABLE all_events (
    event_type TEXT,  -- partition key
    event_id TIMEUUID,
    user_id UUID,
    payload TEXT,
    PRIMARY KEY (event_type, event_id)
);
-- Проблема: event_type='login' может содержать миллиарды записейгигантская партиция
 
--Хорошо: партиция на каждого пользователя
CREATE TABLE user_events (
    user_id UUID,     -- partition key
    event_id TIMEUUID,
    event_type TEXT,
    payload TEXT,
    PRIMARY KEY (user_id, event_id)
);
-- user_id разбивает данные на миллионы маленьких партиций

4. Используйте TIMEUUID для времязависимых данных

TIMEUUID — это UUID v1, который содержит timestamp. Идеален для clustering key в time-series данных.

CREATE TABLE messages (
    chat_id UUID,
    message_id TIMEUUID,  -- Содержит timestamp создания
    sender_id UUID,
    text TEXT,
    PRIMARY KEY (chat_id, message_id)
) WITH CLUSTERING ORDER BY (message_id DESC);
 
-- Преимущества:
-- 1. Автоматическая сортировка по времени создания
-- 2. Уникальность даже при миллионах записей в секунду
-- 3. Можно извлечь timestamp: SELECT dateOf(message_id) FROM messages

5. TTL для автоматического удаления

-- Вставка с TTL (Time To Live) 24 часа
INSERT INTO user_sessions (session_id, user_id, data)
VALUES (uuid(), uuid(), '{"key": "value"}')
USING TTL 86400;
 
-- Cassandra автоматически удалит эту запись через 24 часа
-- Нет нужды в cron-джобах для очистки старых данных

Подводные камни и решения

1. Tombstones и удаление данных

Проблема: В Cassandra удаление — не мгновенная операция. DELETE создаёт tombstone (маркер удаления), который живёт до следующей compaction.

-- DELETE создаёт tombstone
DELETE FROM users WHERE user_id = ?;
 
-- Tombstone остаётся на диске до compaction (по умолчанию 10 дней)
-- Если удалили миллион записеймиллион tombstones
-- При SELECT Cassandra читает tombstonesзамедление запросов

Решение 1: Используйте TTL вместо DELETE для временных данных:

-- Вместо DELETE через 24 часа используем TTL
INSERT INTO sessions (session_id, data) VALUES (?, ?)
USING TTL 86400;
 
-- Cassandra сама удалит через 24 часа, tombstones минимальны

Решение 2: Настройте gc_grace_seconds для быстрой очистки tombstones:

-- По умолчанию tombstones живут 10 дней (864000 секунд)
-- Если не используете ручные repair, можно сократить до 1 дня
ALTER TABLE sessions WITH gc_grace_seconds = 86400;

Решение 3: Мониторьте tombstone_warn_threshold и tombstone_failure_threshold:

# cassandra.yaml
tombstone_warn_threshold: 1000 # Предупреждение при 1000 tombstones в запросе
tombstone_failure_threshold: 100000 # Ошибка при 100k tombstones

2. Secondary Indexes — когда они НЕ решение

Проблема: Соблазнительно создать secondary index для запросов не по partition key:

-- Создали таблицу с partition key = user_id
CREATE TABLE users (
    user_id UUID PRIMARY KEY,
    email TEXT,
    full_name TEXT
);
 
-- Хотим искать по emailсоздаём secondary index
CREATE INDEX ON users (email);
 
-- Запрос работает
SELECT * FROM users WHERE email = 'test@example.com';

Почему это плохо:

  • Secondary index НЕ distributed — каждый узел хранит локальный индекс
  • Запрос по secondary index требует опроса всех узлов кластера
  • При росте кластера до 100 узлов каждый запрос опрашивает все 100
  • Latency растёт линейно с количеством узлов

Решение: Создайте отдельную таблицу с нужным partition key:

-- Таблица для запросов по email
CREATE TABLE users_by_email (
    email TEXT PRIMARY KEY,
    user_id UUID,
    full_name TEXT
);
 
-- Теперь запрос по emailO(1), использует partition key
SELECT * FROM users_by_email WHERE email = 'test@example.com';

3. ALLOW FILTERING — красный флаг

Проблема: Cassandra не даёт делать WHERE по колонке, которая не в partition key или clustering key:

--Ошибка
SELECT * FROM users WHERE full_name = 'John';
-- Error: Cannot execute this query as it might involve data filtering
 
-- Cassandra предлагает добавить ALLOW FILTERING:
SELECT * FROM users WHERE full_name = 'John' ALLOW FILTERING;

Почему ALLOW FILTERING опасен:

ALLOW FILTERING заставляет Cassandra читать все партиции и фильтровать в памяти. Для таблицы с миллионом записей это означает чтение миллиона строк на каждый запрос.

Решение: Перемоделируйте таблицу под ваши запросы:

CREATE TABLE users_by_name (
    full_name TEXT,
    user_id UUID,
    email TEXT,
    PRIMARY KEY (full_name, user_id)
);

Если вы видите ALLOW FILTERING в production коде — это сигнал, что моделирование данных неправильное. Переделайте таблицу под паттерн запроса.

4. Hot Partitions (перегруженные партиции)

Проблема: Если partition key имеет неравномерное распределение, одна партиция может получать большую часть нагрузки.

--Плохой partition key: большинство запросов к одному значению
CREATE TABLE page_views (
    page_url TEXT,  -- partition key
    view_id TIMEUUID,
    user_id UUID,
    PRIMARY KEY (page_url, view_id)
);
 
-- Проблема: главная страница '/' получает 90% трафика
-- Партиция '/' перегружена, остальные узлы простаивают

Решение: Добавьте bucketing (распределение по корзинам):

Что такое bucketing:

Bucketing — это техника разделения одной "горячей" партиции на множество маленьких партиций путём добавления синтетического ключа (bucket). Вместо одного partition key используется составной ключ (page_url, bucket), где bucket — случайное число от 0 до N.

Как это работает:

  1. При записи генерируем случайное число bucket (например, от 0 до 99)
  2. Cassandra хеширует пару (page_url, bucket) и распределяет данные по узлам
  3. Вместо одной партиции ('/') получаем 100 партиций: ('/', 0), ('/', 1), ..., ('/', 99)
  4. Каждая партиция получает ~1% нагрузки вместо 100%
  5. При чтении параллельно запрашиваем все 100 корзин и объединяем результаты
--Хороший подход: composite partition key с bucketing
CREATE TABLE page_views (
    page_url TEXT,
    bucket INT,  -- Случайное число 0-99 (синтетический ключ для распределения)
    view_id TIMEUUID,
    user_id UUID,
    PRIMARY KEY ((page_url, bucket), view_id)
);
 
-- Теперь главная страница распределена по 100 партиций:
-- ('/', 0), ('/', 1), ..., ('/', 99)
-- Нагрузка равномерна по узламкаждая партиция получает ~1% трафика

Код для записи:

import random
 
page_url = '/'
bucket = random.randint(0, 99)  # Случайная корзина 0-99
 
session.execute(
    "INSERT INTO page_views (page_url, bucket, view_id, user_id) VALUES (?, ?, ?, ?)",
    (page_url, bucket, uuid.uuid1(), user_id)
)

Код для чтения:

# Читаем из всех 100 корзин параллельно
from concurrent.futures import ThreadPoolExecutor
 
def read_bucket(bucket):
    return session.execute(
        "SELECT * FROM page_views WHERE page_url = ? AND bucket = ? LIMIT 100",
        (page_url, bucket)
    )
 
with ThreadPoolExecutor(max_workers=10) as executor:
    results = executor.map(read_bucket, range(100))
    all_views = [view for bucket_views in results for view in bucket_views]

5. Batch операции и производительность

Проблема: BATCH в Cassandra НЕ увеличивает производительность, а часто ухудшает её.

# ❌ Антипаттерн: batch для разных партиций
batch = BatchStatement()
for user in users:
    batch.add(insert_stmt, (user.id, user.email, user.name))
session.execute(batch)
 
# Проблема: Cassandra должна координировать запись во все партиции
# Coordinator узел становится узким местом

Решение: Используйте асинхронные запросы:

# ✅ Правильно: параллельные асинхронные вставки
from cassandra.query import SimpleStatement
 
futures = []
for user in users:
    future = session.execute_async(insert_stmt, (user.id, user.email, user.name))
    futures.append(future)
 
# Ждём завершения всех операций
for future in futures:
    future.result()

Когда использовать BATCH:

Только для атомарных операций в пределах одной партиции:

BEGIN BATCH
    INSERT INTO users_by_id (user_id, email) VALUES (?, ?);
    INSERT INTO users_by_email (email, user_id) VALUES (?, ?);
APPLY BATCH;
 
-- Обе записи имеют одинаковый partition key (user_id или email)
-- Cassandra гарантирует атомарность

Мониторинг и наблюдаемость

Ключевые метрики

Метрики узла:

  • Read Latency (p95, p99) — задержка чтения
  • Write Latency (p95, p99) — задержка записи
  • Pending Compactions — количество ожидающих compaction задач
  • Heap Memory Usage — использование памяти JVM
  • GC Pause Time — паузы сборки мусора Java

Метрики таблиц:

  • SSTable Count — количество SSTable файлов на диске
  • Bloom Filter False Positive Rate — процент ложных положительных результатов
  • Tombstone Scanned — количество tombstones, прочитанных в запросах

Метрики кластера:

  • Hinted Handoff — количество отложенных записей
  • Read Repair — частота исправлений несогласованности при чтении
  • Streaming — активные потоки данных между узлами

Prometheus + Grafana

Cassandra экспортирует метрики через JMX. Используйте JMX Exporter для интеграции с Prometheus:

# docker-compose.yml
services:
  cassandra:
    image: cassandra:5.0
    environment:
      - JVM_OPTS=-javaagent:/opt/jmx_exporter/jmx_prometheus_javaagent.jar=7070:/opt/jmx_exporter/config.yml
    ports:
      - "7070:7070" # Prometheus metrics
    volumes:
      - ./jmx_exporter:/opt/jmx_exporter
 
  prometheus:
    image: prom/prometheus
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
    ports:
      - "9090:9090"
 
  grafana:
    image: grafana/grafana
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin

prometheus.yml:

scrape_configs:
  - job_name: "cassandra"
    static_configs:
      - targets: ["cassandra:7070"]

Логирование запросов

Включите slow query log в cassandra.yaml:

# cassandra.yaml
slow_query_log_timeout: 500ms # Логировать запросы дольше 500ms

Логи попадут в /var/log/cassandra/debug.log:

WARN  [ReadStage-2] 2025-12-03 10:15:32,145 ReadCommand.java:178 -
Query SELECT * FROM articles_by_author WHERE author_id = ? took 1234ms

Production Checklist

Перед деплоем

  • Репликация: минимум RF=3 для production
  • Топология: узлы в разных availability zones (минимум 3 AZ)
  • Snitch: настройте GossipingPropertyFileSnitch или Ec2Snitch для multi-AZ
  • Compaction: выберите стратегию (SizeTieredCompactionStrategy vs LeveledCompactionStrategy)
  • JVM Heap: 8-16GB (не более 50% RAM, не более 32GB)
  • GC: настройте G1GC вместо CMS
  • Мониторинг: настройте алерты на latency и compaction pending
  • Backups: автоматизируйте snapshots (ежедневно)
  • Тесты: нагрузочное тестирование с realistic data volume

Безопасность

# cassandra.yaml
authenticator: PasswordAuthenticator # Вместо AllowAllAuthenticator
authorizer: CassandraAuthorizer # Вместо AllowAllAuthorizer
 
# Включите SSL для client-to-node
client_encryption_options:
  enabled: true
  keystore: /path/to/keystore.jks
  keystore_password: changeit
 
# Включите SSL для node-to-node
server_encryption_options:
  internode_encryption: all
  keystore: /path/to/keystore.jks
  keystore_password: changeit

Создание пользователя с ограниченными правами:

-- Создаём роль для приложения
CREATE ROLE app_user WITH PASSWORD = 'secure_password' AND LOGIN = true;
 
-- Даём права только на конкретный keyspace
GRANT SELECT, MODIFY ON KEYSPACE production_app TO app_user;
 
-- Запрещаем DROP и TRUNCATE
REVOKE DROP ON KEYSPACE production_app FROM app_user;

Capacity Planning

Формула для оценки disk space:

Total Disk = (Data Size) × (Replication Factor) × (Compaction Overhead)
Compaction Overhead = 1.5 для STCS, 1.1 для LCS

Пример: 1TB данных, RF=3, STCS:

Total Disk = 1TB × 3 × 1.5 = 4.5TB

Формула для количества узлов:

Nodes = Total Disk / (Disk per Node × 0.5)
0.5 = запас на компактификацию и ремонт

Пример: 4.5TB total, 500GB SSD per node:

Nodes = 4.5TB / (500GB × 0.5) = 18 узлов

Формула для RAM:

RAM per Node = (SSTable Index Size × 2) + Heap + OS Cache
SSTable Index Size ≈ 1-5% от данных на узле
Heap = 8-16GB
OS Cache = минимум 8GB

Итоги

Вы научились:

  • Понимать архитектуру Cassandra — peer-to-peer, нет master-узлов
  • Моделировать данные — query-first подход, денормализация
  • Интегрировать с Python — cassandra-driver, async версия
  • Избегать подводных камней — tombstones, ALLOW FILTERING, hot partitions
  • Мониторить кластер — метрики, логи, алерты
  • Планировать capacity — disk, RAM, узлы

Следующие шаги:

  1. Изучите Lightweight Transactions (LWT) для атомарных операций
  2. Попробуйте Materialized Views для автоматической денормализации
  3. Настройте Multi-Datacenter репликацию для geo-распределения
  4. Изучите Change Data Capture (CDC) для event streaming
  5. Реализуйте Time Window Compaction Strategy для time-series данных

Полезные ссылки:

Cassandra — мощный инструмент для построения масштабируемых distributed систем. Начните с простого use case (time-series метрики), освойте Query First подход к моделированию, и постепенно переходите к сложным сценариям с multi-datacenter репликацией.

Cassandra превращает проблему масштабирования в задачу добавления узлов, но требует переосмысления подхода к моделированию данных. Начинайте с одного keyspace и одной таблицы — усложняйте постепенно.