Skip to content

AxonOps — AI-Native Control Plane for Open Source Data Platforms

Cassandra Data Read Path

In an LSM-tree storage engine, data for a single partition may exist in multiple locations: the active memtable, any flushing memtables, and numerous SSTables on disk. Unlike B-tree databases where a row exists in exactly one location, Cassandra must check all potential sources and merge results to reconstruct the current state of the data.

This design creates a fundamental trade-off. Writes are fast because they simply append to the memtable, but reads must potentially examine many files. The read path employs several optimization techniques—bloom filters, partition indexes, and row caches—to minimize disk I/O and maintain acceptable read latency despite this multi-source architecture. The BTI (Big Trie Index) format introduced in Cassandra 5.0 uses memory-mapped off-heap indexes, eliminating the need for the key cache used by the legacy index format.


When a read request arrives, Cassandra locates and assembles data from multiple storage locations using a tiered approach. Each stage is optimized to either satisfy the query immediately or efficiently narrow down where to search next.

How the Read Path Achieves High Performance

Section titled “How the Read Path Achieves High Performance”

The LSM-tree architecture enables Cassandra's exceptional write throughput by appending data sequentially rather than updating in place. The read path complements this design with a series of optimizations that efficiently locate and assemble data from multiple sources.

LocationContentsWhy Data Exists Here
Active memtableMost recent writesWrites go to memtable first; not yet flushed
Flushing memtablesRecent writesMemtable being written to disk; still in memory
SSTable 1Older writesFlushed yesterday
SSTable 2Even older writesFlushed last week
SSTable NHistorical writesFlushed months ago, not yet compacted away

The read path intelligently searches these locations using a layered optimization strategy—bloom filters eliminate unnecessary disk access, indexes enable direct seeks, and caches accelerate repeated access patterns. The result is predictable, low-latency reads even with data distributed across many SSTables.

This describes Cassandra 5.0+ with BTI (Big Trie Index)

The legacy index format (pre-5.0) includes an additional key cache lookup between the bloom filter and partition index steps. See the Partition Index section for details on both formats.

Stage 1: Row Cache (optional fast path)

If row caching is enabled for the table and the entire partition is cached in memory, return it immediately. This is the fastest possible path—no memtable or SSTable access required. However, row cache is rarely beneficial (see Row Cache section) and is disabled by default.

Stage 2: Memtables

Check the active memtable and any memtables currently being flushed to disk. Memtables are in-memory sorted structures, so lookups are fast (O(log n)). Data found here represents the most recent writes that have not yet been persisted to SSTables.

Stage 3: SSTables

For each SSTable that might contain the partition:

  1. Bloom filter check: A probabilistic filter answers "Is this partition possibly in this SSTable?" If the answer is "definitely no," skip this SSTable entirely—no disk I/O required. If "maybe yes," proceed to the next step.

  2. Partition index lookup: Find the uncompressed byte offset where this partition's data begins in the data file. This is an O(log n) or O(key_length) operation depending on the index format.

  3. Compression metadata lookup: If the SSTable is compressed (default), translate the uncompressed offset to the actual compressed chunk position on disk. The compression metadata maps uncompressed offsets to compressed chunk boundaries.

  4. Data file read: Seek to the compressed chunk, decompress it, and read the partition's data.

This four-step process is repeated for each SSTable. Bloom filters make this scalable—they eliminate the vast majority of SSTables from consideration with a simple memory lookup, keeping read latency consistent even as SSTable count grows.

Stage 4: Merge

Data fragments from memtables and SSTables are merged into a single result:

  • Timestamp comparison: For each cell (column value), the version with the highest timestamp wins
  • Tombstone application: Deleted data (marked with tombstones) is filtered out
  • TTL expiration: Expired data is excluded from the result

The merge process reconstructs the current state of the partition from its distributed fragments.

The stages are ordered by cost and likelihood of success:

