Zum Inhalt springen
Deutsch

Cassandra-Journal

CassandraJournal speichert Events in einem Cassandra-Cluster. Anders als SQLite (eine Datei pro Node) wird Cassandra über Cluster-Nodes geteilt — jeder Node kann Events für jede persistenceId anhängen, lesen oder abfragen.

import { ActorSystem, ActorSystemOptions, CassandraJournal, CassandraJournalOptions } from 'actor-ts';
const cassandraJournalOptions = CassandraJournalOptions.create()
.withContactPoints(['cassandra-1:9042', 'cassandra-2:9042'])
.withKeyspace('my_app_events')
.withEventsTable('events');
const actorSystemOptions = ActorSystemOptions.create().withPersistence({
journal: new CassandraJournal(cassandraJournalOptions),
});
const system = ActorSystem.create('my-app', actorSystemOptions);

Cassandra ist die Produktionswahl für Multi-Node-Cluster mit geteilter Persistenz:

  • Sharded Entities, die zwischen Nodes wandernPersistentActors, die auf verschiedenen Nodes gespawnt werden, müssen während des Rebalance die Journals der anderen lesen.
  • Cross-Node-Projektionen — eine Projektion auf Node-A muss Events sehen, die auf Node-B geschrieben werden.
  • High-Throughput-Single-Shard-Szenarien, die die Pro-Maschine- Decke von SQLite überschreiten.

Für Single-Node-Deployments ist SqliteJournal einfacher und billiger — Cassandra hat operative Komplexität (Multi-Node- Cluster, Repair, Tuning), die du nicht brauchst.

type CassandraJournalOptions = {
contactPoints: string[]; // Cluster-Contact-Points
keyspace: string; // Keyspace (extern erstellt)
eventsTable?: string; // Name der Events-Tabelle, Default 'events'
metadataTable?: string; // Max-Seq-pro-pid-Tabelle, Default 'metadata'
allIdsTable?: string; // persistenceIds()-Lookup, Default 'all_persistence_ids'
tagIndexTable?: string; // Tag-Index-Seitentabelle, Default 'events_by_tag'
partitionSize?: number; // Zeilen pro Partition, Default 500_000
autoCreateTables?: boolean; // Tabellen bei erster Verbindung erstellen, Default true
useTagIndex?: boolean; // events_by_tag-Seitentabelle pflegen, Default false
consistency?: number; // CQL-Consistency, Default LOCAL_QUORUM (6)
lightweightTransactions?: boolean; // LWT-serialisierte Appends, Default true
serialConsistency?: number; // Paxos-Consistency für den LWT-Anspruch
/* ... plus Treiber-Level-Verbindungsoptionen ... */
};
FeldWas
contactPointsInitiale Cassandra-Contact-Nodes. Der Treiber entdeckt den Rest.
keyspaceBereits existierender Keyspace. Das Framework erstellt Tabellen, aber nicht den Keyspace selbst.
eventsTableName der Events-Tabelle. Default events.
metadataTableTabelle, die die höchste Sequenznummer pro pid verfolgt. Default metadata.
allIdsTableLookup-Tabelle hinter persistenceIds(). Default all_persistence_ids.
tagIndexTableTag-Index-Seitentabelle. Default events_by_tag. Wird nur geschrieben, wenn useTagIndex gesetzt ist.
partitionSizeZeilen pro Partition, bevor auf einen neuen Bucket gerollt wird. Default 500_000.
autoCreateTablesTabellen bei der ersten Verbindung automatisch erstellen. Default true.
useTagIndexDie events_by_tag-Seitentabelle für indizierte Tag-Queries pflegen. Default false.
consistencyCQL-Consistency-Level (numerisch, aus types.consistencies des Treibers) für alle Reads und Writes. Default LOCAL_QUORUM (6).
lightweightTransactionsNebenläufige Appends per LWT auf der metadata-Zeile serialisieren. Default true. Siehe Gleichzeitige Appends.
serialConsistencyConsistency für die Paxos-Phase der LWT (numerisch, aus types.consistencies des Treibers). Unbelegt gilt das Cluster-weite SERIAL des Treibers; auf einem Multi-DC-Keyspace setze localSerial (9).

Das Framework erstellt drei Tabellen automatisch bei der ersten Verwendung — events, metadata und all_persistence_ids — plus events_by_tag, wenn useTagIndex aktiviert ist. Schemas:

