Time-Series Database#
Problem statement (interviewer prompt)
Design a time-series database (Prometheus / InfluxDB / TimescaleDB). It must ingest 1M+ samples/s, compress to ~1 byte/sample, support range queries with aggregations, downsample for long-term storage, and gracefully reject high-cardinality writes.
flowchart LR
W[Writers<br/>agents, services]
ING([Ingest API])
WAL[WAL]
MEM[Mem buffer<br/>per-series]
TSM[(Compressed TSM blocks)]
Q[Query Engine]
R([Reader / Dashboard])
W --> ING --> WAL --> MEM
MEM -. flush .-> TSM
R --> Q --> TSM
Q --> MEM
classDef client fill:#dbeafe,stroke:#1e40af,stroke-width:1px,color:#0f172a;
classDef edge fill:#cffafe,stroke:#0e7490,stroke-width:1px,color:#0f172a;
classDef service fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
classDef datastore fill:#fee2e2,stroke:#991b1b,stroke-width:1px,color:#0f172a;
classDef cache fill:#fed7aa,stroke:#9a3412,stroke-width:1px,color:#0f172a;
classDef queue fill:#ede9fe,stroke:#5b21b6,stroke-width:1px,color:#0f172a;
classDef compute fill:#d1fae5,stroke:#065f46,stroke-width:1px,color:#0f172a;
classDef storage fill:#e5e7eb,stroke:#374151,stroke-width:1px,color:#0f172a;
classDef external fill:#fce7f3,stroke:#9d174d,stroke-width:1px,color:#0f172a;
classDef obs fill:#f3e8ff,stroke:#6b21a8,stroke-width:1px,color:#0f172a;
class W,WAL,MEM,Q service;
class TSM datastore;
class ING compute;
class R obs;
flowchart TB
subgraph Sources[Sources]
EXP([Exporters / SDKs])
SCR[Pull scraper - Prometheus]
AGT[Push agent - Telegraf / OTel]
EDGE([Edge devices / IoT])
end
subgraph Ingest
LB[Ingest LB]
AUTH[AuthN / multi-tenant]
REL[Relabel + filter]
DEDUP[Dedup window]
WAL[Per-tenant WAL]
end
subgraph Memory[Hot tier]
SER[Series cache<br/>label-set → ID]
MEM[In-memory chunks<br/>2h Prometheus / segment]
INV[(Inverted index<br/>label → series ids)]
end
subgraph Compaction
FLUSH[Flush to disk every 2h]
HEAD[Head block]
LEV[Compaction levels]
DELTA[Delta + Gorilla compression]
TSI[TSM / Parquet files]
end
subgraph Long[Long-term tier]
LTS[(Thanos / Mimir / Cortex<br/>S3 + sidecar)]
DOWN[Downsampling 5m / 1h / 1d]
RETN[Retention policies]
end
subgraph Query
PROMQL[PromQL / Flux / SQL]
PLANNER[Query planner]
EXEC[Range vector eval]
AGG[Aggregations: rate, irate, histogram_quantile]
RECORDING[Recording rules]
ALERT[Alert rules]
end
subgraph Downstream
DASH[Grafana]
PD[PagerDuty / Opsgenie]
SLO[SLO platform]
end
Sources --> LB --> AUTH --> REL --> DEDUP --> WAL --> MEM
MEM --- SER
MEM --- INV
MEM -. FLUSH .-> HEAD --> LEV --> TSI
TSI -. upload .-> LTS
LTS --> DOWN
Query --> MEM
Query --> TSI
Query --> LTS
Query --> PROMQL --> PLANNER --> EXEC --> AGG
Query --> RECORDING
Query --> ALERT --> PD
Query --> DASH
classDef client fill:#dbeafe,stroke:#1e40af,stroke-width:1px,color:#0f172a;
classDef edge fill:#cffafe,stroke:#0e7490,stroke-width:1px,color:#0f172a;
classDef service fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
classDef datastore fill:#fee2e2,stroke:#991b1b,stroke-width:1px,color:#0f172a;
classDef cache fill:#fed7aa,stroke:#9a3412,stroke-width:1px,color:#0f172a;
classDef queue fill:#ede9fe,stroke:#5b21b6,stroke-width:1px,color:#0f172a;
classDef compute fill:#d1fae5,stroke:#065f46,stroke-width:1px,color:#0f172a;
classDef storage fill:#e5e7eb,stroke:#374151,stroke-width:1px,color:#0f172a;
classDef external fill:#fce7f3,stroke:#9d174d,stroke-width:1px,color:#0f172a;
classDef obs fill:#f3e8ff,stroke:#6b21a8,stroke-width:1px,color:#0f172a;
class EXP,EDGE client;
class LB edge;
class AUTH,REL,DEDUP,WAL,SER,FLUSH,HEAD,LEV,DELTA,TSI,DOWN,RETN,PLANNER,EXEC,AGG,RECORDING service;
class INV,PROMQL datastore;
class LTS storage;
class SCR,AGT,MEM,ALERT,DASH,PD,SLO obs;
Why TSDB needs a special engine#
- Each metric series is identified by label set
(name, {labels…}). - Most queries read a small subset of series over a wide time range.
- Data is mostly written once, read many; values are floats; timestamps monotonic.
- Compression: delta encoding for timestamps + Gorilla / XOR for floats can hit 1-2 bytes/sample.
Hot path layout (Prometheus)#
- In-memory head block holds 2 hours of samples per series.
- Inverted index maps labels → series IDs.
- Series cache turns label hash → ID at ingest.
- After 2 h, head block sealed and written to disk as immutable block.
Long-term & downsampling#
- Single Prometheus retains weeks; for years use Thanos / Mimir / Cortex / VictoriaMetrics.
- Downsample to 5 m / 1 h / 1 d for fast historical queries.
Cardinality is the killer#
- Each unique label set = new series.
- Putting
user_idin a label can explode series count → memory crash. - Always cap cardinality at ingest; reject high-cardinality writes.
Glossary & fundamentals#
Concepts referenced in this design. Each row links to its canonical page; the tag column shows whether it is a high-level (HLD) or low-level (LLD) concept.
| Tag | Concept | What it is | Page |
|---|---|---|---|
HLD |
CAP / PACELC | C vs A under partition; L vs C otherwise | cap-pacelc |
HLD |
LSM vs B-Tree engines | WAL, memtable, SSTables, compaction | storage-engines-lsm-btree |
HLD |
Observability | metrics, logs, traces, SLOs | observability |
HLD |
Service mesh | sidecar mesh, mTLS, traffic policy | service-mesh |
HLD |
Search internals | inverted index, BM25, embeddings, ANN | search-internals |
LLD |
Testing strategy | pyramid, doubles, TDD, contracts | testing-strategy |
LLD |
Immutability | immutable types, persistent collections | immutability |
Quick reference#
Functional#
- Ingest timestamped numeric (and sometimes string) samples tagged by labels.
- Query with range, aggregations, downsampling.
- Retention + downsampling rules.
- Alerting on derived series.
Non-functional#
- 1M+ samples/s per node (compressed format).
- p99 query < 1 s for typical dashboard panels.
- Retention from days (hot) to years (cold tier).
Capacity#
- Active series per node: 1-10 M depending on RAM.
- Disk: ~1-2 bytes / sample after Gorilla compression.
Schema#
- Series =
(name, label set)→ unique ID. - Sample =
(series_id, ts, value). - Inverted index:
label=value → posting list of series IDs.
API#
# Prometheus push gateway / remote_write
POST /api/v1/write protobuf
GET /api/v1/query?query=rate(http_requests_total[5m])
GET /api/v1/query_range
Trade-offs#
- Cardinality limit: every prod TSDB lives or dies by it. Never label with user IDs.
- Pull vs push: Prometheus pulls (good for service discovery); Influx pushes (good for short-lived jobs).
- One backend vs federated: Thanos / Mimir federate many Prom shards behind a single query API.
- Long retention = cold tier on object storage; query merges hot + cold via sidecars.
Refs#
- "Gorilla: A Fast, Scalable, In-Memory Time Series Database" (FB, VLDB '15).
- Prometheus storage docs; TimescaleDB chunking docs.
- Thanos / Cortex / VictoriaMetrics architecture posts.
FAQ#
How do time series databases compress samples to about 1 byte?#
Gorilla style compression delta encodes timestamps and XOR encodes float values, exploiting the fact that adjacent samples in a series rarely differ much. This often achieves around 1.3 bytes per sample on average.
What is the role of the WAL in a TSDB?#
Incoming samples are appended to a write ahead log first, then buffered in an in memory per series structure. The WAL ensures durability so recent writes survive a crash before being flushed to disk.
How does downsampling work in a time series database?#
A background job aggregates raw samples into coarser resolutions like 5 minute and 1 hour rollups. Older data is queried at lower resolution, cutting storage and speeding up wide range queries.
Why is high cardinality dangerous for a TSDB?#
Each unique label combination creates a separate series with its own index entry and memory buffer. Unbounded labels like user_id or request_id can blow up memory and degrade query performance.
When should I pick a TSDB over a relational database?#
Pick a TSDB when ingest is append only, queries are range scans with aggregations over time, and data has natural retention. Stick with SQL for transactional workloads or rich joins.