Базы данных Cassandra, ScyllaDB и wide-column: модель запросов и компромиссы
0%

Cassandra, ScyllaDB и wide-column: модель запросов и компромиссы

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, который реально ограничивает чтение с диска.

Анатомия PRIMARY KEY: хеш-часть, сортирующая часть и физическая раскладка партиции

Из этой картинки следуют жёсткие правила, которые нельзя обойти настройками:

  1. WHERE обязан целиком задать партиционный ключ. Не половину. WHERE room_id = ? при ключе ((room_id, bucket)) — ошибка.
  2. Кластеризующие колонки ограничиваются слева направо. При PRIMARY KEY ((a), b, c, d) можно b = ? AND c > ?, но нельзя c = ? без b.
  3. Никаких агрегаций, джойнов и произвольных сортировок. ORDER BY по неключевой колонке невозможен физически: данные уже лежат в другом порядке, а собирать их в память по всему кластеру никто не будет.

Query-first: схема как материализация списка запросов

В реляционной модели (см. реляционную модель и нормализацию) сначала описывают сущности, потом пишут запросы. В Cassandra порядок обратный и это не стилистика, а необходимость: если запрос не «упал» ровно в один партиционный ключ, он будет стоить обхода всего кластера.

Так одна предметная сущность превращается в несколько таблиц. Это не «денормализация от лени» — это ровно то, что реляционная СУБД делает индексами, только явно и в вашем коде.

Пунктирные связи здесь — не внешние ключи (их не существует), а обязательство приложения писать в обе таблицы. Отсюда первый честный 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 или векторных часов здесь нет.

Обратите внимание на шаг с hint: hinted handoff — это оптимизация восстановления, а не механизм долговечности. Если узел лежал дольше max_hint_window_in_ms (3 часа), подсказки выбрасываются, и разошедшиеся данные вернёт только anti-entropy-репейр. Отсюда: repair не опция, а обязательная операционная процедура.

Путь записи и чтения: LSM в деталях

Запись в Cassandra почти бесплатна, потому что она никогда не читает перед записью. Нет проверки уникальности, нет проверки внешних ключей, нет «найти строку и обновить». INSERT и UPDATE — одна и та же операция: положить ячейку с timestamp-ом в memtable. Отсюда линейная масштабируемость записи, которой нет у B-tree-хранилищ (сравнение подходов — в индексах и планах выполнения).

Асимметрия очевидна: запись трогает один последовательный лог и память, чтение может трогать десяток файлов. Именно поэтому компакция — центральная операционная тема Cassandra, а не фоновая деталь.

Сравнение стратегий компакции: STCS, LCS, TWCS и их цена

Сводно, в цифрах, которые стоит держать в голове:

Стратегия 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), и приоритизация фоновых задач (компакция, стриминг) ниже пользовательских запросов.

Честное сравнение по тому, что реально видно в эксплуатации:

Аспект 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), а не свой кластер.

Чек-лист продакшн-ошибок

Список, который стоит пройти перед запуском:

  1. Партиции без ограничения роста. Целевой размер — до 100 МБ и до ~100 тыс. строк. Проверяется nodetool tablehistograms (колонка Partition Size, 99-й перцентиль). Лечится добавлением bucket-а в партиционный ключ, но задним числом это миграция всех данных.
  2. gc_grace_seconds меньше реального цикла repair. Генератор зомби-данных.
  3. ALLOW FILTERING и обычные 2i в горячем пути. Латентность растёт с размером кластера, а не с размером ответа.
  4. Один rack на весь DC. Отказ AZ убивает кворум, хотя RF=3.
  5. Батчи как bulk insert. Координатор становится бутылочным горлышком.
  6. Биндинг null вместо unset. Тихая генерация миллиардов tombstone-ов.
  7. Heap JVM больше 16–20 ГБ без ZGC. Паузы GC съедают p99 сильнее, чем диски.
  8. Отсутствие token-aware драйвера или non-prepared statements. Лишний сетевой хоп на каждый запрос — это десятки процентов латентности.
  9. QUORUM вместо LOCAL_QUORUM в мультирегионе. Каждый запрос платит межконтинентальный RTT — 150+ мс.
  10. Незамониторенный NTP. Перекос часов = молча потерянные записи из-за LWW.
  11. Диски заполнены больше 70%. STCS требует места на слияние; при 85% компакция встаёт, и кластер входит в спираль деградации.
  12. 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, и это хорошая новость.

Источники

Что дальше

Мы разобрали хранилище, оптимизированное под запись по ключу и категорически неспособное отвечать на аналитические вопросы. Следующая статья — про противоположный полюс: ClickHouse и колоночные аналитические БД: движки, партиционирование, MergeTree, где та же идея LSM-слияний используется совершенно иначе — ради сканирования миллиардов строк за доли секунды.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов