Linear Merge Compaction

Data Structures & Storage intermediate Building with AI 10 min read

Overview

In Log-Structured Merge-tree (LSM-tree) storage engines, data is initially buffered in an in-memory structure (the memtable) and then flushed to disk as immutable Sorted String Tables (SSTables). Over time, these SSTables accumulate across multiple levels, and the storage engine must periodically merge them to reclaim space, remove deleted or overwritten entries, and maintain efficient read performance. This process is called compaction.

Linear merge compaction is the foundational mechanism underlying all LSM-tree compaction strategies. It exploits the fact that SSTables are individually sorted — meaning that merging multiple sorted files into a single sorted output can be accomplished with a simple linear scan through each file simultaneously, identical to the merge step in merge sort. This is an O(N) operation where N is the total number of entries across all input SSTables.

Understanding compaction is essential for AI engineers because vector databases like Weaviate and Milvus, as well as key-value stores like RocksDB and Pebble that back many AI infrastructure systems, all rely on LSM-tree compaction to maintain performance under heavy write workloads typical of embedding ingestion pipelines.

How It Works

The Merge Sort Analogy

Each SSTable on disk is already sorted by key. When the storage engine needs to combine multiple SSTables, it opens all input files simultaneously and maintains a pointer (or iterator) at the beginning of each file. At every step, it compares the current key at each pointer, selects the smallest (or largest, depending on sort order), writes it to the output SSTable, and advances that pointer. This is exactly the merge phase of the classic merge sort algorithm.

Key Concept: Linear Merge

Given k sorted SSTables with a combined total of N entries, the merge operation runs in O(N log k) time using a min-heap to efficiently select the next smallest key across all k inputs. When k is small (e.g., 2–4 SSTables), the overhead is effectively linear in N.

Handling Overwrites and Tombstones

During the merge, when the same key appears in multiple input SSTables, the engine keeps only the most recent version (determined by a sequence number or timestamp). Deleted keys are represented by special markers called tombstones. During compaction, if a tombstone exists and no older version of that key survives in deeper levels, the key and its tombstone are both dropped from the output — reclaiming disk space.

Compaction Strategies

While the linear merge operation itself is constant, LSM-tree engines differ in when and which SSTables they choose to merge. The two classical strategies are:

Size-Tiered Compaction (STCS)

SSTables of similar size are grouped into tiers. When enough SSTables of a given size accumulate (typically 4), they are merged together into a single larger SSTable. This minimizes write amplification — each byte is rewritten approximately O(log N) times over its lifetime — but increases read amplification because multiple overlapping SSTables at each tier must be checked during reads.

Leveled Compaction (LCS)

SSTables are organized into levels with exponentially increasing capacity (e.g., Level 1 holds 10 SSTables, Level 2 holds 100). Within each level, SSTables have non-overlapping key ranges. When a level overflows, one SSTable is selected and merged with the ~10 overlapping SSTables in the next level. This produces excellent read performance (typically only one SSTable per level needs to be checked) but increases write amplification to approximately O(10 × levels).

Amplification Tradeoffs

Every compaction strategy navigates a three-way tradeoff: write amplification (how many times each byte is rewritten), read amplification (how many SSTables must be consulted per read), and space amplification (how much temporary disk overhead compaction requires). No strategy can minimize all three simultaneously — this is known as the RUM conjecture.

The Linear Merge in Practice

Consider a leveled compaction where an SSTable at Level 1 overlaps with 10 SSTables at Level 2. The engine performs the following steps:

  1. Open iterators on all 11 input SSTables (1 from L1 + 10 from L2).
  2. Initialize a min-heap with the first key from each iterator.
  3. Repeatedly extract the minimum key, resolve duplicates by keeping the newest version, and write to a new output SSTable.
  4. When an output SSTable reaches the target file size (e.g., 64 MB), seal it and start a new one.
  5. Atomically swap the new SSTables into the level’s metadata and delete the old input files.
// Pseudocode: k-way merge of sorted SSTables
func mergeCompact(inputs []SSTableIterator) []SSTable {
    heap := NewMinHeap()
    for i, iter := range inputs {
        if iter.Valid() {
            heap.Push(HeapEntry{key: iter.Key(), seq: iter.Seq(), source: i})
        }
    }

    writer := NewSSTableWriter()
    var lastKey []byte

    for heap.Len() > 0 {
        entry := heap.Pop()
        // Skip older versions of the same key
        if !bytes.Equal(entry.key, lastKey) {
            if !entry.isTombstone() {
                writer.Add(entry.key, entry.value)
            }
            lastKey = entry.key
        }
        // Advance the source iterator
        inputs[entry.source].Next()
        if inputs[entry.source].Valid() {
            heap.Push(HeapEntry{
                key:    inputs[entry.source].Key(),
                seq:    inputs[entry.source].Seq(),
                source: entry.source,
            })
        }
    }
    return writer.Finish()
}

Compaction Granularity and Scheduling

Modern engines like RocksDB and Pebble don’t compact entire levels at once. Instead, they use file-level granularity — selecting individual SSTable files for compaction based on heuristics such as:

Compaction runs in background threads with rate limiting to prevent I/O starvation of foreground read and write operations. Pebble, for example, uses a concurrency-controlled compaction scheduler that can run multiple compactions simultaneously across different level pairs.

Practical Applications

Embedding Ingestion Pipelines

When ingesting millions of document embeddings into an AI system backed by a key-value store (like Weaviate’s use of LSM-based storage), the write-heavy workload produces many SSTables rapidly. Without well-tuned compaction, read latency degrades because the engine must search through an ever-growing number of files. Size-tiered compaction suits the initial bulk load, while leveled compaction is preferred for the steady-state query serving phase.

RocksDB in Production AI Systems

RocksDB is the storage engine behind many AI infrastructure components, from feature stores to metadata indexes. Its default leveled compaction uses the linear merge operation thousands of times per day in a busy system. Engineers tune parameters like max_bytes_for_level_base (size of Level 1, typically 256 MB), max_bytes_for_level_multiplier (growth factor, typically 10), and target_file_size_base (individual SSTable size, typically 64 MB) to balance write throughput and read latency.

Write Amplification Budgets

In a leveled compaction scheme with a multiplier of 10, the worst-case write amplification is approximately 10 × number_of_levels. For a database storing 1 TB of data with 64 MB SSTables and a 256 MB Level 1, you get roughly 5 levels, yielding a worst-case write amplification of ~50×. This means each byte of user data may be written to disk up to 50 times over its lifetime — a critical consideration for SSD endurance and I/O budgeting in AI infrastructure.

Compaction Stalls

If compaction cannot keep up with the write rate, the system enters a compaction stall — writes are throttled or blocked entirely while the engine catches up. This manifests as tail latency spikes in embedding pipelines and must be monitored via metrics like rocksdb.compaction.pending and rocksdb.stall.micros.

Key Takeaways

Further Reading