HardТеория4 min

Apache Cassandra

Модель данных, partitioning, gossip protocol, consistency levels -- архитектура распределённой БД

Что такое Cassandra

Apache Cassandra -- распределённая NoSQL база данных, спроектированная для работы с огромными объёмами данных на множестве серверов без единой точки отказа.

Ключевые свойства

Свойство Описание
Masterless Нет мастера, все узлы равны
Линейная масштабируемость 2x узлов = 2x пропускной способности
Высокая доступность Работает при потере нескольких узлов
Write-optimized Очень быстрая запись (LSM-tree)
Tunable consistency Настраиваемая консистентность
Multi-datacenter Встроенная поддержка нескольких ЦОД

Архитектура

Ring Topology

                    Node A
                   (Token: 0-24)
                  ╱            ╲
            Node F              Node B
         (Token: 75-99)     (Token: 25-49)
                  ╲            ╱
                    Node E
                   (Token: 50-74)

Data for key "user_123":
  hash("user_123") = 37 → Node B (primary)
  Replicas: Node E, Node A (RF=3)

Каждый узел отвечает за диапазон токенов. Данные распределяются по кольцу на основе хеша partition key.

Gossip Protocol

Узлы обмениваются информацией о состоянии кластера через gossip:

  • Каждую секунду узел отправляет gossip сообщение случайному узлу
  • Сообщение содержит информацию обо всех известных узлах
  • Через несколько раундов все узлы знают обо всех
  • Обнаружение отказов через отсутствие heartbeat

Преимущество: нет центрального координатора. Кластер самоорганизуется и восстанавливается автоматически.

Модель данных

Partition Key и Clustering Key

PRIMARY KEY ((partition_key), clustering_key1, clustering_key2)
             ^^^^^^^^^^^^^^^^^  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
             Определяет узел    Определяет порядок внутри партиции
<?php

declare(strict_types=1);

/**
 * Cassandra data model design patterns
 */
final class CassandraSchemaDesign
{
    /**
     * Table design follows query patterns, NOT entity relationships.
     *
     * Rule: One table per query pattern
     * Anti-pattern: Designing like relational DB with JOINs
     */

    /**
     * Query 1: Get all orders for a user, sorted by date
     */
    public function ordersTableDesign(): string
    {
        return <<<CQL
            CREATE TABLE orders_by_user (
                user_id UUID,
                order_date TIMESTAMP,
                order_id UUID,
                total_amount DECIMAL,
                status TEXT,
                items LIST<FROZEN<order_item>>,
                PRIMARY KEY ((user_id), order_date, order_id)
            ) WITH CLUSTERING ORDER BY (order_date DESC, order_id ASC);

            CREATE TYPE order_item (
                product_id UUID,
                name TEXT,
                price DECIMAL,
                quantity INT
            );
        CQL;
    }

    /**
     * Query 2: Get order by ID
     * Different table for different query!
     */
    public function orderByIdDesign(): string
    {
        return <<<CQL
            CREATE TABLE orders_by_id (
                order_id UUID PRIMARY KEY,
                user_id UUID,
                order_date TIMESTAMP,
                total_amount DECIMAL,
                status TEXT,
                items LIST<FROZEN<order_item>>
            );
        CQL;
    }

    /**
     * Query 3: Get orders by status within a date range
     */
    public function ordersByStatusDesign(): string
    {
        return <<<CQL
            CREATE TABLE orders_by_status (
                status TEXT,
                order_date TIMESTAMP,
                order_id UUID,
                user_id UUID,
                total_amount DECIMAL,
                PRIMARY KEY ((status), order_date, order_id)
            ) WITH CLUSTERING ORDER BY (order_date DESC);
        CQL;
    }
}

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

Правило Описание
Denormalize Дублировать данные для каждого паттерна запросов
No JOINs Все данные для запроса -- в одной таблице
Partition size Держать партиции < 100 MB, < 100K строк
Know your queries Проектировать таблицы от запросов, не от данных
Avoid ALLOW FILTERING Медленно, сканирует весь кластер

Consistency Levels

Cassandra позволяет настраивать консистентность для каждого запроса:

Level Write Read Описание
ONE 1 узел 1 узел Минимальная задержка
QUORUM N/2+1 N/2+1 Баланс
LOCAL_QUORUM N/2+1 в локальном DC N/2+1 в локальном DC Для multi-DC
ALL Все реплики Все реплики Максимальная консистентность
EACH_QUORUM Quorum в каждом DC -- Для multi-DC writes

Strong Consistency формула

R + W > N → Strong Consistency

R = read consistency level
W = write consistency level
N = replication factor

