LSM Compaction Debt
criticalSummary
In a log-structured merge-tree (LSM) engine, sustained write rate outpaces the background compaction process that merges on-disk SSTables. The number of SSTables a read must check grows (read amplification climbs), and once the backlog crosses an engine-defined threshold, the engine deliberately slows or stops accepting writes to keep worst-case read latency bounded, rather than let read amplification grow without limit.
Description
An LSM engine never updates data in place. Writes land first in an in-memory memtable and are also appended to a write-ahead log for durability. When the memtable fills, it is frozen and flushed to disk as an immutable SSTable (sorted string table). Over time this produces many SSTables, each holding its own sorted run of keys, some overlapping in key range with others. A read for a single key cannot assume the key lives in exactly one place: in the worst case it must check every SSTable that could contain that key range, plus the active memtable, and merge what it finds by recency. This is read amplification, and it grows with the number of SSTables a read has to touch.
Compaction is the background process that bounds this. It merges multiple SSTables into fewer, larger ones, combining overlapping key ranges into a single sorted run, discarding obsolete versions of overwritten keys, and dropping keys whose tombstone (the marker a LSM engine writes instead of deleting in place) has aged past the point where it could still shadow a value in an older, not-yet-compacted SSTable. Compaction is what makes deletes and overwrites actually free space, and what keeps the number of files a read must check from growing without bound.
Compaction debt is what accumulates when the write rate, and therefore the memtable flush rate, produces new SSTables faster than compaction can merge them away. The backlog is visible directly: the count of pending compaction tasks grows, the number of SSTables per level (or, in a tiered strategy, per bucket) grows past what the compaction strategy was sized for, and read latency degrades because reads now check more files per key. This is a feedback loop, not a one-time cost: a larger backlog means the next compaction pass has more data to merge, which takes longer, which lets the backlog grow further while it runs. Left alone, debt does not self-correct once the write rate that created it continues.
Because unbounded read amplification is worse for correctness-adjacent reasons than slower writes, both Cassandra and RocksDB deliberately choose to protect reads by throttling or halting writes once debt crosses a threshold, rather than let SSTable count grow indefinitely. This is the mechanism, not an incidental side effect: a write stall under compaction debt is the engine enforcing a bound on worst-case read cost at the expense of write availability.
Cassandra exposes this operationally through nodetool compactionstats, which reports pending compaction tasks; a sustained, growing pending-compaction count under continuous write load is the direct signal of debt building. compaction_throughput_mb_per_sec (in cassandra.yaml) caps the I/O bandwidth compaction is allowed to consume in the background, so it competes with foreground read and write I/O for the same disk; raising it lets compaction catch up faster but takes I/O bandwidth away from live traffic while it does. SSTable count per read, visible per-table via nodetool tablestats/cfstats, is the read-amplification signal that debt drives up directly.
RocksDB names the mechanism as an explicit column-family option pair: level0_slowdown_writes_trigger and level0_stop_writes_trigger. When the number of level-0 SSTable files (files flushed directly from memtables, before they have been organized into non-overlapping runs by compaction) crosses the slowdown trigger, RocksDB deliberately inserts write delay to give compaction room to catch up; if it keeps growing and crosses the stop trigger, RocksDB stops accepting writes entirely until level-0 file count drops back down. This is the concrete stall/throttle mechanism this failure describes for RocksDB-based engines. The exact default numeric values for both triggers are not asserted here and should be confirmed against the RocksDB version in use; withholding the number is deliberate rather than an oversight, the mechanism is what matters operationally and the default has shifted across RocksDB releases.
This failure is a specific, LSM-only instance of the general write-amplification problem: compaction rewrites the same logical data multiple times as it moves through levels, which is a cost a B-Tree engine's in-place update model does not pay the same way (see write_amplification_cascade for the cross-engine comparison and why the two amplification models must not be collapsed into one bucket). It is also a distinct phenomenon from checkpoint_amplification: a PostgreSQL checkpoint is a periodic, bounded, time-boxed flush of dirty buffer-pool pages on a B-Tree engine, while compaction debt is an open-ended, backlog-driven condition on an LSM engine that has no fixed period and does not resolve itself on a timer, only by compaction throughput exceeding write rate for a sustained interval.
Characteristics
Triggers
- ·Sustained write throughput exceeds the compaction strategy's steady-state merge throughput
- ·compaction_throughput_mb_per_sec (Cassandra) set too low for the actual write rate, deliberately capping compaction I/O below what is needed to keep up
- ·Large bulk load, backfill, or bulk import that spikes the memtable flush rate far above normal steady-state writes
- ·Anti-entropy repair (Cassandra) streaming in overlapping SSTables and adding to the compaction backlog on top of normal write traffic
- ·Undersized compaction thread pool or too few concurrent compactors for the node's core count and disk throughput
- ·Level-0 SSTable count crossing level0_slowdown_writes_trigger or level0_stop_writes_trigger (RocksDB-based engines)
Detection Signals
Mitigation Strategies
Increase compaction_throughput_mb_per_sec (Cassandra) or the equivalent background I/O rate limit for a RocksDB-based engine, and/or increase the number of concurrent compaction threads. This lets compaction consume more of the disk's I/O capacity to work down the backlog faster. The cost moves directly to foreground I/O: compaction and live reads/writes share the same disk, so raising compaction's budget takes bandwidth away from application traffic while the backlog drains, and can itself cause a temporary latency spike on the traffic it is trying to protect.
Size-tiered compaction favors write throughput but tolerates more read amplification and space amplification; leveled compaction favors read latency and bounds space amplification but costs more write amplification per byte, since each byte tends to be rewritten across more levels; a time-window strategy fits time-series data with natural expiry (TTL-heavy workloads) and avoids compacting old, cold data at all. Picking the wrong strategy for the write and read pattern is a common root cause of a backlog that raising throughput alone cannot fix. Changing strategy requires re-compacting existing data under the new strategy, which is itself compaction work and temporarily adds to the debt before it reduces it.
Add nodes and let the partitioner redistribute data, so each node's write rate, and therefore its memtable flush rate and compaction workload, drops proportionally. This addresses the root cause (per-node write rate exceeding per-node compaction throughput) rather than compaction's symptoms, at the cost of additional hardware and the operational work of a rebalance, which itself generates streaming and compaction I/O while it runs.
Rather than disabling or fighting level0_slowdown_writes_trigger and level0_stop_writes_trigger (RocksDB) or the equivalent Cassandra backpressure, treat the resulting write latency or rejection as the intended signal: the engine is bounding worst-case read amplification on purpose. The mitigation here is application-side, queue or shed writes gracefully when the engine signals backpressure, rather than retrying aggressively into a stalled write path, which only adds queued load that discharges as a burst once the stall clears.
Every overwrite and every delete (tombstone) of the same key adds compaction work later. Using TTLs so expired data ages out via a time-window strategy instead of accumulating as tombstones, batching writes to the same partition to reduce per-write memtable churn, and avoiding read-modify-write patterns that repeatedly overwrite hot keys all reduce the volume compaction has to merge. This is a workload-shape change, not a configuration change, and it moves cost to application design and, for TTL-based expiry, to the read path needing to tolerate eventual rather than immediate space reclamation.
Recovery Steps
- 1.Confirm compaction debt is the cause: check pending compaction tasks (nodetool compactionstats) or level-0 SSTable file count, and correlate with the read-latency trend
- 2.Temporarily raise the compaction throughput budget or thread count to work down the backlog faster, accepting the foreground I/O contention this adds
- 3.Identify whether a specific event (bulk load, repair, TTL expiry wave) triggered the spike, and throttle or reschedule that source if it is still running
- 4.If writes are actively stalling or being rejected, apply application-side backpressure (queue, shed, or slow the write path) rather than retrying into the stall
- 5.Once pending compactions and SSTable-per-read counts trend back down to baseline, restore normal compaction throughput settings
- 6.If the backlog recurs under normal load rather than only during spikes, revisit compaction strategy and per-node write rate rather than repeating the throughput bump
Estimated recovery time: Minutes to restore write availability once throttling is relaxed and backpressure is applied, but hours to fully drain a large compaction backlog and return read amplification to baseline, since compaction throughput is bounded by the same disk the backlog itself competes for. A backlog caused by a one-time bulk load typically drains within hours; a backlog caused by sustained write rate exceeding steady-state compaction throughput does not drain until the write rate drops or compaction capacity (throughput, threads, or nodes) is increased.
Affected Systems
Patterns
Technologies
Basis
The memtable/SSTable/compaction mechanism and read/write/space amplification tradeoffs are standard LSM-tree theory (Database Internals, DDIA) and are well documented for Cassandra and RocksDB specifically. Cassandra's compaction_throughput_mb_per_sec and compactionstats, and RocksDB's level0_slowdown_writes_trigger/level0_stop_writes_trigger mechanism, are documented features; the exact current default numeric values for the RocksDB triggers and for Cassandra's throughput knob are not asserted here and should be confirmed against the specific version in use before being cited as a hard number, which is the reason confidence is not higher.
Sources & Claims
An LSM engine flushes in-memory memtables to immutable on-disk SSTables, and compaction merges SSTables across levels to bound the number of files a read must check and to reclaim space from overwritten or deleted (tombstoned) keys
pendingddia or accepted reference · Alex Petrov, Database Internals, LSM Trees and compaction; Kleppmann, Designing Data-Intensive Applications, ch. 3
storage-engine-internals-spine batch 2
Cassandra's nodetool compactionstats reports pending compaction tasks, and compaction_throughput_mb_per_sec (cassandra.yaml) throttles the background I/O bandwidth compaction is allowed to use
pendingofficial documentation · Apache Cassandra documentation: nodetool compactionstats; cassandra.yaml compaction_throughput_mb_per_sec
storage-engine-internals-spine batch 2; exact default value for compaction_throughput_mb_per_sec not asserted, has changed across major Cassandra versions
RocksDB's level0_slowdown_writes_trigger and level0_stop_writes_trigger column family options respectively slow down and fully stop foreground writes once the number of level-0 SSTable files crosses a configured threshold, bounding worst-case read amplification
pendingofficial documentation · RocksDB Tuning Guide / column family options: level0_slowdown_writes_trigger, level0_stop_writes_trigger
storage-engine-internals-spine batch 2; exact default threshold values intentionally withheld pending confirmation against the RocksDB version in use
Size-tiered compaction favors write throughput at the cost of higher read and space amplification, while leveled compaction favors bounded read and space amplification at the cost of higher write amplification
pendingddia or accepted reference · RocksDB and Cassandra documentation on compaction strategy tradeoffs; Database Internals, compaction strategies
storage-engine-internals-spine batch 2