Skip to content

InfluxDB cardinality: keep user and device IDs out of tags

IDs in tags grow the InfluxDB index with every new visitor. With them in fields and a daily MySQL rollup, a year for 900+ listings loads in under a second.

6 min read8 sections

I designed the analytics layer for an advertising marketplace. It took over two million writes a day, fed a dashboard that updated live, and answered a full year of history in under a second.

The first schema decision in that system turned out to matter most. For each identifier it came down to one question: does this value go in a tag or in a field?

I put every identifier in a field, including the listing, user and device IDs, and made none of them tags. The rest of this post is about why, what that cost, and what made up for it.

01

The schema that looks right

Here is an event as most people would first write it, for someone viewing a listing on some device:

snippet.txt
Text · 1 line
listing_event,listing_id=48213,user_id=u_9f2c,device_id=d_71ab,event=view count=1i 1717430400000

It reads well. Everything you might filter by is in the tag set, and the only field is the thing you're counting. Most examples you'll find have this shape, with a host or a region in the tags.

The trouble is that host and region take their values from small, fixed sets. Listing IDs, user IDs and device IDs keep growing.

02

Every combination of tag values is a series

InfluxDB indexes tags and not fields. The cost depends on what that index holds, which is series.

A series is a measurement plus one exact combination of tag values. Every distinct combination the database has ever seen is a separate series with its own entry in the index. Fields play no part in this. You can write a million distinct field values into one series and it stays one series.

The number of series is therefore bounded by the product of the number of distinct values in each tag:

snippet.txt
Text · 1 line
series(listing_event) <= |listing_id| x |user_id| x |device_id| x |event|

In practice you won't reach the full product, because not every user touches every listing, but you don't have to. With a user and a device in the tag set, every new visitor creates new series, and an advertising platform exists to bring in new visitors.

That number is the series cardinality, and it only goes up. A tag with a small, fixed set of values adds a known number of series. An identifier adds new ones as traffic grows, so with identifiers in the tags the index grows with your traffic instead of with your schema.

A growing index uses more memory, slows down writes, and makes queries that used to be instant touch far more of it than they should. It holds up at launch and fails once your traffic is good, which is the worst possible time. That is the most common way these installations die.

03

The schema I used

Identifiers go in fields. Tags are kept for values that come from a small, known set and that you filter by:

