10. Cassandra архитектура
Распределенное кольцо токенов, gossip и Paxos
Введение
В предыдущей статье мы рассмотрели Cassandra как представителя колоночных (wide-column) NoSQL-систем, изучили модель данных, LSM-дерево и границы применимости. Теперь мы погрузимся в её архитектуру — топологию кластера, механизмы репликации, координации и восстановления. Понимание этих элементов абсолютно необходимо Senior Go-инженеру, проектирующему высоконагруженные сервисы, где ставки на доступность и линейную масштабируемость превыше немедленной строгой консистентности.
Cassandra не просто распределённая база данных — это децентрализованная система без единой точки отказа, где каждый узел равноправен и способен обслужить запрос. Её архитектура воплощает принципы Amazon Dynamo и Google Bigtable, сплавленные в единое целое.
Кольцо и consistent hashing
В основе распределения данных в Cassandra лежит концепция consistent hashing. Все узлы кластера логически располагаются на кольце, охватывающем весь диапазон хеш-значений (от -2^63 до 2^63-1 при использовании Murmur3Partitioner).
flowchart TD
K["Ключ партиции<br>user:42"] --> Hash["Murmur3 Hash"]
Hash --> Token["Токен = -54321"]
Token --> Ring["Кольцо токенов<br>от -2^63 до 2^63-1"]
Ring --> Owner["Нода-владелец<br>и её реплики"]
classDef default fill:#1e293b,stroke:#334155,color:#f8fafc;
classDef ring fill:#312e81,stroke:#6366f1,color:#ffffff;
class Ring,Owner ring;
- Partitioner (по умолчанию Murmur3Partitioner) вычисляет 64-битный хеш от partition key. Это даёт равномерное распределение и минимизирует «горячие» диапазоны.
- Token — значение хеша, определяющее позицию на кольце.
- Нода отвечает за непрерывный диапазон токенов от предыдущей ноды (исключительно) до своего токена включительно.
- Virtual Nodes (vnodes) — каждый физический узел может владеть несколькими (по умолчанию 256) малыми диапазонами токенов, разбросанными по всему кольцу. Это упрощает перебалансировку при добавлении/удалении нод и улучшает равномерность распределения данных и нагрузки.
flowchart LR
subgraph Ring["Кольцо токенов кластера"]
N1a["Нода A (vnode 1)"] --> N2a["Нода B (vnode 1)"]
N2a --> N1b["Нода A (vnode 2)"]
N1b --> N3a["Нода C (vnode 1)"]
N3a --> N2b["Нода B (vnode 2)"]
N2b --> N1a
end
classDef default fill:#1e293b,stroke:#334155,color:#f8fafc;
Для Go-драйвера gocql это знание критично: драйвер получает метаданные топологии (карту токенов и узлов) и может отправлять запрос напрямую на реплику, владеющую данными (TokenAwareHostPolicy). Это устраняет лишний сетевой прыжок через координатор и снижает latency.
Репликация: фактор и стратегии
Каждая партиция хранится на нескольких узлах — фактор репликации (replication_factor, RF) определяет количество копий. Доступны две главные стратегии размещения реплик:
- SimpleStrategy: Реплики размещаются на последовательных узлах кольца, двигаясь по часовой стрелке. Используется для одно-датацентровых кластеров.
- NetworkTopologyStrategy: Позволяет указать количество реплик в каждом дата-центре (DC) и стойке (rack). Реплики распределяются так, чтобы обеспечить живучесть при выходе из строя целого DC или стойки.
// Пример создания keyspace в Go
cql := `CREATE KEYSPACE myks WITH replication = {
'class': 'NetworkTopologyStrategy',
'DC1': 3,
'DC2': 2
}`
При записи координатор отправляет запрос всем репликам и ждёт подтверждений в соответствии с указанным consistency level. Внутренне Cassandra использует Gossip-подобный протокол для мониторинга состояния узлов и быстрого обнаружения отказов.
Gossip-протокол и обнаружение сбоев
Узлы Cassandra постоянно общаются между собой с помощью gossip-протокола (вариант Scuttlebutt). Каждую секунду узел случайно выбирает один-два других узла и синхронизирует с ними состояние кластера:
- Кто жив, кто мёртв.
- Версия схемы.
- Распределение токенов.
Для детектирования отказов используется Phi Accrual Failure Detection — адаптивный алгоритм, который не бинарно «жив/мёртв», а вычисляет вероятность отказа, что даёт устойчивость к кратковременным сетевым флуктуациям. Если подозрение превышает порог (phi > 8), узел помечается как down.
Hinted Handoff и Read Repair
Даже при временном отказе отдельных реплик Cassandra гарантирует, что данные в итоге станут консистентными, благодаря двум механизмам:
Hinted Handoff: Когда координатор не может доставить запись на одну из реплик (узел down), он сохраняет hint (подсказку) локально в системной таблице system.hints. Как только узел снова появляется, hint доставляется, и данные восстанавливаются. Время хранения hint ограничено (по умолчанию 3 часа).
Read Repair: При чтении координатор сравнивает ответы от реплик. Если обнаружены расхождения (устаревшие версии), он отправляет свежие данные отставшим репликам. Вероятность выполнения read repair управляется параметром read_repair_chance (по умолчанию 0.1 — 10% запросов).
sequenceDiagram
participant Client as Клиент Go
participant Coordinator as Координатор
participant Replica1 as Реплика 1
participant Replica2 as Реплика 2 (Down)
Coordinator->>Replica1: Write
Coordinator-->>Coordinator: Сохранить Hint для Реплики 2 в system.hints
Note over Replica2: Узел восстанавливается в кольце
Coordinator->>Replica2: Hint: доставка пропущенных данных
Client->>Coordinator: Read
Coordinator->>Replica1: Read
Coordinator->>Replica2: Read
Coordinator->>Coordinator: Read Repair, если обнаружено расхождение
С точки зрения Mechanical Sympathy, hinted handoff — это фоновый процесс, генерирующий дополнительный сетевой трафик и дисковые записи (hints пишутся на диск). В высоконагруженных Go-сервисах важно следить за накоплением hints (метрика pending_hints) — их большой объём говорит о долговременной недоступности узлов и может забить диск.
Compaction: как LSM-дерево оптимизирует хранение
Как мы обсуждали в предыдущей статье, данные записываются в Memtable и сбрасываются в SSTable на диск. Но со временем накапливаются десятки и сотни SSTable, что замедляет чтение. Compaction объединяет эти файлы, удаляя устаревшие версии данных и освобождая место. Выбор стратегии влияет на производительность кардинально.
SizeTiered Compaction Strategy (STCS)
По умолчанию. SSTable группируются по размеру (примерно одинаковые). Когда в группе набирается достаточно файлов (по умолчанию 4), они сливаются в один большой.
- Плюсы: Отличная производительность записи, мало системных вызовов слияния.
- Минусы: Во время слияния может потребоваться до 2x дискового пространства, чтение может замедляться при большом количестве SSTable.
Leveled Compaction Strategy (LCS)
SSTable организованы в уровни. Уровень L1 в 10 раз больше L0, L2 в 10 раз больше L1 и т.д. Слияния происходят небольшими порциями.
- Плюсы: Более стабильная производительность чтения (меньше файлов для проверки), меньше дискового пространства для временных данных.
- Минусы: Выше write amplification, может не справляться с очень интенсивной записью.
TimeWindow Compaction Strategy (TWCS)
Оптимизирована для временных рядов с TTL. Данные группируются по временным окнам (например, 1 день). Устаревшие окна удаляются целиком, без слияния. Идеальна для сенсорных данных, логов, метрик.
Внутренние индексы и вторичные индексы
Cassandra не поддерживает B-Tree индексы в стиле PostgreSQL. Вместо этого:
- Primary Index — встроен в хранение: данные физически отсортированы по partition key + clustering columns.
- Secondary Index (2i) — индексы по отдельным колонкам, хранятся на тех же узлах, что и основные данные. Подходят для низко-кардинальных полей, но не для высоко-кардинальных, так как require scatter-gather запросы.
- SASI (SSTable Attached Secondary Index) — улучшенные вторичные индексы, реализованные внутри SSTable, с поддержкой LIKE, range запросов. В Go-драйвере для использования нужно просто объявить индекс при создании таблицы и добавлять условие WHERE.
Однако, в отличие от SQL, запросы с вторичным индексом всё равно требуют обхода всех партиций, что на больших кластерах неприемлемо. Оптимальный путь — денормализация и проектирование нескольких таблиц под разные запросы.
Легковесные транзакции (Lightweight Transactions)
Cassandra обеспечивает сравнительно слабые гарантии по умолчанию (eventual consistency). Для сценариев, где нужна строгая атомарность и изоляция (например, регистрация уникального пользователя), введены легковесные транзакции (LWT) на основе протокола Paxos.
INSERT INTO users (id, name) VALUES (uuid(), 'Alice') IF NOT EXISTS;
Под капотом выполняется серия раундов голосования между репликами, что гарантирует линеаризуемость операции. Это значительно дороже обычной записи (в 3–10 раз медленнее), поэтому их используют точечно.
В Go-коде через gocql можно выполнить LWT:
var applied bool
err := session.Query(
`INSERT INTO users (id, name) VALUES (?, ?) IF NOT EXISTS`,
id, name).ScanCAS(&applied, nil)
if err != nil {
log.Printf("Ошибка при выполнении CAS: %v", err)
}
if !applied {
// пользователь уже существует
}
Mechanical Sympathy: влияние архитектуры на Go-сервис
Рассмотрим физическую картину запроса от Go-сервиса до кластера Cassandra:
- Драйвер gocql инициализируется с набором начальных узлов и подписывается на события топологии. Периодически он обновляет карту токенов, что создаёт небольшой фоновый сетевой трафик.
- При вызове
session.Query(...).Iter()драйвер выбирает оптимальную ноду по TokenAwareHostPolicy. Создаётся TCP-соединение (берётся из пула), формируется CQL-запрос, отправляется черезwrite. Горутина паркуется, ожидая ответа. - Нода-координатор (если это не реплика данных) перенаправляет запрос на нужную реплику, либо отвечает сама. Внутри ноды выполняется поиск в Memtable и SSTable. Благодаря Bloom filter большинство ненужных SSTable пропускаются без дисковых операций. Если всё же требуется чтение с диска, происходит
pread— горутина на стороне сервера не блокируется (Cassandra внутри асинхронна). - Ответ возвращается клиенту. Горутина Go пробуждается, драйвер десериализует результат в структуры.
Основные факторы, влияющие на производительность Go-сервиса:
- Размер пула соединений: слишком маленький пул создаёт контенцию, слишком большой — перегружает сервер. gocql по умолчанию использует 2 соединения на ноду для записи и 1 для чтения, но рекомендуется тонкая настройка.
- Выбор consistency:
ONEдаст микросекундную задержку, но риск устаревших данных,QUORUM— более высокая latency, особенно при пересечении DC. - Размер страницы (
fetchSize): при сканировании больших диапазонов не тяните всё сразу, иначе GC Go получит гигантские слайсы.
Итог
Архитектура Cassandra — это реализация принципов распределённых систем: consistent hashing, gossip, hinted handoff и настраиваемая консистентность. Она позволяет строить truly линейно-масштабируемые, устойчивые к сбоям кластеры для записи колоссальных объёмов данных. Для Go-разработчика владение этими механизмами означает возможность точно выбирать consistency level, проектировать денормализованные таблицы под запросы и предвидеть поведение системы при сетевых разделениях.
Освоив wide-column мир, мы двинемся к другому краю спектра — аналитическим колоночным базам, оптимизированным для сложных запросов над огромными массивами данных. Начнём с эталонного представителя этого класса: 11. ClickHouse. OLAP база.