9. Column базы. Cassandra
Колоночные базы данных: архитектура Cassandra, LSM-деревья и драйвер gocql
Введение
После погружения в key-value хранилища (2. Key Value базы) и документные базы данных (7. Document базы. MongoDB), мы переходим к третьему крупному классу NoSQL — колоночным (wide-column) базам данных. Если key-value оперирует непрозрачными блобами, а документные — самодостаточными JSON-объектами, то колоночные базы оптимизированы для сценариев, где данные естественно организованы в строки с динамическим набором колонок, а запросы часто читают не всю строку, а лишь несколько колонок из огромного диапазона.
Apache Cassandra — флагман этого класса, разработанный инженерами Facebook (для поиска по входящим сообщениям) и позже переданный в фонд Apache Software Foundation. Он проектировался для решения задач, в которых традиционные реляционные СУБД пасовали: хранение входящих сообщений, ленты активности, события IoT и временные ряды с требованием записи сотен тысяч операций в секунду на кластер.
Для Go-разработчика Cassandra — это выбор, когда необходима линейная масштабируемость записи, работа без единой точки отказа и устойчивость к разделению сети, а строгая консистентность в реальном времени не является абсолютным приоритетом.
Модель данных: от bigtable к Column Family
Cassandra заимствует модель данных у Google Bigtable, но адаптирует её к распределённой архитектуре. Основные понятия:
- Keyspace — аналог базы данных в реляционном мире или namespace. Определяет репликацию (число реплик, стратегию размещения по дата-центрам).
- Таблица (Column Family) — контейнер строк. В Cassandra одна таблица часто называется Column Family (историческое название).
- Строка — идентифицируется первичным ключом (
PRIMARY KEY). Ключ может быть составным:PRIMARY KEY (partition_key, clustering_column1, clustering_column2). - Колонка — пара имя/значение + временная метка. В отличие от реляционных баз, имена колонок могут быть динамическими, и строки в одной таблице могут иметь совершенно разный набор колонок. Это и есть wide-column концепция: строки разрежены.
-- Пример CQL таблицы для хранения событий с датчиков:
CREATE TABLE sensor_events (
sensor_id UUID,
event_time TIMESTAMP,
event_type TEXT,
value DOUBLE,
PRIMARY KEY (sensor_id, event_time)
);
Здесь sensor_id — partition key (определяет, на каком узле хранится строка), event_time — clustering column (упорядочивает строки внутри партиции). Подобная модель позволяет эффективно выбирать все события конкретного сенсора за временной диапазон.
Архитектура Cassandra: равноправные узлы и consistent hashing
Cassandra — это децентрализованная система без master-узлов. Все ноды равноправны, любая может принять запрос (координатор), найти нужные реплики и выполнить операцию. Это достигается благодаря consistent hashing и протоколу gossip.
flowchart TD
Client["Клиент Go<br>(драйвер gocql)"] --> Node1["Нода A<br>(Координатор)"]
Node1 --> Node2["Нода B<br>(Реплика 1)"]
Node1 --> Node3["Нода C<br>(Реплика 2)"]
Node1 --> Node4["Нода D<br>(Реплика 3)"]
Client -.-> |"токен-осведомлённая<br>маршрутизация"| Node2
classDef default fill:#1e293b,stroke:#334155,color:#f8fafc;
classDef coord fill:#312e81,stroke:#6366f1,color:#ffffff;
class Node1 coord;
- Распределение партиций: Каждая нода получает диапазон токенов (хеш от partition key). По умолчанию используется хеш-функция Murmur3, которая равномерно распределяет данные. Реплики размещаются на последовательных нодах кольца (с учётом стоек и дата-центров).
- Gossip-протокол: Ноды обмениваются информацией о состоянии кластера (жива/мертва, версия схемы) каждые несколько миллисекунд. Это позволяет системе самоорганизовываться.
- Hinted Handoff: Если одна из реплик временно недоступна, координатор сохраняет запись (hint) локально и доставляет её, когда нода возвращается.
- Read Repair: При чтении координатор может сравнить данные с нескольких реплик и исправить расхождение на лету.
Под капотом: LSM-дерево и путь записи
В отличие от B-Tree, используемых в PostgreSQL и MongoDB, Cassandra (как и многие другие wide-column системы) основана на LSM-Tree (Log-Structured Merge-Tree). Это фундаментально влияет на производительность.
flowchart TD
Write["Запись от клиента"] --> Memtable["Memtable<br>в памяти<br>сортировка по ключу"]
Memtable -->|"размер превышен"| Flush["Сброс на диск"]
Flush --> SSTable["SSTable<br>неизменяемый файл"]
Memtable -->|"при сбое"| CommitLog["Commit Log<br>WAL на диске"]
SSTable1["SSTable 1"] --> Compaction["Compaction<br>слияние SSTable"]
SSTable2["SSTable 2"] --> Compaction
Compaction --> NewSST["Новый SSTable"]
Read["Чтение"] --> Memtable
Read --> BloomFilter["Bloom filter<br>проверка SSTable"]
BloomFilter --> SSTable
classDef default fill:#1e293b,stroke:#334155,color:#f8fafc;
classDef active fill:#312e81,stroke:#6366f1,color:#ffffff;
class Memtable,SSTable,Compaction active;
- Запись моментально попадает в CommitLog (WAL, аналог WAL), обеспечивающий долговечность при крахе, и в Memtable — сбалансированное дерево (или skip list) в оперативной памяти, где данные сортируются по ключу.
- Когда Memtable заполняется, он сбрасывается на диск в неизменяемый файл SSTable (Sorted String Table). SSTable — это последовательный набор отсортированных ключей с соответствующими значениями. Запись на диск — чисто последовательная, что даёт максимальную пропускную способность диска (сотни МБ/с без случайного I/O).
- Чтение объединяет данные из Memtable и нескольких SSTable (с учётом временных меток, чтобы отдать последнее значение). Для ускорения поиска по SSTable используется Bloom filter — вероятностная структура, быстро определяющая, может ли ключ находиться в этом файле. Это радикально снижает количество дисковых чтений.
- Compaction — фоновый процесс, сливающий несколько SSTable в одну, удаляя устаревшие версии и освобождая место. Выбор стратегии (SizeTiered, Leveled, TimeWindow) критичен для производительности.
Mechanical Sympathy LSM
С точки зрения процессора и ОС, LSM-дерево даёт идеальный профиль записи:
- Запись в Memtable — чистая работа с памятью, без syscall’ов.
- CommitLog — синхронная запись на диск. При использовании batch-режима (group commit) Cassandra накапливает несколько записей и делает один
fsync, что амортизирует стоимость переключений контекста. - Сброс Memtable — большой последовательный
write, который даже на HDD даёт отличную скорость, а на SSD использует всю полосу. - Чтение, напротив, может потребовать обхода нескольких SSTable, что генерирует несколько системных вызовов
pread. Однако Bloom filter сводит вероятность лишних чтений к минимуму.
Консистентность: настраиваемая и eventual
Cassandra известна своей моделью tunable consistency. Для каждой операции клиент (и Go-драйвер) может указать желаемый уровень консистентности:
ONE— достаточно ответа от одной реплики. Минимальная задержка, но можно прочитать устаревшие данные.QUORUM— требуется ответ от большинства реплик (RF/2 + 1). Обеспечивает сильную консистентность при условии QUORUM на чтение и запись.ALL— должны ответить все реплики. Надёжно, но любая недоступная нода блокирует операцию.LOCAL_QUORUM,EACH_QUORUM— для мульти-DC конфигураций.
Это напрямую связано с моделями согласованности и теоремой CAP. Cassandra — это AP-система (доступность + устойчивость к разделению) с возможностью настройки в сторону CP через консистентность.
// Пример с gocql: выбор консистентности
session, _ := cluster.CreateSession()
query := session.Query(`SELECT value FROM sensor_events WHERE sensor_id = ?`, id)
query.Consistency(gocql.LocalQuorum)
iter := query.Iter()
// ...
Cassandra и Go: драйвер gocql
Основной драйвер для Go — github.com/gocql/gocql. Он управляет пулом соединений, автоматически обнаруживает топологию кластера (через события TOPOLOGY_CHANGE) и выполняет маршрутизацию запросов к нужным нодам. Драйвер поддерживает token-aware политику, что позволяет обращаться напрямую к реплике, владеющей данными, минуя лишний прыжок координатора.
package main
import (
"fmt"
"log"
"time"
"github.com/gocql/gocql"
)
func main() {
cluster := gocql.NewCluster("10.0.0.1", "10.0.0.2", "10.0.0.3")
cluster.Keyspace = "my_keyspace"
cluster.Consistency = gocql.Quorum
cluster.Timeout = 5 * time.Second
cluster.PoolConfig.HostSelectionPolicy = gocql.TokenAwareHostPolicy(gocql.RoundRobinHostPolicy())
session, err := cluster.CreateSession()
if err != nil {
log.Fatalf("Ошибка создания сессии Cassandra: %v", err)
}
defer session.Close()
// Вставка телеметрии
sensorID := gocql.TimeUUID()
eventTime := time.Now()
err = session.Query(`
INSERT INTO sensor_events (sensor_id, event_time, event_type, value)
VALUES (?, ?, ?, ?)`,
sensorID, eventTime, "temperature", 24.5,
).Exec()
if err != nil {
log.Printf("Insert failed: %v", err)
}
fmt.Println("Телеметрия успешно сохранена в Cassandra!")
}
Особенности работы с драйвером:
- Запросы компилируются в CQL и отправляются по TCP. Драйвер переиспользует буферы для снижения аллокаций.
- Итератор по результатам (
Iter) лениво подкачивает страницы, управляяfetchSizeдля избегания перегрузки клиента. Для эффективной обработки больших объемов всегда используйте постраничное чтение. - Типы данных Cassandra (UUID, timeuuid, blob) требуют правильной Go-типизации. Драйвер предоставляет собственные обёртки.
Когда выбирать Cassandra
Cassandra блестяще подходит для:
- Временных рядов и IoT: огромный поток записи, запросы по партициям (например, все события датчика за день).
- Хранения логов, событий, метрик (с ограниченным сроком хранения — TTL).
- Систем рекомендаций и персонализации, где нужна денормализация и запись > чтения.
- Глобальных, мульти-датацентровых систем, где важна доступность при сетевых разделениях.
Не подходит для:
- Сложных ad-hoc запросов с JOIN и произвольными WHERE (нет полноценного SQL, только CQL).
- Приложений с частыми обновлениями одних и тех же строк (LSM-tree неэффективен для overwrite-нагрузок).
- Финансовых операций, требующих строгой ACID-транзакционности (лёгкие транзакции есть, но дороги).
В сравнении с ClickHouse, который тоже колоночный, но ориентирован на аналитику (чтение больших диапазонов колонок), Cassandra оптимизирована под оперативную запись и точечное чтение.
Итог
Колоночные базы данных, и Cassandra в частности, — это ответ на вызовы горизонтального масштабирования, колоссального объёма записи и динамической схемы. Их внутреннее устройство на LSM-дереве, распределённая архитектура без единой точки отказа и настраиваемая консистентность делают их мощным, но требовательным к пониманию инструментом. Для Go-разработчика владение Cassandra открывает возможность строить системы, обрабатывающие миллионы событий в секунду с линейной масштабируемостью.
В следующей статье мы детально рассмотрим архитектуру Cassandra: топологию кластера, механизмы репликации, выбор лидера и стратегии размещения, чтобы ещё глубже понять, как проектировать надёжные сервисы с её использованием.