De weg naar ACID-transacties in Cassandra 6

Door Phil Eaton | 16 augustus 2026

Cassandra is een overtuigend datasysteem. Het is een van de weinige vendor-neutrale, open-source databases die een SQL-achtige querytaal ondersteunen met ingebouwde sharding en replicatie. Deze gewenste combinatie is de reden waarom Cassandra zoveel (grote) gebruikers heeft, waaronder Apple, eBay, Bloomberg en Netflix.

Cassandra is aanzienlijk geëvolueerd sinds de eerste release. Van een 'eventually consistent' datamodel zonder transacties en een schemaloze NoSQL-interface via Thrift, tot waar het (in de komende 6.0 release) staat als een ACID-transactionele SQL-achtige database (met kanttekening: er zijn ernstige SQL-beperkingen en transacties zijn non-interactief, daar komen we later op terug).

Ondertussen is een kernkenmerk gelijk gebleven door het gebrek aan joins, automatische sharding en een beperkt verhaal rondom secundaire indexen: je modelleert tabellen op basis van queries. Als resultaat kan je applicatie eindigen met denormalisatie, waarbij één write wordt omgezet in meerdere writes om de database in staat te stellen later efficiënt antwoord te geven op verschillende queries.

In dit artikel richten we een Cassandra-cluster van drie nodes op één machine in, gebruikmakend van de cassandra-6.0 branch (een pre-release status) om enkele transactionele workloads te testen over vier van Cassandra's transactionele opties: geen (standaard), BATCH-updates, Lightweight Transaction (LWT) updates en Accord (d.w.z. ACID) updates. Accord-transacties worden pas beschikbaar bij de release van Cassandra 6 (mogelijk later dit jaar), vandaar dat we de pre-release branch gebruiken.

Het opzetten van een cluster

Installeer Java 21, het ant build-systeem, gcc en Go voor onze concurrent test runner, Monastery.

sudo apt-get install -y openjdk-21-jdk ant gcc golang
git clone https://github.com/theconsensuslabs/monastery
cd monastery
CGO_ENABLED=1 go build -buildmode=plugin -o cql.so ./plugins/cql
CGO_ENABLED=1 go build -o monastery .

Haal vervolgens Cassandra op en build deze.

git clone https://github.com/apache/cassandra
cd cassandra
git checkout cassandra-6.0
ant artifacts -Dcheck.skip=true -Dant.gen-doc.skip=true -Dno-javadoc=true

Richt mappen en configuraties in voor drie nodes, waarbij ze unieke IP-adressen en JMX-poorten krijgen.

