IoT Platform Architecture¶
A finance review flags the storage bill: 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.