StageCostRationale
Row cacheLowest (memory lookup)If hit, avoids all other work; checked first
MemtablesLow (memory, sorted structure)Contains newest data; must be checked before SSTables
SSTablesHigh (disk I/O)Bloom filters minimize unnecessary disk access
MergeCPU-boundOnly performed after all data is collected

Memtables must be checked before SSTables because they contain more recent writes. If an SSTable contains column=A, value=1, timestamp=100 and the memtable contains column=A, value=2, timestamp=200, the memtable's value must win—but both must be read to make this determination.

1. ROW CACHE (if enabled)2. MEMTABLES3. SSTABLES (for each SSTable)4. MERGEEntire row cached?Return immediatelyCheck active memtableCheck any flushing memtablesa. Bloom FilterPartition possibly present?b. Partition IndexFind uncompressed offsetc. Compression MetadataMap to compressed chunkd. Data FileDecompress & readSkip SSTableCombine results from all sourcesMost recent timestamp wins per cellApply tombstonesSELECT * FROM usersWHERE user_id = XRESULTMaybeNoHitMissmemtable dataSSTable data

Bloom filters are probabilistic data structures that quickly determine if a partition key is possibly present in an SSTable. They were introduced by Burton Howard Bloom in 1970 (Bloom, B.H., 1970, "Space/Time Trade-offs in Hash Coding with Allowable Errors").

Why Bloom Filters Are Essential in Cassandra

Section titled “Why Bloom Filters Are Essential in Cassandra”

The LSM-tree architecture creates a fundamental read challenge: data for a single partition may be spread across dozens or hundreds of SSTables. Without optimization, reading a single row could require checking every SSTable on disk.

ScenarioSSTables to CheckDisk I/O Without Bloom Filters
New table5-105-10 disk seeks per read
Mature table50-10050-100 disk seeks per read
Write-heavy workload200+200+ disk seeks per read

Bloom filters solve this by providing a space-efficient probabilistic test for set membership. For each SSTable, Cassandra maintains a bloom filter in memory that can answer: "Is partition key X possibly in this SSTable?"

  • "Definitely NO" → Skip this SSTable entirely (no disk I/O)
  • "Maybe YES" → Check the index and data files (may be a false positive)

The critical property: false negatives are impossible. If the bloom filter says "no," the partition is guaranteed not to be in that SSTable. This allows Cassandra to eliminate most SSTables from consideration without any disk access.

Bloom Filter SSTable EliminationBloom Filter SSTable EliminationSSTables on DiskSSTable 1SSTable 2SSTable 3SSTable 4SSTable 5Bloom FilterBloom FilterBloom FilterBloom FilterBloom FilterQuery: partition_key = 'user:42'Skip(no disk I/O)Check Index& Read Data3 SSTables skipped(guaranteed not present)Only 2 SSTables needdisk I/O (may be false positive)NOMAYBENONOMAYBE

A bloom filter consists of a bit array of m bits and k independent hash functions. Both insertion and lookup are O(k) operations—constant time regardless of how many elements are stored.

Insertion:

When a partition key is added to the SSTable, it is hashed with k different hash functions. Each hash function produces a position in the bit array, and those k bits are set to 1.

Lookup:

To check if a key exists, hash it with the same k functions and check the corresponding bits:

  • If all k bits are 1 → the key is possibly present (could be a false positive)
  • If any bit is 0 → the key is definitely not present (guaranteed)
BloomFilter cluster_bits Bit Array (m bits) bits 0 1 0 1 1 0 1 0 0 1 1 0 1 0 0 1 result All bits = 1? Maybe present bits->result key Partition Key "user:123" h1 hash₁ key->h1 h2 hash₂ key->h2 h3 hash₃ key->h3 h1->bits pos 3 h2->bits pos 6 h3->bits pos 10

False positives occur when different keys happen to set the same bit positions. The probability of a false positive depends on:

  • m = number of bits in the array
  • n = number of elements inserted
  • k = number of hash functions

The optimal number of hash functions and the false positive probability are:

