IoT Platform Architecture¶
Elena flags the storage bill in review: 10 million devices, one sample every 30 seconds, and someone kept "just in case" raw resolution for a full year. Nobody has plotted a single point older than three weeks at anything finer than an hourly average. A. The fix is a bigger discount on object storage. B. The fix is a downsample pyramid that never should have been skipped. C. The fix is dropping to 5-minute sampling at the device. Predict before you read on.
B: tens of millions of devices, small numeric samples, a handful of query shapes, and a downsample pyramid are the actual shape of this workload. The dominant constraint is write rate + retention cost, not join complexity. If you model this as "just events in a lakehouse," you will pay full-resolution storage for data nobody plots at 1 Hz a year later.
Related: time series, downsampling, TSDB vs OLAP, cardinality.
Requirements¶
| Axis | Target |
|---|---|
| Volume | 10 million devices × 1 sample / 30 s ≈ 333k samples/s. Multiple sensors per device can multiply this. Payload ~80–200 bytes. |
| Latency | Latest value per device: < 1 s after ingest is nice, < 5 s is usually enough. 1-hour chart: sub-second. 1-year trend: seconds, on hourly data. Anomaly flag: seconds. |
| Access | Latest-by-device, range charts, fleet aggregates (avg by firmware), exception lists. Rarely: arbitrary joins to CRM on the hot path. |
| Retention | 7 days raw. 30 days at 1-minute. 1 year at hourly. Raw older than 7 days is a cost bug unless compliance says otherwise. |
| Cost | Storage follows resolution. 333k/s raw for a year is tens of TB/day uncompressed — do not keep it. |
| Failure | Device storms (retry loops), clock skew, a dead partition for one factory, poisoned NaNs. Losing a 1-minute rollup is worse than losing one raw second. |
Do not topic-per-device
10 million Kafka topics (or ClickHouse tables) is an operations incident. One (or a few) topics, key = device_id. Factories/regions may be separate clusters for isolation, not 10M topics.
Capacity sketch¶
333,333 samples/s × 120 bytes × 86400 ≈ 3.5 TB/day uncompressed raw.
| Layer | Sketch |
|---|---|
| Kafka | 3.5 TB/day × ~2× compression × RF=3 / 24 h retention ≈ ~9 TB disk for one day of replay. Partitions: 333k/s / ~5k/s/partition ≈ 70+ partitions; use 96–192 to leave headroom. |
| ClickHouse raw 7 d | Columnar 10:1 → ~0.35 TB/day × 7 ≈ 2.5 TB hot. |
| 1-min rollup | 30 s → 60 s is ~2× fewer points if one sensor; with min/max/avg/count you store more columns but far fewer rows per hour of wall time. 30 days of 1-min is small vs raw year. |
| Hourly 1 year | Fleet-scale, not device-second-scale. This is the only layer you should plot "last year" against. |
If each device has 10 sensors at 1 Hz instead of 1/30 s, multiply ingest by 300. Architecture is the same; V1 hardware is not. Recompute before copying this page into a budget.
V1 — fewest parts¶
One serving store. Pick from the query mix:
| Pick ClickHouse when | Pick TimescaleDB when |
|---|---|
Fleet SQL, high cardinality (device_id millions), already have CH | You want Postgres: joins to relational asset tables, moderate rate (<~100k samples/s), continuous aggregates |
| 333k/s is in-range for a small CH cluster | Operators live in SQL/Postgres |
V1 with ClickHouse (the 10M-device case):
devices → MQTT (or vendor cloud) → Kafka bridge
→ Kafka (device_id key, 96 partitions)
→ Flink or even Kafka Engine
→ ClickHouse raw 7d + 1-min MV
→ Grafana
Asset metadata (firmware, customer, lat/lon) lives in Postgres. Join it in Grafana or denormalise customer_id onto the sample in Flink. Do not join 10M-row dimension tables on every ingest query without thought.
V1 table¶
CREATE TABLE device_metrics
(
timestamp DateTime64(3),
device_id String,
sensor LowCardinality(String),
value Float32,
unit LowCardinality(String),
quality UInt8
)
ENGINE = MergeTree
PARTITION BY toYYYYMMDD(timestamp)
ORDER BY (device_id, sensor, timestamp)
TTL timestamp + INTERVAL 7 DAY;
Latest values:
SELECT device_id, sensor, argMax(value, timestamp) AS latest
FROM device_metrics
WHERE device_id IN ('d001', 'd002', 'd003')
GROUP BY device_id, sensor;
At fleet scale, argMax over 7 days of all devices is a bad query. Constrain time (timestamp > now() - interval 5 minute) or keep a ReplacingMergeTree "latest" table updated by Flink.
What you would not add yet¶
- Iceberg (add when 30-day/1-year rollups leave ClickHouse)
- Trino
- Per-device Kafka topics
- Prometheus as the device store (
device_idcardinality will kill it) - Anomaly ML platform
- Kafka Streams and Flink and Spark
MQTT vs Kafka
MQTT is a device protocol (fan-in from constrained clients). Kafka is the platform bus. Bridge MQTT → Kafka; do not make Flink speak MQTT unless you enjoy operational pain.
This architecture works while… / breaks when…¶
Works while:
queries lead with device_id (+ sensor) and a time window — the ORDER BY prefix
"latest value" queries are constrained to a short recent window
sample rate stays in-range for one modest ClickHouse cluster (~333k samples/s)
asset metadata is small enough to join in Grafana or denormalise at ingest
7 days of raw plus a 1-minute rollup covers the operational questions
device clocks are trusted enough that event time ≈ ingest time
Breaks when:
a fleet-wide query arrives ("latest value for ALL devices") — argMax over 7 days of
every device is a different workload than argMax over three, and needs a
ReplacingMergeTree latest-value table instead
device_id keying goes skewed — a gateway or a chatty firmware version dominates
one partition
retention questions extend past raw+7d into months or years of history — that is a
downsampling pyramid, not a bigger TTL
clock skew becomes material: late and out-of-order samples from disconnected devices
need watermarks and a lateness policy, not "sort on read"
metadata joins get large enough that per-query dimension joins dominate the cost
Two of these — the fleet query and the clock domain — are the ones teams discover late, because both look fine at pilot scale with 100 well-connected devices.
Bottleneck at the end of V1¶
| Symptom | Cause | Wrong fix |
|---|---|---|
| Insert storm, millions of parts | One-row inserts per sample | Batch 1–10 s in the bridge/Flink |
| Query "latest for 10M devices" times out | Scanning raw 7d | Dedicated latest table / 5 min window |
| One Kafka partition lagging | Hot gateway or mis-keyed region | Re-key; more consumers will not split the hot partition |
| Charts for last year are empty or huge | You queried raw | Hourly table only |
| Device clocks 3 hours off | Event time vs ingest time | Store both; window on ingest for "live", event time for science |
| NaN/Inf wrecks averages | No quality flag | quality column; avgIf(value, quality=1) |
Tiny inserts are the classic ClickHouse IoT footgun. The bridge must batch.
V2 — pyramid + optional specialist TSDB¶
graph TD
D["Devices"] --> MQTT["MQTT / vendor ingest"]
MQTT --> K["Kafka\nkey=device_id"]
K --> F["Flink\nvalidate, stamp, windows"]
F --> CH["ClickHouse raw 7d"]
F --> TS["Timescale or CH 1-min 30d"]
F --> ICE["Iceberg hourly 1y"]
F --> AL["Anomaly topic"]
CH --> G["Grafana live"]
TS --> G
ICE --> TRI["Trino / Spark forensics"]
PG["Postgres assets"] --> F Downsample in Flink (event time)¶
stream \
.key_by(lambda e: (e["device_id"], e["sensor"])) \
.window(TumblingEventTimeWindows.of(Time.minutes(1))) \
.aggregate(MetricAggregator()) # min, max, avg, count, p95 if you must \
.add_sink(RollupSink())
Watermarks: devices go idle. Use idleness on keys or a separate processing-time flush, or 1-minute windows never close for quiet sensors. See Flink watermark incident.
Where Timescale still wins¶
If the product is "Postgres + Grafana + joins to tickets," Timescale hypertables + continuous aggregates are operationally simpler up to a rate you have measured. At 333k/s and 10M series, ClickHouse (or a purpose TSDB like VictoriaMetrics for metrics-shaped data) is the safer hot store. Hybrid is allowed: VM for numeric fleet metrics, ClickHouse for wide events.
Anomaly detection (stateful, not a new database)¶
class AnomalyDetector(KeyedProcessFunction):
def open(self, ctx):
self.mean = self.get_runtime_context().get_state(
ValueStateDescriptor("mean", Types.FLOAT()))
self.m2 = self.get_runtime_context().get_state(
ValueStateDescriptor("m2", Types.FLOAT()))
self.n = self.get_runtime_context().get_state(
ValueStateDescriptor("n", Types.LONG()))
def process_element(self, reading, ctx):
n = (self.n.value() or 0) + 1
mean = self.mean.value() or 0.0
delta = reading["value"] - mean
mean += delta / n
# Welford M2 omitted in this sketch; persist m2 similarly
self.n.update(n)
self.mean.update(mean)
std = 1.0 # from M2
if n > 30 and abs(reading["value"] - mean) > 3 * std:
yield Anomaly(reading["device_id"], reading["value"])
This belongs in Flink keyed state. Do not query ClickHouse per sample for a z-score.
How it fails¶
| Failure | Symptom | Absorb with |
|---|---|---|
| Device retry storm | Ingest 10×, duplicate timestamps | Dedup key (device_id, sensor, timestamp) ReplacingMergeTree; rate-limit the bridge |
| Idle keys stall watermarks | No 1-min output overnight | Watermark idleness; processing-time backup |
Partition by toYYYYMMDD + tiny batches | Too many parts | Larger inserts; fewer partitions if needed |
ORDER BY (timestamp, device_id) | "this device, 24h" scans the day | Device first in the key — ORDER BY lab |
| Losing MQTT broker | Gap in raw | Kafka as buffer after the bridge; QoS on MQTT; gap detection on max(ts) per device |
| Firmware sends new sensor name | Cardinality creep | LowCardinality is not infinite; allow-list sensors |
What you still do not add at V2¶
- Pinot (unless a customer-facing live tile at huge QPS).
- Per-tenant ClickHouse clusters at 10k tiny tenants — use
tenant_idinORDER BYand quotas. - Raw-to-S3 dump without a table format and a rollup job (you will never query it cheaply).
- Graph DB for device topology until someone has a multi-hop question.
Evolution at 10× (3.3M samples/s or 100M devices)¶
- More Kafka partitions and Flink slots — linear, if keys are even.
- Shard ClickHouse by
hash(device_id)so a device's series lives on one node (latest-value queries stay local). - Regional ingest — devices should not cross the ocean to a single Kafka.
- Drop raw sooner (3 days) if 1-min is the real product.
- Pre-compute fleet dashboards (per firmware per minute) so Grafana never scans 100M devices.
- Revisit Timescale if you started there — 10× is often the ClickHouse/VM migration, not "bigger Postgres."
Hot keys: a gateway that reports 10k devices as one device_id will 10× worse. Key the actual series.
Decision table¶
| Decision | Choice | Why | Reject |
|---|---|---|---|
| Device protocol | MQTT → Kafka | Constrained clients vs platform bus | Kafka on every sensor |
| Hot raw | ClickHouse | High card device_id, SQL charts | Prometheus labels |
| 1-min / hourly | Flink windows or CH MVs | Pyramid is the product | Keep raw forever |
| Assets | Postgres | Slowly changing, relational | Duplicating into 10M Kafka compacted topics first |
| Anomaly | Flink state | Per-key, sub-second | Batch Spark every hour only (too late) |
| Year views | Hourly Iceberg or CH | Scan budget | Raw 1 Hz |
Apply this at work¶
- Write samples/s = devices × sensors × Hz. Recompute GB/day.
- Freeze the pyramid (7d / 30d / 1y) as a product spec.
- Put
device_idfirst inORDER BYif the UI is per-device. - Batch inserts. Measure parts count on day two.
- Decide latest-value path (query vs dedicated table) before demoing a fleet map.
Run the ClickHouse lab with a device-first vs time-first ORDER BY. Use the cardinality calculator before anyone adds firmware_git_sha as a Prometheus label.
Query catalogue¶
| ID | Question | Resolution needed | Store |
|---|---|---|---|
| Q1 | Latest temperature for device D | seconds | CH argMax on 5 min or latest table |
| Q2 | Chart D, sensor T, last 6 h at ~1 point/min | sub-second | 1-min rollup |
| Q3 | Fleet p95 by firmware, last 24 h | seconds | rollup, not 10M raw |
| Q4 | Year trend for D | seconds on hourly | hourly table |
| Q5 | Which devices were silent 15 min? | minutes | watermark/gap job, not Prom |
| Q6 | Join device → customer address | rare | Postgres assets + id |
Q1 vs Q4 using the same raw table is how year views take down the cluster.
Clock domains¶
| Clock | Use |
|---|---|
| Device event time | Science, "when the sample was taken" |
| Ingest time | "is the pipeline alive"; gap detection |
| Processing time | Last-resort windows when devices are idle |
Store both device_ts and ingest_ts. Alerts on "pipeline down" use ingest. Alerts on "machine overheating" use event time after clamping skew (drop or sidecar 6-hour-off firmware).
NTP on devices is a product requirement. Flink cannot fix a factory full of 2019 clocks without a policy.
Batching at the edge¶
| Where | Batch |
|---|---|
| Device | often 1 sample; constrained RAM |
| MQTT bridge / gateway | must batch 100s–1000s or 1 s |
| Flink sink to CH | 1–5 s / 100k rows |
| CH Kafka engine | kafka_max_block_size, flush interval |
If the gateway does one HTTP INSERT per sample, you will hit incident 4 shape B at 333k/s immediately.
Latest-value table (when argMax dies)¶
CREATE TABLE device_latest
(
device_id String,
sensor LowCardinality(String),
timestamp DateTime64(3),
value Float32,
quality UInt8
)
ENGINE = ReplacingMergeTree(timestamp)
ORDER BY (device_id, sensor);
Flink upserts on every sample (or every N seconds). Grafana fleet map reads this, not 7 days of raw. ReplacingMergeTree needs FINAL or a FROM device_latest FINAL in queries / a collapsing pattern you understand. Alternative: Redis device:sensor → value for the map, CH for charts.
On-call 15 minutes¶
- No 1-min points overnight? Watermark idleness — incident 3. Check
current_watermarkvs wall clock. - One factory missing? Kafka key = factory gateway id (hot or dead), not 10k devices.
- Averages are NaN?
quality/avgIf. - CH insert delay?
system.parts. - Prometheus scrape failing? That is cluster health, not device health — do not "fix" by adding
device_idlabels.
What "10 million devices" does to Kafka keys¶
Hash(device_id) with 192 partitions is even if ids are even. Vendor ids that are MAC-ish are fine. Sequential ids are fine. A single device_id=gateway-uk-1 reporting 50k sensors as one key is not. Model the series (device_id, sensor) in the Kafka key if one physical box fans in.
Estimate: 10M devices × 2 sensors = 20M keys. Flink keyed state for z-score at 100 B/key ≈ 2 GB plus RocksDB overhead — fits. Session state with 7-day raw buffers does not.
What happened next¶
Elena got the number she wanted and the fleet kept its history. Raw resolution now lives for 30 days rather than a year, with 1-minute, 1-hour and 1-day rollups behind it — and since nobody had plotted anything older than three weeks at finer than hourly, the dashboards did not change at all.
The saving is the ratio between what was stored and what was read. A year of 30-second samples for 10 million devices exists to answer questions that are asked at hourly granularity, which means the expensive copy was never the one being queried.
What the 30 days buys is the thing to defend in the next review: it is the window in which an incident can still be investigated at full resolution. Shorten it to save more and the next firmware bug becomes unreconstructable — which is a different kind of cost, and one that does not appear on the storage bill.