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);Wann verwenden
Abschnitt betitelt „Wann verwenden“Cassandra ist die Produktionswahl für Multi-Node-Cluster mit geteilter Persistenz:
- Sharded Entities, die zwischen Nodes wandern —
PersistentActors, 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.
Konfiguration
Abschnitt betitelt „Konfiguration“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 ... */};| Feld | Was |
|---|---|
contactPoints | Initiale Cassandra-Contact-Nodes. Der Treiber entdeckt den Rest. |
keyspace | Bereits existierender Keyspace. Das Framework erstellt Tabellen, aber nicht den Keyspace selbst. |
eventsTable | Name der Events-Tabelle. Default events. |
metadataTable | Tabelle, die die höchste Sequenznummer pro pid verfolgt. Default metadata. |
allIdsTable | Lookup-Tabelle hinter persistenceIds(). Default all_persistence_ids. |
tagIndexTable | Tag-Index-Seitentabelle. Default events_by_tag. Wird nur geschrieben, wenn useTagIndex gesetzt ist. |
partitionSize | Zeilen pro Partition, bevor auf einen neuen Bucket gerollt wird. Default 500_000. |
autoCreateTables | Tabellen bei der ersten Verbindung automatisch erstellen. Default true. |
useTagIndex | Die events_by_tag-Seitentabelle für indizierte Tag-Queries pflegen. Default false. |
consistency | CQL-Consistency-Level (numerisch, aus types.consistencies des Treibers) für alle Reads und Writes. Default LOCAL_QUORUM (6). |
lightweightTransactions | Nebenläufige Appends per LWT auf der metadata-Zeile serialisieren. Default true. Siehe Gleichzeitige Appends. |
serialConsistency | Consistency 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.
Konsistenzmodell
Abschnitt betitelt „Konsistenzmodell“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
tskö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.
Gleichzeitige Appends
Abschnitt betitelt „Gleichzeitige Appends“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 pidINSERT INTO metadata (persistence_id, max_sequence_nr, updated_at) VALUES (?, ?, ?) IF NOT EXISTS;
-- every later appendUPDATE 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.
Was es kostet
Abschnitt betitelt „Was es kostet“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.
Das Crash-Fenster
Abschnitt betitelt „Das Crash-Fenster“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
expectedSeqgelingt. - 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
highestSeqmeldet, 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.
Abwählen
Abschnitt betitelt „Abwählen“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.
Multi-DC
Abschnitt betitelt „Multi-DC“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: falsefä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.
Backup + Restore
Abschnitt betitelt „Backup + Restore“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.
Stolperfallen
Abschnitt betitelt „Stolperfallen“Wie geht’s weiter
Abschnitt betitelt „Wie geht’s weiter“- Persistenz im Überblick — das größere Bild.
- SQLite-Journal — die Single-Node-Alternative.
- In-Memory-Journal — für Tests.
- Snapshots — den Recovery-Scan begrenzen.
Die CassandraJournal-API-Referenz
deckt die vollständigen Optionen ab.
