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.
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. D. Compress harder at the same resolution.
Predict before you read on.
It's (C). 10 million devices × 1 sample / 30 s ≈ 3.3×10⁵ points/s ≈ 2.9×10¹⁰ / day ≈ 10¹³ / 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 |
What happened next¶
It was C. Nobody renders more than 1,200 points, so storing a year of raw 30-second samples means keeping 10¹³ points to draw charts that discard essentially all of them.
Tiered rollups — 1 minute, 1 hour, 1 day — with raw kept for days rather than years cut the 160 TB by orders of magnitude and made the dashboards faster, because a year-long chart now reads daily rollups instead of aggregating billions of raw points on every page load.
The aggregation was never optional. The only choice was whether to do it once on write or every time someone opens Grafana, and the raw-retention number is where that choice is actually made: it is the window in which an incident can still be investigated at full resolution.
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.
Reference¶
Behaviour at the next orders of magnitude, the trade-offs, the alternatives, and what to check when inheriting someone else's version of this — kept here rather than in the walkthrough above.
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 (device → customer → fleet). 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.