Cassandra, ScyllaDB и wide-column: модель запросов и компромиссы
Есть класс задач, где реляционная база проигрывает не из-за плохой оптимизации, а структурно. Вам нужно принимать миллион записей в секунду, круглосуточно, из пяти датацентров на трёх континентах, никогда не останавливаясь — даже когда датацентр целиком уходит в офлайн. Данные пишутся один раз, читаются по известному ключу и умирают по TTL. Джойнов нет, транзакций через пол-схемы нет, произвольная аналитика не нужна.
Именно под этот профиль спроектированы wide-column-хранилища. И плата за него честная и жёсткая: вы больше не задаёте произвольные запросы. Вы заранее перечисляете запросы, которые будете выполнять, и строите под каждый отдельную таблицу. SQL-мышление — «нормализуй сущности, а дальше JOIN достанет что угодно» — здесь не просто неоптимально, оно приводит к системе, которая разваливается в проде на третий месяц.
Эта статья — про то, как wide-column устроен внутри, какие решения он навязывает, во что они обходятся, и как выглядят типичные катастрофы. Про CAP, BASE и общую таксономию NoSQL — в обзоре NoSQL; про репликацию и шардирование в общем виде — в соответствующей статье.
Родословная: Dynamo снизу, Bigtable сверху
Cassandra — гибрид двух статей, которые определили целое десятилетие распределённых систем.
От Amazon Dynamo (2007) взята вся «нижняя половина»: консистентное хеширование по кольцу токенов, отсутствие мастера, кворумное чтение/запись, hinted handoff, anti-entropy-репейр через деревья Меркла, gossip для членства в кластере. Это ответ на вопрос «как хранить данные на 500 узлах так, чтобы падение любого не стоило доступности».
От Google Bigtable (2006) взята «верхняя половина»: модель данных, где строка — это разреженная отсортированная карта колонок, и LSM-хранилище: commit log, memtable в памяти, иммутабельные SSTable на диске, фоновая компакция.
Важно, что HBase взял только половину Bigtable-наследия — он строго консистентен, у него один RegionServer на регион, и он платит доступностью. Cassandra взяла обе половины и платит консистентностью. Это фундаментальная развилка, к которой мы вернёмся в сравнении.
Первоисточники стоит прочитать целиком, они короткие и до сих пор актуальные: Dynamo: Amazon’s Highly Available Key-value Store и Bigtable: A Distributed Storage System for Structured Data.
Модель данных: партиция, кластеризация и то, что этим определяется
Всё в Cassandra выводится из одного объекта — партиции. Партиция это неделимая единица размещения: она целиком лежит на одних и тех же RF узлах, целиком читается одним узлом-координатором без сетевых обходов к другим партициям, и внутри неё строки хранятся физически отсортированными.
CREATE TABLE chat.messages_by_room (
room_id uuid,
bucket text, -- '2026-07' — искусственное дробление партиции
msg_id timeuuid, -- монотонный по времени, сортируем по нему
author_id uuid,
body text,
attachments set<text>,
PRIMARY KEY ((room_id, bucket), msg_id)
) WITH CLUSTERING ORDER BY (msg_id DESC)
AND compaction = {'class': 'TimeWindowCompactionStrategy',
'compaction_window_unit': 'DAYS',
'compaction_window_size': 1}
AND default_time_to_live = 31536000 -- год
AND gc_grace_seconds = 259200; -- 3 суток, см. раздел про репейры
Разбор ключа:
((room_id, bucket))— партиционный ключ. От него берётся Murmur3-хеш → токен → узел. Хеш означает: по партиционному ключу нельзя искать диапазон, только точное равенство (илиINпо списку значений).msg_id— кластеризующая колонка. Определяет физический порядок строк внутри партиции. По ней доступны диапазоны,ORDER BY(только по объявленному или обратному порядку) иLIMIT, который реально ограничивает чтение с диска.
Из этой картинки следуют жёсткие правила, которые нельзя обойти настройками:
WHEREобязан целиком задать партиционный ключ. Не половину.WHERE room_id = ?при ключе((room_id, bucket))— ошибка.- Кластеризующие колонки ограничиваются слева направо. При
PRIMARY KEY ((a), b, c, d)можноb = ? AND c > ?, но нельзяc = ?безb. - Никаких агрегаций, джойнов и произвольных сортировок.
ORDER BYпо неключевой колонке невозможен физически: данные уже лежат в другом порядке, а собирать их в память по всему кластеру никто не будет.
Query-first: схема как материализация списка запросов
В реляционной модели (см. реляционную модель и нормализацию) сначала описывают сущности, потом пишут запросы. В Cassandra порядок обратный и это не стилистика, а необходимость: если запрос не «упал» ровно в один партиционный ключ, он будет стоить обхода всего кластера.
с ожидаемым QPS и латентностью"] --> B{"Для каждого запроса:
какой ключ известен
вызывающему?"} B --> C["Это partition key"] C --> D{"Что нужно вернуть:
одну строку или диапазон?"} D -->|диапазон| E["Порядок сортировки
→ clustering columns"] D -->|одну| F["clustering columns = уникализация"] E --> G{"Оценка мощности партиции:
строк × размер строки"} F --> G G -->|"< 100 МБ и < 100k строк"| H["Таблица готова"] G -->|"больше"| I["Добавить bucket в partition key:
время, хеш-корзину, шард-номер"] I --> G H --> J["Тот же факт нужен по другому ключу?"] J -->|да| K["Ещё одна таблица.
Пишем в обе из приложения"] J -->|нет| L["Готово"] K --> B
Так одна предметная сущность превращается в несколько таблиц. Это не «денормализация от лени» — это ровно то, что реляционная СУБД делает индексами, только явно и в вашем коде.
Пунктирные связи здесь — не внешние ключи (их не существует), а обязательство приложения писать в обе таблицы. Отсюда первый честный trade-off: консистентность между этими таблицами — ваша забота, база её не обеспечит.
CQL, который выглядит как SQL и врёт
CQL синтаксически похож на SQL, и это главная ловушка для новичка. Несколько конструкций компилируются, но в проде ведут себя катастрофически.
-- ❌ Полный обход кластера: координатор опрашивает ВСЕ узлы,
-- читает все партиции и фильтрует в памяти. На 200 ГБ данных — минуты и OOM.
SELECT * FROM messages_by_room WHERE author_id = ? ALLOW FILTERING;
-- ❌ Обычный вторичный индекс (2i): локальный индекс на КАЖДОМ узле.
-- Чтение по нему = scatter-gather по всему кластеру. p99 растёт линейно с числом узлов.
CREATE INDEX ON messages_by_room (author_id);
-- ⚠️ Materialized View: база сама поддерживает вторую таблицу,
-- но при сбоях расходится с базовой и чинится только ручной перестройкой.
-- Официально помечены как experimental с 3.0 и по сей день.
CREATE MATERIALIZED VIEW messages_by_author AS ...;
-- ✅ Явная вторая таблица + запись из приложения (или CDC-консьюмер).
-- Скучно, многословно, предсказуемо.
INSERT INTO messages_by_author (author_id, bucket, msg_id, room_id, body) VALUES (?, ?, ?, ?, ?);
ALLOW FILTERING — это не подсказка оптимизатору, а расписка «я понимаю, что стоимость запроса не ограничена». Правило простое: в продакшн-коде его быть не должно. Единственное законное исключение — фильтрация внутри уже заданной партиции, где объём ограничен размером партиции:
-- Это допустимо: партиция задана, фильтр применяется к сотне строк.
SELECT * FROM messages_by_room
WHERE room_id = ? AND bucket = ? AND author_id = ? ALLOW FILTERING;
В Cassandra 5.0 появились SAI (Storage-Attached Indexes) — вторичные индексы, встроенные в SSTable и на порядок дешевле старых 2i по записи и по месту. Они снимают часть боли (низкоселективные фильтры внутри известного диапазона), но не отменяют главного: индекс всё равно локальный, и запрос без партиционного ключа всё равно scatter-gather. SAI — способ не плодить таблицу ради редкого запроса, а не способ вернуть себе SQL.
Распределение: кольцо, vnodes, топология и кворумы
Токен-кольцо — пространство [-2^63, 2^63). Каждый узел владеет набором диапазонов; партиция с токеном t принадлежит первому узлу по часовой стрелке, а следующие RF−1 реплики выбираются по стратегии репликации.
Vnodes (виртуальные узлы) — каждый физический узел берёт не один диапазон, а num_tokens штук, разбросанных по кольцу. Это выравнивает нагрузку и распараллеливает восстановление: при потере узла его данные стримятся не с трёх соседей, а с десятков. Историческое значение по умолчанию 256 оказалось вредным (раздувает деревья Меркла, ухудшает доступность при отказах), и с Cassandra 4.0 рекомендуется num_tokens: 16 с аллокатором allocate_tokens_for_local_replication_factor.
# cassandra.yaml — узел в проде, 3 DC, RF=3 в каждом
cluster_name: 'chat-prod'
num_tokens: 16
allocate_tokens_for_local_replication_factor: 3
endpoint_snitch: GossipingPropertyFileSnitch # топология из cassandra-rackdc.properties
auto_bootstrap: true
# Хранилище: коммитлог отдельно от данных, если диски разные
commitlog_directory: /var/lib/cassandra/commitlog
data_file_directories:
- /var/lib/cassandra/data
commitlog_sync: periodic
commitlog_sync_period: 10000ms # окно потери данных при одновременном падении всех реплик
# Память: heap 8–16 ГБ, НЕ больше — иначе паузы GC съедают p99
# остальное отдаём page cache ОС, она кэширует SSTable эффективнее
memtable_allocation_type: offheap_objects
concurrent_reads: 32 # ~16 × число дисков данных
concurrent_writes: 64 # ~8 × число ядер
concurrent_compactors: 4
# Защита от «толстых» партиций и tombstone-бомб — включать ОБЯЗАТЕЛЬНО
tombstone_warn_threshold: 1000
tombstone_failure_threshold: 20000
batch_size_warn_threshold: 5KiB
batch_size_fail_threshold: 50KiB
# cassandra-rackdc.properties — то, что снитч сообщает кольцу
dc=eu-central-1
rack=eu-central-1a
Ключевое: rack — это домен отказа. NetworkTopologyStrategy раскладывает RF реплик по разным rack-ам внутри DC. Если все узлы объявлены в одном rack, три реплики могут оказаться в одной зоне доступности, и падение AZ убьёт кворум. Если rack-ов ровно RF и они равного размера — раскладка идеальна. Промежуточные варианты (например, 5 rack при RF=3) дают перекос при отказах.
-- Keyspace на 3 DC: локальные кворумы в каждом регионе
CREATE KEYSPACE chat WITH replication = {
'class': 'NetworkTopologyStrategy',
'eu-central-1': 3,
'us-east-1': 3,
'ap-southeast-1': 3
} AND durable_writes = true;
Уровни консистентности: R + W > N
Cassandra не даёт вам выбирать между CP и AP один раз при установке — она даёт выбирать на каждый запрос. Это её самая недооценённая сильная сторона.
| CL | Что ждёт координатор | Гарантия | Переживает |
|---|---|---|---|
ANY |
запись принята хоть куда, включая hint | почти никакой | всё, кроме тотального отказа |
ONE |
1 реплика | нет | RF−1 отказов |
LOCAL_ONE |
1 реплика в локальном DC | нет, но без межрегиональной латентности | отказ удалённых DC |
QUORUM |
⌊RF_total/2⌋+1 по ВСЕМ DC | линеаризуемость чтения при R+W>N | зависит от общего RF |
LOCAL_QUORUM |
кворум в локальном DC | согласованность в пределах DC | полную потерю других DC |
EACH_QUORUM |
кворум в каждом DC (только запись) | кросс-DC согласованность | ничего: падает DC — падают записи |
ALL |
все реплики | сильнейшая | ничего |
Правило R + W > N означает: множества прочитанных и записанных реплик обязательно пересекаются, значит чтение увидит хотя бы одну реплику со свежей записью, а конфликт разрешит по timestamp. При RF=3: W=QUORUM(2) + R=QUORUM(2) = 4 > 3 — согласовано. W=ONE + R=ONE = 2 < 3 — вы можете прочитать устаревшее значение.
Рабочий дефолт для мультирегиональной системы: LOCAL_QUORUM на чтение и на запись. Он даёт согласованность внутри региона без межконтинентальных RTT в критическом пути. Между регионами данные доезжают асинхронно — и это осознанный выбор, а не баг.
Разрешение конфликтов — last-write-wins по timestamp записи (микросекунды). Отсюда два тяжёлых следствия: перекос часов между узлами напрямую превращается в потерю данных (обязателен chrony/NTP с жёстким мониторингом), а конкурентные обновления одного поля молча теряются, без всякого сигнала о конфликте — CRDT или векторных часов здесь нет.
координатором одну из реплик — 0 лишних хопов par параллельная рассылка C->>R1: mutation (ts=1721...) C->>R2: mutation C->>R3: mutation end R1-->>C: ack (commitlog fsync + memtable) R2-->>C: ack Note over C: 2 из 3 = кворум достигнут C-->>App: OK (p99 ~2–5 мс) Note over C,R3: R3 не ответила →
координатор пишет HINT себе на диск R3->>C: gossip: я вернулась C->>R3: проигрывание hint (до 3 часов по умолчанию) Note over R1,R3: Если узел лежал дольше max_hint_window —
hint выброшен, чинит только repair
Обратите внимание на шаг с hint: hinted handoff — это оптимизация восстановления, а не механизм долговечности. Если узел лежал дольше max_hint_window_in_ms (3 часа), подсказки выбрасываются, и разошедшиеся данные вернёт только anti-entropy-репейр. Отсюда: repair не опция, а обязательная операционная процедура.
Путь записи и чтения: LSM в деталях
Запись в Cassandra почти бесплатна, потому что она никогда не читает перед записью. Нет проверки уникальности, нет проверки внешних ключей, нет «найти строку и обновить». INSERT и UPDATE — одна и та же операция: положить ячейку с timestamp-ом в memtable. Отсюда линейная масштабируемость записи, которой нет у B-tree-хранилищ (сравнение подходов — в индексах и планах выполнения).
(последовательная запись)"] W2 --> W3["Вставка в memtable
(сортированная структура в памяти)"] W3 --> W4["ACK клиенту"] W3 -.->|"memtable переполнена"| W5["Flush → новый иммутабельный SSTable
+ Bloom filter + индексы + сегмент commitlog освобождён"] end subgraph R["Путь чтения — самое дорогое место"] R1["SELECT по партиционному ключу"] --> R2["Опрос memtable"] R2 --> R3{"Для каждого SSTable:
Bloom filter"} R3 -->|"точно нет"| R4["Пропустить файл — 0 I/O"] R3 -->|"возможно есть"| R5["Partition index summary в памяти
→ смещение в индексе"] R5 --> R6["Чтение сжатого чанка
(chunk cache / page cache ОС)"] R6 --> R7["Слияние версий ячеек:
побеждает наибольший timestamp"] R4 --> R7 R7 --> R8{"Ячейка — tombstone?"} R8 -->|да| R9["Не возвращать, но
СЧИТАТЬ прочитанной"] R8 -->|нет| R10["Результат клиенту"] R9 --> R10 end W5 -.->|"SSTable-ы копятся"| R3
Асимметрия очевидна: запись трогает один последовательный лог и память, чтение может трогать десяток файлов. Именно поэтому компакция — центральная операционная тема Cassandra, а не фоновая деталь.
Сводно, в цифрах, которые стоит держать в голове:
| Стратегия | Write amplification | SSTable на чтение | Запас места | Профиль нагрузки |
|---|---|---|---|---|
| STCS | 2–4x | 4–10 | до +50% пик | append-heavy, чтения по свежему |
| LCS | 10–30x | 1–2 | ~+10% | read-heavy, частые перезаписи, NVMe |
| TWCS | ~1x | 1–2 в окне | ~+размер окна | time-series с TTL, без обновлений |
| UCS (5.0) | настраивается | настраивается | настраивается | универсальная, один параметр scaling_parameters |
Практический ориентир: компакция должна успевать. Если nodetool compactionstats стабильно показывает растущую очередь, а SSTables per read в nodetool tablehistograms уходит за 10 — вы уже в проблеме, p99 чтения деградирует, и лечится это либо compaction_throughput_mb_per_sec (по умолчанию 64 — часто слишком мало для NVMe, ставьте 128–256 или 0 при выделенных дисках), либо большим числом узлов.
Tombstone-ы: главный источник продакшн-катастроф
Иммутабельные SSTable означают, что удалить данные на месте невозможно. Удаление — это запись специального маркера-надгробия (tombstone) с новым timestamp. Реальное освобождение места происходит только когда компакция сведёт вместе tombstone и все более старые версии ячейки — и только после истечения gc_grace_seconds.
Три сценария, которые убивают кластеры, и все три — про tombstone-ы.
1. Очередь на Cassandra. Классика антипаттернов: партиция-очередь, куда пишут задачи и откуда их удаляют после обработки. Каждое чтение «дай следующие 10 задач» проходит через тысячи tombstone-ов удалённых задач, прежде чем найдёт живые строки. Латентность растёт линейно с числом обработанных задач, потом узел падает с TombstoneOverwhelmingException. Cassandra — не брокер сообщений; для очередей есть Redis, Kafka и специализированные системы.
2. Запись null. Это неочевидно и потому особенно опасно:
-- Приложение биндит все поля, часть из них null.
-- Cassandra интерпретирует null как УДАЛЕНИЕ → создаёт tombstone на каждое поле.
INSERT INTO users (id, name, phone, address) VALUES (?, ?, null, null);
-- Правильно: не отправлять неизвестные поля вообще.
-- В Java-драйвере — unset(), в Python — не передавать ключ в dict,
-- в prepared statements — bind только заполненных колонок.
INSERT INTO users (id, name) VALUES (?, ?);
Массовый ETL, наивно биндящий все колонки, за ночь создаёт миллиарды tombstone-ов и роняет чтения.
3. Коллекции целиком. UPDATE t SET tags = {'a','b'} WHERE id = ? — это удаление всей коллекции (range tombstone) плюс вставка. Инкрементальные операции tags = tags + {'c'} tombstone не создают. Разница на нагруженной таблице — порядок величины.
Диагностика:
# Сколько tombstone-ов реально читается — самый важный график в мониторинге
nodetool tablehistograms chat.messages_by_room
# Percentile SSTables Write Latency Read Latency Partition Size Cell Count
# 99% 10 35.43 us 943.13 us 43388 179
# Крупнейшие партиции и статистика по tombstone в конкретном SSTable
sstablemetadata /var/lib/cassandra/data/chat/messages_by_room-*/nb-*-big-Data.db \
| grep -E 'Estimated droppable tombstones|Partition Size|Maximum timestamp'
# Живой поиск проблемных запросов в логах
grep -E 'Read [0-9]+ live rows and [0-9]+ tombstone cells' /var/log/cassandra/system.log
Метрика Estimated droppable tombstones выше 0.2 при STCS означает, что стоит подтолкнуть компакцию (unchecked_tombstone_compaction) или пересмотреть модель.
Repair: обязательная операционная работа
Anti-entropy repair сравнивает реплики через деревья Меркла и стримит расхождения. Он нужен, потому что hinted handoff и read repair покрывают не все случаи (см. состояние «Зомби» выше).
# Полный repair одного диапазона токенов — так делать НЕ надо на большом кластере:
# строит деревья Меркла для всех данных, грузит диск и сеть на часы
nodetool repair -full chat
# Правильно: subrange-repair небольшими порциями, только локальный DC,
# с ограничением параллелизма. Именно это и делает Reaper.
nodetool repair -pr -dc eu-central-1 --job-threads 2 chat messages_by_room
# Инкрементальный repair (помечает восстановленные SSTable как repaired).
# До 4.0 был источником багов и «переполнения anticompaction»; в 4.0+ пригоден,
# но на TWCS-таблицах его включать НЕЛЬЗЯ — он ломает временные окна.
nodetool repair -inc chat
На проде де-факто стандарт — Cassandra Reaper: планировщик, который режет кольцо на сотни поддиапазонов, ведёт их по расписанию, следит за нагрузкой и умеет останавливаться. Настройка простая и она же — единственный жёсткий инвариант эксплуатации:
Полный цикл repair каждой таблицы должен завершаться быстрее, чем
gc_grace_secondsэтой таблицы. Если repair занимает 5 дней,gc_grace_secondsне может быть меньше 5 дней. Если вы уменьшили gc_grace до 3 дней «чтобы место освобождалось быстрее» — вы включили генератор зомби-данных.
Для чисто TTL-таблиц с TWCS и без удалений допустим gc_grace_seconds = 0 — удалять нечего, tombstone-ов нет, окна опадают целиком. Это одна из причин, почему TWCS так любят в time-series.
Транзакции, батчи и счётчики: что стоит дороже, чем кажется
Lightweight transactions (LWT) дают линеаризуемый compare-and-set через Paxos:
-- Регистрация уникального имени: единственный корректный способ
INSERT INTO users_by_login (login, user_id) VALUES (?, ?) IF NOT EXISTS;
-- Условное обновление с проверкой версии (оптимистичная блокировка)
UPDATE orders SET status = 'shipped', version = 8
WHERE order_id = ? IF version = 7;
Цена честная и высокая: классический Paxos в Cassandra — это четыре круговых обхода (prepare/promise, read, propose/accept, commit) вместо одного. Латентность растёт в 4–8 раз, пропускная способность по горячей партиции падает до сотен операций в секунду, а конкурирующие LWT на одном ключе вызывают contention и повторы. LWT — инструмент для 1% операций (уникальность логина, идемпотентный захват ресурса), а не режим работы. Cassandra 5.x/ACCORD обещает мультиключевые транзакции за один round-trip на базе leaderless-консенсуса — стоит следить, но в проде это ещё молодо.
Батчи — самое частое непонимание. BEGIN BATCH в Cassandra это не оптимизация производительности:
-- ✅ Законное применение: атомарное обновление ОДНОГО факта в нескольких таблицах.
-- Координатор пишет batchlog на 2 узла, потом применяет мутации.
-- Гарантия: атомарность (всё применится рано или поздно), но НЕ изоляция —
-- читатель может увидеть одну таблицу обновлённой, а вторую ещё нет.
BEGIN LOGGED BATCH
INSERT INTO messages_by_room (room_id, bucket, msg_id, author_id, body) VALUES (?,?,?,?,?);
INSERT INTO messages_by_author (author_id, bucket, msg_id, room_id, body) VALUES (?,?,?,?,?);
APPLY BATCH;
-- ❌ Антипаттерн: батч как «bulk insert» разных партиций.
-- Координатор становится узким горлышком: он держит все мутации,
-- рассылает их на десятки узлов и ждёт всех. Это МЕДЛЕННЕЕ,
-- чем N параллельных асинхронных запросов, и грузит batchlog.
BEGIN BATCH
INSERT INTO events (id, ...) VALUES (uuid1, ...);
... 500 разных партиций ...
APPLY BATCH;
Правильный «bulk insert» — конкурентные подготовленные запросы с ограничением параллелизма:
# Python-драйвер: token-aware балансировка + управляемая конкурентность.
from cassandra.cluster import Cluster, ExecutionProfile, EXEC_PROFILE_DEFAULT
from cassandra.policies import TokenAwarePolicy, DCAwareRoundRobinPolicy
from cassandra.concurrent import execute_concurrent_with_args
from cassandra import ConsistencyLevel
profile = ExecutionProfile(
# token-aware поверх DC-aware: запрос идёт СРАЗУ на узел-владелец партиции,
# координатор = реплика, лишний сетевой хоп исчезает (это 20–40% латентности)
load_balancing_policy=TokenAwarePolicy(DCAwareRoundRobinPolicy(local_dc="eu-central-1")),
consistency_level=ConsistencyLevel.LOCAL_QUORUM,
request_timeout=5.0,
)
cluster = Cluster(["10.0.1.10", "10.0.2.10"], execution_profiles={EXEC_PROFILE_DEFAULT: profile})
session = cluster.connect("chat")
# Prepared statement обязателен: парсинг CQL на каждый запрос — заметная доля CPU,
# и только prepared даёт драйверу знание партиционного ключа для token-awareness.
stmt = session.prepare("""
INSERT INTO messages_by_room (room_id, bucket, msg_id, author_id, body)
VALUES (?, ?, ?, ?, ?)
""")
rows = [(room, bucket, msg_id, author, body) for ...]
# concurrency подбирается по числу узлов: ~ 32 × количество узлов, но не больше
# native_transport_max_threads на узле, иначе получите OverloadedException
results = execute_concurrent_with_args(session, stmt, rows, concurrency=128)
failed = [r for ok, r in results if not ok]
Счётчики — отдельный тип колонок с отдельными правилами: они не идемпотентны (повтор при таймауте может учесть инкремент дважды), не могут жить в одной таблице с обычными колонками, требуют read-before-write внутри узла и потому в разы дороже обычной записи. Точный счётчик просмотров на Cassandra — плохая идея; приблизительный — нормальная.
ScyllaDB: та же модель, другая машина
ScyllaDB — построчная переработка Cassandra на C++ поверх фреймворка Seastar, совместимая по CQL и по протоколу. Идея одна: убрать JVM и убрать разделяемое состояние между ядрами.
Shard-per-core. Каждое ядро — независимый шард со своей памятью, своим набором партиций, своим планировщиком; между ядрами нет блокировок и почти нет обмена. Плюс собственный планировщик I/O, обход page cache (свой unified cache), и приоритизация фоновых задач (компакция, стриминг) ниже пользовательских запросов.
приоритет I/O"| ST["ровный p99: 1–5 мс"] end
Честное сравнение по тому, что реально видно в эксплуатации:
| Аспект | Apache Cassandra 5.x | ScyllaDB |
|---|---|---|
| Реализация | Java/JVM | C++/Seastar, shared-nothing по ядрам |
| Пропускная способность на узел | базовая | обычно в 2–5 раз выше на том же железе |
| p99 под нагрузкой | всплески из-за GC и компакции | заметно ровнее, приоритизация I/O встроена |
| Вертикальное масштабирование | плохо использует >32 ядер | линейно до 100+ ядер, узлы i4i.metal реальны |
| Тюнинг | десятки параметров JVM + yaml | автотюнинг (scylla_setup), почти нечего крутить |
| Совместимость | эталон | CQL/драйверы/SSTable-формат совместимы; часть nodetool-команд отличается |
| Экосистема | Reaper, Medusa, огромный корпус опыта | свои Manager/Monitoring, опыта меньше |
| Лицензия | Apache 2.0, полностью открытая | с 2025 ядро под AGPL; открытая «community» ветка урезана, часть возможностей — только в enterprise/cloud |
| Риск | проект ASF, вендоронезависимый | один вендор, лицензионная политика уже менялась |
Практический вывод. Если у вас 30+ узлов Cassandra и вы упираетесь в железо, миграция на ScyllaDB часто даёт консолидацию 3:1 или 5:1 по числу узлов — это прямая экономия. Если у вас 6 узлов и всё работает, выигрыш не окупит операционного переучивания. Главный не-технический риск ScyllaDB — вендорный: смена лицензии ядра на AGPL в 2025 и урезание community-редакции показали, что стратегия может меняться. Cassandra под Apache 2.0 в ASF таких сюрпризов структурно не преподносит.
Место в мире: кому вообще нужен wide-column
| Критерий | Cassandra / Scylla | HBase | Google Bigtable | DynamoDB | MongoDB | PostgreSQL |
|---|---|---|---|---|---|---|
| Модель | wide-column, партиции | wide-column поверх HDFS | wide-column, управляемый | key-value + документы | документы | реляционная |
| Архитектура | masterless, peer-to-peer | master + RegionServers + ZK | разделены compute/storage | управляемая, скрыта | primary + secondary | primary + реплики |
| Консистентность | настраиваемая (CL) | строгая на регион | строгая на строку | настраиваемая (eventual/strong) | настраиваемая | ACID |
| Доступность при отказе узла | полная, без выборов | пауза на перенос региона | прозрачно | прозрачно | выборы 5–12 с | failover 10–60 с |
| Мультирегион active-active | родная, лучшая в классе | нет, только DR | ограниченная | Global Tables | zone sharding, сложнее | логическая репликация, вручную |
| Пиковая запись на узел | 50–200k+ ops/s | 10–50k | — (per node не применимо) | по provisioned capacity | 10–40k | 5–30k |
| Ad-hoc запросы | нет | нет | нет | почти нет | да | да |
| JOIN / агрегации | нет | нет | нет | нет | ограниченные | полные |
| Транзакции | LWT / ACCORD | на строку | на строку | до 100 элементов | мультидокументные | полные |
| Порог входа в эксплуатацию | высокий | очень высокий | нулевой (SaaS) | нулевой (SaaS) | средний | низкий |
| Минимальный вменяемый размер | 3 узла на DC | 5+ узлов + HDFS + ZK | — | — | 3 узла | 1 узел |
Отдельно про DynamoDB: это тот же Dynamo, но с оплатой за операции и без ops-нагрузки. Экономика переворачивается на объёмах. При 5k ops/s DynamoDB почти наверняка дешевле кластера Cassandra с командой поддержки. При 500k ops/s круглосуточно счёт за DynamoDB обычно кратно выше стоимости железа под Cassandra/Scylla — и это стандартный триггер миграции «в обратную сторону».
Грубая экономика собственного кластера
Прикидка для 20 ТБ полезных данных, RF=3, умеренная нагрузка чтения:
- Данных на диске:
20 ТБ × 3 (RF) = 60 ТБ, плюс запас на компакцию и не более 70% заполнения диска → ~90–100 ТБ raw. - Рекомендуемая плотность: 1–3 ТБ данных на узел (Cassandra) или 5–10 ТБ (ScyllaDB на NVMe). Больше — и восстановление узла растягивается на сутки, а компакция перестаёт успевать.
- Отсюда: ~30 узлов Cassandra или ~10 узлов Scylla. Минимум по 3 узла на DC независимо от объёма — RF=3 требует трёх.
- В облаке (i4i.4xlarge-класс) это порядка 30 × 1.1 $/ч ≈ 24k $/мес против ~8k $/мес на Scylla. Плюс кросс-DC-трафик, который в мультирегионе легко становится второй по величине статьёй.
- Плюс люди: кластер Cassandra требует дежурного, который понимает компакцию, репейры и стриминг. Это 0.5–1 FTE минимум, и это дороже железа.
Последний пункт — самый недооценённый. Cassandra не «ставится и работает»; она требует непрерывной операционной дисциплины.
Когда НЕ брать wide-column
Явный список ситуаций, где решение почти наверняка ошибочно:
- Данных меньше нескольких терабайт и нет требования мультирегиональной записи. Один PostgreSQL с репликой закроет задачу лучше по всем осям, включая latency.
- Нужны ad-hoc запросы и аналитика. Wide-column физически не умеет отвечать на незапланированные вопросы. Для аналитики — ClickHouse.
- Много обновлений одних и тех же строк. LWW теряет конкурентные записи, а компакция захлёбывается на перезаписях.
- Нужны транзакции между сущностями. Перевод денег, резервирование склада, любой инвариант через несколько ключей — см. NewSQL.
- Очереди и брокеры. Разобрано выше: tombstone-и делают это невозможным.
- Неизвестные паттерны доступа. Если продукт ещё ищет себя и запросы меняются каждую неделю, схема «таблица на запрос» превращается в непрерывную миграцию.
- Маленькая команда без опыта эксплуатации распределённых систем. Если нужен именно этот класс — берите управляемый сервис (Astra, Scylla Cloud, DynamoDB, Bigtable), а не свой кластер.
Чек-лист продакшн-ошибок
Список, который стоит пройти перед запуском:
- Партиции без ограничения роста. Целевой размер — до 100 МБ и до ~100 тыс. строк. Проверяется
nodetool tablehistograms(колонка Partition Size, 99-й перцентиль). Лечится добавлением bucket-а в партиционный ключ, но задним числом это миграция всех данных. gc_grace_secondsменьше реального цикла repair. Генератор зомби-данных.ALLOW FILTERINGи обычные 2i в горячем пути. Латентность растёт с размером кластера, а не с размером ответа.- Один rack на весь DC. Отказ AZ убивает кворум, хотя RF=3.
- Батчи как bulk insert. Координатор становится бутылочным горлышком.
- Биндинг
nullвместоunset. Тихая генерация миллиардов tombstone-ов. - Heap JVM больше 16–20 ГБ без ZGC. Паузы GC съедают p99 сильнее, чем диски.
- Отсутствие token-aware драйвера или non-prepared statements. Лишний сетевой хоп на каждый запрос — это десятки процентов латентности.
QUORUMвместоLOCAL_QUORUMв мультирегионе. Каждый запрос платит межконтинентальный RTT — 150+ мс.- Незамониторенный NTP. Перекос часов = молча потерянные записи из-за LWW.
- Диски заполнены больше 70%. STCS требует места на слияние; при 85% компакция встаёт, и кластер входит в спираль деградации.
- Repair не автоматизирован. Ручной
nodetool repairраз в квартал не работает — нужен Reaper или эквивалент по расписанию.
Полезные команды на каждый день:
nodetool status chat # UN/DN, владение токенами, перекос по узлам
nodetool tpstats # дропнутые сообщения: MUTATION/READ dropped ≠ 0 — узел перегружен
nodetool tablestats chat.messages_by_room # SSTables per read, bloom filter false ratio
nodetool compactionstats -H # очередь компакции: растёт → беда
nodetool netstats # стриминг при bootstrap/repair
nodetool proxyhistograms # латентности со стороны координатора — то, что видит клиент
nodetool gcstats # паузы GC; регулярные >200 мс = перенастроить heap
Мини-итог
Wide-column — это не «база данных без схемы» и не «быстрая замена SQL». Это специализированный инструмент с очень конкретным профилем: линейно масштабируемая запись, предсказуемое чтение по заранее известному ключу, непрерывная доступность в мультирегионе — в обмен на отказ от произвольных запросов, джойнов, транзакций и дешёвой эксплуатации.
Три вещи, которые стоит унести:
- Схема выводится из списка запросов, а не из списка сущностей. Один факт живёт в нескольких таблицах, и за их согласованность отвечает приложение.
- Партиция — единица всего. Её размер определяет и латентность, и равномерность нагрузки, и возможность масштабироваться. Ошибка в партиционном ключе не чинится настройками.
- Иммутабельность SSTable — источник и скорости записи, и всех операционных проблем. Tombstone-ы, компакция и репейры — не детали реализации, а то, чем вы будете заниматься каждый день.
Если хотя бы одно из требований (мультирегиональная запись, десятки терабайт, миллионы записей в секунду, работа при потере датацентра) не про вас — скорее всего, правильный ответ находится в PostgreSQL, и это хорошая новость.
Источники
- Dynamo: Amazon’s Highly Available Key-value Store — SOSP 2007, первоисточник кольца и кворумов.
- Bigtable: A Distributed Storage System for Structured Data — OSDI 2006, первоисточник модели и LSM-хранилища.
- Apache Cassandra Documentation — официальные доки, разделы Data Modeling и Operating.
- DataStax: Cassandra Data Modeling — методика query-first с разбором примеров.
- Jeff Carpenter, Eben Hewitt. Cassandra: The Definitive Guide, 3rd ed., O’Reilly, 2020 — лучшая книга по теме.
- The Last Pickle blog — архив глубоких разборов компакции, репейров и tombstone-ов от команды, написавшей Reaper.
- ScyllaDB Documentation и Seastar — устройство shard-per-core.
- Martin Kleppmann. Designing Data-Intensive Applications, O’Reilly, 2017 — главы 5–6 и 9 про репликацию, партиционирование и консистентность.
Что дальше
Мы разобрали хранилище, оптимизированное под запись по ключу и категорически неспособное отвечать на аналитические вопросы. Следующая статья — про противоположный полюс: ClickHouse и колоночные аналитические БД: движки, партиционирование, MergeTree, где та же идея LSM-слияний используется совершенно иначе — ради сканирования миллиардов строк за доли секунды.