snippet.js
JavaScript · 20 lines
import { InfluxDB, Point } from '@influxdata/influxdb-client' const writeApi = new InfluxDB({ url: INFLUX_URL, token: INFLUX_TOKEN })  .getWriteApi(INFLUX_ORG, INFLUX_BUCKET, 'ms') export function recordEvent(e) {  const point = new Point('listing_event')    // tags: a closed set, known before the first write    .tag('event', e.type)    // fields: anything that grows with traffic    .stringField('listing_id', String(e.listingId))    .stringField('user_id', e.userId)    .stringField('device_id', e.deviceId)    .stringField('session_id', e.sessionId)    .stringField('page_url', e.pageUrl)    .intField('count', 1)    .timestamp(e.at)   writeApi.writePoint(point)}

The same event in line protocol:

snippet.txt
Text · 1 line
listing_event,event=view listing_id="48213",user_id="u_9f2c",device_id="d_71ab",count=1i 1717430400000

Each candidate tag gets the same short test. Can I write down every value it will ever have? If I can, it may be a tag. If the list grows every time someone signs up, lists a business or opens the site on a new phone, it's a field.

The event type passes. We decide that set in code, and traffic doesn't change it. A listing ID fails, because every new listing is a new value, and new listings are what the business wants more of.

04

The cost: no grouping by listing in the time-series store

With listing IDs in fields, you can't group by listing efficiently inside the time-series store. In InfluxQL, GROUP BY accepts tags and time but not fields. Other query paths can group by any column, but only after reading the rows, so they scan the whole time window instead of going straight to a series. A per-listing report over a year is the worst case for that kind of work.

So the obvious objection is right. The question the business asks most often, "how is my listing doing?", is the one this schema makes expensive to answer from the time-series store. That was acceptable, because the time-series store was never meant to answer it.

05

A daily rollup in MySQL answers the per-listing questions

The design works because of a second decision made alongside the first. Raw events stay in the time-series store for seven days. Once a day, a summary per listing is written to the relational database, and the dashboard and the invoices query that summary. The raw data goes to object storage as an archive.

The time-series store has one job: take the writes and serve recent time windows. It never has to answer a question grouped by listing over a long period, and that is the only kind of question that putting identifiers in fields makes slow.

The rollup goes into a plain MySQL table:

query.sql
SQL · 8 lines
CREATE TABLE listing_daily (  listing_id  BIGINT UNSIGNED NOT NULL,  day         DATE            NOT NULL,  event       VARCHAR(64)     NOT NULL,  total       INT UNSIGNED    NOT NULL,  uniques     INT UNSIGNED    NOT NULL,  PRIMARY KEY (listing_id, day, event));

The daily job reads one day of raw events, where a scan is cheap because the window is small, and writes one row per listing, day and event. One way to write it:

snippet.js
JavaScript · 31 lines
async function rollUp(day) {  const flux = `    from(bucket: "${INFLUX_BUCKET}")      |> range(start: ${day.start}, stop: ${day.stop})      |> filter(fn: (r) => r._measurement == "listing_event")      |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")      |> keep(columns: ["_time", "event", "listing_id", "session_id", "page_url"])  `   const buckets = new Map()  for await (const { values, tableMeta } of queryApi.iterateRows(flux)) {    const row = tableMeta.toObject(values)    const key = `${row.listing_id}|${row.event}`    const b = buckets.get(key) ?? { total: 0, seen: new Set() }    b.total += 1    b.seen.add(`${row.session_id}|${row.page_url}`)    buckets.set(key, b)  }   const rows = [...buckets].map(([key, b]) => {    const [listingId, event] = key.split('|')    return [listingId, day.date, event, b.total, b.seen.size]  })   await mysql.query(    `INSERT INTO listing_daily (listing_id, day, event, total, uniques)     VALUES ?     ON DUPLICATE KEY UPDATE total = VALUES(total), uniques = VALUES(uniques)`,    [rows],  )}

The job keeps totals and uniques apart. Uniques are keyed by session and page URL, so someone who searches in two cities counts twice, because they were looking for two different things. Totals are the larger number and look better in a report, while uniques tell an advertiser how many different people saw their listings. Keeping both in the table means neither number has to be hidden.

The upsert makes the job safe to rerun. If a day is rolled up twice, the second run overwrites the first instead of doubling it.

In the raw store, the session and the page URL are fields too. The unique count needs them, and both are values that grow with traffic.

06

A year of data for over 900 listings in under a second

The annual report never touches a raw row. It reads 365 summarized rows per listing:

query.sql
SQL · 6 lines
SELECT d.listing_id, d.event, SUM(d.total) AS totalFROM listing_daily dJOIN listings l ON l.id = d.listing_idWHERE l.account_id = ?  AND d.day >= CURDATE() - INTERVAL 1 YEARGROUP BY d.listing_id, d.event;

I measured the worst case. For accounts with over 900 listings, a year of daily rollups is roughly 330,000 rows aggregated in a single query, and it returns in under one second. "Under a second" means nothing on an account with three listings, so the largest accounts are the ones worth measuring.

DataWhere it's keptWhat reads it
Raw events, identifiers in fieldstime-series store, 7 dayslive dashboard, the daily rollup
One row per listing, day and eventrelational databaseannual report, invoices
Raw archiveobject storagenobody, unless something needs reprocessing

The same split also mattered for the hosting bill, which is covered in its own post. For the index, the result was that it stayed small, because nothing in it grew with traffic.

07

Long histories of one entity belong in the relational database

A time-series store is good at "what happened recently, sliced a few fixed ways" and bad at "what happened to this one entity over a long time". The second is a relational question, and it has a relational answer: a summary table with a primary key.

Once the long-range questions go to the relational database, the tag decision is easy. Tags cover the fixed slices. Identifiers belong to the long-range questions, so they don't need an index in a store that doesn't answer those questions.

When I see identifiers used as tags, the reason is rarely that someone didn't know better. More often the time-series store is the only database in the design, so it has to answer every question, including the one it handles worst, and the index grows with the traffic to pay for that.

08

What I'd do differently

I'd revisit the seven-day window. It was chosen early and never checked. It was almost certainly right, but I never looked at whether anyone needed to reprocess a window longer than a week, or what thirty days would have cost. The rollup design depends on that number, and I picked it and moved on.

I'd review the event types on a schedule. The analytics tracked around 180 event types over four years, with no regular review of which ones anyone still looked at. Some certainly stopped being used and kept being written. The tag test above keeps the index small, but it doesn't stop a closed set from growing, and nobody was removing event types from this one.

I'd also write the tag test down before the first write. A schema is cheap to change on day one and expensive once the series exist. The decision fits in one question, and it's worth putting that question where the next person adding a tag will see it.

Where this came fromThe analytics layer I designed for a US marketplace for truck services, which took over two million writes a day.Read the case study →Related serviceHigh-write data systemsView service →Next postWhere I split web and React Native code in our design systemRead →

Writing

If you’re working on something similar, send us the details.

The first call takes 30 minutes, and you’re under no obligation.

Tell us about your projectcontact@microtech.consulting