koptimal=mnln2k_{\text{optimal}} = \frac{m}{n} \ln 2 Pfalse positive(1ekn/m)kP_{\text{false positive}} \approx \left(1 - e^{-kn/m}\right)^k

In practice, Cassandra uses the bloom_filter_fp_chance setting to configure the desired false positive rate, and calculates the required bit array size accordingly.

Consider a read that must check 100 SSTables:

Bloom Filter FP RateExpected False PositivesDisk Reads Avoided
1% (default)199
0.1%0.199.9
10%1090

With the default 1% false positive rate, bloom filters eliminate approximately 99% of unnecessary SSTable reads. The trade-off is memory: lower false positive rates require larger bit arrays.

-- Default false positive rate: 1%
-- Lower = more accurate, more memory
-- Higher = less accurate, less memory
-- Read-heavy table with many SSTables
ALTER TABLE hot_reads WITH bloom_filter_fp_chance = 0.001;
-- Write-heavy table where reads are rare
ALTER TABLE write_heavy WITH bloom_filter_fp_chance = 0.1;
-- Check current setting
SELECT bloom_filter_fp_chance FROM system_schema.tables
WHERE keyspace_name = 'ks' AND table_name = 'table';
Bloom filter size ≈ -1.44 × log2(fp_chance) × partition_count
Example for 10 million partitions:
- fp_chance = 0.01: ~10 bits/key = 12.5 MB per SSTable
- fp_chance = 0.001: ~14 bits/key = 17.5 MB per SSTable
- fp_chance = 0.1: ~5 bits/key = 6.25 MB per SSTable
Terminal window
# Check bloom filter effectiveness
nodetool tablestats keyspace.table | grep -i bloom
# JMX metrics
# BloomFilterFalsePositives - count of false positives
# BloomFilterFalseRatio - false positive rate

Once a bloom filter indicates that a partition key is possibly present in an SSTable, the next step is to find the exact byte offset where that partition's data begins in the data file. This is the role of the partition index.

Each SSTable consists of multiple files. The data file (*-Data.db) contains the actual row data, but it may be gigabytes in size. Without an index, finding a specific partition would require scanning the entire file sequentially—an O(n) operation that would make reads impossibly slow.

SSTable File ComponentsSSTable File ComponentsSSTable File ComponentsBloom Filter(*-Filter.db) Quick rejectionPartition Index(*-Index.db) Key to offset mappingCompression Metadata(*-CompressionInfo.db) Chunk offset mappingData File(*-Data.db) Compressed row dataQuery:Partition Key X1. Check2. Lookup(if maybe)3. Map tocompressed chunk4. Seek & decompress

The partition index provides an O(log n) or O(key_length) lookup to translate a partition key into a byte offset, enabling direct disk seeks to the data location.

After the bloom filter returns "maybe present," the read path consults the partition index:

  1. Look up the partition key in the index structure
  2. Retrieve the uncompressed byte offset where the partition begins
  3. Look up compression metadata to find the compressed chunk containing that offset
  4. Seek to the compressed chunk, decompress it, and read the partition data
Partition Index Lookup FlowPartition Index Lookup FlowPartition IndexCompression MetadataData File (*-Data.db)Index LookupO(log n) or O(key_length)Map offset tocompressed chunk 13. other chunks .Chunk 13 (compressed)Decompress to finduser:12345 data. other chunks .Partition Key\"user:12345\"Uncompressed Offset847,291Compressed ChunkOffset: 234,5671. Query2. Uncompressed offset3. Lookup chunk4. Compressed offset5. Seek & decompress

Cassandra's partition index implementation has evolved significantly to improve memory efficiency and lookup performance.

The original index architecture used a three-tier system:

Legacy Index Lookup (Pre-Cassandra 5.0)Legacy Index Lookup (Pre-Cassandra 5.0)In Memory (On-Heap)Off-HeapOn DiskKey Cache(hot keys only)Partition Summary(sampled every 128th key)Compression Metadata(chunk offset mapping)Index File(*-Index.db)(all keys + offsets)Data File(*-Data.db)Partition Key1. Check cacheHit: tocompression lookupMiss2. Find rangein index file3. Uncompressed offset4. Seek tocompressed chunk

How the legacy index worked:

  1. Key Cache (heap): Stores recently accessed partition key → offset mappings. Cache hit = skip to compression metadata lookup.
  2. Partition Summary (heap): A sampled index that stores every Nth key (default: every 128th). Provides a starting point for binary search in the index file.
  3. Index File (disk): Contains all partition keys and their data file offsets, sorted by token order.
  4. Compression Metadata (off-heap): Maps uncompressed offsets to compressed chunk positions.

Lookup process for cache miss:

  1. Binary search the partition summary to find the range containing the key
  2. Seek to that position in the index file
  3. Scan/binary search within the index file to find the exact key
  4. Read the uncompressed data file offset
  5. Look up compression metadata to find the compressed chunk
  6. Seek to the compressed chunk and decompress

Problems with the legacy approach:

IssueImpact
Heap memory consumptionPartition summary grows with partition count; causes GC pressure
Multiple disk seeksSummary → Index File → Data File = 2-3 seeks per read
Memory scales with dataMore partitions = more heap required
Index file sizeFull copy of every partition key on disk

Cassandra 5.0 introduced a new index format based on tries (prefix trees), implemented in CASSANDRA-18398. This approach was inspired by research on succinct data structures and space-efficient tries.

TrieIndex cluster_trie Trie Index Structure (memory-mapped, off-heap) root root u u root->u a a root->a us s u->us admin d a->admin user e us->user usercolon r: user->usercolon admincolon min: admin->admincolon user1 1 → offset 1000 usercolon->user1 user2 2 → offset 5000 usercolon->user2 admin1 1 → offset 9000 admincolon->admin1 note Keys: user:1, user:2, admin:1 Shared prefixes compressed Lookup: O(key_length)

How the trie index works:

  1. Partition keys are stored in a trie structure where common prefixes are shared
  2. The trie is serialized to disk and memory-mapped (off-heap)
  3. Lookup traverses the trie character by character: O(key_length)
  4. Leaf nodes contain the byte offset into the data file

Advantages of the trie index:

ImprovementBenefit
Off-heapNo GC pressure; memory-mapped from disk
Prefix compressionKeys sharing prefixes stored once; 50-80% smaller
O(key_length) lookupConsistent performance regardless of partition count
Single disk readTrie traversal finds offset directly; no summary + index two-step
Better for long keysPrefix sharing particularly effective for UUIDs, paths

Size comparison:

Index Type10M Partitions (UUID keys)Memory Type
Legacy (summary)~200-400 MB heapOn-heap
Trie~50-100 MBOff-heap (mmap)

BTI Format Does Not Use Key Cache

Starting with Cassandra 5.0, the default BTI (Big Trie Index) format does not use the key cache. The trie-based index is already memory-mapped and provides efficient O(key_length) lookups without caching. Key cache settings are only relevant for SSTables using the legacy index format (Cassandra 4.x and earlier).

The key cache stores partition key → SSTable offset mappings in memory, bypassing index lookups entirely for frequently accessed partitions. This optimization is only relevant for the legacy index format.

# cassandra.yaml (legacy index only)
# Global key cache size
key_cache_size_in_mb: 100
# Keys to save when flushing cache to disk
key_cache_keys_to_save: 10000
# How often to save key cache (seconds)
key_cache_save_period: 14400 # 4 hours
-- Per-table key cache settings (legacy index only)
ALTER TABLE hot_table WITH caching = {'keys': 'ALL'};
ALTER TABLE cold_table WITH caching = {'keys': 'NONE'};

When key cache helps (legacy index):

  • Tables with hot partitions accessed repeatedly
  • Read-heavy workloads with temporal locality
  • Partitions that are read more often than they are written