CREATE TABLE events (
persistence_id text,
partition_nr bigint,
sequence_nr bigint,
timestamp bigint,
payload text,
tags set<text>,
PRIMARY KEY ((persistence_id, partition_nr), sequence_nr)
) WITH CLUSTERING ORDER BY (sequence_nr ASC);
CREATE TABLE metadata (
persistence_id text PRIMARY KEY,
max_sequence_nr bigint,
updated_at bigint
);
CREATE TABLE all_persistence_ids (
tag text,
persistence_id text,
PRIMARY KEY (tag, persistence_id)
);
-- Only when useTagIndex is enabled:
CREATE TABLE events_by_tag (
tag text,
timestamp bigint,
persistence_id text,
sequence_nr bigint,
payload text,
tags set<text>,
PRIMARY KEY ((tag), timestamp, persistence_id, sequence_nr)
) WITH CLUSTERING ORDER BY (timestamp ASC, persistence_id ASC, sequence_nr ASC);

Die Events-Tabelle nutzt einen zusammengesetzten Partition-Key (persistence_id, partition_nr): die Events einer persistenceId werden in Partitions von partitionSize Zeilen (Default 500.000) gebucketet, sodass ein langlebiger Stream mehrere Partitions umfasst und Recovery eine Partition pro Bucket liest. Die metadata-Tabelle verfolgt die höchste Sequenznummer pro pid; all_persistence_ids stützt persistenceIds(). Die optionale events_by_tag-Tabelle ist nach tag geschlüsselt — Projection-Queries treffen eine Partition pro Tag.

Provisioniere den Keyspace mit angemessener Replikation:

CREATE KEYSPACE my_app_events
WITH replication = {
'class': 'NetworkTopologyStrategy',
'datacenter1': 3,
};

NetworkTopologyStrategy mit einem Replikations-Faktor von 3 ist typisch für Produktion. Die Writes des Frameworks gehen via LOCAL_QUORUM, was 2 von 3 Replicas für das Ack braucht.

Cassandra ist schließlich konsistent über Replicas — aber die Appends jeder pid werden serialisiert (siehe Gleichzeitige Appends). Praktische Garantien:

  • Die Events einer gegebenen pid haben eine strikte Total-Order (sequenceNr).
  • Replays sehen Events in seq-Reihenfolge, unabhängig davon, welche Cassandra-Replica antwortet.
  • Cross-pid-Event-Order in Tag-Queries ist Timestamp-gebunden, aber nicht strikt — Events mit demselben ts können sich verschachteln.

Für die meisten Event-sourced Anwendungen ist das in Ordnung — innerhalb einer einzelnen Entity (pid) ist die Reihenfolge strikt; über Entities hinweg ist die partielle Reihenfolge über Timestamp akzeptabel.

Jeder append beansprucht seinen Sequenzbereich per Cassandra Lightweight Transaction (LWT) auf der metadata-Zeile, bevor auch nur ein Event geschrieben wird:

-- first append for a pid
INSERT INTO metadata (persistence_id, max_sequence_nr, updated_at)
VALUES (?, ?, ?) IF NOT EXISTS;
-- every later append
UPDATE metadata SET max_sequence_nr = ?, updated_at = ?
WHERE persistence_id = ? IF max_sequence_nr = ?;

Nur der Anspruch eines Writers gewinnt die Paxos-Runde. Die übrigen erhalten einen JournalConcurrencyError, der den vom Gewinner hinterlassenen Head trägt — derselbe Kontrakt, den die relationalen Backends bieten.

Das ist keine optionale Buchhaltung. Ein CQL-INSERT ist ein Upsert: ohne den Anspruch würden zwei Writer, die beide Head N lesen, beide die expectedSeq-Prüfung bestehen und beide sequence_nr = N+1 schreiben — der zweite überschreibt den ersten stillschweigend, während beiden Aufrufern gemeldet wird, ihr Event sei persistiert. Die relationalen Journals können dieses Rennen nicht verlieren — ihr Primary Key weist den Verlierer ab — die LWT schließt diese Lücke also für Cassandra.

Ein Paxos-Round-Trip pro append, nicht pro Event. Der Anspruch ist ein einzelnes konditionales Statement gegen eine kleine Partition; die Events selbst gehen weiterhin im selben unlogged Batch pro Partition raus wie zuvor. Kalkuliere grob das 3–4-Fache der Latenz eines Appends ohne LWT — Paxos ist eine Prepare/Propose/Commit-Sequenz über ein Quorum, gegenüber einem Round-Trip für einen normalen Write. Der Durchsatz pro pid sinkt entsprechend; der Durchsatz über pids hinweg bleibt unberührt, da jeder Anspruch nur auf seiner eigenen metadata-Partition konkurriert.

Der Anspruch auf der Metadata-Zeile ist bewusst günstiger als die naheliegende Alternative INSERT … IF NOT EXISTS auf den Events: die kostet einen Round-Trip pro Event, und ein konditionaler Batch muss innerhalb einer einzelnen Partition bleiben — was der events_by_tag-Dual-Write ohnehin ausschließt.

Der Anspruch vor dem Schreiben kehrt das Fehlerfenster um, statt es zu beseitigen:

  • Schlägt der Event-Batch fehl, gibt das Journal eine kompensierende Freigabe ab — konditional auf den beanspruchten Wert, sodass ein Writer, der den Head berechtigterweise weiterbewegt hat, nie zurückgedreht wird — und ein Retry mit demselben expectedSeq gelingt.
  • Stirbt der Prozess zwischen dem committeten Anspruch und den Events, steht der Head vor den gespeicherten Events: eine Lücke. Ein Replay liest dann weniger Events, als highestSeq meldet, und der nächste Append setzt hinter der Lücke fort.

Die vorherige Reihenfolge hatte das spiegelbildliche Fenster (Events geschrieben, Head nicht vorgerückt, Waisen beim Retry überschrieben). Die Lücke ist der sicherere der beiden Kompromisse für die Nebenläufigkeitsgarantie, weil sie erkennbar ist — vergleiche highestSeq mit der höchsten tatsächlich vorhandenen sequence_nr — ein verlorener Write dagegen nicht.

const cassandraJournalOptions = CassandraJournalOptions.create()
.withContactPoints(['cassandra-1:9042'])
.withKeyspace('my_app_events')
.withLightweightTransactions(false);

Tu das nur, wenn du einen Writer pro persistenceId wirklich garantierst und den Round-Trip zurückbrauchst. Cluster-Sharding strebt diese Invariante an, garantiert sie aber nicht während eines Rebalance oder eines Split-Brain — also genau dann, wenn zwei Writer für eine pid koexistieren. Mit abgeschalteter LWT verwirft ein unterlegener nebenläufiger Append sein Event und meldet Erfolg.

Setze die Option einheitlich über die gesamte Flotte. Cassandra garantiert LWT-Linearisierbarkeit nur, wenn jeder Write auf eine Partition durch Paxos läuft; ein Rolling Change, bei dem einige Nodes metadata konditional und andere es schlicht schreiben, hebt die Garantie für die Dauer des Rollouts auf. Musst du auf einem laufenden Cluster umschalten, drainiere zuvor die Writer der betroffenen pids.

Cassandra unterstützt nativ Multi-Datacenter-Replikation. Konfiguriere Replikation pro DC:

CREATE KEYSPACE my_app_events
WITH replication = {
'class': 'NetworkTopologyStrategy',
'dc1': 3,
'dc2': 3,
};

Das actor-ts-Journal kümmert das nicht — Writes gehen ins lokale DC (über LOCAL_QUORUM), Cross-DC-Replikation ist asynchron und wird von Cassandra gehandhabt.

Setze serialConsistency auf einem Multi-DC-Keyspace. Der Append-Anspruch ist eine Paxos-Runde, und die Default-Serial- Consistency des Treibers ist das Cluster-weite SERIAL — ein Quorum von Replicas über jedes DC. Unangetastet zieht das jeden Append über die Inter-DC-Strecke und hebt genau das auf, was LOCAL_QUORUM dir bringt:

import { types } from 'cassandra-driver';
const cassandraJournalOptions = CassandraJournalOptions.create()
.withContactPoints(['cassandra-1:9042'])
.withKeyspace('my_app_events')
.withSerialConsistency(types.consistencies.localSerial);

LOCAL_SERIAL hält Paxos innerhalb des lokalen DC. Der Kompromiss: Appends sind dann nur innerhalb eines DC linearisierbar — in Ordnung, wenn eine gegebene pid zu einem Zeitpunkt aus einem DC geschrieben wird (das übliche Sharding-Layout), und unsicher, wenn du dieselbe pid gleichzeitig in zwei DCs routest.

Approximative Write-Performance (einzelner Cassandra-Cluster):

  • Single-pid-Append — dominiert von der Paxos-Runde, die den Sequenzbereich beansprucht, also grob das 3–4-Fache eines einfachen Quorum-Writes. Mit lightweightTransactions: false fällt er zurück auf Sub-Millisekunde auf Journal-Ebene, getrieben von Cassandras Commit-Log + Memtable — lies Gleichzeitige Appends, bevor du diesen Kompromiss eingehst.
  • Cross-pid-Durchsatz — skaliert linear mit der Cluster-Größe. Jede pid konkurriert nur auf ihrer eigenen metadata-Partition, die LWT serialisiert also keine unbeteiligten Writer.
  • Tag-Query — durch die Tag-Partitions-Größe begrenzt. Heiße Tags (jedes Event mit ‘audit’ getaggt) werden zu heißen Partitions; überlege feinkörnigeres Tagging oder Bucketing, wenn du einen Tag siehst, der 100M+ Events trägt.

Cassandra hat seine eigene Backup-Strategie — Snapshots via nodetool snapshot, inkrementelle Backups, plus operatives Tooling (Medusa, Cassandra Backup Tool). Das Journal fügt nichts Besonderes hinzu; behandle es, wie du jeden anderen Cassandra-Keyspace behandeln würdest.

Die CassandraJournal-API-Referenz deckt die vollständigen Optionen ab.