for i in 1 2 3; do
n=node$i
# Voer eerst `killall java` uit om de datamappen op te schonen voor schone re-runs.
rm -rf /etc/cassandra/$n /var/log/cassandra/$n /var/lib/cassandra
mkdir -p /etc/cassandra/$n /var/log/cassandra/$n
cp -r ~/cassandra/conf/* /etc/cassandra/$n/
echo "
cassandra_storagedir=\"/var/lib/cassandra/$n\"
JVM_OPTS=\"\$JVM_OPTS -Dcassandra.jmx.local.port=7${i}99\"" >> /etc/cassandra/$n/cassandra-env.sh
echo "
cluster_name: 'theconsensus-lab'
listen_address: 127.0.0.$i
rpc_address: 127.0.0.$i
seed_provider:
- class_name: org.apache.cassandra.locator.SimpleSeedProvider
parameters:
- seeds: \"127.0.0.1:7000\"
accord:
enabled: true" >> /etc/cassandra/$n/cassandra.yaml
# Stel het maximale geheugengebruik in.
echo "
-Xms4G
-Xmx4G" >> /etc/cassandra/$n/jvm-server.options
done

Start nu de drie nodes één voor één op. (-R stelt ons in staat om als root uit te voeren.)

CASSANDRA_CONF=/etc/cassandra/node1 CASSANDRA_LOG_DIR=/var/log/cassandra/node1 /root/cassandra/bin/cassandra -R >> /var/log/cassandra/node1/console.log 2>&1

Wacht tot de node is opgestart. Uiteindelijk zul je dit zien:

$ /root/cassandra/bin/nodetool -p 7199 status
Datacenter: datacenter1
=======================
Status=Up/Down
|/ State=Normal/Leaving/Joining/Moving
--  Address    Load       Tokens  Owns (effective)  Host ID                               Rack
UN  127.0.0.1  72.73 KiB  16      100.0%            6d194555-f6eb-41d0-c000-000000000001  rack1

Hier betekent "UN" "Up" en "Normal".

Voeg nu node2 toe.

CASSANDRA_CONF=/etc/cassandra/node2 CASSANDRA_LOG_DIR=/var/log/cassandra/node2 /root/cassandra/bin/cassandra -R >> /var/log/cassandra/node2/console.log 2>&1

En zodra deze up is, zal nodetool status er uiteindelijk zo uitzien:

$ /root/cassandra/bin/nodetool -p 7299 status
Datacenter: datacenter1
=======================
Status=Up/Down
|/ State=Normal/Leaving/Joining/Moving
--  Address    Load       Tokens  Owns (effective)  Host ID                               Rack
UN  127.0.0.1  74.46 KiB  16      100.0%            6d194555-f6eb-41d0-c000-000000000001  rack1
UN  127.0.0.2  79.89 KiB  16      100.0%            6d194555-f6eb-41d0-c000-000000000002  rack1

Start nu de laatste node.

CASSANDRA_CONF=/etc/cassandra/node3 CASSANDRA_LOG_DIR=/var/log/cassandra/node3 /root/cassandra/bin/cassandra -R >> /var/log/cassandra/node3/console.log 2>&1

En wacht tot deze is aangesloten.

$ /root/cassandra/bin/nodetool -p 7399 status
Datacenter: datacenter1
=======================
Status=Up/Down
|/ State=Normal/Leaving/Joining/Moving
--  Address    Load        Tokens  Owns (effective)  Host ID                               Rack
UN  127.0.0.1  163.14 KiB  16      64.7%             6d194555-f6eb-41d0-c000-000000000001  rack1
UN  127.0.0.2  173.47 KiB  16      59.3%             6d194555-f6eb-41d0-c000-000000000002  rack1
UN  127.0.0.3  76.7 KiB    16      76.0%             6d194555-f6eb-41d0-c000-000000000003  rack1

Aangezien dit een weergave van het cluster is, zou je dezelfde resultaten krijgen als je nodetool status op elke node in het cluster uitvoert (in ieder geval in deze foutloze omgeving).

Boekhouding

Stel dat we een accounts tabel hebben die saldi bijhoudt na overboekingen. We genereren overboekingen en passen deze toe op Cassandra met elke beschikbare methode (gewone Cassandra, BATCH, LWT per rij, conditionele BATCH met LWT en Accord). We partitioneren onze accounts tabel op customer ID en sorteren op account ID.

We werken met slechts twee rekeningen, met een beginbalans van elk 1.000. Twee schrijvers zullen gelijktijdig overboekingen tussen de twee rekeningen produceren. Naast de eerste as (bijv. LWT vs Accord) hebben we een tweede as waarbij één variant een read-modify-write doet om de overboekingen te produceren en één variant van de workload blind writes doet (geen reads betrokken).

Terwijl de twee schrijvers gelijktijdig schrijven, hebben we een derde thread die gelijktijdig leest en controleert of de som van de saldi tussen beide rekeningen 2.000 is.

Wanneer de gelijktijdige writes voltooid zijn en de workload eindigt, controleren we of de som van de saldi nog steeds 2.000 is. Voor de read-modify-write varianten controleren we ook of het eindsaldo van beide rekeningen een bekend getal is (omdat de intentie van de workload deterministisch is).

En we hebben een derde en laatste as. Eén set workloads overschrijdt partitiegrenzen door over te boeken tussen rekeningen die toebehoren aan verschillende klanten. De andere set workloads overschrijdt geen partitiegrenzen door alleen over te boeken tussen rekeningen van dezelfde klant.

De tabellen hebben ook twee op kolommen voor operaties die in staat zijn tot conditionele writes (LWT en Accord) om te gebruiken als idempotency keys. Het is geen valsspelen dat gewone updates en non-LWT BATCH updates de idempotency-kolommen niet gebruiken, omdat ze dat simpelweg niet kunnen.

Gewone updates

Monastery stelt ons in staat om gelijktijdige operaties op een database door een vast aantal clients te scripten. Monastery voert het script voor ons uit tegen de database.

We beginnen met het definiëren van een setup-sectie van het script.

CREATE KEYSPACE IF NOT EXISTS lab WITH replication = {'class':'NetworkTopologyStrategy','datacenter1':3};
DROP TABLE IF EXISTS lab.accounts;
CREATE TABLE lab.accounts (customer int, account_id int, balance int, PRIMARY KEY (customer, account_id));
INSERT INTO lab.accounts (customer, account_id, balance) VALUES (1, 1, 1000);
INSERT INTO lab.accounts (customer, account_id, balance) VALUES (1, 2, 1000);

(bron: blind-plain-same.cql)

Omdat de replicatiefactor 3 is en er slechts 3 nodes in het cluster zitten, zal sharding effectief niet plaatsvinden. Maar als we meer nodes aan het cluster zouden toevoegen en de replicatiefactor op 3 zouden houden, zou sharding wel betekenisvol worden.

Vervolgens specificeren we een concurrent-sectie voor onze twee schrijvers en één lezer. Elke client voert een actie herhaaldelijk uit. De schrijvers sturen herhaaldelijk blind updates om eenheden tussen rekeningen over te boeken. En de lezers proberen herhaaldelijk te bevestigen dat het saldo van de twee rekeningen constant is.

--- concurrent
w1: repeat 400 as x {
UPDATE lab.accounts SET balance = {x} WHERE customer = 1 AND account_id = 1;  -- assert ok
UPDATE lab.accounts SET balance = 2000 - {x} WHERE customer = 1 AND account_id = 2;  -- assert ok
}
w2: repeat 400 as x {
UPDATE lab.accounts SET balance = 2000 - {x} WHERE customer = 1 AND account_id = 1;  -- assert ok
UPDATE lab.accounts SET balance = {x} WHERE customer = 1 AND account_id = 2;  -- assert ok
}
r1: repeat 1500 {
SELECT balance FROM lab.accounts WHERE customer = 1;  -- assert sum(0) = 2000 or error
}

(bron: blind-plain-same.cql)

De laatste sectie van het script is een finale check-fase waarin we laatste queries en assertions kunnen maken.

---
check: SELECT balance FROM lab.accounts WHERE customer = 1;  -- assert sum(0) = 2000

(bron: blind-plain-same.cql)

Er is geen saldo voor rekening 1 en 2 waarvan we kunnen aannemen dat het hierop uitkomt, omdat ze volledig met elkaar concurreren. Echter, de algemene invariant blijft dat er geen geld gewonnen of verloren mag gaan.

Wanneer we dit script met Monastery uitvoeren, zien we vaak dat isolatie wordt geschonden (wat verwacht is) in de concurrent-sectie (d.w.z. de saldi tellen niet op tot 2.000). De finale assertion kan slagen of niet, maar als deze slaagt, is dat door geluk en niet door een garantie.

$ ./monastery cql '127.0.0.1?consistency=quorum' blind-plain-same.cql
COUNT  CLIENT  ASSERTION               GOT
939  r1      sum(0) = 2000 or error  ({1847}, {1850}) +776 more
f48ab59a-62cb-4061-bb0e-5883b369ef7d
939 assertion(s) failed

In deze run zag de concurrent lezer 939 van de 1500 keer niet-overeenstemmende saldi. Maar de eind-writes blijken uiteindelijk weer in balans. (Dit zijn blind writes, dus dit is waarschijnlijker dan read-modify-writes, die inconsistentie zouden versterken.)

Gewone Cassandra is dus geen goede keuze voor een bank, althans niet in dit specifieke datamodel. Maar we hebben andere opties! Laten we naar BATCH kijken.

BATCH-updates

Batches, in hun huidige vorm voltooid in Cassandra 1.2 (januari 2013), laten je een aantal statements combineren in één mutatie per partitie die geïsoleerd en atomair wordt toegepast. Als de batch over partities heen gaat, is er ook de garantie dat de statements uiteindelijk worden toegepast, zelfs bij node-storingen.

Alle statements in een batch delen dezelfde timestamp, waar normaal gesproken elk statement zijn eigen timestamp heeft. Conflicten worden beslist op basis van de timestamp per cel, niet per rij. Dus wanneer twee conflicterende batches verschillende timestamps hebben, wint de latere batch elke cel die beide hebben geschreven, en geen enkele kolom eindigt met een waarde uit een andere batch dan zijn buurman. Maar timestamps worden door de client gegenereerd, en twee clients kunnen gelijkstaan (tie). Cassandra lost een timestamp-tie per cel op door de hogere waarde te behouden, waardoor twee gelijke batches elk sommige kolommen kunnen winnen en andere kunnen verliezen. We zullen dit zo zien gebeuren.

Als we blind-plain-same.cql nemen en updates wrappen als een BATCH, komen we eigenlijk uit op iets consistenten.

CREATE KEYSPACE IF NOT EXISTS lab WITH replication = {'class':'NetworkTopologyStrategy','datacenter1':3};
DROP TABLE IF EXISTS lab.accounts;
CREATE TABLE lab.accounts (customer int, account_id int, balance int, PRIMARY KEY (customer, account_id));
INSERT INTO lab.accounts (customer, account_id, balance) VALUES (1, 1, 1000);
INSERT INTO lab.accounts (customer, account_id, balance) VALUES (1, 2, 1000);
--- concurrent
w1: repeat 400 as x {
BEGIN BATCH \
UPDATE lab.accounts SET balance = {x} WHERE customer = 1 AND account_id = 1; \
UPDATE lab.accounts SET balance = 2000 - {x} WHERE customer = 1 AND account_id = 2; \
APPLY BATCH;
}
w2: repeat 400 as x {
BEGIN BATCH \
UPDATE lab.accounts SET balance = 2000 - {x} WHERE customer = 1 AND account_id = 1; \
UPDATE lab.accounts SET balance = {x} WHERE customer = 1 AND account_id = 2; \
APPLY BATCH;
}
r1: repeat 1500 {
SELECT balance FROM lab.accounts WHERE customer = 1; -- assert sum(0) = 2000
}
---
check: SELECT balance FROM lab.accounts WHERE customer = 1; -- assert sum(0) = 2000

(bron: blind-batch-same.cql)

Voer het uit:

$ ./monastery cql '127.0.0.1?consistency=quorum' blind-batch-same.cql
no assertion failures
a98088c0-5aec-421a-8ef7-10e15642b243

Dat is geweldig! Althans voor een enkele partitie.

Grotendeels in ieder geval. De meeste runs komen zo schoon terug. Maar voer het een paar keer vaker uit en elke paar runs vang je een foute som.

$ ./monastery cql '127.0.0.1?consistency=quorum' blind-batch-same.cql
COUNT  CLIENT  ASSERTION      GOT
6  r1      sum(0) = 2000  ({1897}, {1893}) +3 more
252c3bcc-35b0-4586-9741-239abf1b577c
6 assertion(s) failed

Kijk naar de twee saldi. Beiden zijn groot. 1.897 en 1.893 samen zijn 3.790, ruim boven de 2.000. Het landen op twee grote getallen gebeurt wanneer de grote helft van de ene batch naast de grote helft van de andere komt te staan.

Dit is het eerder genoemde timestamp-voorbehoud en geen bug. Omdat beide schrijvers twee rijen zo frequent updaten, botsen hun door de client gegenereerde timestamps vrij vaak. Bij een tie vergelijkt Cassandra de waarden zelf en behoudt de grootste, cel per cel. Rekening 1 lost op naar de grotere van x en 2000 - x, en dat geldt ook voor rekening 2. Beide cellen behouden het grote getal.

We kunnen dit zelfs handmatig zien. Stel expliciet dezelfde timestamp in op twee batches en laat ze op beide rijen verschillen.

DROP TABLE IF EXISTS lab.tie;
CREATE TABLE lab.tie (customer int, account_id int, balance int, PRIMARY KEY (customer, account_id));
INSERT INTO lab.tie (customer, account_id, balance) VALUES (1, 1, 1000) USING TIMESTAMP 1755300000000000;
INSERT INTO lab.tie (customer, account_id, balance) VALUES (1, 2, 1000) USING TIMESTAMP 1755300000000000;
BEGIN BATCH USING TIMESTAMP 1755400000000000
UPDATE lab.tie SET balance = 100 WHERE customer = 1 AND account_id = 1;
UPDATE lab.tie SET balance = 1900 WHERE customer = 1 AND account_id = 2;
APPLY BATCH;
BEGIN BATCH USING TIMESTAMP 1755400000000000
UPDATE lab.tie SET balance = 1800 WHERE customer = 1 AND account_id = 1;
UPDATE lab.tie SET balance = 200 WHERE customer = 1 AND account_id = 2;
APPLY BATCH;
SELECT customer, account_id, balance, WRITETIME(balance) FROM lab.tie WHERE customer = 1;

(bron: tie.cql)

Geen van beide batches schreef (1800, 1900), maar dat is wat we krijgen.

$ /root/cassandra/bin/cqlsh 127.0.0.1 -f tie.cql
customer | account_id | balance | writetime(balance)
----------+------------+---------+--------------------
1 |          1 |    1800 |   1755400000000000
1 |          2 |    1900 |   1755400000000000
(2 rows)

Maar opnieuw, dit is de gedocumenteerde last-write-wins conflictresolutie.

BATCH-updates, cross-partition

Laten we onze workload iets aanpassen om eenheden over partities heen over te boeken: tussen klanten.

CREATE KEYSPACE IF NOT EXISTS lab WITH replication = {'class':'NetworkTopologyStrategy','datacenter1':3};
DROP TABLE IF EXISTS lab.accounts;
CREATE TABLE lab.accounts (customer int, account_id int, balance int, PRIMARY KEY (customer, account_id));
INSERT INTO lab.accounts (customer, account_id, balance) VALUES (1, 1, 1000);
INSERT INTO lab.accounts (customer, account_id, balance) VALUES (2, 1, 1000);
--- concurrent
w1: repeat 400 as x {
BEGIN BATCH \
UPDATE lab.accounts SET balance = {x} WHERE customer = 1 AND account_id = 1; \
UPDATE lab.accounts SET balance = 2000 - {x} WHERE customer = 2 AND account_id = 1; \
APPLY BATCH;
}
w2: repeat 400 as x {
BEGIN BATCH \
UPDATE lab.accounts SET balance = 2000 - {x} WHERE customer = 1 AND account_id = 1; \
UPDATE lab.accounts SET balance = {x} WHERE customer = 2 AND account_id = 1; \
APPLY BATCH;
}
r1: repeat 1500 {
SELECT balance FROM lab.accounts WHERE customer IN (1, 2); -- assert sum(0) = 2000
}
---
check: SELECT balance FROM lab.accounts WHERE customer IN (1, 2); -- assert sum(0) = 2000

(bron: blind-batch-cross.cql)

Voer het uit:

$ ./monastery cql '127.0.0.1?consistency=quorum' blind-batch-cross.cql
COUNT  CLIENT  ASSERTION      GOT
231  r1      sum(0) = 2000  ({10}, {1989}) +230 more
b8bb3045-fd26-423b-a67e-44bbf1543bd5
231 assertion(s) failed

We zien inderdaad dat atomiciteit behouden blijft (de writes van elke batch werden uiteindelijk samen toegepast), maar niet de isolatie (gelijktijdige reads zagen niet-overeenstemmende saldi). De finale check slaagde ook, hoewel dat na wat we zagen met tied timestamps niet geheel gegarandeerd is: als de laatste twee batches een tie hebben, kan de duurzame eindstatus ook gemixt zijn. Ook dit is wat de documentatie ons vertelt.

Maar laten we teruggaan naar het werken met dezelfde partitie en kijken naar een andere beperking van BATCH-updates: read-modify-write workloads.

BATCH-updates, read-modify-write

Laten we onze workload iets veranderen, maar het schema hetzelfde houden. Deze keer hebben we twee schrijvers die beiden eenheden van de ene rekening aftrekken en aan de andere toevoegen. Omdat beide schrijvers in dezelfde richting toevoegen en aftrekken, is er een logisch eindsaldo voor elke rekening.

CREATE KEYSPACE IF EXISTS lab WITH replication = {'class':'NetworkTopologyStrategy','datacenter1':3};
DROP TABLE IF EXISTS lab.accounts;
CREATE TABLE lab.accounts (customer int, account_id int, balance int, op1 int, op2 int, PRIMARY KEY (customer, account_id));
INSERT INTO lab.accounts (customer, account_id, balance, op1, op2) VALUES (1, 1, 1000, 0, 0);
INSERT INTO lab.accounts (customer, account_id, balance, op1, op2) VALUES (1, 2, 1000, 0, 0);
--- concurrent
w1: repeat 200 {
p = SELECT balance - 1 FROM lab.accounts WHERE customer = 1 AND account_id = 1;
q = SELECT balance + 1 FROM lab.accounts WHERE customer = 1 AND account_id = 2;
BEGIN BATCH \
UPDATE lab.accounts SET balance = {p} WHERE customer = 1 AND account_id = 1; \
UPDATE lab.accounts SET balance = {q} WHERE customer = 1 AND account_id = 2; \
APPLY BATCH;
}
w2: repeat 200 {
p = SELECT balance - 1 FROM lab.accounts WHERE customer = 1 AND account_id = 1;
q = SELECT balance + 1 FROM lab.accounts WHERE customer = 1 AND account_id = 2;
BEGIN BATCH \
UPDATE lab.accounts SET balance = {p} WHERE customer = 1 AND account_id = 1; \
UPDATE lab.accounts SET balance = {q} WHERE customer = 1 AND account_id = 2; \
APPLY BATCH;
}
r1: repeat 1000 {
SELECT balance FROM lab.accounts WHERE customer = 1; -- assert sum(0) = 2000
}
---
check: SELECT balance FROM lab.accounts WHERE customer = 1; -- assert sum(0) = 2000
check: SELECT balance FROM lab.accounts WHERE customer = 1 AND account_id = 1; -- assert ({600})
check: SELECT balance FROM lab.accounts WHERE customer = 1 AND account_id = 2; -- assert ({1400})

(bron: rmw-batch-same.cql)

Volgens de grammatica kunnen we SELECTs niet eens binnen de BATCH plaatsen. Dus de read-fase maakt geen deel uit van de "transactie". Er is dus in feite geen consistentie die we kunnen bieden voor read-modify-write met alleen BATCH. Laten we het proberen.

$ ./monastery cql '127.0.0.1?consistency=quorum' rmw-batch-same.cql
COUNT  CLIENT  ASSERTION      GOT
831  r1      sum(0) = 2000  ({797}, {1210}) +166 more
1  check   ({1400})       ({1210})
1  check   ({600})        ({797})
1  check   sum(0) = 2000  ({797}, {1210})
fb0dcd27-a93c-4f6b-9db0-35a4944a58ef
834 assertion(s) failed

Niet geweldig! Maar opnieuw, dit is gedocumenteerd. En we hebben nog lightweight transactions!

Lightweight transactions (LWT)

Lightweight transactions (LWT) verschenen in Cassandra 2.0 (september 2013), wat ons een atomaire compare-and-swap gaf gebaseerd op Paxos. We kunnen niet atomair SELECTen en dan UPDATEn, maar we kunnen tenminste atomair conditioneel UPDATEn.

Daarnaast zijn LWT-timestamps afgeleid van Paxos en zijn ze uniek per partitie, waardoor timestamp-ties die we zagen in de BATCH-workloads simpelweg niet mogelijk zijn bij gebruik van LWT.

Een beperking van LWT is dat er wel een manier is om te weten dat een conditionele update definitief is mislukt, maar er is geen manier om te weten of het is geslaagd of niet als de LWT time-out. In de LWT-workload maken we daarom gebruik van de op velden om een idempotency token op te slaan. Elke schrijver krijgt zijn eigen op-veld. En elke schrijver loopst en probeert de LWT opnieuw uit te voeren die een unieke op-waarde invoegt, totdat hij de op-waarde terugkrijgt die hij had verzonden.

Daarnaast gaan reads standaard niet via Paxos, hoewel LWT dat wel doet. De client voert CONSISTENCY SERIAL uit om aan te geven dat SELECTs via Paxos moeten gaan. Deze reads kunnen ook falen bij een time-out, dus we controleren of de reads optellen tot 2.000 of dat de read een fout geeft.

Laten we rmw-batch-same.cql herschrijven in termen van LWT.

CREATE KEYSPACE IF NOT EXISTS lab WITH replication = {'class':'NetworkTopologyStrategy','datacenter1':3};
DROP TABLE IF EXISTS lab.accounts;
CREATE TABLE lab.accounts (customer int, account_id int, balance int, op1 int, op2 int, PRIMARY KEY (customer, account_id));
INSERT INTO lab.accounts (customer, account_id, balance, op1, op2) VALUES (1, 1, 1000, 0, 0);
INSERT INTO lab.accounts (customer, account_id, balance, op1, op2) VALUES (1, 2, 1000, 0, 0);
--- concurrent
w1: repeat 200 as op {
retry {
a, p = SELECT balance, balance - 1 FROM lab.accounts WHERE customer = 1 AND account_id = 1;
b, q = SELECT balance, balance + 1 FROM lab.accounts WHERE customer = 1 AND account_id = 2;
BEGIN BATCH \
UPDATE lab.accounts SET balance = {p}, op1 = {op} \
WHERE customer = 1 AND account_id = 1 IF balance = {a} AND op1 < {op}; \
UPDATE lab.accounts SET balance = {q} WHERE customer = 1 AND account_id = 2 IF balance = {b}; \
APPLY BATCH;  -- assert ok or error
SELECT op1 FROM lab.accounts WHERE customer = 1 AND account_id = 1;  -- assert ({{op}})
}
}
w2: repeat 200 as op {
retry {
a, p = SELECT balance, balance - 1 FROM lab.accounts WHERE customer = 1 AND account_id = 1;
b, q = SELECT balance, balance + 1 FROM lab.accounts WHERE customer = 1 AND account_id = 2;
BEGIN BATCH \
UPDATE lab.accounts SET balance = {p}, op2 = {op} \
WHERE customer = 1 AND account_id = 1 IF balance = {a} AND op2 < {op}; \
UPDATE lab.accounts SET balance = {q} WHERE customer = 1 AND account_id = 2 IF balance = {b}; \
APPLY BATCH;  -- assert ok or error
SELECT op2 FROM lab.accounts WHERE customer = 1 AND account_id = 1;  -- assert ({{op}})
}
}
r1: CONSISTENCY SERIAL;
r1: repeat 1000 {
SELECT balance FROM lab.accounts WHERE customer = 1;  -- assert sum(0) = 2000 or error
}
---
check: SELECT balance FROM lab.accounts WHERE customer = 1;  -- assert sum(0) = 2000
check: SELECT balance FROM lab.accounts WHERE customer = 1 AND account_id = 1;  -- assert ({600})
check: SELECT balance FROM lab.accounts WHERE customer = 1 AND account_id = 2;  -- assert ({1400})

(bron: rmw-lwt-same.cql)

Voer het uit:

$ ./monastery cql '127.0.0.1?consistency=quorum' rmw-lwt-same.cql
no assertion failures
0e1d7cf1-c67a-4f3a-ad0a-9ce4c29bc06c

Heel mooi. En LWT kan ook de oude blind-write workload prima uitvoeren.

CREATE KEYSPACE IF NOT EXISTS lab WITH replication = {'class':'NetworkTopologyStrategy','datacenter1':3};
DROP TABLE IF EXISTS lab.accounts;
CREATE TABLE lab.accounts (customer int, account_id int, balance int, PRIMARY KEY (customer, account_id));
INSERT INTO lab.accounts (customer, account_id, balance) VALUES (1, 1, 1000);
INSERT INTO lab.accounts (customer, account_id, balance) VALUES (1, 2, 1000);
--- concurrent
w1: repeat 400 as x {
BEGIN BATCH \
UPDATE lab.accounts SET balance = {x} WHERE customer = 1 AND account_id = 1 IF EXISTS; \
UPDATE lab.accounts SET balance = 2000 - {x} WHERE customer = 1 AND account_id = 2; \
APPLY BATCH;  -- assert ok or error
}
w2: repeat 400 as x {
BEGIN BATCH \
UPDATE lab.accounts SET balance = 2000 - {x} WHERE customer = 1 AND account_id = 1 IF EXISTS; \
UPDATE lab.accounts SET balance = {x} WHERE customer = 1 AND account_id = 2; \
APPLY BATCH;  -- assert ok or error
}
r1: CONSISTENCY SERIAL;
r1: repeat 1500 {
SELECT balance FROM lab.accounts WHERE customer = 1;  -- assert sum(0) = 2000 or error
}
---
check: SELECT balance FROM lab.accounts WHERE customer = 1;  -- assert sum(0) = 2000

(bron: blind-lwt-same.cql)

Voer het uit:

$ ./monastery cql '127.0.0.1?consistency=quorum' blind-lwt-same.cql
no assertion failures
54e91e0c-8d9b-4028-9601-f5300807e483

Fantastisch! Maar LWT werkt alleen op een enkele partitie. Laten we de blind-write workload met LWT schrijven, maar dit keer eenheden overboeken tussen klanten.

CREATE KEYSPACE IF NOT EXISTS lab WITH replication = {'class':'NetworkTopologyStrategy','datacenter1':3};
DROP TABLE IF EXISTS lab.accounts;
CREATE TABLE lab.accounts (customer int, account_id int, balance int, PRIMARY KEY (customer, account_id));
INSERT INTO lab.accounts (customer, account_id, balance) VALUES (1, 1, 1000);
INSERT INTO lab.accounts (customer, account_id, balance) VALUES (2, 1, 1000);
--- concurrent
w1: repeat 400 as x {
BEGIN BATCH \
UPDATE lab.accounts SET balance = {x} WHERE customer = 1 AND account_id = 1 IF EXISTS; \
UPDATE lab.accounts SET balance = 2000 - {x} WHERE customer = 2 AND account_id = 1; \
APPLY BATCH;  -- assert ok
}
w2: repeat 400 as x {
BEGIN BATCH \
UPDATE lab.accounts SET balance = 2000 - {x} WHERE customer = 1 AND account_id = 1 IF EXISTS; \
UPDATE lab.accounts SET balance = {x} WHERE customer = 2 AND account_id = 1; \
APPLY BATCH;  -- assert ok
}
r1: CONSISTENCY SERIAL;
r1: repeat 1500 {
SELECT balance FROM lab.accounts WHERE customer IN (1, 2);  -- assert sum(0) = 2000 or error
}
---
check: SELECT balance FROM lab.accounts WHERE customer IN (1, 2);  -- assert sum(0) = 2000

(bron: blind-lwt-cross.cql)

En voer het uit:

$ ./monastery cql '127.0.0.1?consistency=quorum' blind-lwt-cross.cql
COUNT  CLIENT  ASSERTION  ERROR
400  w1      ok         Batch with conditions cannot span multiple partitions
400  w2      ok         Batch with conditions cannot span multiple partitions
8842fdf5-dfc9-4415-9c78-9fb68aa20791
800 assertion(s) failed

We hebben dus LWT, waarmee we consistente read-modify-write binnen een enkele partitie kunnen realiseren, maar het werkt helemaal niet over partities heen. En we hebben batches, die niet geïsoleerd zijn over partities.

Dit is waarom de mensen bij Apple en de University of Michigan Accord hebben gemaakt.

Volledige transacties met Accord

Accord is het door EPaxos geïnspireerde leaderless consensus-protocol dat tabellen in Cassandra in staat stelt om gemarkeerd te worden als transactional_mode='full'. Alle lees- en schrijfoperaties op deze tabellen gaan via de Accord-consensus. En eindelijk krijgen we echte ACID-transacties, zij het non-interactieve.

Bij LWT was de waarde die we lazen om te gebruiken in de compare-and-swap vaak verouderd (stale). De LWT zou falen en we zouden het opnieuw moeten proberen. Maar een gefaalde LWT betekent niet altijd dat de write niet is gebeurd. Bijvoorbeeld, het kan erop wijzen dat de client een time-out had terwijl de eigenlijke write (uiteindelijk) is geslaagd. Het schrijven en bewaken tegen de op kolom stelde ons in staat om er zeker van te zijn dat we dezelfde write niet twee keer (of meer) toepasten.

In Accord wordt de conditie geëvalueerd op hetzelfde moment als de read, dus de read is niet verouderd en de belangrijkste reden waarom een client een fout zou zien, is een client time-out of een node-storing. Beiden zijn onwaarschijnlijk in onze ideale localhost-omgeving. De idempotency key zou nog steeds nuttig zijn in een echt systeem, maar we laten hem weg in onze lab-omgeving.

CREATE KEYSPACE IF NOT EXISTS lab WITH replication = {'class':'NetworkTopologyStrategy','datacenter1':3};
DROP TABLE IF EXISTS lab.accounts;
CREATE TABLE lab.accounts (customer int, account_id int, balance int, op1 int, op2 int, PRIMARY KEY (customer, account_id)) WITH transactional_mode = 'full';
INSERT INTO lab.accounts (customer, account_id, balance, op1, op2) VALUES (1, 1, 1000, 0, 0);
INSERT INTO lab.accounts (customer, account_id, balance, op1, op2) VALUES (1, 2, 1000, 0, 0);
--- concurrent
w1: repeat 200 {
BEGIN TRANSACTION \
LET x = (SELECT balance FROM lab.accounts WHERE customer = 1 AND account_id = 1); \
IF x.balance >= 1 THEN \
UPDATE lab.accounts SET balance -= 1 WHERE customer = 1 AND account_id = 1; \
UPDATE lab.accounts SET balance += 1 WHERE customer = 1 AND account_id = 2; \
END IF \
COMMIT TRANSACTION;  -- assert ok
}
w2: repeat 200 {
BEGIN TRANSACTION \
LET x = (SELECT balance FROM lab.accounts WHERE customer = 1 AND account_id = 1); \
IF x.balance >= 1 THEN \
UPDATE lab.accounts SET balance -= 1 WHERE customer = 1 AND account_id = 1; \
UPDATE lab.accounts SET balance += 1 WHERE customer = 1 AND account_id = 2; \
END IF \
COMMIT TRANSACTION;  -- assert ok
}
r1: repeat 1000 {
BEGIN TRANSACTION \
LET x = (SELECT balance FROM lab.accounts WHERE customer = 1 AND account_id = 1); \
LET y = (SELECT balance FROM lab.accounts WHERE customer = 1 AND account_id = 2); \
SELECT x.balance, y.balance; \
COMMIT TRANSACTION;  -- assert sum(0, 1) = 2000 or error
}
---
check: SELECT balance FROM lab.accounts WHERE customer = 1;  -- assert sum(0) = 2000
check: SELECT balance FROM lab.accounts WHERE customer = 1 AND account_id = 1;  -- assert ({600})
check: SELECT balance FROM lab.accounts WHERE customer = 1 AND account_id = 2;  -- assert ({1400})

(bron: rmw-accord-same.cql)

Voer het uit:

$ ./monastery cql '127.0.0.1?consistency=quorum' rmw-accord-same.cql
no assertion failures
471d1ba8-ae5b-4781-b881-d00a2cd9adcc

Dat is cool, maar binnen dezelfde partitie was dit ook al mogelijk in de LWT-versie. Laten we de cross-partition workload proberen.

CREATE KEYSPACE IF NOT EXISTS lab WITH replication = {'class':'NetworkTopologyStrategy','datacenter1':3};
DROP TABLE IF EXISTS lab.accounts;
CREATE TABLE lab.accounts (customer int, account_id int, balance int, op1 int, op2 int, PRIMARY KEY (customer, account_id)) WITH transactional_mode = 'full';
INSERT INTO lab.accounts (customer, account_id, balance, op1, op2) VALUES (1, 1, 1000, 0, 0);
INSERT INTO lab.accounts (customer, account_id, balance, op1, op2) VALUES (2, 1, 1000, 0, 0);
--- concurrent
w1: repeat 200 {
BEGIN TRANSACTION \
LET x = (SELECT balance FROM lab.accounts WHERE customer = 1 AND account_id = 1); \
IF x.balance >= 1 THEN \
UPDATE lab.accounts SET balance -= 1 WHERE customer = 1 AND account_id = 1; \
UPDATE lab.accounts SET balance += 1 WHERE customer = 2 AND account_id = 1; \
END IF \
COMMIT TRANSACTION;  -- assert ok
}
w2: repeat 200 {
BEGIN TRANSACTION \
LET x = (SELECT balance FROM lab.accounts WHERE customer = 1 AND account_id = 1); \
IF x.balance >= 1 THEN \
UPDATE lab.accounts SET balance -= 1 WHERE customer = 1 AND account_id = 1; \
UPDATE lab.accounts SET balance += 1 WHERE customer = 2 AND account_id = 1; \
END IF \
COMMIT TRANSACTION;  -- assert ok
}
r1: repeat 1000 {
BEGIN TRANSACTION \
LET x = (SELECT balance FROM lab.accounts WHERE customer = 1 AND account_id = 1); \
LET y = (SELECT balance FROM lab.accounts WHERE customer = 2 AND account_id = 1); \
SELECT x.balance, y.balance; \
COMMIT TRANSACTION;  -- assert sum(0, 1) = 2000 or error
}
---
check: SELECT balance FROM lab.accounts WHERE customer IN (1, 2);  -- assert sum(0) = 2000
check: SELECT balance FROM lab.accounts WHERE customer = 1 AND account_id = 1;  -- assert ({600})
check: SELECT balance FROM lab.accounts WHERE customer = 2 AND account_id = 1;  -- assert ({1400})

(bron: rmw-accord-cross.cql)

Voer het uit:

$ ./monastery cql '127.0.0.1?consistency=quorum' rmw-accord-cross.cql
no assertion failures
f5c6ce4a-dbfa-48dd-b6c1-3367ef563df4

Dit is volledig nieuw in Cassandra 6 (of zal dat zijn, wanneer het wordt uitgebracht). Zeer cool.

Mogelijke bug?

Tijdens het gebruik van Accord zag ik af en toe dat de concurrent lezer saldi rapporteerde die niet optelden tot 2.000. Dit gebeurde alleen in de workloads binnen dezelfde partitie. Hoewel het in de RMW-workload waarschijnlijk een probleem in de workload zelf was, gebeurden de ongeldige sommen ook in de eenvoudigere blind-write workload.

Hier is de blind-write workload:

CREATE KEYSPACE IF NOT EXISTS lab WITH replication = {'class':'NetworkTopologyStrategy','datacenter1':3};
DROP TABLE IF EXISTS lab.accounts;
CREATE TABLE lab.accounts (customer int, account_id int, balance int, PRIMARY KEY (customer, account_id)) WITH transactional_mode = 'full';
INSERT INTO lab.accounts (customer, account_id, balance) VALUES (1, 1, 1000);
INSERT INTO lab.accounts (customer, account_id, balance) VALUES (1, 2, 1000);
--- concurrent
w1: repeat 400 as x {
BEGIN TRANSACTION \
UPDATE lab.accounts SET balance = {x} WHERE customer = 1 AND account_id = 1; \
UPDATE lab.accounts SET balance = 2000 - {x} WHERE customer = 1 AND account_id = 2; \
COMMIT TRANSACTION;  -- assert ok
}
w2: repeat 400 as x {
BEGIN TRANSACTION \
UPDATE lab.accounts SET balance = 2000 - {x} WHERE customer = 1 AND account_id = 1; \
UPDATE lab.accounts SET balance = {x} WHERE customer = 1 AND account_id = 2; \
COMMIT TRANSACTION;  -- assert ok
}
r1: repeat 1500 {
BEGIN TRANSACTION \
LET x = (SELECT balance FROM lab.accounts WHERE customer = 1 AND account_id = 1); \
LET y = (SELECT balance FROM lab.accounts WHERE customer = 1 AND account_id = 2); \
SELECT x.balance, y.balance; \
COMMIT TRANSACTION;  -- assert sum(0, 1) = 2000 or error
}
---
check: SELECT balance FROM lab.accounts WHERE customer = 1;  -- assert sum(0) = 2000

(bron: blind-accord-same.cql)

En als we dit een paar keer uitvoeren, zien we vrij consistent fouten in r1.

$ ./monastery cql '127.0.0.1?consistency=quorum' blind-accord-same.cql
COUNT  CLIENT  ASSERTION                  GOT
1  r1      sum(0, 1) = 2000 or error  ({1851, 1853})
70352e3f-f960-4036-8475-140a9314ce81
1 assertion(s) failed

Ik heb echter nooit een fout gezien in het eindresultaat. Er kan een isolatie-bug zijn in gelijktijdige transacties, zelfs terwijl het duurzame resultaat niet fout is.

Zelfs als dit een bug is, is het niet bijzonder ernstig. Gedistribueerde systemen hebben bugs. En Cassandra 6 is nog niet eens uitgebracht.

Slotgedachten

Dit was mijn eerste kennismaking met Cassandra. Ik vind het erg leuk. Ik hou van de ingebouwde replicatie en sharding. Ik hou van het vernieuwende consensus-protocol en de strikte serialiseerbaarheid. Het is interessant om te zien hoe het over de jaren is geëvolueerd, en het zal interessant zijn om te zien hoe ze blijven streven naar een meer general-purpose databasesysteem. Interactieve transacties zouden cool zijn.

En tot slot kijk ik ernaar uit om via de ASF JIRA hulp te krijgen bij de vraag of dit daadwerkelijke bugs zijn of gewoon fouten in mijn eigen code.

Update (17 augustus 2026): C. Scott Andreas van Apple heeft bevestigd dat we een werkelijke bug in Cassandra hebben gevonden.