Skip to content

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

Cassandra Data Write Path

This document describes the write path within a single Cassandra node—how incoming mutations are persisted to durable storage. The focus is on the storage engine mechanics: commit log, memtable, and flush operations.

For cluster-level write coordination—how writes are routed to replicas and how consistency levels are satisfied—see Distributed Data: Consistency.


When a node receives a write (whether as coordinator or replica), the storage engine performs these steps:

Replica Node (Linux Server)Cassandra Process (JVM)Off-Heap MemoryFilesystem/var/lib/cassandra/commitlog/var/lib/cassandra/data/keyspace/tableStorage EngineMemtable(per table)Row Cache(optional)Key CacheBloom FiltersCompressionMetadataCommitLog-7-001.logCommitLog-7-002.logSSTable-1-Data.dbSSTable-2-Data.dbSSTable-3-Data.dbCoordinator NodeWrite-ahead logReplayed on crash recoveryImmutable sorted filesCreated by flushMutation1. Append(durability)2. Insert(queryable)3. Flush(background)

The write is considered durable once it reaches the commit log. The memtable update makes the data immediately queryable. Flushing to SSTable happens later, in the background.


The commit log is a write-ahead log providing durability. Its sole purpose is crash recovery—if a node fails before memtables flush, the commit log is replayed on restart.

For a deep dive into commit log internals—segment file format, sync block structure, replay algorithm, compression, and encryption—see the dedicated Commit Log reference.

commitlog_directory/
├── CommitLog-7-1234567890.log (active - awaiting flush)
├── CommitLog-7-1234567891.log (active - awaiting flush)
├── CommitLog-7-1234567892.log (active - awaiting flush)
└── CommitLog-7-1234567893.log (current - receiving writes)

Segments grow up to commitlog_segment_size_in_mb (default 32MB). Once all referenced memtables flush, the segment is deleted. See Commit Log: Segment Architecture for the full lifecycle.

Commit log files follow a specific naming pattern:

CommitLog-<version>-<segment_id>.log
Example: CommitLog-7-1702345678901.log
│ │ │
│ │ └── Segment ID (base timestamp + sequence)
│ └──── Commitlog format version
└────────────── Prefix
ComponentDescription
CommitLogFixed prefix identifying the file type
versionCommitlog serialization format version. Changes between major Cassandra releases when the format evolves.
segment_idUnique identifier combining a base timestamp with a sequence number.

Segment ID Generation:

The segment ID is computed as: base_id + sequence_number

ComponentDescription
base_idSet at Cassandra startup to the greater of: current time (milliseconds) or (highest existing segment ID + 1)
sequence_numberAtomic counter starting at 1, incremented for each new segment

This approach ensures:

  • Monotonic ordering: IDs always increase, even across restarts
  • Uniqueness: No collisions from rapid segment creation
  • Recovery safety: If existing segments have future-dated IDs (e.g., clock skew), new segments still receive higher IDs
Example startup scenario:
Existing segments: CommitLog-7-1702345678901.log
CommitLog-7-1702345678902.log
Current time: 1702345700000 (greater than max existing)
Base ID set to: 1702345700000
New segments: CommitLog-7-1702345700001.log (base + 1)
CommitLog-7-1702345700002.log (base + 2)
CommitLog-7-1702345700003.log (base + 3)

Version History:

VersionCassandra VersionNotes
63.0 - 3.11Introduced with storage engine rewrite
74.0+Current format
Terminal window
# List commit log segments
ls -la /var/lib/cassandra/commitlog/
# The base timestamp approximates when Cassandra started
# (first segment ID after restart reflects startup time)

Commit log parameters by version:

Parameter4.04.1+Default
Sync periodcommitlog_sync_period_in_mscommitlog_sync_period10000 / 10s
Group windowcommitlog_sync_batch_window_in_mscommitlog_sync_group_window2 / 2ms
Segment sizecommitlog_segment_size_in_mbcommitlog_segment_size32 / 32MiB
Total spacecommitlog_total_space_in_mbcommitlog_total_spacevaries
# cassandra.yaml (4.0 syntax shown, 4.1+ uses duration/size literals)
# Sync mode determines durability guarantees
# periodic: sync every N milliseconds (default, best throughput)
# group: sync after window of writes (replaces old "batch" behavior)
# batch: sync after each write (lowest throughput, strongest durability)
commitlog_sync: periodic
commitlog_sync_period_in_ms: 10000 # 4.1+: commitlog_sync_period: 10s
# For group mode (4.1+: commitlog_sync_group_window)
# commitlog_sync: group
# commitlog_sync_batch_window_in_ms: 2
# Segment size (default 32MB)
commitlog_segment_size_in_mb: 32 # 4.1+: commitlog_segment_size: 32MiB
# Directory (should be on fast storage, separate from data)
commitlog_directory: /var/lib/cassandra/commitlog
# Maximum total space for commit log segments
commitlog_total_space_in_mb: 8192 # 4.1+: commitlog_total_space: 8GiB

Cassandra supports three sync modes: periodic, batch, and group. See Commit Log: Sync Modes for detailed diagrams and trade-offs.

ModeConfigurationThroughputData Loss Window
Periodic (10s)commitlog_sync: periodicHighestUp to 10 seconds
Periodic (1s)commitlog_sync_period: 1sHighUp to 1 second
Group (2ms)commitlog_sync: groupMediumUp to group window
Batchcommitlog_sync: batchLowestPer-write fsync

Batch mode

Batch mode performs fsync after each write, providing strongest durability at the cost of throughput. There is no batch window setting; for windowed sync, use group mode.

Understanding Filesystem Buffering and fsync

Section titled “Understanding Filesystem Buffering and fsync”

To understand why 10-second periodic sync is acceptable, it is necessary to understand UNIX filesystem architecture.

Write Path Through the Operating System:

Application (Cassandra)KERNEL SPACEPage Cache (RAM)PHYSICAL DISKwrite() system callWritten by application, not yet on diskData sits here until:• fsync() called• Kernel flush daemon runs (~30 sec)• Memory pressure forces evictionDisk I/O SchedulerDirty pagesPersistent StorageData survives power failureonly after reaching here• SSD: ~0.1ms per fsync• HDD: ~5-10ms per fsynccopies to page cachefsync() orkernel writeback

What write() Does:

  • Copies data from application memory to kernel page cache
  • Returns immediately (sub-microsecond)
  • Data is NOT durable—power loss loses unfsynced data

What fsync() Does:

  • Forces all dirty pages for a file to physical storage
  • Waits for disk controller acknowledgment
  • Expensive: 0.1ms (SSD) to 10ms (HDD) per call
  • Required for true durability

The 10-second default seems dangerous, but consider:

1. Distributed Durability

With replication factor 3 and QUORUM writes:

Write to partition key X:
├── Node A: write() to commitlog (page cache) ✓
├── Node B: write() to commitlog (page cache) ✓
└── Node C: write() to commitlog (page cache) ✓
Data loss requires:
- All 3 nodes lose power simultaneously
- Within the 10-second sync window
- Before kernel writeback occurs (~30 seconds)

The probability of all replicas failing within 10 seconds is extremely low in properly deployed clusters.

2. Performance Impact of fsync

MetricPeriodic (10s)Batch (2ms)
write() latency~1μs~1μs
fsync() frequencyOnce per 10 secondsEvery 2ms
fsync cost amortizationAcross thousands of writesPer batch only
Throughput (SSD)100,000+ writes/secLimited by fsync
Throughput (HDD)50,000+ writes/sec~100 writes/sec × batch size
Write latency P99Sub-millisecond2-10ms (fsync bound)

Throughput Calculations:

For periodic sync, fsync overhead is amortized:

Effective write cost=twrite+tfsyncwrites per sync period\text{Effective write cost} = t_{write} + \frac{t_{fsync}}{\text{writes per sync period}} Effective write cost=1μs+0.1ms100,0001μs\text{Effective write cost} = 1\mu s + \frac{0.1ms}{100{,}000} \approx 1\mu s

For batch sync, each batch pays the fsync cost:

Max batches/sec=1tbatch_window+tfsync\text{Max batches/sec} = \frac{1}{t_{batch\_window} + t_{fsync}} Max batches/sec (SSD)=12ms+0.1ms476 batches/sec\text{Max batches/sec (SSD)} = \frac{1}{2ms + 0.1ms} \approx 476 \text{ batches/sec} Max batches/sec (HDD)=12ms+10ms83 batches/sec\text{Max batches/sec (HDD)} = \frac{1}{2ms + 10ms} \approx 83 \text{ batches/sec}

fsync overhead comparison:

Overhead ratio=fsync calls (batch)fsync calls (periodic)=500/sec0.1/sec=5000×\text{Overhead ratio} = \frac{\text{fsync calls (batch)}}{\text{fsync calls (periodic)}} = \frac{500/sec}{0.1/sec} = 5000\times

3. When to Use Batch Sync

Batch sync is appropriate when:

  • Single-node deployment (no replication for durability)
  • Regulatory requirements mandate immediate persistence
  • Data loss of any amount is unacceptable
  • Write throughput requirements are modest
# Batch sync configuration
commitlog_sync: batch
commitlog_sync_batch_window_in_ms: 2
# Writes are grouped into 2ms windows
# fsync called at end of each window
# Higher latency but stronger single-node durability
ScenarioRecommended ModeRationale
Multi-node cluster (RF≥3)Periodic 10sReplication provides durability
Single node, can tolerate lossPeriodic 10sBest performance
Single node, no data lossBatch 2-50msfsync per batch
Compliance requirementsBatchRegulatory mandate
Maximum throughputPeriodic 10sMinimal fsync overhead

Most production deployments use periodic sync with 10-second intervals. The distributed nature of Cassandra means true data loss requires catastrophic, correlated failures across multiple nodes.

Place the commit log on dedicated fast storage:

  • Separate physical device from data directory
  • SSD strongly recommended
  • Prevents data I/O from blocking commit log writes

The memtable is an in-memory sorted data structure holding recent writes. Data in the memtable is queryable immediately after the write completes.

Memtable cluster_memtable MEMTABLE (ConcurrentSkipListMap) Sorted by: partition token → clustering columns cluster_p1 cluster_p2 p1_header Token: -923874... Partition: user_id=abc123 p1_rows Row: created=2024-01-01 col1 col2 col3 Row: created=2024-01-02 col1 col2 col3 Row: created=2024-01-03 col1 col2 col3 p2_header Token: -512983... Partition: user_id=def456 p1_header->p2_header token order p2_rows Row: created=2024-01-15 col1 col2 col3 note One memtable per table per node

Memtable parameters by version:

Parameter4.04.1+Default
Heap spacememtable_heap_space_in_mbmemtable_heap_space1/4 heap
Off-heap spacememtable_offheap_space_in_mbmemtable_offheap_space1/4 heap
Cleanup thresholdmemtable_cleanup_thresholdDeprecated (derived from memtable_flush_writers)0.11
# cassandra.yaml (4.0 syntax shown)
# Total heap space for all memtables
memtable_heap_space_in_mb: 2048 # 4.1+: memtable_heap_space: 2GiB
# Total off-heap space for memtables
memtable_offheap_space_in_mb: 2048 # 4.1+: memtable_offheap_space: 2GiB
# Allocation type
# heap_buffers: on-heap (default)
# offheap_objects: objects off-heap, metadata on-heap
# offheap_buffers: everything off-heap
memtable_allocation_type: heap_buffers
# Concurrent flush operations
memtable_flush_writers: 2
# Flush threshold as fraction of heap (4.0 only; deprecated in 4.1+)
memtable_cleanup_threshold: 0.11

The effective memtable flush threshold is the minimum of two values:

\text{Threshold}_{\text{cleanup}} = \text{Heap Size} \times \texttt{memtable_cleanup_threshold} \text{Threshold}_{\text{configured}} = \texttt{memtable_heap_space_in_mb} Effective Limit=min(Thresholdcleanup,Thresholdconfigured)\text{Effective Limit} = \min(\text{Threshold}_{\text{cleanup}}, \text{Threshold}_{\text{configured}})

Example with 32GB heap:

Thresholdcleanup=32GB×0.11=3.52GB\text{Threshold}_{\text{cleanup}} = 32\text{GB} \times 0.11 = 3.52\text{GB} Thresholdconfigured=2048MB=2GB\text{Threshold}_{\text{configured}} = 2048\text{MB} = 2\text{GB} Effective Limit=min(3.52GB,2GB)=2GB\text{Effective Limit} = \min(3.52\text{GB}, 2\text{GB}) = 2\text{GB}