Key cache lookup flow (legacy index):

  1. Hash the partition key
  2. Check key cache for (SSTable ID, partition key) → offset mapping
  3. Hit: Seek directly to data file offset (skip bloom filter and index)
  4. Miss: Fall back to bloom filter → index lookup path

Row cache stores entire rows in memory. Use with caution—it is rarely beneficial.

# cassandra.yaml (disabled by default)
row_cache_size_in_mb: 0
-- Enable per-table
ALTER TABLE hot_config WITH caching = {
'keys': 'ALL',
'rows_per_partition': 10
};
-- Cache all rows
ALTER TABLE tiny_lookup WITH caching = {
'keys': 'ALL',
'rows_per_partition': 'ALL'
};
  • Very small number of extremely hot rows
  • Rows that rarely change
  • Read latency is critical
  • High cardinality (many unique partitions)
  • Write-heavy tables (invalidation overhead)
  • Large rows (memory waste)
  • Random access patterns (low hit rate)

Read latency scales with the number of SSTables that must be checked.

Read Latency ≈ (SSTables_checked × lookup_time) + merge_time
Well-compacted table (2 SSTables):
- Bloom filter checks: 2 × 0.1ms = 0.2ms
- Index lookups: 2 × 0.5ms = 1ms
- Data reads: 2 × 1ms = 2ms
- Total: ~3.2ms
Poorly compacted table (50 SSTables):
- Bloom filter checks: 50 × 0.1ms = 5ms
- Index lookups (10 pass bloom): 10 × 0.5ms = 5ms
- Data reads: 10 × 1ms = 10ms
- Merge time: ~2ms
- Total: ~22ms
Terminal window
# SSTable count per table
nodetool tablestats keyspace.table | grep "SSTable count"
# SSTables accessed per read
nodetool tablehistograms keyspace.table
# Look for "SSTable Count per Read"
  • Ensure compaction keeps pace with writes
  • Use appropriate compaction strategy
  • Run nodetool compact for immediate compaction (use sparingly)

The coordinator waits for responses from replicas based on consistency level.

Consistency LevelReplicas ContactedReplicas Required
ONE11
QUORUM(RF/2)+1(RF/2)+1
LOCAL_QUORUM(local RF/2)+1(local RF/2)+1
ALLRFRF

When replicas return different data, read repair synchronizes them:

  • Blocking read repair: Repair before returning result (slower, stronger consistency)
  • Background read repair: Repair after returning result (faster, eventual consistency)
-- Configure read repair chance
ALTER TABLE my_table WITH read_repair_chance = 0.1;
ALTER TABLE my_table WITH dclocal_read_repair_chance = 0.1;

# More concurrent reads
concurrent_reads: 64
# Larger key cache (legacy index only - not used with BTI format)
# key_cache_size_in_mb: 200
-- Lower bloom filter false positive rate
ALTER TABLE hot_reads WITH bloom_filter_fp_chance = 0.001;
-- Use LCS for fewer SSTables
ALTER TABLE hot_reads WITH compaction = {
'class': 'LeveledCompactionStrategy',
'sstable_size_in_mb': 160
};
Terminal window
# Read latency
nodetool proxyhistograms
nodetool tablehistograms keyspace.table
# JMX metrics
# ClientRequest.Read.Latency
# Table.ReadLatency
# Table.SSTablesPerReadHistogram

Diagnosis:

Terminal window
nodetool proxyhistograms # Coordinator latency
nodetool tablehistograms ks.table # Local latency
nodetool tablestats ks.table # SSTable count, bloom filter stats

Common Causes:

SymptomCauseSolution
High SSTable countCompaction behindCheck compaction stats
High bloom filter false positivesfp_chance too highLower fp_chance
Many tombstones scannedDelete-heavy workloadReview data model
High index lookup timeLarge partitions, disk I/OEnsure SSDs, check partition sizes
"Read X live rows and Y tombstone cells"

See Tombstones for diagnosis and resolution.