Cardinality¶
03:14 AM. Page: Prometheus OOM-killed, restarting, WAL replay taking 40 minutes. Nothing about traffic changed this week. The only recent change: Friday's deploy added one new label, user_id, to an existing HTTP metric, "for better debugging."
What happened? A) a memory leak in Prometheus itself, B) the scrape interval got misconfigured, C) one label turned the metric's series count into the billions, or D) a network partition caused duplicate scrapes. Pick one before reading on.
It's (C): user_id looks like observability, but it is a new time series per user per other-label combination — an index that grows until the TSDB cannot compact, scrape, or query — because cardinality is not "how many rows," it is how many distinct series identities the engine keeps hot.
Start with the situation¶
SaaS observability. You already scrape:
A PM wants “latency per user” on the same Grafana. The patch is:
Dashboards over hundreds of millions of events/day can store per-user facts in ClickHouse. They cannot index per-user Prom series.
IoT analogue: {device_id, sensor} is the series — 10 M devices × 5 sensors = 50 M series. Painful but bounded. Add firmware_build + session_id and you are gone.
Explore numbers: cardinality calculator.
Why the obvious approach breaks¶
Every unique (name, label set) is a series. Each series has:
- an inverted index entry (label value → series ids);
- a head block in RAM;
- WAL / chunk writes even if you scrape once.
Cost is #series, not #samples, until samples pile up. A label with 10⁷ values multiplies every other dimension.
They are never fully independent, but the product is the planning bound. Engineering reviews use the product, not hope.
Build the mental picture¶
A series is a named line on a graph. Grafana can draw 20 lines. Prometheus can hold millions of lines in the index. It cannot hold a line per customer per endpoint per status per user.
Columns in ClickHouse are not lines. GROUP BY user_id is a query-time hash table. You pay it when you query, not at every scrape.
flowchart TB
subgraph prom["Prometheus identity"]
S1["svc, region, status, endpoint, user=1"]
S2["svc, region, status, endpoint, user=2"]
S3["... user=N"]
end
subgraph ch["ClickHouse row"]
R["one row: columns include user_id"]
end If you can name a bound (“we have ≤ 200 endpoints”), it can be a label. If the bound is “our user table,” it is a column in an event store.
Under the hood¶
What Prometheus stores¶
- Head: in-memory samples for active series.
- Postings: inverted index, label pair → series.
- Chunks: compressed samples on disk, per series.
Memory scales roughly linearly with active series (plus label string length). Order of magnitude: hundreds of bytes to a few KB per series of overhead, not counting samples. 10 M series is a dedicated, well-run Prom/VM. 10⁹ series is not a Prom problem.
Churn (series that appear once — trace_id) is worse: index grows, compaction storms, scrape payloads huge, then the series goes idle but not immediately forgotten.
Explosion, stepwise¶
| Labels | Distinct (example) | Series |
|---|---|---|
service | 50 | 50 |
+ region | 10 | 500 |
+ status | 20 | 10,000 |
+ endpoint | 500 | 5,000,000 |
+ user_id | 1,000,000 | 5×10¹² |
Endpoint at 500 is already a conversation (path templating vs raw URLs). User_id is not a conversation; it is a page.
Raw URLs: /orders/8891 as endpoint is user-level cardinality. Template to /orders/:id before it becomes a label.
Why ClickHouse survives the same points¶
CREATE TABLE http_events
(
ts DateTime64(3),
service LowCardinality(String),
region LowCardinality(String),
status UInt16,
endpoint LowCardinality(String),
user_id String,
latency_ms UInt32
)
ENGINE = MergeTree
ORDER BY (service, endpoint, ts);
user_id is a column. The primary index does not allocate a granule list per user unless you sort by user. 5×10⁸ rows/day is parts and compression, not 5×10¹² series.
GROUP BY user_id on a day can still OOM — that is query cardinality, not ingest identity. Different knob (max_memory_usage, two-level agg, pre-agg).
Ingest cardinality vs query cardinality¶
| Kind | When you pay | Example |
|---|---|---|
| Ingest / identity | Every scrape, every series in RAM/index | Prom user_id label |
| Query | When that SQL runs | CH GROUP BY user_id on a day |
| Index | When you build inverted/star-tree on the column | Pinot inverted user_id |
ClickHouse lets you ingest 2 M users/day as a column for cheap, then refuse the query GROUP BY user_id without customer_id =. That is an application limit, not a TSDB series limit. Prometheus does not give you that split: ingest is identity.
Pinot sits in the middle: ingest of a high-card column as a raw dimension can be fine; promoting it to inverted/star-tree makes it Prom-shaped again.
IoT: device_id is bounded — until it is not¶
10 M devices × 5 sensors = 50 M series if {device_id, sensor} is the Prom identity. Some VM clusters hold that. Add session_id (new UUID each Wi‑Fi join) and you get churn: series that live for 20 minutes and pollute the index forever-ish.
Better IoT split:
fleet Prom/VM: temperature{model, site, customer} # bounded
drill-down CH: (ts, device_id, sensor, value) # 10 M ids as column
ORDER BY (device_id, sensor, ts)
device_id in ClickHouse with that ORDER BY is the skip key for “chart this thermostat.” In Prom it is 10 M lines on every global query unless you always {device_id="..."}.
VictoriaMetrics / Mimir / Thanos¶
They raise the ceiling (better compression, sharding). They do not make user_id a good label. 100× more series is still the wrong model for events.
Put it to work¶
Bound labels (Prom):
http_requests_total{service, region, env, status, endpoint} # endpoint templated
http_request_duration_seconds_bucket{..., le}
Per-user / per-request (ClickHouse / Pinot):
IoT Prom-style (if you must): device_id only if 10 M series is funded (VM cluster, not one pod). Prefer: fleet metrics with {model, site} + drill-down SQL on {device_id, ts, value}.
Recording rules do not fix cardinality. A rule that does sum by (user_id) creates more series if you already exploded, or fails if you did not store user_id. Rules are for reducing dimensions (sum by (service)).
Relabel to drop:
# Prometheus scrape relabel — drop user_id if a client added it
metric_relabel_configs:
- regex: user_id
action: labeldrop
Measure:
prometheus_tsdb_head_series
prometheus_tsdb_symbol_table_size_bytes
count({__name__=~".+"})
topk(10, count by (__name__)({__name__=~".+"}))
If one metric name owns 80% of series, you found the label.
Python bound (same as the calculator):
services, regions, statuses, endpoints, users = 50, 10, 20, 500, 1_000_000
print("without users", services * regions * statuses * endpoints)
print("with users", services * regions * statuses * endpoints * users)
# 5e6 vs 5e12
Where teams get caught¶
Un-templated HTTP path
endpoint="/users/uuid/orders/uuid" is a unique series per request path. Middleware must templatize before metrics.
Exemplar / trace_id as label
Exemplars exist so you do not put trace ids on series. Labels are not a join key to Jaeger.
LowCardinality(user_id) in ClickHouse
That is a dictionary of millions of keys. Use String. LowCardinality is for service, sensor.
Kubernetes pod label on every metric
Pods churn. pod cardinality ≈ deploy rate × replicas. Prefer deployment/workload; keep pod on a few debug metrics.
Grafana legend = *
UI will try to render 50k series. The TSDB still computed them. Limit topk.
How it fails¶
| Failure | Signal |
|---|---|
| Prom OOM | head_series cliff; GC; WAL replay 45 min |
| Scrape timeout | /metrics megabytes because of label explosion on the app |
| Compaction behind | Disk; query timeouts |
| CH query OOM | GROUP BY user_id on 7 days without filter — query cardinality |
Pinot inverted on user_id | Segment heap; see Pinot |
How to investigate¶
topk(20, count by (__name__, user_id)(http_requests_total))— if this even parses slowly, stop.- Cardinality dashboard: series per metric name, per label name.
count(count by (user_id)(http_requests_total))→ unique users as series dimension.- App: dump
/metricssize. If it is 80 MB, the incident started in the process, not in Prom. - ClickHouse:
SELECT uniqExact(user_id) FROM http_events WHERE ts > now() - 3600— this is allowed.SELECT count() FROM system.metricsanalogue in Prom ishead_series.
Marks/parts: if you tried to “fix” Prom by dumping into CH and ORDER BY (user_id, ts) with 50 M users, you get a wide index and tiny granules per user — different problem (too many marks), still better than Prom.
Scale 10× / 100× / 1000×¶
| Growth | Prom labels | Event columns |
|---|---|---|
| 10× traffic, same label set | Samples 10×, series ~constant | Rows 10×, parts/merges |
| 10× endpoints (raw URLs) | Series 10× | LowCardinality may break; still rows |
| 100× users as labels | Death | Rows; query with tenant filter |
| 1000× devices as Prom series | Sharded VM/Mimir, or abandon Prom for IoT | CH/Timescale sharded by device |
Traffic growth is a sample problem. Dimension growth is a series problem. Only the second is this page.
Trade-offs¶
| Put it in labels | Put it in columns |
|---|---|
Fast PromQL rate by that dim | Fast if in ORDER BY prefix or you filter |
| Bounded dims only | High-card OK |
| Alerting ecosystem | SQL / OLAP |
| Memory ~ series | Memory ~ query |
Hybrid is normal: Prom for {service, endpoint} SLOs; ClickHouse for user/session drill-down.
Alternatives¶
| Need per-user | Tool |
|---|---|
| Dashboards, SQL | ClickHouse, Pinot, Timescale |
| Logs | Loki/ELK with careful index fields |
| Traces | Tempo/Jaeger, not metric labels |
| “PromQL but bigger” | VictoriaMetrics, Mimir — still no user_id |
Apply¶
Label review in PR: bound? if not, reject. Path templating in HTTP metrics is a platform standard, not an app choice.
If a product metric needs user_id, it is an event — Kafka topic, OLAP table, ORDER BY (customer_id, ts).
Platform checklist before merge:
- Product of label bounds written in the PR (not “should be small”).
- HTTP paths templated in middleware.
- Histogram
lebuckets counted in the product. - A cardinality dashboard exists (
head_seriesby metric name). - If someone says “we’ll add remote_write,” that scales samples, not a 10¹² series bound.
Incident smell: /metrics grew from 2 MB to 80 MB after a “debug label” shipped on Friday. Roll back the label before you scale the TSDB. The TSDB is working as designed.
For a walk-through of the product-of-bounds arithmetic, use the cardinality calculator. Change one label at a time. The cliff is never “10× traffic”; it is the unbounded dimension.
Compare engines once the identity is a column: TSDBs, ClickHouse, TSDB vs OLAP.
Practice the idea¶
Use the cardinality calculator to predict the multiplier from adding user_id. Then run the time-series lab and observe prometheus_tsdb_head_series before and after the label is enabled.
Check your understanding¶
http_request_duration_seconds_bucket has labels service (80), le (12 buckets), endpoint (templated 40), method (8). Traffic: 200 M requests/day, 2 M DAU.
Engineer A adds user_id. Engineer B adds customer_id (2,000 tenants). Engineer C writes requests to ClickHouse with those as columns and keeps Prom without them.
Estimate series for A and B (product bound). Who is right for a “p95 per tenant” product dashboard vs SLO per service?
Answer
Base Prom series ≤ 80 × 12 × 40 × 8 = 307,200 (if independent). Comfortable.
B: × 2,000 customers → ≤ ~6×10⁸ series bound. Reality: not every tenant hits every endpoint, but tens of millions of series is plausible. Too high for one Prometheus; maybe a sharded VM if you truly need PromQL per tenant. Prefer customer_id as a column or a recording path into CH.
A: × 2 M users → 10¹¹–10¹² bound. Invalid. Do not ship.
C is right for both: Prom keeps service SLOs (histogram_quantile by service, endpoint). ClickHouse ORDER BY (customer_id, ts) (or Pinot inverted customer_id) serves p95 per tenant from events (quantile / histogram columns). 200 M rows/day is a normal CH ingest if batched.
Product dashboard p95 per tenant: C (or B only with a dedicated high-card TSDB and a hard series cap — still worse). SLO per service: Prom without A/B labels.