← назад к разделу

Ключ к пониманию Cassandra — её топология. В PostgreSQL и MongoDB есть первичный узел, через который идёт запись. В Cassandra такого узла нет: все узлы кластера равноправны, и запись принимает любой из них. Именно из этого растут и её сильные стороны (масштаб записи, отсутствие единой точки отказа), и её ограничения.

Дальше речь идёт о ветке 5.x — она вышла в 2024 году и на ней стоит начинать новые кластеры. Само устройство кольца, пути записи и уплотнения не менялось годами, но умолчания и отдельные механизмы между версиями разъезжаются: в 4.0 поменялось число виртуальных узлов на физический, в 5.0 появились индексы, прикреплённые к хранилищу (SAI), и единая стратегия уплотнения. Где такое расхождение важно, дальше стоит оговорка с номером версии.

запись принимает любой узел, а где она ляжет — решает токен ключа 0 25 50 75 A B C D B CD D AB 1. ключ раздела: order-42 2. хеш ключа → токен 4 3. участок (0..25] — узел B 4. копии по кругу: B → C → D 1. ключ раздела: user-7 2. хеш ключа → токен 70 3. участок (50..75] — узел D 4. копии по кругу: D → A → B RF = 3: три копии на трёх узлах кольца координатор — тот узел, что принял запрос vnodes: засечек у каждого узла много

Узлы — это засечки на кольце токенов. Ключ раздела хешируется в число, число попадает на участок между засечками, и участком владеет ближайшая засечка по часовой стрелке. Копии кладут на следующие узлы по кругу — а после последней засечки круг замыкается на первую.

Обязательно

Кольцо и партиционирование по токенам

Узлы Cassandra образуют логическое кольцо. Всё пространство возможных значений хеша делится на диапазоны, и каждый узел отвечает за свой участок кольца.

Когда вы пишете строку, Cassandra берёт её ключ раздела (partition key), прогоняет через хеш-функцию (партиционер, обычно Murmur3) и получает число — токен. Токен указывает точку на кольце, а значит — узел, который отвечает за этот диапазон. Так по ключу раздела детерминированно определяется, где живут данные. Это принцип консистентного хеширования: при добавлении или удалении узла перераспределяется лишь небольшая часть данных, а не всё кольцо.

Раскладку можно проверить руками — узлы здесь просто засечки, а владелец ключа ищется как первая засечка не меньше токена:

живой пример

public class TokenRing {
    record Node(String name, long token) {}

    static long token(String key) {
        long hash = 0xcbf29ce484222325L;
        for (int i = 0; i < key.length(); i++) {
            hash = (hash ^ key.charAt(i)) * 0x100000001b3L;
        }
        return Math.floorMod(hash, 100);
    }

    public static void main(String[] args) {
        Node[] ring = {new Node("A", 0), new Node("B", 25),
                       new Node("C", 50), new Node("D", 75)};
        for (String key : new String[]{"order-42", "user-7", "cart-9"}) {
            long token = token(key);
            int owner = 0;
            while (owner < ring.length && ring[owner].token() < token) {
                owner++;
            }
            String copies = "";
            for (int i = 0; i < 3; i++) {
                copies += ring[(owner + i) % ring.length].name() + " ";
            }
            System.out.println(key + ": токен " + token + " -> копии " + copies.trim());
        }
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

package main

import (
	"fmt"
	"strings"
)

type node struct {
	name  string
	token int64
}

func token(key string) int64 {
	var hash uint64 = 0xcbf29ce484222325
	for _, c := range key {
		hash = (hash ^ uint64(c)) * 0x100000001b3
	}
	return (int64(hash)%100 + 100) % 100
}

func main() {
	ring := []node{{"A", 0}, {"B", 25}, {"C", 50}, {"D", 75}}
	for _, key := range []string{"order-42", "user-7", "cart-9"} {
		t := token(key)
		owner := 0
		for owner < len(ring) && ring[owner].token < t {
			owner++
		}
		var copies []string
		for i := 0; i < 3; i++ {
			copies = append(copies, ring[(owner+i)%len(ring)].name)
		}
		fmt.Printf("%s: токен %d -> копии %s\n", key, t, strings.Join(copies, " "))
	}
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

function token(key) {
  let hash = 0xcbf29ce484222325n;
  for (const c of key) hash = BigInt.asUintN(64, (hash ^ BigInt(c.charCodeAt(0))) * 0x100000001b3n);
  return ((BigInt.asIntN(64, hash) % 100n) + 100n) % 100n;
}

const ring = [['A', 0n], ['B', 25n], ['C', 50n], ['D', 75n]];
for (const key of ['order-42', 'user-7', 'cart-9']) {
  const t = token(key);
  let owner = 0;
  while (owner < ring.length && ring[owner][1] < t) owner++;
  const copies = [0, 1, 2].map((i) => ring[(owner + i) % ring.length][0]);
  console.log(`${key}: токен ${t} -> копии ${copies.join(' ')}`);
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

MASK64 = (1 << 64) - 1


def token(key: str) -> int:
    h = 0xCBF29CE484222325
    for c in key:
        h = ((h ^ ord(c)) * 0x100000001B3) & MASK64
    if h >= 1 << 63:
        h -= 1 << 64
    return h % 100


ring = [("A", 0), ("B", 25), ("C", 50), ("D", 75)]
for key in ("order-42", "user-7", "cart-9"):
    t = token(key)
    owner = 0
    while owner < len(ring) and ring[owner][1] < t:
        owner += 1
    copies = " ".join(ring[(owner + i) % len(ring)][0] for i in range(3))
    print(f"{key}: токен {t} -> копии {copies}")
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

На практике каждый физический узел отвечает не за один большой участок, а за множество мелких — это виртуальные узлы (vnodes). Они выравнивают нагрузку: когда узел выходит из строя, его данные подхватывают не один сосед, а много узлов сразу, поэтому восстановление идёт быстрее и равномернее.

Сколько именно таких участков приходится на узел, задаёт настройка num_tokens, и её умолчание со временем менялось. В ветке 3.x стояло 256 — нагрузка раскладывалась ровно, но сверка и восстановление данных выходили дорогими: слишком много мелких диапазонов, каждый со своим учётом. В 4.0 умолчание понизили до 16, а для новых кластеров с равномерной раскладкой токенов советуют и вовсе 4. Так что «множество мелких участков» сегодня — это единицы и десятки, а не сотни; сотни вы увидите только в кластере, доставшемся от старых версий.

Gossip: как узлы знают друг о друге

Раз главного узла нет, кто хранит карту кластера — кто жив, кто отвечает за какие диапазоны? Никто централизованно. Узлы обмениваются состоянием через протокол gossip: раз в секунду каждый узел «сплетничает» с несколькими случайными соседями, передавая, что он знает о состоянии всех остальных. Через несколько раундов информация о новом или упавшем узле расходится по всему кластеру.

Любой узел, приняв запрос клиента, становится координатором этого запроса: он знает по токену, какие узлы хранят нужную партицию, рассылает им операцию и собирает ответы. Координатором может быть любой узел — отсюда и отсутствие единой точки отказа.

Путь записи: почему запись такая быстрая

Высокая скорость записи Cassandra — не магия, а следствие устройства пути записи. Когда координатор доставил запись на узел-реплику, происходит два действия одновременно:

  1. запись дописывается в commit log — журнал на диске, только добавление в конец (последовательная запись, поэтому быстрая); он нужен для долговечности — по нему восстановят данные после сбоя;
  2. запись кладётся в memtable — структуру в оперативной памяти.

На этом запись считается выполненной — искать и перезаписывать что-то в структуре данных на диске не пришлось. Одна оговорка про журнал: по умолчанию (commitlog_sync: periodic) он сбрасывается на диск не при каждой записи, а раз в 10 секунд, и подтверждённые за это окно записи узел при внезапном отключении питания потеряет. Спасает не журнал, а репликация: копии тех же данных лежат на других узлах. Здесь стоит сразу закрыть напрашивающийся вопрос — а как они там оказались, если реплика в момент записи лежала? Координатор в таком случае откладывает «подсказку» (hint) себе и доигрывает пропущенное, когда узел возвращается; подробно этот механизм разобран в статье про согласованность и репликацию. Отсюда и скорость: никаких обновлений на месте. Когда memtable наполняется, её целиком сбрасывают на диск в новый файл — SSTable (Sorted String Table). SSTable неизменяем: его больше никогда не правят, только читают.

Важное следствие: обновление и удаление — это тоже записи, а не правки. Обновили строку — записали новую версию в свежий SSTable. Удалили строку — записали специальную пометку «удалено», её называют tombstone (надгробие). Старые версии и tombstone какое-то время сосуществуют в разных SSTable.

запись журнал + memtable SSTable 1 сброс memtable SSTable 2 новая версия один файл после уплотнения

Запись сначала попадает в журнал и память, потом уходит в неизменяемый файл, новая версия строки ложится рядом отдельным файлом, а уплотнение оставляет один.

Отложенная передача подсказок

Отдельная часть пути записи, без которой непонятно, что происходит при отказе. Реплика, которой адресована запись, недоступна — координатор не выбрасывает данные: он сохраняет у себя подсказку (hint) и отдаёт её реплике, когда та вернётся. Так закрывается короткая недоступность: перезапуск узла, обновление, сетевая заминка.

Три вещи, которые про это стоит знать. Подсказки хранятся ограниченное время (max_hint_window_in_ms, по умолчанию три часа) — узел, отсутствовавший дольше, подсказок не получит, и его придётся догонять восстановлением. Подсказки не заменяют требуемый уровень согласованности: если для QUORUM не хватило живых реплик, запись всё равно не удалась, даже если подсказка сохранена. И сами подсказки занимают место и нагружают координатор — пачка недоступных узлов означает пачку растущих файлов подсказок.

Кто знает, где какой ЦОД: snitch

Раскладка реплик по дата-центрам не берётся из воздуха: узел определяет, кто ему «сосед по стойке», кто «в том же ЦОД», а кто «далеко», через настройку топологии (snitch). Она читает либо файл с описанием стоек и ЦОД, либо метаданные облака (в облачных установках), либо просто считает всех соседями (простой вариант — только для одного ЦОД).

Зачем это нужно практически: стратегия NetworkTopologyStrategy раскладывает копии так, чтобы они попали в разные стойки и разные ЦОД, — иначе отказ одной стойки унесёт все три копии сразу. А LOCAL_QUORUM вообще не имеет смысла без знания, какие реплики локальные. Настройка топологии — то, что задают при поднятии кластера и почти никогда не меняют потом: смена её на живом кластере означает пересчёт раскладки реплик и полное восстановление данных.

Compaction и tombstones

Раз данные только дописываются, со временем накапливается много SSTable, где одна и та же строка встречается в разных версиях. Чтобы чтение не замедлялось и место не расходовалось зря, работает фоновый процесс compaction (уплотнение): он сливает несколько SSTable в один, оставляя только актуальную версию каждой строки и выкидывая перекрытые.

Здесь же окончательно применяются tombstone: удалённые данные физически исчезают только после compaction, и не сразу — tombstone держат заданный срок (gc_grace_seconds, по умолчанию 10 дней), чтобы удаление успело разойтись по всем репликам. Отсюда классическая грабля: массовые удаления создают массу tombstone, а они замедляют чтение (движку приходится пропускать «надгробия»), пока compaction их не уберёт. Cassandra не любит, когда её используют как очередь с постоянным «положил-удалил».

Чтение при таком устройстве — это слияние: движок собирает нужную строку из memtable и нескольких SSTable, выбирая самую свежую версию. Ускоряют это вспомогательные структуры (Bloom-фильтры, индексы разделов), чтобы не заглядывать в SSTable, где искомой строки заведомо нет.

Путь чтения и где он тормозит

Чтение устроено сложнее записи, и это главный источник задержек в Cassandra. Координатор находит реплики, одна из них ищет данные — и вот где ищет.

Сначала кэш строк, если он включён (по умолчанию нет). Потом memtable в памяти. Потом файлы на диске (SSTable) — и здесь самое важное: строка могла записываться много раз, и её части лежат в разных файлах. Чтобы собрать актуальное значение, реплика читает все файлы, где эта строка может быть, и склеивает версии по времени записи. Отсеять лишние файлы помогают фильтр Блума (быстрый ответ «этого ключа тут точно нет») и кэш ключей («в этом файле он вот на этом смещении»).

Отсюда главная метрика чтения: сколько SSTable пришлось прочитать на один запрос. Один-два — норма; десять — уже задержки; сорок — запрос, который «иногда отвечает секунду». Видно это в nodetool tablehistograms (гистограмма SSTable на чтение) и в nodetool tablestats. И лечится это не индексом, а уплотнением: правильно выбранная стратегия держит число файлов на чтение маленьким.

Второй источник задержек — метки удаления. Запрос, который обходит раздел с тысячами удалённых строк, обязан прочитать и отбросить их все; на пороге tombstone_warn_threshold (1000) в журнал идёт предупреждение, на tombstone_failure_threshold (100 000) запрос отклоняется с ошибкой. Это не теория, а самый частый способ получить неработающий запрос на живом кластере — обычно на таблице, которую используют как очередь.

Пределы, за которыми модель ломается

Цифры, которые стоит помнить, потому что именно на них натыкаются в проде.

Размер одного раздела: до сотни мегабайт — нормально, сотни мегабайт — уже проблема (чтение раздела не влезает в память, уплотнение становится дорогим), гигабайты — неработоспособно. Число строк в разделе держат в пределах сотни тысяч. Проверяется это nodetool tablehistograms — там есть гистограмма размера раздела, а nodetool tablestats показывает максимальный.

Число меток удаления на запрос: предупреждение на тысяче, отказ на ста тысячах (значения настраиваемые). Число SSTable на чтение: держат до десятка.

И следствие неизменяемости файлов, которое ловят на дисках: уплотнению нужно свободное место. Размерная стратегия в худшем случае требует столько же свободного места, сколько занимает сама таблица (она сливает крупные файлы в один новый, прежде чем удалить старые). Диск, заполненный больше чем наполовину, — это кластер, на котором уплотнение однажды не запустится; файлы начнут накапливаться, чтение поедет, а починить это без добавления места будет нельзя. Уровневая стратегия в этом смысле скромнее (сливает небольшими порциями), и это один из доводов в её пользу.

Репликация и несколько дата-центров

Данные каждой партиции хранятся не на одном узле, а на нескольких — их число задаёт replication factor (RF). RF = 3 означает три копии на трёх разных узлах кольца. Это основа и надёжности (упал узел — данные есть на других), и доступности (запрос обслужит любая живая реплика). Подробно про то, как из RF и уровней согласованности складываются гарантии, — в статье про согласованность и репликацию.

Отдельная сила Cassandra — несколько дата-центров. С правильной стратегией репликации (NetworkTopologyStrategy) копии раскладывают по нескольким ЦОД, и кластер работает в режиме «активный-активный»: каждый ЦОД принимает и чтение, и запись локально, а данные асинхронно расходятся между ЦОД. Приложение читает и пишет в ближайший ЦОД с низкой задержкой, а падение целого ЦОД не останавливает сервис.

ЦОД Москва ЦОД Петербург копии уходят асинхронно и так же обратно

Каждый дата-центр держит свои копии и обслуживает чтение и запись локально, а между центрами данные расходятся асинхронно.

Дополнительно: при первом чтении можно пропустить

Глубже: стратегии уплотнения: STCS, LCS и TWCSрасширенное

Раздел про уплотнение объясняет, зачем оно нужно. Как именно сливать SSTable, решает стратегия, и её выбирают под сценарий таблицы при создании (WITH compaction = {...}), потому что смена стратегии на большой таблице это перезапись всех её данных.

STCS, размерная (SizeTieredCompactionStrategy), стратегия по умолчанию: сливает SSTable похожего размера, четыре маленьких в один побольше, потом четыре побольше в ещё больший. Дёшево по записи, зато одна строка может лежать в десятке файлов разного возраста, и чтение собирает её по всем. Ещё ей нужно свободное место под слияние самых больших файлов, до половины диска. Подходит таблицам, где пишут много, а читают в основном свежее.

LCS, уровневая (LeveledCompactionStrategy), держит данные слоями фиксированного размера, и на каждом уровне ключ встречается в одном файле, поэтому чтение строки заглядывает в один-два файла вместо десяти. Плата это в несколько раз больше операций записи на диск, так что для таблиц с потоком записи выше чтения она вредна. Берут для таблиц профилей, справочников, всего, что читают чаще, чем пишут, и что часто обновляют.

TWCS, по временным окнам (TimeWindowCompactionStrategy), сделана под ряды с TTL: события, метрики, сообщения с ограниченным сроком. SSTable группируются по окну времени записи (час, день), внутри окна сливаются, между окнами никогда. Когда весь файл окна протух по TTL, его удаляют целиком без уплотнения, и tombstone в такой таблице почти не бывает. Условие: данные приходят примерно по порядку времени, а обновлений и удалений старого нет, иначе старые окна перестают быть неприкосновенными.

CREATE TABLE metrics (...)
  WITH compaction = {'class': 'TimeWindowCompactionStrategy',
                     'compaction_window_unit': 'DAYS',
                     'compaction_window_size': 1}
  AND default_time_to_live = 2592000;

В 5.0 появилась UnifiedCompactionStrategy, которая одной настройкой умеет вести себя и как размерная, и как уровневая, и на неё стоит смотреть в новых кластерах. Здоровье уплотнения проверяют по nodetool compactionstats: очередь задач, которая не уменьшается, значит диск не успевает, и чтение будет медленнее с каждым часом.

Коротко

  • В Cassandra нет главного узла: узлы образуют кольцо, запись принимает любой (становясь координатором), а данные распределены по токену ключа раздела (консистентное хеширование, vnodes).
  • Топологию узлы знают через gossip — обмен состоянием со случайными соседями, без центрального диспетчера.
  • Путь записи — commit log (долговечность) + memtable (память) → сброс в неизменяемый SSTable. Никаких правок на месте, поэтому запись быстрая; обновление и удаление — это новые записи (удаление — tombstone).
  • Compaction сливает SSTable, оставляя актуальные версии и убирая tombstone; массовые удаления плодят tombstone и замедляют чтение.
  • Replication factor задаёт число копий; NetworkTopologyStrategy раскладывает их по нескольким ЦОД в режиме активный-активный — отсюда геораспределённость и постоянная доступность.
  • Стратегия уплотнения выбирается под таблицу: STCS по умолчанию для записи, LCS для чтения и обновлений ценой записи, TWCS для рядов с TTL; менять на живой таблице дорого.
  • Подсказки (hinted handoff) закрывают короткую недоступность реплики, но живут ограниченное время и не заменяют нужный уровень согласованности; раскладку по ЦОД и стойкам знает настройка топологии (snitch).
  • Главная метрика чтения — число SSTable на запрос (норма единицы, десятки — задержки); метки удаления на пороге ста тысяч заставляют запрос падать.
  • Пределы: раздел до сотни мегабайт и сотни тысяч строк, а уплотнению нужно свободное место — диск, заполненный больше половины, однажды остановит уплотнение.

Что почитать дальше