Skip to main content

Reference: Time-Series Storage and Cardinality

A reference on time-series storage — per-series compressed chunks, head blocks and the WAL, downsampling, and label cardinality as the real capacity limit.

What it is

A time series is a sequence of (timestamp, value) samples for one series, identified by a metric name and a set of labels (request_latency{service="api", region="eu"}). Metrics systems, monitoring, and IoT all store millions of series, each written continuously and almost never updated. That workload (append-only, ordered by time, queried by label and time range) rewards a purpose-built layout instead of a generic table.

Important

Key takeaway: Store samples as per-series append streams in time-partitioned immutable blocks, with an in-memory head and a write-ahead log for recent data. That layout makes writes cheap, compresses roughly 10×, and lets queries read only the blocks and series they need. The system’s real capacity limit is cardinality, the number of distinct series.

When you care

“Design a metrics system” asks for a storage layout and a write path, and the follow-up is always “what breaks first?” The answer is cardinality, not volume.

Note

Interview signal: Saying “the deciding question is what the write and the query each touch” and then explaining why one row per sample in a generic database fights you shows you can derive the layout instead of reciting it. Naming a label like user_id as the thing that kills the system is the practical detail.

Three layouts

OptionHowVerdict
One row per sample in a generic store(metric, labels, ts, value) rowsWrites scale, but every query regroups rows into series, the label index is still your problem, and a 16-byte sample carries tens of bytes of row overhead. You rebuild a time-series database inside a database that fights you
Purpose-built time-series layoutPer-series chunks in an in-memory head, protected by a log; periodically sealed into immutable blocksThe right answer: it matches the workload
Pre-aggregate onlyCompute fixed rollups at ingest; store no raw seriesCheap and fast, but a question you did not pre-aggregate (“p99 by customer during last Tuesday’s incident”) cannot be answered

Use pre-aggregation selectively, as recording rules for known-expensive dashboard queries, on top of raw storage.

The write path

  1. Agents batch samples and send on each scrape interval. A router hashes the series identity (name plus sorted labels), so every sample of a series lands on the same ingester shard.
  2. The ingester appends the batch to its write-ahead log (a sequential write, the only durability cost on the hot path) and applies it to the head: look up the series (creating it if new; this is where cardinality is born) and append to its active chunk.
  3. Compression happens in the append. Timestamps are stored as delta-of-delta (for a 10-second scrape, almost always a few bits), and values are XORed against the previous float (slowly changing gauges compress to almost nothing). This Gorilla-style compression reaches about 1.5 bytes per sample, roughly a 10× saving over 16 bytes raw, and it only works if samples are stored per series.
  4. Every two hours, the head seals into an immutable block (chunks plus the label index for that window) written to object storage; the log segments for that window are dropped and head memory is freed.
  5. Each series is written to two or three ingesters, so losing one costs nothing and a restart replays only the log.

Worked example. 5 million samples per second at 10-second scrapes means about 50 million active series. That is 5,000,000 × 86,400 ≈ 4.3 × 10¹¹ samples a day: about 650 GB a day at 1.5 bytes per sample, versus about 6.9 TB a day at 16 bytes. The high write rate never touches a database; it is a hash lookup and an in-memory append, with one sequential log stream per shard. The expensive step (building immutable, indexed, compressed blocks) happens every two hours in bulk, off the hot path.

The read path

  1. Resolve series. The label index is an inverted index: labels are terms and series are documents. A query’s selectors (service="api" and __name__="request_latency") are set intersections over posting lists, returning perhaps 12,000 series ids. This intersection is the query’s real cost driver, before a single sample is read.
  2. Fetch chunks for those series and the time range: recent data from the heads (RAM), older data from blocks, with block-level caching (immutability makes caches trivially correct).
  3. Aggregate with push-down: each shard pre-aggregates its share of series and the query node merges partial results, so the fan-in is shards × groups, not series × samples.

Downsampling. A compaction job rewrites sealed blocks at lower resolution: 10-second raw to 5-minute and 1-hour aggregates, storing min, max, sum, and count per window so averages and rates remain computable. A 13-month query reads the 1-hour blocks, about 360× fewer samples than raw, and the query engine picks the resolution by range. Retention becomes literal: delete raw blocks after 30 days, keep downsampled ones.

Cardinality: the failure dimension

A series exists for every distinct label combination. The system fails abruptly when that count explodes: one deploy adds a user_id label to a popular metric and 1,000 series become 50 million overnight, multiplying head memory, index size, and query fan-out at once. It is the metrics equivalent of a celebrity problem: series-per-metric is power-law distributed and one bad label is the whole tail.

Worked example. A request counter labeled by service (5), endpoint (20), status class (5), and customer (100) has 5 × 20 × 5 × 100 = 50,000 series. Drop the customer label (high-cardinality detail belongs in logs and traces) and it is 500.

Defenses, in the order to deploy them:

  1. Per-tenant and per-metric series limits enforced at ingest, with new series beyond the cap rejected loudly.
  2. A cardinality dashboard that shows the top metrics by series count, so a bad label is visible before it is an outage.
  3. Label allow-lists and review for new labels.
  4. Recording rules that pre-aggregate the shapes people actually query.

Warning

Production reality: Monitor the monitoring system. If the metrics store is down during the incident it exists to diagnose, it has failed at its one job. Run a small independent “meta-monitoring” path (heartbeats, dead-man switches) that alerts when the main one stops reporting.

When to pick what

NeedChooseWhy
Millions of series, append-only samplesA time-series database layoutCompression and per-series locality
Fast queries over recent dataAn in-memory head plus a results cacheRAM-speed reads
Cheap long-range queriesDownsampled blocks, chosen by rangeAbout 360× fewer samples
Protect against label explosionsSeries limits at ingestRejects the bad actor early
Per-user or per-request detailLogs or traces, not metric labelsUnbounded cardinality
Known-slow dashboard queriesRecording rulesCompute once, read many

Caution

Common mistake: Putting an unbounded identifier (user id, request id, URL path with ids) in a metric label. The system’s capacity is the series count, and every unique value is a new series; metrics are for aggregates.