Designing a Metrics / Time-Series Store

A system design interview guide to building an operational metrics store — the kind that ingests millions of data points per second, keeps years of history without going broke, and answers a dashboard query in milliseconds.

Student edition · start with the guarantee

Store timestamped metric series while preserving the meaning of counters, gauges, and aggregated distributions.

Correctness contract: A series identity is stable; writes have an explicit durable/ordering policy; rollups retain enough information for the queries they promise.

Basic HTTP, databases, and programming are assumed. Specialized terms are explained below. Named products motivate the problem; this is an educational design, not a claim about their current internals. Capacity numbers are illustrative decimal payload estimates, not benchmarks. Add indexes, protocol overhead, replicas, headroom, and failure capacity before provisioning.

Every large system is wired with sensors: counters for requests served, gauges for memory used, timers for latency, one metric for almost everything an operator might want to graph. Collected together at scale, these emit an astonishing firehose of timestamped numbers, and somebody has to store them in a way that is cheap to write, cheap to keep, and fast to query. A general-purpose database struggles with this workload because it was not built for it — the access pattern is overwhelmingly append-only writes and range-scan reads over a very specific shape of data. A purpose-built time-series store, of the sort an operational data store like ODS provides, makes a handful of specialized choices that a relational database cannot. This guide builds up those choices: the data model, the write path, downsampling into retention tiers, the compression tricks that make years of history affordable, and the query path that ties it together.

Contents

  1. Requirements and Clarifying Questions
  2. Back-of-the-Envelope Estimation
  3. The Data Model
  4. Counters vs Gauges
  5. High Cardinality
  6. API and Data Model
  7. The Write Path
  8. Rollups and Retention Tiers
  9. Compression
  10. The Query Path
  11. Read vs Write Separation
  12. Failure Modes
  13. Tradeoffs and Alternatives
  14. A 45-Minute Interview Plan
  15. Common Mistakes
  16. Summary
  17. Check Yourself
  18. Key Terms
  19. Concepts to understand first
  20. Worked example
  21. Runnable Python model

Concepts to understand first

Each definition includes a concrete example and the condition under which the mechanism is useful. You can read this guide without opening another guide.

Cardinality

The number of distinct identities a store must track. A metric series is its metric name plus the full label combination. The product of label-value counts is an upper bound when not every combination occurs.

Example. 100 hosts × 20 routes × 5 status codes can produce up to 10,000 series. Adding a unique request_id can make every request create a new series.

Limit. Hashing every ID to a distinct digest does not reduce cardinality or guarantee anonymity. Use bounded categories, explicit limits, or intentional fixed-bucket aggregation.

Rollups and weighted aggregation

A precomputed summary per time bucket. Sums and counts merge by addition, min/max by comparison. An average is derived as total sum divided by total count, not an unweighted average of bucket averages.

Example. One window has [10], another [20,20,20]. Merge sum=70 and count=4 to get 17.5, not the unweighted mean of window means (10+20)/2=15.

Limit. Do not sum cumulative counter snapshots to compute traffic, or average p99 values to get a global p99. Retain reset-aware counter increases or compatible histogram distributions.

Write-ahead log and fsync

A WAL records updates before data pages are considered committed. Recovery replays it after a crash. fsync (or an equivalent storage contract) requests that buffered bytes reach durable storage rather than only process/OS memory.

Example. A server acknowledges after its WAL commit and required replica writes are durably accepted. A process crash can then reconstruct the committed update.

Limit. Appending to a file alone does not guarantee power-loss durability. Device behavior, filesystem ordering, replicas, corruption, and the declared fault budget still matter.

Inverted indexes and columnar storage

An inverted index maps searchable terms to document postings. A columnar file groups values by column so analytical scans can read only selected fields. They optimize different access patterns.

Example. 'ERROR' → document IDs {2,7}; 'service=api' → {1,2,7}. Intersection returns {2,7}. A monthly count may instead scan only time, service, and status columns.