When total memtable size across all tables reaches 2GB, flush begins for the largest memtable.

Terminal window
# Check memtable size
nodetool tablestats keyspace.table | grep -i memtable
# JMX metrics
# org.apache.cassandra.metrics:type=Table,name=MemtableOnHeapSize
# org.apache.cassandra.metrics:type=Table,name=MemtableOffHeapSize
# org.apache.cassandra.metrics:type=Table,name=MemtableLiveDataSize

Memtables are flushed to SSTables under the following conditions:

TriggerDescription
Size thresholdMemtable reaches configured size limit
Commit log pressureCommit log segments cannot be recycled
Manual flushnodetool flush command
Shutdownnodetool drain flushes all memtables
Memory pressureJVM heap pressure triggers emergency flush
  1. Current memtable marked "flushing" (no new writes accepted)
  2. New empty memtable created for incoming writes
  3. Flushing memtable written to disk as SSTable (sorted, sequential I/O)
  4. SSTable files created atomically
  5. Flushing memtable discarded; commit log segments eligible for recycling

During flush, a single table may temporarily have multiple memtables: one active memtable receiving new writes, while older memtables are still being written to disk. This allows writes to continue uninterrupted during flush operations.

Single table: memtable lifecycle during flushSingle table: memtable lifecycle during flushActiveMemtable(receives writes)FlushingMemtable(writing to disk)Discarded(SSTable created)New empty memtablecreated immediatelyflush triggeredflush complete

All memtables (active and flushing) are checked during reads to ensure data visibility.

Terminal window
# Flush specific table
nodetool flush keyspace_name table_name
# Flush all tables in keyspace
nodetool flush keyspace_name
# Flush all tables on node
nodetool flush
# Prepare for shutdown (flush + stop writes)
nodetool drain

From the node's perspective, a write is complete when both steps finish:

Write completion timeline (single node):
T+0.0ms: Mutation received
T+0.1ms: Commit log append begins
T+0.2ms: Commit log append complete (data durable*)
T+0.3ms: Memtable update begins
T+0.4ms: Memtable update complete (data queryable)
T+0.4ms: Acknowledgment sent to coordinator
* Durability depends on commitlog_sync mode
StepTime (typical)Result
Commit log append0.1–0.5msData survives node restart
Memtable insert0.1–0.3msData visible to queries
Total0.2–0.8msNode acknowledges write

The node sends its acknowledgment to the coordinator (or client, if this node is the coordinator). Cluster-level consistency—how many nodes must acknowledge before the client receives success—is handled by the coordination layer, not the storage engine.


# Larger memtables reduce flush frequency
memtable_heap_space_in_mb: 4096 # 4.1+: memtable_heap_space: 4GiB
# More flush writers for parallel I/O
memtable_flush_writers: 4
# Periodic sync for best throughput (accepts larger data loss window)
commitlog_sync: periodic
commitlog_sync_period_in_ms: 10000 # 4.1+: commitlog_sync_period: 10s
# Separate commit log storage
commitlog_directory: /mnt/nvme/commitlog
# Periodic sync for lower latency
commitlog_sync: periodic
commitlog_sync_period_in_ms: 10000
# Fast commit log storage
commitlog_directory: /mnt/nvme/commitlog

Symptoms: Many commit log segments, write latency increasing

Terminal window
# Check segment count
ls -la /var/lib/cassandra/commitlog/ | wc -l
# Check flush activity
nodetool tpstats | grep -i flush

Causes:

  • Memtables not flushing (disk I/O bottleneck)
  • commitlog_total_space too low (4.0: commitlog_total_space_in_mb)
  • Too many tables consuming flush capacity

Solutions:

  • Add faster storage for commit log
  • Separate commit log and data directories
  • Increase memtable_flush_writers

See Commit Log: Operational Considerations for monitoring metrics and recommendations.

Symptoms: High heap usage, GC pressure

Terminal window
# Check memtable sizes
nodetool tablestats | grep -i memtable

Causes:

  • Flush not keeping up with writes
  • Too many tables

Solutions:

  • Move memtables off-heap: memtable_allocation_type: offheap_buffers
  • Reduce number of tables
  • Increase flush throughput