Cassandra & ScyllaDB¶
03:14 AM. PagerDuty: p99 write latency on the spans cluster jumped from 4 ms to 400 ms. One node is pegged at 100% CPU; the other five in the ring are idle. Writes are still acking, just slow, and only for traffic tagged service=checkout-api.
What's paging you?
A. Compaction fell behind and SSTables are piling up. B. A hot partition — one partition key is absorbing far more traffic than the others. C. Tombstone accumulation from a TTL or delete-heavy workload on that key. D. A network partition or GC pause on that one node.
Pick one before reading on. This cluster exists to append ~400,000 spans per second, keep the last 24 hours queryable as "this service, this time range," survive a zone loss, and ack writes in a few milliseconds — no join, no ad-hoc WHERE payload LIKE, no single primary to fail over — and Cassandra (with Scylla as a shard-per-core runtime for the same model) is the write-optimised, query-bound store built for exactly that shape, provided you model it as partition key + clustering key and nothing else.
Start with the situation¶
Observability writes in this academy: every span is (service, ts, trace_id, payload). The ops UI reads one service, last 15 minutes. Multi-region: a local read should not wait for another continent.
Secondary academy uses: IoT per-device time series (not the device registry identity document — that is KV), and e-commerce inbox / event log per user_id when the query is always “latest N events for this user.”
Why the obvious approach breaks¶
A relational primary serialises writes through WAL and B-trees. At hundreds of thousands of writes per second you fight:
- One leader’s disk and CPU
- Index maintenance on every insert
- Failover measured in seconds to minutes
- Range queries that want a different physical order than the primary key
Cassandra’s answer — log-structured merge, no leader for data, hash partitions — creates new hard problems: hot partitions, tombstones, unbounded wide rows, repair, and quorum math. The CQL looks like SQL. The execution is not SQL.
Build the mental picture¶
flowchart LR
W["Write"]
C["Any coordinator node"]
R["Replicas for this token"]
CL["CommitLog + memtable"]
SS["Flush SSTables"]
M["Compaction"]
W --> C --> R --> CL --> SS --> M - Hash(
partition key) → token → replica set (RF copies). - Inside a partition, rows are sorted by clustering key — a wide row.
- Writes append (commitlog + memtable). Reads merge memtable + SSTables + cache.
- You only get fast reads if you already know the partition key.
Think: a huge distributed sorted map. Key = partition. Value = sorted list of clustering cells. There is no mystery secondary index that makes WHERE event_type = 'purchase' cheap.
Under the hood¶
Tokens, replicas, coordinators¶
Every node owns token ranges (vnodes). PRIMARY KEY ((service, bucket), ts) hashes both service and bucket as the partition key. The coordinator is any node you contacted; it forwards to the replica set.
Replication factor 3, simple strategy vs network topology: in production you use NetworkTopologyStrategy and LOCAL_QUORUM so a write does not wait for the far DC.
Partition key vs clustering key¶
PRIMARY KEY ((pk1, pk2), ck1, ck2)
-- ^^^^^^^^^^^ ^^^^^^^^
-- partition clustering (order inside the partition)
| Partition key | Clustering key | |
|---|---|---|
| Purpose | Which nodes store the data | Order and range scans inside one partition |
| Uniqueness | Together with CK, identifies a row | |
| Anti-pattern | Low cardinality (country, service alone) | Unbounded growth (ts forever on one PK) |
Wide row: many clustering cells under one partition. Useful: last 1 hour of spans for api-gateway in bucket 2024011513. Lethal: all spans for api-gateway since 2019 in one partition (compaction, repair, and reads fall over).
Write path¶
- Coordinator sends to replicas.
- Each replica appends commitlog, puts in memtable.
- When memtable fills, flush immutable SSTable.
- Compaction merges SSTables, drops garbage (eventually).
No B-tree leaf split on the write path. That is why writes are fast — and why deletes are tombstones, not holes.
Read path¶
Coordinator reads from enough replicas to satisfy consistency. Each replica checks memtable, row cache (if any), Bloom filters, partition index, then SSTables. Results merge. More SSTables → slower reads until compaction catches up.
Consistency: QUORUM¶
QUORUM = floor(RF/2)+1. With RF=3, QUORUM=2.
| If you write with | And read with | You get |
|---|---|---|
QUORUM | QUORUM | Strong for that row (same DC story aside) |
LOCAL_QUORUM | LOCAL_QUORUM | Strong in the local DC — what you want multi-region |
ONE | ONE | Fast, can be stale, can miss a write |
ALL | anything | Fragile; one dead replica blocks |
R + W > RF is the textbook overlap rule. LOCAL_QUORUM is the production default for multi-DC, not ALL.
Tombstones¶
A delete writes a tombstone. TTL expiry writes a tombstone. Inserting null can write a tombstone. Collections (list append/remove) generate many.
Reads must see tombstones until gc_grace_seconds has passed and compaction has dropped them — otherwise a replica that missed the delete can resurrect data during repair.
Tombstone storms
A partition that deleted 10 million cells will time out on read (TombstoneOverwhelmingException) long after the product thought data was gone. TTLing a high-churn table without bounding partition size is a standard outage.
Compaction¶
| Strategy | Fit |
|---|---|
| Size-tiered (STCS) | Write-heavy, default-ish; can leave large SSTables |
| Leveled (LCS) | Read-heavy, more write amp |
| Time-window (TWCS) | Time-series with TTL — observability spans. One window per interval; drop whole SSTables |
For the observability use case, TWCS + TTL + time-bucketed partition keys is the design. STCS on an infinite service partition is how disks fill with overlapping SSTables.
LWT (lightweight transactions)¶
UPDATE … IF / INSERT IF NOT EXISTS runs Paxos for that partition: multiple round trips, serial consistency, throughput far below a normal write.
Use for: “allocate this device_id once,” “compare-and-set firmware.” Do not use for: every span insert, every session touch. LWT is not a substitute for Postgres transactions across keys.
ScyllaDB — shard-per-core, not a new model¶
Scylla speaks CQL and Cassandra drivers. Internals: one shard per CPU core, shared-nothing, no JVM GC pauses, explicit CPU pinning. You still design partition keys. You still get tombstones and hot partitions. You get more throughput per box and tail latency that does not sawtooth with GC.
Treat Scylla as a drop-in runtime for a Cassandra data model, not as a reason to skip modelling. Compaction, TWCS, and QUORUM remain your problem. Repair/streaming details differ; operations are not copy-paste of 2015 Cassandra runbooks — but this academy will not re-document Scylla’s admin surface.
Put it to work¶
Observability table (do this)¶
CREATE KEYSPACE obs WITH replication = {
'class': 'NetworkTopologyStrategy',
'dc1': 3
};
CREATE TABLE obs.spans_by_service (
service text,
bucket int, -- unix hour
ts timeuuid,
host text,
trace_id text,
duration int,
payload blob,
PRIMARY KEY ((service, bucket), ts)
) WITH CLUSTERING ORDER BY (ts DESC)
AND compaction = {
'class': 'TimeWindowCompactionStrategy',
'compaction_window_unit': 'HOURS',
'compaction_window_size': 1
}
AND default_time_to_live = 86400
AND gc_grace_seconds = 86400;
-- write
INSERT INTO obs.spans_by_service (service, bucket, ts, host, trace_id, duration, payload)
VALUES ('api-gateway', 1705312800, now(), 'gwy-12', 'abc', 23, 0x...);
-- read last 15 minutes for one service: at most 2 buckets
SELECT ts, trace_id, duration
FROM obs.spans_by_service
WHERE service = 'api-gateway'
AND bucket IN (1705312800, 1705309200)
AND ts >= minTimeuuid('2024-01-15 10:45:00')
AND ts <= maxTimeuuid('2024-01-15 11:00:00');
IoT telemetry (per device, bucketed)¶
CREATE TABLE iot.readings (
device_id text,
day date,
ts timestamp,
metric text,
value double,
PRIMARY KEY ((device_id, day), metric, ts)
) WITH CLUSTERING ORDER BY (metric ASC, ts DESC)
AND default_time_to_live = 604800
AND compaction = {'class': 'TimeWindowCompactionStrategy',
'compaction_window_unit': 'DAYS',
'compaction_window_size': 1};
The registry (device_id → firmware, owner) is a separate one-row-per-device table, not this wide time series.
E-commerce event log per user¶
CREATE TABLE shop.user_events (
user_id text,
ts timeuuid,
kind text,
payload text,
PRIMARY KEY (user_id, ts)
) WITH CLUSTERING ORDER BY (ts DESC);
Add a bucket (user_id, week) the moment a power user can write unbounded events. Celebrity users are hot partitions — shard: PRIMARY KEY ((user_id, shard), ts) with shard = hash(event_id) % N on write, fan-out N reads.
LWT example (device claim)¶
Measure latency. If this is on the 50k QPS path, you designed wrong.
Where teams get caught¶
CQL is not SQL
No joins, no OR across partitions, no unconstrained ALLOW FILTERING in production, no COUNT(*) of the table.
ALLOW FILTERING
Means “scatter-gather and hope.” Forbidden on the hot path.
Secondary indexes
Cassandra 2i are per-node, QPS-sensitive, and a hotspot magnet. Materialise a second table instead.
Batches
BATCH is for the same partition (atomicity of a few rows). Multi-partition batches are a coordinator tax, not a transaction.
Unbounded partitions
Aim for tens of MB, not tens of GB. Bucket time. Cap list/collection sizes.
Repair neglected
Without repair, deleted data resurrects after gc_grace_seconds. TWCS tables with TTL still need an operational story.
How it fails¶
| Failure | What you see | Cause |
|---|---|---|
| Hot partition | One node CPU/disk 100%, others bored | PK cardinality too low (service only, country) |
| Tombstone timeout | Read errors, huge scan histograms | Deletes/TTL on a fat partition |
| Read latency after write burst | SSTable count high | Compaction lag; wrong strategy |
UnavailableException | Writes fail | Not enough replicas up for CL |
WriteTimeout with LWT | Paxos unfinished | Contention on one PK |
| Disk full | Flush fails, node down | Compaction leftover + snapshots + commitlog |
| Resurrection | Deleted row returns | Missed repair, short gc_grace |
Multi-DC: using QUORUM instead of LOCAL_QUORUM makes a remote DC outage stall local ingest.
How to investigate¶
- Nodestats / Scylla manager: latency, pending compactions, SSTable count per table.
nodetool tablehistograms/ tracing:TRACING ONin cqlsh for a slowSELECT— count of tombstones and SSTables touched.- Per-partition size:
nodetool tablehistogramsand estimated partition size. Find the celebrity key. - Coordinator vs replica: if coordinator CPU is high, clients are not token-aware (driver should send to a replica).
- Query in logs: any
ALLOW FILTERINGor unboundINlists.
For Scylla, shard-aware drivers matter: a poorly pinned client turns shard-per-core into cross-shard chatter.
Scale: 10× / 100× / 1000×¶
10× (4M spans/s peak). Add nodes; linear only if new tokens take load. If api-gateway is 60% of traffic and PK is (service, hour), that hour’s partition still lives on RF nodes — bucket smaller (minute) or shard service.
100×. TWCS windows, TTL, and bucket size are mandatory. Streaming/rebuild time dominates ops; prefer Scylla or carefully sized Cassandra with automation. Never let a partition exceed a fraction of one shard’s disk. Analytics has already moved to ClickHouse; if not, this is the incident.
1000×. You are sharding the key ((service, minute, shard)), isolating tenants, and treating Cassandra as a durability buffer, not a query engine. Compaction throughput and repair become a dedicated practice. LWT is banned except on tiny control tables. Consider whether a log (Kafka) + OLAP (ClickHouse) removed the need for a 24 h CQL read path at all.
Cluster size does not fix a hot partition. That ceiling is one node’s disk and CPU.
Trade-offs¶
You gain: write throughput, multi-DC availability, predictable single-partition reads, operational independence from a primary.
You give up: ad-hoc query, cheap indexes, multi-row ACID, easy deletes, “small cluster” friendliness (repair, compaction, JVM or shard tuning).
Scylla trades JVM pauses for core-count and shard-aware ops. It does not trade away modelling.
Alternatives¶
| Workload | Often better |
|---|---|
| Session cache, TTL, tiny values | Redis |
| AWS-only KV + sort key, no nodes | DynamoDB |
| Dashboards, aggregates | ClickHouse |
| Device registry, low QPS | Postgres |
| Metrics with rollups | TSDB |
| Unknown query shapes | Postgres + warehouse |
Apply¶
You will see this when someone “puts events in Cassandra so we can query them later,” when service is the only PK, when TTL’d tables time out on read, or when LWT is proposed to “make it consistent like Postgres.”
List queries. Bucket time. Measure partition size. Use LOCAL_QUORUM. Put analytics elsewhere.
Practice the idea¶
Use the consistent-hashing visualiser to see how keys move when a node is added. Then run the Cassandra lab to compare that even ring distribution with an application-level hot partition. They are different problems and require different fixes.
Check your understanding¶
Design spans_by_service for 400k writes/s
api-gateway is 40% of traffic. RF=3, two DCs. Reads: last 15 minutes per service, p99 50 ms. Retention 24 h.
- Write the PRIMARY KEY. Why is
PRIMARY KEY (service, ts)wrong? - Compaction strategy and TTL?
gc_grace_seconds? - Consistency level for write and read?
- What breaks at 10× if gateway traffic stays 40%?
- Product wants
WHERE trace_id = ?. What do you do instead of a secondary index?
Answer
-
PRIMARY KEY ((service, bucket), ts)withbucket= hour or minute.(service, ts)as PK (onlyservicepartitioned) puts all gateway spans for all time on one partition — unbounded wide row and a permanent hotspot. -
TWCS with window ≈ bucket (1 hour). TTL 86400.
gc_grace_secondson the order of a day (or less if you understand repair/TWCS drop behaviour) — do not leave 10 days of tombstones on a high-churn table. -
LOCAL_QUORUM/LOCAL_QUORUM. NotALL. NotONEif operators will believe the UI. -
The hot
(service, bucket)still maps to RF nodes. 10× load on that partition is 10× on those disks. Split bucket to minutes and/or add a shard:(service, bucket, shard). -
Materialise
spans_by_trace (trace_id, ts)as a second table written by the pipeline (or a CDC/consumer). Do not add 2i ontrace_idof a 400k/s table.