Skip to content

Downsampling

Storage review, Thursday. Finance flags the observability budget: the raw-readings disk has grown to 160 TB and is still climbing. Someone points out the dashboards only ever render a 1,200-pixel-wide chart — nobody looks at more than 1,200 points at once, no matter how many billions of rows sit behind it.

Predict before you read on: (A) buy more/cheaper disk, (B) shorten raw retention with nothing to replace it, (C) build tiered rollups (1 min / 1 hour / 1 day) and drop raw after days not years, or (D) compress harder at the same resolution?

It's (C). 10 million devices × 1 sample / 30 s ≈ 3.3×10⁵ points/s ≈ 2.9×10¹⁰ / day10¹³ / year, and at 16 bytes/point (time + id hash + value) that's already ~160 TB/year before indexes and replicas. You will aggregate. The only question is whether you aggregate once, on write, or every time someone opens Grafana.


Start with the situation

IoT {timestamp, device_id, sensor, value} plus observability events at hundreds of millions/day.

Queries:

Tile Time span What the pixel needs
Debug 1–6 h Raw or 30 s
Ops week 7–30 d 1–5 min
Exec year 1 y 1 h–1 d
Alert last 5–15 min Raw / 1 min, max not only avg

Retention money: keep raw hot for 7 days, not 7 years.


Why the obvious approach breaks

Downsampling destroys information. Average of a minute hides a 5 s 100% CPU. p95 of a day cannot be recovered from 24 hourly p95s by averaging them. Late event time means a rollup you already closed is wrong unless you recompute.

If you downsample in the UI (Grafana “min interval”), 10 M devices still send 10 M series to the UI path. Pre-aggregation must happen per series / per device in the store.


Build the mental picture

A pyramid. Each layer is tumbling windows (windows) with fewer rows.

flowchart TB
    R["Raw 30s  — 7d hot"] --> M["1 min  — 30d"]
    M --> H["1 hour — 1y"]
    H --> D["1 day  — 5y"]

The query router picks the coarsest layer that still fills the pixels (and the statistics the tile needs).

Storage is dominated by the finest layer you keep × retention. Dropping raw after 7 days is the only lever that actually bends the year-cost curve.


Under the hood

Storage math (order of magnitude)

10 M devices, 1 sensor, 30 s raw:

Layer Points / year (if kept all year) Relative to raw
30 s raw ~1×10¹³ 1
1 min ~5×10¹² ½ (two 30 s samples → one minute bucket)
1 h ~8.8×10¹⁰ ~1/120
1 d ~3.7×10⁹ ~1/2880

Keeping raw 7 d + 1 min 30 d + 1 h 1 y + 1 d 5 y is tens of TB, not 160 TB × 5 years. Replicas and indexes still multiply — but the pyramid is why TSDBs quote “10× compression” and still need TTL.

Which statistics to keep

Keep Why
count, sum rates, averages that merge (sum/sum)
min, max spikes survive
quantile state p95 that merges (t-digest, DDSketch, CH quantilesState)
last gauges’ current value
only avg insufficient for ops

Merge law: avg of avgs is wrong unless weighted by count. Always store sum+count or avgState.

Continuous aggregates vs batch vs stream

Mechanism Example Late data
Timescale cagg time_bucket MV, refresh policy Refresh window must overlap lateness
ClickHouse MV insert-time partial aggs Late insert updates the state if it still hits the MV; TTL raw independent
Prom recording rules rate:...:5m Only as good as scrape presence
Flink window job write 1 min table Watermarks define close
Nightly batch Spark on Iceberg Cheap, stale

Grafana “recording” in the browser is not a layer.

Query routing

-- pseudo: pick table by span
-- span < 6h  -> readings_raw
-- span < 40d -> readings_1m
-- else       -> readings_1h

Do this in the API or Grafana using different metrics/tables, not UNION ALL raw with 1h (the optimizer will not save you if you still touch raw).

TTL vs downsampling

TTL deletes. Downsampling copies a summary then deletes. TTL without a layer behind it is amnesia. A layer without TTL is a second full-price copy.


Put it to work

Timescale continuous aggregate:

CREATE MATERIALIZED VIEW readings_1h
WITH (timescaledb.continuous) AS
SELECT
    time_bucket('1 hour', ts) AS hour,
    device_id,
    sensor,
    avg(value) AS avg_v,
    max(value) AS max_v,
    min(value) AS min_v,
    count(*)   AS n,
    sum(value) AS sum_v
FROM readings
GROUP BY hour, device_id, sensor;

SELECT add_continuous_aggregate_policy('readings_1h',
    start_offset => INTERVAL '3 hours',
    end_offset   => INTERVAL '1 hour',
    schedule_interval => INTERVAL '30 minutes');

SELECT add_retention_policy('readings', INTERVAL '7 days');
SELECT add_compression_policy('readings', INTERVAL '2 days');

end_offset 1 hour: do not close the bucket until event time has some slack for late IoT. If devices buffer 40 minutes, 1 hour is still tight — set from measured lag (time semantics).

ClickHouse rollup + TTL:

CREATE TABLE readings_raw (...)
ENGINE = MergeTree
PARTITION BY toYYYYMMDD(ts)
ORDER BY (device_id, sensor, ts)
TTL ts + INTERVAL 7 DAY;

CREATE TABLE readings_1m
(
    m DateTime,
    device_id String,
    sensor LowCardinality(String),
    avg_s AggregateFunction(avg, Float64),
    max_s AggregateFunction(max, Float64),
    min_s AggregateFunction(min, Float64),
    n_s   AggregateFunction(count)
)
ENGINE = AggregatingMergeTree
PARTITION BY toYYYYMM(m)
ORDER BY (device_id, sensor, m)
TTL m + INTERVAL 90 DAY;

CREATE MATERIALIZED VIEW readings_1m_mv TO readings_1m AS
SELECT
    toStartOfMinute(ts) AS m,
    device_id,
    sensor,
    avgState(value) AS avg_s,
    maxState(value) AS max_s,
    minState(value) AS min_s,
    countState()    AS n_s
FROM readings_raw
GROUP BY m, device_id, sensor;

-- query
SELECT m, avgMerge(avg_s), maxMerge(max_s), countMerge(n_s)
FROM readings_1m
WHERE device_id = 'd-9' AND m >= now() - INTERVAL 14 DAY
GROUP BY m
ORDER BY m;

Prometheus: 15 d local, remote_write to VM/Thanos; recording rules for 5m rates; not 10 M device labels (cardinality).

Graphite-style CH: GraphiteMergeTree exists for drop policies; most teams still prefer explicit MVs they can query with SQL.


Where teams get caught

Only avg in the rollup

You cannot answer “did we spike?” a month later. Add max/min/count.

TTL shorter than cagg refresh

Raw gone before the 1h bucket finalized. Refresh from nothing. Align TTL ≫ lateness + refresh.

MV on a table you INSERT with SELECT *

Column mismatch silently drops metrics. MV is a push trigger on insert, not a historical backfill. POPULATE / backfill separately.

Percentile columns as Float64 p95

Cannot merge. Use quantilesState / sketches.

Partition by ingest date, TTL by event time

Late rows land in today’s partition and TTL yesterday’s partition — you delete the wrong files or none. Same clock for PARTITION, TTL, query.

UI downsampling as the plan

Grafana min step still queries the TSDB for every series. 10 M series never arrive.


How it fails

Failure Cause
Disk still 160 TB Raw TTL not actually dropping (system.parts old partitions)
Year chart empty Query still hitting raw that TTL’d
Year chart too smooth Only avg 1d
Cagg lag 12 h Refresh job failing; late offset too large
Double count Hybrid raw UNION rollup overlapping time
Mutation storm Trying to “fix” rollups with UPDATEs instead of rebuild

How to investigate

Storage:

-- ClickHouse
SELECT
    table,
    formatReadableSize(sum(bytes_on_disk)) AS disk,
    sum(rows) AS rows
FROM system.parts
WHERE active AND table LIKE 'readings%'
GROUP BY table;

SELECT partition, min(min_date), max(max_date), count()
FROM system.parts
WHERE table = 'readings_raw' AND active
GROUP BY partition
ORDER BY partition;