QUORUM + QUORUM = (3/2+1) + (3/2+1) = 4 > 3 ✓ (strong)
ONE + ONE = 1 + 1 = 2 < 3 ✗ (eventual)

PHP + Cassandra

<?php

declare(strict_types=1);

/**
 * Cassandra operations from PHP using DataStax driver
 */
final class CassandraOrderRepository
{
    private \Cassandra\Session $session;

    public function __construct(string $contactPoints, string $keyspace)
    {
        $cluster = \Cassandra::cluster()
            ->withContactPoints($contactPoints)
            ->withDefaultConsistency(\Cassandra::CONSISTENCY_LOCAL_QUORUM)
            ->withDefaultPageSize(100)
            ->build();

        $this->session = $cluster->connect($keyspace);
    }

    /**
     * Insert order into multiple tables (denormalized)
     */
    public function createOrder(array $orderData): void
    {
        $batch = new \Cassandra\BatchStatement(\Cassandra::BATCH_LOGGED);

        // Table 1: orders_by_user
        $stmt1 = $this->session->prepare(<<<CQL
            INSERT INTO orders_by_user (user_id, order_date, order_id, total_amount, status)
            VALUES (?, ?, ?, ?, ?)
        CQL);

        $batch->add($stmt1, [
            'user_id' => new \Cassandra\Uuid($orderData['user_id']),
            'order_date' => new \Cassandra\Timestamp(time()),
            'order_id' => new \Cassandra\Uuid($orderData['order_id']),
            'total_amount' => new \Cassandra\Decimal((string) $orderData['total']),
            'status' => 'pending',
        ]);

        // Table 2: orders_by_id
        $stmt2 = $this->session->prepare(<<<CQL
            INSERT INTO orders_by_id (order_id, user_id, order_date, total_amount, status)
            VALUES (?, ?, ?, ?, ?)
        CQL);

        $batch->add($stmt2, [
            'order_id' => new \Cassandra\Uuid($orderData['order_id']),
            'user_id' => new \Cassandra\Uuid($orderData['user_id']),
            'order_date' => new \Cassandra\Timestamp(time()),
            'total_amount' => new \Cassandra\Decimal((string) $orderData['total']),
            'status' => 'pending',
        ]);

        $this->session->execute($batch);
    }

    /**
     * Pagination using token-based approach
     */
    public function getUserOrders(string $userId, ?string $pagingState = null): array
    {
        $stmt = $this->session->prepare(<<<CQL
            SELECT order_date, order_id, total_amount, status
            FROM orders_by_user
            WHERE user_id = ?
            ORDER BY order_date DESC
        CQL);

        $options = ['page_size' => 20];
        if ($pagingState !== null) {
            $options['paging_state_token'] = $pagingState;
        }

        $result = $this->session->execute($stmt, [
            'arguments' => [new \Cassandra\Uuid($userId)],
            ...$options,
        ]);

        return [
            'orders' => iterator_to_array($result),
            'next_page' => $result->pagingStateToken(),
            'has_more' => $result->isLastPage() === false,
        ];
    }

    /**
     * Lightweight transaction (compare-and-set)
     */
    public function updateStatusIfPending(string $orderId, string $newStatus): bool
    {
        $stmt = $this->session->prepare(<<<CQL
            UPDATE orders_by_id
            SET status = ?
            WHERE order_id = ?
            IF status = 'pending'
        CQL);

        $result = $this->session->execute($stmt, [
            'arguments' => [$newStatus, new \Cassandra\Uuid($orderId)],
            'consistency' => \Cassandra::CONSISTENCY_SERIAL,
        ]);

        $row = $result->first();
        return $row['[applied]'] ?? false;
    }
}

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

Подходит

  • Запись > 100K ops/sec (IoT, логи, events)
  • Multi-datacenter с автоматической репликацией
  • Данные с известными паттернами запросов
  • Time-series данные (с правильным моделированием)
  • Высокая доступность важнее консистентности

Не подходит

  • Ad-hoc запросы и аналитика (нет JOIN, нет агрегаций)
  • Транзакции между несколькими партициями
  • Малый объём данных (< 100 GB) -- overkill
  • Часто меняющиеся паттерны запросов
  • Команда без опыта распределённых систем

Итоги

  • Cassandra -- masterless, линейно масштабируемая, write-optimized БД
  • Gossip protocol обеспечивает самоорганизацию кластера без координатора
  • Модель данных проектируется от запросов, а не от данных (денормализация)
  • Consistency levels позволяют балансировать между C и A по запросу
  • Идеальна для write-heavy workloads и multi-DC deployments
  • Требует опытной команды для правильного моделирования и эксплуатации

Связанные темы