Limit. Tokenization affects meaning, indexes have storage/write cost, and incomplete shard responses must be marked. Search refresh is visibility, not the same as durable commit.

Event time and watermarks

Event time is when the producer says an event happened; processing time is when a consumer handles it. A watermark is the processor's policy-driven estimate of event-time progress, used to emit and eventually clean up window state.

Example. An event for 12:00 arrives at 12:07. A 5-minute allowed-lateness policy may update its window, divert it to correction, or reject it according to the API contract.

Limit. Watermarks are not proof all earlier events arrived. Idle inputs, skew, and unbounded delay require explicit policies; later corrections must propagate to rollups and reports.

Offsets and checkpoints

An offset is a position in one log partition. A checkpoint is a recoverable, consistent record of input positions and processing state. On restart, state must correspond exactly to the input that is considered processed.

Example. If a batch increments a total and crashes before saving its offset, replay increments twice. Commit the total, dedup state, and partition position together, or use a compatible transactional/idempotent sink.

Limit. Saving an offset after a write avoids that specific loss window but can still duplicate; framework checkpoints alone do not make arbitrary external effects transactional.

1. Requirements and Clarifying Questions

FunctionalNon-functional
Write points (name, tags, ts, value).Sustain 500 K points/s; never block writers on reads.
Query by metric, tag filter, and time range with aggregation.Dashboard queries under 1 s over the last few hours.
Support counters (rate) and gauges.Alert evaluation keeps working during query storms.
Downsample and expire old data automatically.Years of history at low cost.

2. Back-of-the-Envelope Estimation

Illustrative inputs: 5 million active series sampled every 10 seconds produce 500,000 points/s and 43.2 billion points/day. Sixteen payload bytes per point give 691.2 GB/day before indexes, framing, replicas, and storage overhead. Gorilla's paper reports about 1.37 bytes/point on its measured workload; applying that as a hypothesis gives 59.2 GB/day, not a universal bound.

Rollup storage depends on the actual fields and measured encoded size. For four 8-byte aggregates, 5-minute payloads alone are 5M×288×32≈46.1 GB/day before compression/metadata. Measure count/sum/min/max separately and add retained tiers, index/head memory, WAL, churn, compaction space, replicas, and failure headroom. Five million series can fit on some fleets, but arithmetic payload size alone does not prove a few servers meet the workload.

3. The Data Model

The whole design rests on a deliberately narrow data model. A single data point is the combination of four things, and pinning them down early is what lets every later layer be specialized.

So a data point is (name, {tags}, timestamp, value), and a series is the ordered sequence of (timestamp, value) pairs that share one name-and-tags identity. This is the unit everything operates on: writes append a point to a series, reads scan a range within one or more series, and compression works down the (timestamp, value) columns of a series. The narrowness is the point — because the shape is so constrained, the storage engine can make assumptions a general database never could.

ComponentExampleRole
Metric namehttp.requestsNames the family of series.
Tagshost=web42, status=200Selects one specific series within the family.
Timestamp1700000000Position along the time axis of that series.
Value97.3The measurement at that instant.

4. Counters vs Gauges

Values come in two flavors, and the distinction changes how they are queried even though they are stored the same way. Knowing which is which is the difference between a sensible graph and a meaningless one.

A common interview trap is treating a counter like a gauge. Plotting a raw counter shows an ever-rising line that tells you little and resets to zero whenever the process restarts. The store keeps the raw monotonic values; the query layer applies a rate function to make them useful. That function must be reset-aware: when a value drops, the process restarted, so the increase is the new value, not a negative number. Knowing where that conversion happens — read time, not write time — signals real familiarity.
Raw counter (requests_total) process restart: 100 → 5 Per-step increase naive difference: +10 +10 +10 +10 +10 +10 -95 +10 +10 +10 +10 reset-aware: +10 +10 +10 +10 +10 +10 +5 +10 +10 +10 +10 A drop means the counter restarted; count the new value as the increase since the reset.
A counter only goes up, except when the process restarts and it starts again from zero. A naive difference shows a huge negative spike. Reset-aware rate functions, such as Prometheus rate(), treat any drop as a restart.

5. High Cardinality

The hardest scaling problem in a metrics store is not the volume of points; it is the number of distinct series, known as cardinality. Cardinality is the product of every tag's distinct values. One metric tagged by host, status code, endpoint, and region explodes combinatorially: a thousand hosts times a hundred endpoints times a dozen status codes is already over a million distinct series from a single metric name.

Cardinality hurts because the store must track an index entry and an in-flight write buffer for every active series. A tag whose values are effectively unbounded — a user id, a request id, a raw timestamp jammed into a label — is the classic cardinality bomb: each new value mints a brand-new series forever, and the index grows without limit until memory is exhausted. The defenses are mostly disciplinary: keep tags to bounded, low-cardinality dimensions; never put unbounded identifiers in labels; and put limits and monitoring on series creation so a misbehaving client cannot take the system down by inventing millions of series.

TagCardinalityVerdict
region~10 valuesFine — bounded and small.
status~40 valuesFine — bounded.
hostthousandsAcceptable, watch it.
user_idmillions+Cardinality bomb — never use as a tag.

6. API and Data Model

# write (agents push batches; or the server scrapes /metrics endpoints)
POST /api/v1/write
  [{ "name": "http_requests_total",
     "labels": { "service": "checkout", "status": "500", "host": "web42" },
     "samples": [[1758808987000, 18234], [1758808997000, 18251]] }]

# query: errors per second by service over the last hour, 1-minute steps
GET /api/v1/query_range?query=sum by (service) (rate(http_requests_total{status="500"}[5m]))
                       &start=-1h&end=now&step=60s
StructureKeyHolds
Series indexhash(name + sorted labels) → series_idThe label set for each series.
Inverted index (postings)label=value → sorted list of series_idsFast tag filtering by list intersection.
Head blockseries_id → open compressed chunkThe last ~2 hours in memory, backed by a WAL.
Persistent blocks(time range) → chunks + indexImmutable files for older data; merged by compaction.
Rollup blocks(series_id, resolution, window)sum, count, min, max (and last for gauges).

7. The Write Path

The write path is engineered around one fact: the workload is overwhelmingly appends. Points arrive in roughly time order, almost always newer than what came before, and are rarely updated once written. That lets the ingestion path be far simpler and faster than a general database's.

Time-series store: ingest, WAL and in-memory head block, raw blocks flushed at full detail, background compaction into rollups, query engine
Incoming points are resolved to a series, logged to the WAL and appended to an in-memory head block. Full blocks are flushed to disk at full detail. A background compactor later downsamples old raw blocks into rollups. The query engine reads the head block, raw blocks and rollups and merges them by time.

The stages:

function write(point):
  series_id = index.resolve(point.name, point.tags)  # name + tags -> series
  wal.append(series_id, point.ts, point.value)       # durability first
  buffer[series_id].append(point.ts, point.value)    # hot in-memory append
  if buffer[series_id].block_complete():
    flush(compress(buffer[series_id]))                # raw, compressed block

function compactor():                                 # background, off the write path
  for block in raw_blocks_older_than(ROLLUP_AGE):
    write_rollup(downsample(block, "5m"))             # sum, count, min, max per window

8. Rollups and Retention Tiers

Choose retention and aggregates from query requirements. For gauges, store sum, count, min, and max; combine means as total sum/total count. If you retain a fifth aggregate, name it and explain its use rather than silently assuming five two-byte values. Missing samples and uneven spacing require an explicit sample-weighted versus time-weighted policy.

For cumulative counters, sum of snapshot values is not traffic. Retain a reset-aware increase/rate representation, boundary information, and duration; take rate per original series before summing across instances so resets remain visible. A drop suggests a reset under the exporter contract, but can also be an ordering/source error.

For histograms, sum compatible bucket counts/sums or merge a documented distribution sketch, then compute a quantile. Global p99 cannot be obtained by averaging per-window or per-shard p99s. Once raw data expires, queries are restricted to retained resolution and sufficient statistics; do not promise arbitrary historical detail.

9. Compression

Even with rollups, the raw tier holds an enormous number of points, so compression is what makes the whole thing economical. Time-series data compresses extraordinarily well because both its columns are highly regular, and the canonical techniques — popularized by the Gorilla in-memory store — exploit that regularity directly. The trick is to compress timestamps and values as two separate streams.

Delta-of-delta timestamp and XOR float compression
Timestamps are compressed with delta-of-delta: regular cadence makes the second difference almost always zero, stored in a tiny number of bits. Values are XORed against the previous value: slowly changing floats differ in only a few bits, which are packed compactly.

Delta-of-delta on timestamps. Points usually arrive at a fixed cadence — say every minute. The difference between consecutive timestamps (the delta) is therefore nearly constant, and the difference between consecutive deltas (the delta-of-delta) is almost always zero. Storing a long run of zeros takes almost no space, so a stream of regular timestamps collapses to a handful of bits per point instead of a full 64-bit timestamp.

XOR on values. Adjacent readings of a real metric tend to be close — CPU usage at 50.1% then 50.2%. XORing each float against the previous one yields a result that is mostly zero bits, with only a small middle band of differing bits. Storing just that band of meaningful bits, rather than the whole 64-bit float, shrinks slowly-changing series dramatically. When a value is unchanged, the XOR is all zeros and costs a single bit.

# timestamps: store the second difference (usually 0)
dod[i] = (ts[i] - ts[i-1]) - (ts[i-1] - ts[i-2])   # regular cadence -> 0

# values: XOR against previous, store only the changed bits
xor = bits(value[i]) ^ bits(value[i-1])            # mostly zero bits
store(leading_zeros(xor), meaningful_bits(xor))    # skip the zeros
The combined effect is striking: Facebook's Gorilla paper reports an average of about 1.37 bytes per point, against 16 bytes for a raw 64-bit timestamp plus 64-bit value — roughly 12× smaller. This is what lets the hot tier live in memory and the cold tiers stay affordable. The insight worth stating in an interview is why it works — time-series data is regular in both axes, so you store differences and changed bits rather than full values.

10. The Query Path

A query against a metrics store has a recognizable shape: it picks a metric, filters by tags, restricts to a time range, and aggregates. The engine answers it by turning that into a series selection plus a scan plus a fold.

function query(metric, tag_filter, start, end, agg):
  series = index.match(metric, tag_filter)     # inverted index intersection
  tier   = pick_resolution(start, end)         # finest tier covering the range
  result = []
  for s in series:
    points = storage.scan(s, tier, start, end) # decompress overlapping blocks
    result.append(aggregate(points, agg))      # avg / sum / rate / ...
  return combine(result, agg)                  # group across series

11. Read vs Write Separation

The write and read workloads have almost nothing in common, so a robust design keeps them on separate paths. Writes are a relentless, latency-sensitive append stream that must never stall; reads are spiky, often expensive range scans driven by humans opening dashboards and by alerting rules evaluating continuously. Letting a heavy read query contend with the ingest path is how a metrics system falls over exactly when an incident makes everyone open dashboards at once.

Separation shows up at several layers. The ingest tier and the query tier scale independently, so a surge of dashboard traffic does not slow ingestion and a write spike does not slow queries. The memory buffer serves recent reads directly without disturbing the flush pipeline. And because the data is immutable once flushed, historical reads can be served from the storage tiers with no coordination against ongoing writes at all — there are no locks to contend, since old blocks never change. The result is a system that keeps ingesting through a query storm and keeps serving queries through a write spike.

The theme echoes the rest of the design: specialize relentlessly. Just as the data model is narrowed so storage can make assumptions, the paths are split so each can be tuned for its own workload. A general database mixes these and pays for it; a purpose-built metrics store separates them and wins.

12. Failure Modes

FailureWhat happensMitigation
Ingest node crashesHead block in memory lost.Replay the WAL on restart; run two replicas that both ingest the same series.
Cardinality explosionIndex memory grows until OOM.Per-tenant and per-metric series limits; reject new series past the cap; alert on series churn.
Expensive query (a year of raw data)Query nodes overload.Query limits (samples, series, time); route long ranges to rollups; separate alerting from ad-hoc queries.
Late or out-of-order pointsRejected or placed in a closed block.Accept a small out-of-order window in the head; drop or backfill-block anything older.
Counter resetNegative spikes on graphs.Reset-aware rate().
Missing scrapes (host down)Gaps; alerts may not fire.Alert on absence (up == 0) as well as on values.

13. Tradeoffs and Alternatives

ChoiceOption AOption BPick when
CollectionPull (server scrapes targets, Prometheus)Push (agents send, StatsD/OTLP)Pull gives free up/down health and central control. Push suits short-lived jobs and firewalled hosts.
Storage enginePurpose-built TSDB (Prometheus, VictoriaMetrics, M3)Columnar OLAP (ClickHouse) or general DB (TimescaleDB)TSDB for metrics at scale; OLAP when you need high-cardinality slicing or SQL joins.
Rollup timingAt ingest (pre-aggregate)Background compactionIngest-time is cheaper to query but you lose raw detail you did not plan for.
Long-term storageLocal disksObject storage (Thanos, Cortex/Mimir)Object storage for years of history and global queries.
High-cardinality dataLabels in the TSDBLogs, traces, or an events storePer-user or per-request detail belongs outside the metrics store.

Worked example: why averages and counter snapshots cannot be summed blindly

Two windows have gauge samples [10] and [20,20,20]. A process counter separately records 100,120,5,15 after a reset.

Worked example, in orderCompute the gauge rollup: First window: sum=10,count=1. Second: sum=60,count=3. Combined mean is 70/4=17.5, not mean(10,20)=15.. Detect a counter reset: Counter drops from 120 to 5. Under a reset-to-zero assumption, observed increases are 20+5+10=35, not 15−100 and not the sum of snapshots.. Retain query-specific state: Gauge sum/count/min/max cannot reconstruct arbitrary raw samples. Counter rates need reset-aware increases/duration; p99 across shards needs compatible distributions, not averaged p99 values.. Budget active series: 1000 hosts×100 routes×12 status values is an upper bound of 1.2 million combinations. Only observed combinations exist, but per-request labels can still explode state.1Compute the gauge rollup2Detect a counter reset3Retain query-specific state4Budget active series
Read top to bottom. The table below explains the state and guarantee at each step.
StepState, calculation, and reasoning
1. Compute the gauge rollupFirst window: sum=10,count=1. Second: sum=60,count=3. Combined mean is 70/4=17.5, not mean(10,20)=15.
2. Detect a counter resetCounter drops from 120 to 5. Under a reset-to-zero assumption, observed increases are 20+5+10=35, not 15−100 and not the sum of snapshots.
3. Retain query-specific stateGauge sum/count/min/max cannot reconstruct arbitrary raw samples. Counter rates need reset-aware increases/duration; p99 across shards needs compatible distributions, not averaged p99 values.
4. Budget active series1000 hosts×100 routes×12 status values is an upper bound of 1.2 million combinations. Only observed combinations exist, but per-request labels can still explode state.
Check your understanding: Is Gorilla's published compression ratio a provisioning guarantee?

No. It is an observed result on one workload. Measure representative series, headers, rollup encodings, indexes, churn, replicas, and headroom before selecting a fleet.

Run a small, testable model

Download concepts.py. It uses only the Python 3.10+ standard library. From the directory where you saved it, run:

python3 concepts.py rollups
python3 concepts.py test

The model checks weighted means and reset-aware counter increases, then contrasts them with the wrong calculations.

The program contains deterministic assertions and failure traces. It is a learning model, not a production service: it does not implement network consensus, distributed durability, authentication, or third-party delivery. Read its contracts and try changing an input before extending it.

Primary references for these concepts

14. A 45-Minute Interview Plan

MinutesWhat to doWhat to say or draw
0–5ClarifySeries count, interval, query types, alerting, retention.
5–10Estimate500 K points/s; 59 GB/day compressed; series count drives memory.
10–15Data modelname + labels = series; counters vs gauges; cardinality.
15–30Write path and storageWAL + head block, immutable blocks, Gorilla compression, compaction and rollups.
30–40Read pathInverted index, pick the tier, reset-aware rate, sum of rates.
40–45Scale and failuresShard by series hash, replicas, cardinality limits, query limits, alerting isolation.

15. Common Mistakes

16. Summary

A metrics / time-series store is a specialist database whose every choice flows from a single observation: the data has a narrow, regular shape, and the workload is append-heavy writes with range-scan reads.

ConcernMechanism
What is a data point?Metric name + tags + timestamp + value; name plus tags identifies one series.
How are values interpreted?Gauges graphed directly; counters converted to a rate at read time.
What is the dominant scaling limit?Cardinality — keep tags bounded, never label with unbounded ids.
How do we ingest at scale?Append-optimized path: stateless ingest, in-memory buffer with a WAL, then flush.
How do we keep years of history cheaply?Downsample into coarser resolutions with longer retention as data ages.
How do we make storage affordable?Delta-of-delta timestamps and XOR float compression on regular series.
How do we answer a query fast?Inverted tag index to select series, range scan the right tier, aggregate.
How do we stay stable under load?Separate read and write paths; immutable flushed blocks need no read-write coordination.
The recurring theme: narrow the data model so the engine can specialize, then exploit the regularity everywhere — append-only writes, downsampled tiers, differential compression, and split read/write paths. The result handles a firehose of points, keeps years of history for little money, and still answers a dashboard in milliseconds.

17. Check Yourself

  1. A metric has labels host (2,000 values), endpoint (50), and status (8). What is the worst-case number of series?
    Answer

    2,000 × 50 × 8 = 800,000 series for that one metric name. In practice fewer combinations occur, but plan for the product.

  2. A counter reads 100, then 120, then 7 (restart), then 30, at 10 s intervals. What is the total increase?
    Answer

    20 + 7 + 23 = 50 over 30 s, so the average rate is about 1.67/s. A naive difference would include −113 and give a negative rate.

  3. Two 5-minute windows have averages 10 (from 30 points) and 40 (from 10 points). What is the 10-minute average?
    Answer

    (10×30 + 40×10) ÷ 40 = 700 ÷ 40 = 17.5. The average of averages, 25, is wrong. This is why rollups keep sum and count.

  4. Why does delta-of-delta encoding make regular timestamps almost free?
    Answer

    With a fixed interval every delta is the same, so the delta-of-delta is 0. Gorilla stores a 0 in a single bit.

  5. 5 M series at a 10 s interval, 1.37 bytes per point. How much disk per day for raw data?
    Answer

    5 M ÷ 10 = 500 K points/s × 86,400 = 43.2 B points × 1.37 B ≈ 59 GB/day.

  6. Why should user_id never be a label, and where should that data go instead?
    Answer

    Each user creates a new series, so index memory grows with the user count. Per-user detail belongs in logs, traces, or an events/OLAP store.

18. Key Terms

Series
One metric name plus one exact set of labels.
Cardinality
The number of distinct identities a store must track. A metric series is its metric name plus the full label combination. The product of label-value counts is an upper bound when not every combination occurs. Example and limits above.
Gauge
A value that can go up or down, graphed as is.
Counter
A value that only increases (until restart); graphed as a rate.
Counter reset
A counter dropping to zero when its process restarts.
Head block
In-memory recent data, backed by a WAL.
WAL
Write-ahead log: append-only file replayed after a crash.
Compaction
Background merging of blocks and writing of rollups.
Downsampling
Replacing fine points with coarser aggregates (sum, count, min, max).
Delta-of-delta
Difference between consecutive timestamp gaps; usually 0.
Postings list
Sorted series ids for one label=value; the inverted index.

Further reading


⌂