If readings_raw has partitions older than TTL, TTL is mis-set (ttl_only_drop_parts, merge not running, column vs table TTL).

Query:

-- should be ~14d of minutes, not 14d of 30s
SELECT count() FROM readings_1m
WHERE device_id = 'd-9' AND m > now() - INTERVAL 14 DAY;

Timescale: timescaledb_information.jobs / job stats; chunk exclusion in EXPLAIN.

Prom: retention flag vs disk; remote_write lag.


Scale 10× / 100× / 1000×

Scale Move
10× devices Same pyramid; shard/partition by device_id; batch inserts
100× Drop raw to 24–72 h; 1 min becomes the debug layer; compute 1 min in Flink
1000× Do not store per-device 1 min for a year. Store per-device 1 min for 7 d, per-customer 1 min for 90 d, fleet 1 h forever

The pyramid dimensions can coarsen too: not only time, but identity (devicecustomerfleet). That is downsampling of cardinality, not only of time.


Trade-offs

Keep raw longer Coarser sooner
Forensic debug Cost, merge CPU
True p99 later Need sketches anyway
Simple queries Disk
Recompute rollups Incremental MV
Correct with late data CPU
Batch-friendly Close-time semantics

Alternatives

Tool Downsample story
Timescale cagg Best SQL story
ClickHouse MV + TTL Best at high ingest
VM / Mimir downsampling Prom ecosystem
Iceberg + Spark nightly Lake as cold layer, Trino for humans
Pinot star-tree Dimensional pre-agg, not year IoT

Apply

Write the table:

tile → max span → layer → stats → retention.

Compute TB. If finance would not sign it, you do not have a retention policy; you have a hope.

At work the smell is a 14-day dashboard that scans 30 s raw for 10 M series, or a 13-month disk that is 99% raw.


Check your understanding

10 M devices, 30 s temperature, 3× replication, 24 bytes/point on disk after compression (all-in). Budget: 40 TB usable for this pipeline.

Propose raw / 1 min / 1 h retentions that fit, which stats live in 1 min, and what the 1-year exec chart reads. Show the arithmetic.

Answer

Points/day per layer (10 M devices):

Layer Points/day Bytes/day at 24 B/point (3× repl. all-in)
Raw (30 s) 10e6 × 86400/30 = 2.88×10¹⁰ 691 GB/day
1 min 10e6 × 1440 = 1.44×10¹⁰ 346 GB/day
1 hour 10e6 × 24 = 2.4×10⁸ 5.8 GB/day
1 day 10e6 × 1 = 1×10⁷ 0.24 GB/day

A year of fleet-wide raw would be 691 GB × 365 ≈ 252 TB — more than 6× the 40 TB budget. That is the actual constraint: not that raw is unaffordable for a day, but that it is unaffordable to keep for a year at full fidelity. Coarsen with time, not with a blanket TTL.

A design that fits 40 TB:

  • Raw (30 s), fleet-wide, 7 days: 7 × 691 GB ≈ 4.8 TB — enough to debug last week's incident.
  • 1 min, fleet-wide, 60 days: 60 × 346 GB ≈ 20.8 TB — covers "what did last month look like."
  • 1 hour, fleet-wide, 365 days: 365 × 5.8 GB ≈ 2.1 TB — a full year of per-device hourly trend.
  • 1 day, fleet-wide, 3 years: negligible (≈0.26 TB) — long-horizon capacity planning.
  • Per-customer (2,000 customers) 1-min rollups, kept indefinitely: tens of GB, trivial.

Total ≈ 4.8 + 20.8 + 2.1 + 0.26 ≈ 28 TB, leaving ~12 TB of headroom for quantile sketches, indices, and growth.

1-year exec chart: daily fleet or per-customer aggregates from the 1-day layer, not per-device 30 s raw. Carry sum, count, min, max (and a quantile sketch if you need p95) through every layer — never collapse to average-only, or you lose the ability to reconstruct a proper percentile later.

The point of the arithmetic: compression buys you weeks of full fidelity almost for free, but a year of per-device full fidelity is the expensive part. Downsampling is what buys back the year — by dropping identity (fleet/customer instead of per-device) or resolution (hour/day instead of second), not by a blanket retention cut.