Skip to content

    System Design

    System Design: Storage Engines

    Compare B-trees and LSM-trees, understand write-ahead logs, compaction and amplification, and choose between row, column and time-series storage.

    By Published 17 min read

    Share

    This lesson is part of System Design Building Blocks: Data, Messaging and Scale. Enroll for free, then sign in, to read it in full.

    What you'll learn: By the end of this lesson you will be able to explain how B-tree and LSM-tree engines store and find data, describe what a write-ahead log and compaction do, reason about read, write and space amplification, and choose a storage layout for transactional, analytical and time-series workloads.

    Imagine two ways of keeping a filing room. In the first, every document has an assigned drawer and slot, so each new paper means a walk to its slot, but finding anything is quick.

    In the second, you drop each new paper into an "incoming" tray. When it is full you sort it and file the batch as one neat folder. Writes are very fast, but finding something may mean checking the tray and several folders, and every so often you must merge folders to keep the room tidy.

    Those two filing rooms are, roughly, the B-tree and the LSM-tree: the two designs behind most databases you will use. A storage engine is the part of a database that decides how bytes are laid out on disk and how reads and writes reach them. Understanding it tells you why a database is fast at some things and slow at others.

    The core idea

    Disks (even fast SSDs) are read and written in blocks or pages (commonly 4 to 16 KB), sequential access is much cheaper than random access, and data must survive crashes. Every storage engine answers one question: how do we organise data on disk so that the operations we care about are cheap, while the others are tolerable?

    You cannot have everything. Three costs pull against each other:

    • Write amplification: how many bytes the engine writes to disk for each byte the application writes.
    • Read amplification: how many disk reads are needed to answer one query.
    • Space amplification: how much disk space is used compared with the logical size of the data.

    Improve one, and another usually gets worse. Choosing an engine is choosing which cost to pay.

    B-trees: update in place

    A B-tree keeps data sorted in a tree of fixed-size pages. The root page points to child pages, which point to further children, down to leaf pages holding the actual keys (and often rows or pointers to rows). Because each page holds many keys, the tree is shallow: with hundreds of keys per page, a tree a few levels deep can index hundreds of millions of rows. A lookup reads one page per level, typically 3 to 4 reads, and the upper levels are usually cached in memory.

    graph TD Root["Root page: keys 100 and 200"] --> N1["Page: keys below 100"] Root --> N2["Page: keys 100 to 199"] Root --> N3["Page: keys 200 and above"] N2 --> L1["Leaf: 100 to 140"] N2 --> L2["Leaf: 141 to 199"]

    Writes modify pages in place. To update key 150, the engine finds its leaf page, changes it and writes the page back. If the page is full, it splits into two and the parent gets a new pointer. Because the tree stays sorted, range queries (WHERE id BETWEEN 100 AND 140) are efficient: find the start, then read leaves in order.

    Strengths: predictable, low-latency point reads and range scans, and mature transactional behaviour. Costs: write amplification (changing one small row rewrites a whole page, and random writes scatter across the disk) and free space that fragments as pages split and empty out.

    B-trees are the default index type in PostgreSQL and the structure InnoDB (MySQL's default engine) uses for both tables and indexes, along with many other databases, which is why databases basics described indexes as B-trees.

    The write-ahead log

    Updating pages in place creates a danger. If the power fails halfway through writing a page, or after changing a leaf but before updating its parent after a split, the tree is corrupt. The standard answer is the write-ahead log (WAL), also called a redo log or journal.

    The rule: before changing any data page, append a description of the change to a sequential log and flush it to durable storage. Only then acknowledge the write. The actual page changes can be applied lazily. After a crash, the engine replays the log from the last checkpoint to redo any changes that had not reached the data pages.

    sequenceDiagram participant A as Application participant E as Engine participant W as WAL on disk participant P as Data pages A->>E: write key E->>W: append record and flush W-->>E: durable E-->>A: acknowledge E->>P: apply change later Note over E,P: after a crash, replay the WAL

    Appending to a log is a sequential write, which is cheap, and it makes commits durable. The WAL also feeds replication, which ships the log to followers, and change data capture, which reads it. Flushing on every commit gives the strongest durability; flushing periodically is faster but risks losing the last moments of writes on a crash. That is a real trade-off, not a free setting.

    LSM-trees: append, then merge

    A log-structured merge-tree (LSM-tree) turns random writes into sequential ones. The design, used by Cassandra, RocksDB, LevelDB, HBase and others:

    1. Each write is appended to a WAL (for durability) and inserted into an in-memory sorted structure called the memtable.
    2. When the memtable fills, it is written to disk as an immutable sorted file called an SSTable (sorted string table). This is one large sequential write.
    3. Reads check the memtable first, then SSTables from newest to oldest, until the key is found.
    4. In the background, compaction merges SSTables, discarding overwritten values and deleted entries, and keeping the number of files bounded.
    LSM-tree write path and read pathWrite to WAL append; Write to Memtable; Memtable to Level 0 (flush when full); Level 0 to Level 1 (compaction); Level 1 to Level 2 (compaction); Read to Memtable; Read to Level 0; Read to Level 1; Read to Level 2WriteWriteWAL append: for durabilityWAL appendfor durabilityMemtable: in memoryMemtablein memoryLevel 0: SSTablesLevel 0SSTablesLevel 1: SSTablesLevel 1SSTablesLevel 2: SSTablesLevel 2SSTablesReadReadflush when fullcompactioncompaction

    Deletes cannot remove data from immutable files, so the engine writes a tombstone, a marker saying "this key is deleted", which shadows older values until compaction drops both. Workloads that delete many keys can accumulate tombstones that slow reads until compaction catches up.

    To avoid checking every file on a read, LSM engines attach a Bloom filter to each SSTable: a compact structure that answers "this key is definitely not here" or "it might be here". A negative answer skips the file at almost no cost; false positives merely cost an unnecessary read.

    Compaction strategies

    Compaction is where LSM engines spend their tuning effort.

    Strategy Idea Favours Costs
    Size-tiered Merge files of similar size into bigger ones Write throughput, lower write amplification More files to check per read; temporary extra space during merges
    Leveled Each level is a sorted, non-overlapping run, about ten times larger than the one above (a common ratio) Read latency and space efficiency Higher write amplification: data is rewritten as it moves down levels

    Compaction competes with foreground traffic for disk bandwidth. If writes arrive faster than compaction can keep up, files pile up, reads slow, and the engine may deliberately stall writes to recover. Watching compaction backlog is part of operating an LSM database.

    B-tree versus LSM-tree

    Aspect B-tree LSM-tree
    Write pattern Random, in-place page updates plus WAL Sequential appends, flushes and merges
    Write amplification Moderate: page rewrites plus log Can be high from repeated compaction, depending on strategy
    Read cost Few page reads, predictable May check several files; Bloom filters and caches help
    Range scans Excellent Good, but merges across files
    Space usage Fragmentation from partly filled pages Obsolete versions linger until compaction
    Latency profile Steady Fast writes with occasional compaction-induced spikes
    Typical fit General transactional workloads, read-heavy or mixed Write-heavy ingest, logs, time-stamped data, large key-value stores

    Neither is "faster" in general: a B-tree database with a well-sized cache handles mixed workloads very well, and a write-heavy LSM store can ingest at rates that would stress a B-tree.

    NOTE

    Amplification depends heavily on settings and workload, so treat any single quoted figure with suspicion. Measure on your hardware with your data.

    Row stores versus column stores

    So far each record has been kept together: all the fields of a row sit next to each other. That is a row store, ideal when you usually want whole rows: "load order 9031 with all its fields".

    Analytics asks different questions: "what was the average temperature per site last week?" That touches two columns out of perhaps fifty, across millions of rows, and a row store must read every column of every row to answer it. A column store keeps each column's values together on disk, so the same query reads only the two needed columns.

    graph LR subgraph RowStore["Row store layout"] R1["id 1, site A, temp 21"] R2["id 2, site B, temp 25"] end subgraph ColStore["Column store layout"] C1["id column: 1, 2"] C2["site column: A, B"] C3["temp column: 21, 25"] end

    How to read it: the row layout keeps each record together; the column layout keeps each field together, so an average over temp touches one list.

    Values in one column are similar, so they compress very well (dictionary and run-length encoding), and engines process them in batches with vectorised execution. The downside: reconstructing or updating a single whole row touches many column files, so column stores usually load data in large batches and handle single-row updates poorly.

    OLTP versus OLAP

    This split gives two broad workload families:

    OLTP (transaction processing) OLAP (analytical processing)
    Typical operation Read or write a few rows by key Scan and aggregate millions of rows
    Latency goal Milliseconds Seconds to minutes acceptable
    Concurrency Many small, simultaneous transactions Fewer, heavy queries
    Usual layout Row store (B-tree or LSM) Column store
    Examples of use Orders, accounts, sessions Reports, dashboards, trend analysis

    Do not run heavy analytics on your primary OLTP database, because long scans compete with user-facing queries for cache and I/O. Copy data to a separate analytical system with change data capture or batch export. Some databases offer both layouts in one product; even then, check how the workloads are isolated.

    Time-series data

    Metrics, sensor readings and logs have a distinctive shape: writes are almost all appends of the newest timestamps, data is rarely updated, queries ask for a time range and aggregates, and old data loses value.

    Time-series engines exploit this:

    • Partition by time, so recent data is hot and old partitions are dropped whole, far cheaper than deleting row by row.
    • Compress aggressively. Timestamps are regular (store deltas) and neighbouring values are similar, so specialised encodings help a lot.
    • Downsample. Keep fine-grained data briefly and coarser rollups for years, retaining trends at lower cost.
    • Control cardinality. Each unique combination of labels creates a separate series, so unbounded labels such as user ids can explode series counts and memory. It is one of the most common operational problems with metrics systems.

    Dedicated time-series databases exist, and general-purpose databases with time partitioning or column layouts can serve smaller needs.

    Denormalisation and schema evolution

    Storage layout interacts with data modelling. Relational design normalises data to avoid duplication, then uses joins. At scale, joins across large tables or shards get expensive, so designers denormalise: store data in the shape the query needs, such as a device's site name inside each reading row, accepting duplicated values. The cost is that a rename must update every copy or tolerate staleness, so denormalise deliberately and keep one clear source of truth.

    Schema evolution is the other long-term problem. Safe habits:

    • Add, do not rewrite. A nullable column or optional field is cheap and backwards compatible; renames and type changes need multi-step migrations (expand, backfill, switch reads, contract).
    • Know your online-change behaviour. Some changes lock or rewrite whole tables, which is dangerous on big, busy ones.
    • Schemaless is not free. Readers must handle every historical shape, so version records and tolerate unknown fields. Event formats need the same compatibility rules.

    Choosing an engine

    Work from the workload, not the brand name.

    If your workload is... Lean towards Why Watch out for
    Mixed reads and writes, transactions, joins B-tree relational database Predictable reads, strong transactional semantics Write amplification at very high write rates
    Very high write ingest, key-based access LSM-based store Sequential writes absorb bursts Compaction stalls, tombstones, read tail latency
    Big scans and aggregates over many rows Column store Reads only needed columns, compresses well Single-row updates, small transactions
    Metrics, sensors, event timelines Time-series layout Time partitioning, compression, downsampling High label cardinality
    Text queries and relevance Search index See search and indexing Not a system of record
    Blobs such as images and video Object storage See CDN and object storage Not for small, frequent updates

    Practical advice: start with a mature general-purpose relational database, measure, and move specific workloads to specialised engines when you can name the bottleneck. Running several engines is normal in large systems, but each one adds operational burden and a data-synchronisation problem.

    The same advice as a flowchart.

    flowchart TD Q1{"Mostly whole-row reads and writes with transactions?"} -->|yes| BT["B-tree relational database"] Q1 -->|no| Q2{"Mostly scans and aggregates?"} Q2 -->|yes| CS["Column store"] Q2 -->|no| Q3{"Sustained write ingest by key or time?"} Q3 -->|yes| LSM["LSM store or time-series layout"] Q3 -->|no| Q4{"Large blobs or text relevance?"} Q4 -->|blobs| OS["Object storage"] Q4 -->|text| SI["Search index"]

    How to read it: start from the dominant access pattern. Real systems often answer "yes" to more than one question, which is the signal to split the workloads across engines.

    In practice: an IoT telemetry platform choosing storage for high-write sensor data

    NOTE

    A realistic scenario, not a case history. This is a composite example built from common production patterns. It does not describe any specific company, and every number in it is an illustrative assumption.

    The situation

    A building-monitoring platform collects temperature, humidity and energy readings from 200,000 devices, each reporting every 10 seconds, so about 20,000 writes per second on average (assumed). Seven engineers run it on Kubernetes: devices publish to an MQTT broker, Kafka buffers the stream, and ingest consumers write into a single PostgreSQL primary. Grafana dashboards read from the same database.

    At roughly 100 bytes per reading (assumed), the stream is 1.728 billion rows and about 173 GB of raw data per day, or roughly 63 TB per year. The single readings table has two B-tree indexes, and a nightly DELETE removes data older than 90 days. Pain: insert latency rises through the day, vacuum and WAL volume are high, deletes leave bloat, and fleet-wide dashboard queries take minutes and slow the device-registry queries that share the primary.

    Starting architecture

    Telemetry platform starting architecture: every reader and writer shares one PostgreSQL primaryDevices to MQTT broker; MQTT broker to Kafka; Kafka to Ingest; Ingest to PostgreSQL (insert rows); PostgreSQL to Grafana; PostgreSQL to Registry APIDevices: 200,000Devices200,000MQTT brokerMQTT brokerKafka: stream bufferKafkastream bufferIngest: consumersIngestconsumersPostgreSQL: single primaryPostgreSQLsingle primaryGrafana: dashboardsGrafanadashboardsRegistry API: devicesRegistry APIdevicesinsert rows

    Options considered

    Option What it fixes What it costs Risk
    A. Smaller change: keep PostgreSQL, partition by day, batch with COPY, drop old partitions instead of deleting, remove one index Bloat, delete cost, some insert pressure Little new infrastructure Still one primary; fleet aggregates still scan row-wise
    B. Columnar time-series store (a ClickHouse-style engine) fed from Kafka; PostgreSQL keeps the device registry Ingest and compressed storage; scans touch only needed columns A new database to operate; no cross-store joins Small insert batches can overwhelm background merging
    C. LSM wide-column store keyed by device and day Very high write ingest, simple scaling New operations skill; weak ad-hoc aggregates Dashboards need precomputed tables

    The decision

    Field Entry
    Context 20,000 writes per second, 90-day hot retention, fleet-level aggregates, a small team
    Decision Option A immediately (it is cheap and removes the worst pain), then option B for telemetry within the quarter. Time partitioning by day, daily rollups kept for two years, raw data archived as files in S3-compatible storage
    Good consequences Scans read only needed columns; compression lowers disk cost; dropping a partition replaces row deletes; the registry is isolated from telemetry
    Bad consequences Two databases to run and back up; batching rules are mandatory; the latest value per device needs its own path
    Revisit when Ingest-to-dashboard lag p95 exceeds 60 seconds (assumed), or the fleet grows beyond three times today's size and sharding the store becomes necessary

    The latest reading per device is a point lookup, which a columnar store does poorly, so a small Redis hash (or a compacted Kafka topic feeding a table) serves "current value" queries.

    Rollout and verification

    Rollout: Kafka feeds both stores through independent consumer groupsKafka to Consumers; Kafka to Consumers; Consumers to PostgreSQL; Consumers to Columnar store; Columnar store to GrafanaKafka: topicKafkatopicConsumers: group 1Consumersgroup 1Consumers: group 2Consumersgroup 2PostgreSQL: partitionedPostgreSQLpartitionedColumnar storeColumnar storeGrafana: panelsGrafanapanels

    Kafka is the log, so you can feed both stores from independent consumer groups without application dual writes. Backfill the last 30 days from PostgreSQL, then compare row counts per device and hour between stores daily. Move Grafana panels one at a time, comparing results, and stop PostgreSQL telemetry writes only after two weeks of agreement.

    Prove it with: ingest lag per consumer group, rows per second, insert batch size, the number of unmerged data parts and merge backlog, disk usage and compression ratio, dashboard query p95, and the daily count mismatch between stores. Load test at about 5 times average ingest (assumed), since reconnect storms arrive in bursts.

    The 2 a.m. failure

    The page: "Ingest consumer lag above 10 minutes". A regional network outage ended and about 60,000 devices flushed buffered readings at once. The ingest tier autoscaled to many pods, each inserting very small batches. Every insert creates a new data part, background merges fell behind, and the store began throttling and then rejecting inserts because of too many unmerged parts.

    sequenceDiagram participant D as Devices via MQTT participant K as Kafka participant I as Ingest pods participant S as Columnar store D->>K: burst of buffered readings Note over K,I: lag grows, autoscaler adds pods I->>K: poll small batches K-->>I: records I->>S: many tiny inserts S-->>I: too many parts, insert rejected Note over I,S: merges cannot keep up

    First three checks:

    1. kafka-consumer-groups.sh --bootstrap-server <broker> --describe --group telemetry-ingest to measure lag per partition.
    2. In the store, count active parts per table (in ClickHouse-style systems, a query on system.parts) to confirm the merge backlog.
    3. kubectl get hpa -n ingest to see how far the autoscaler scaled the consumers.

    Mitigation: cap the replica count (consumers beyond the partition count add nothing anyway), raise the minimum batch (for example flush by row count or every second, whichever comes first), and let merges drain; lag then falls on its own. Follow-ups: enforce batching in the ingest library, bound autoscaling, alert on part count as a leading indicator, and add a rehearsal of a reconnect storm to the load test. This is the LSM compaction trade-off earlier in this lesson, seen from the operator's chair.

    Architect's notes

    • Measure the query mix before picking an engine. Count point lookups, range scans and fleet aggregates per day. A dashboard that runs once an hour does not justify the same machine as 20,000 inserts per second.
    • Ask what the retention and the audit rules are. Retention decides cost more than the engine does; legal hold on raw data changes the archive design.
    • The cost trap is the second copy. Two stores, Kafka retention, archives and backups can exceed the original database bill; model total bytes stored, not one table.
    • Own the batching contract. One team must own how writers batch, because bad batching can take down a healthy store.
    • Test restore, not backup. A columnar store restore is a different procedure from PostgreSQL; rehearse it, including the archive replay path.
    • Defer sharding the new store. One well-sized node group may carry this load for a long time; shard when measured ingest or storage demands it.

    Common mistakes

    • Choosing a store by popularity. The engine's layout must match your access pattern. Define reads, writes and queries first.
    • Running analytics on the OLTP primary. Long scans evict cache and slow user requests. Replicate or export to an analytical system.
    • Over-indexing a B-tree table. Every index must be updated on each write, increasing write cost and storage. Index for queries you actually run.
    • Ignoring compaction in LSM systems. Backlogs cause read slowdowns and write stalls. Monitor pending compaction and disk headroom, including temporary space during merges.
    • Heavy deletes with tombstones. Mass deletions can slow range reads. Prefer time-partitioned data that can be dropped whole, or schedule compaction.
    • Unbounded metric labels. Using user ids as labels explodes cardinality. Keep labels to bounded sets.
    • Disabling durability for speed without understanding it. Relaxing log flushing can lose acknowledged writes on a crash. Make the choice consciously per workload.

    Key takeaways

    • Engine choice is a trade-off among three amplifications. Write, read and space costs move against each other.
    • B-trees update pages in place. They give steady point reads and range scans, with write amplification from page rewrites.
    • The WAL makes writes durable. Append and flush first, apply later, replay after a crash; it also powers replication and change capture.
    • LSM-trees turn random writes into sequential ones. Memtable, SSTables, Bloom filters and compaction trade write cost for fast ingest.
    • Compaction is the operational heart of LSM engines. Strategy choice shifts cost between reads, writes and space.
    • Row stores serve whole-row transactions; column stores serve scans. Compression and skipping unused columns make analytics fast.
    • Keep OLTP and OLAP separate. Copy data to a system built for analysis instead of straining the primary.
    • Plan for denormalisation and schema change. Both are long-term costs you pay for read speed and flexibility.

    Practice exercises

    Exercise 1 — Starter (30 min): A table has 50 columns and 100 million rows (assumed). A report needs the average of one numeric column grouped by one categorical column. Explain in steps how many columns a row store and a column store each must read, and estimate the ratio of data read, assuming equal-width columns.

    Exercise 2 — Intermediate (2 hours): Trace by hand an LSM engine handling these operations with a memtable that flushes every 3 keys: put a=1, b=2, c=3, put a=4, delete b, put d=5, put e=6. Draw the memtable and SSTables after each flush, show where a tombstone appears, what a read of a and of b checks, and what one compaction of all SSTables produces.

    Exercise 3 — Advanced (half day): Design storage for an IoT platform ingesting readings from 200,000 devices every 10 seconds (assumed). Choose the engine type and justify it, propose the time partitioning and retention, a downsampling scheme with dates for each tier, an approach to label cardinality, how dashboards query recent versus historical data, and how you would back up and restore. Estimate daily row count and raw storage using stated assumptions about bytes per reading.

    • Databases basics — indexes, transactions and the relational model this lesson builds on.
    • Replication — how the write-ahead log becomes the replication stream.
    • Sharding — splitting data across several engines once one node is not enough.
    • Search and indexing — segments and merging, an LSM-like idea applied to text.
    • Observability and SLOs — time-series data in practice, including cardinality limits.

    Enroll to keep reading

    Lesson 12 of 14 · System Design Building Blocks: Data, Messaging and Scale

    Compare B-trees and LSM-trees, understand write-ahead logs, compaction and amplification, and choose between row, column and time-series storage.

    In the rest of this lesson

    • B-tree versus LSM-tree
    • Row stores versus column stores
    • Time-series data
    • Denormalisation and schema evolution
    • Choosing an engine
    • In practice: an IoT telemetry platform choosing storage for high-write sensor data

    Enrolling is free. It unlocks every lesson in System Design Building Blocks: Data, Messaging and Scale, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments