The system design question
Design a system that collects analytics events or metric counts from lots of services, aggregates them, and lets people query the results. Then use your data model to answer a few product questions.
Agree on the scope (5 minutes)
Say up front what you're covering.
| We'll design | We'll skip |
|---|---|
| Sending events and counts from any service | Alerting on metrics |
| Rolling them up into counts over time | The dashboard UI |
| A query API that dashboards and people use | Logs and request tracing |
| Answering product questions from the data model | Machine learning on the data |
Then agree on what "good" means:
- Never slows the sender: sending an event is a quick in-memory call. If our system is down, the service carries on.
- Takes in a lot: a million events a second at peak, and more next year.
- Fresh: a count shows up in queries within about a minute.
- Fast to query: a dashboard covering the last week loads in a second or two.
- Right where it matters: counts and sums are exact. Unique counts and percentiles can be close estimates, as long as we say so.
Rough numbers (5 minutes)
These are assumptions, but reasonable ones: a million events a second at peak, about 500 bytes each, and around a million distinct combinations of metric and dimensions active at once.
- ~1M
- events per second at peak
- ~40 TB
- of raw events a day
- ~1M
- active metric and dimension combos
- ~70 GB
- of minute rollups a day
| Question | Rough math | What it means |
|---|---|---|
| Can we put every raw event in a database? | 1M a second × 500 bytes | About 500 MB a second, or roughly 40 TB a day. Too much to index. Keep raw events in cheap files and query rollups |
| How big are minute rollups? | 1M combos × 1,440 minutes × about 50 bytes | About 70 GB a day. A normal analytics database handles that, and hourly rollups are 60 times smaller again |
| Can a dashboard scan raw events? | A week of raw is about 300 TB | No. Dashboards read rollups. Odd one-off questions go to a slower batch job over the raw files |
The overall design (10 minutes)
- Each service uses a small library. It records events in memory, adds up simple counts locally, and sends a batch every second or so in the background.
- Batches land on an event stream, split into partitions by metric name and dimensions, so every event for the same thing reaches the same aggregator.
- Aggregators add events up into one-minute buckets and write a row per metric, dimension combo and minute to the metrics store.
- A copy of the raw stream is also written to file storage as compressed files, one folder per hour, for questions the rollups can't answer.
- The query API reads the rollups, picks minute, hour or day rows depending on the time range, and does the final math like rates and percentiles.
What the apps need to do
| Action | Who does it | What's sent |
|---|---|---|
| Send events | Service library, in batches | A list of events, each with a name, time, dimensions like country or status, a value and an event ID |
| Define a metric | Engineer | Name, type (count, sum, distribution, unique count), allowed dimensions and an owner |
| Query | Dashboard or person | Metric, time range, step size, filters and group-by dimensions. Gets back a table of numbers |
| Run a raw query | Analyst | A query over the raw files. Runs as a slow batch job and returns later |
What we store and where
| Data | Where it goes | Why |
|---|---|---|
| Recent raw events | Event stream, kept a few days | Lets aggregators replay after a crash or a bug fix |
| All raw events | Compressed column files in file storage, by hour | Cheap, and good enough for slow one-off questions |
| Rollups | Column-oriented analytics database, split by time | Fast sums over millions of rows, and old data is easy to drop |
| Metric definitions | Small relational database | Few rows, edited rarely, and the aggregators cache it |
Step 1: Getting events in without slowing anyone down (10 minutes)
The library must never block the service. Recording an event just adds to an in-memory buffer. A background thread sends batches. If the buffer fills because we're down, it drops the oldest and counts how many it dropped, instead of eating the service's memory.
For plain counts, the library adds them up before sending: 10,000 'payment succeeded in US' events in a second become one message saying 10,000. That's the biggest single cut in volume, and it's free.
Events go onto a stream that's partitioned by a hash of metric and dimensions. That's sharding the stream, so each aggregator owns a slice and can add up in memory without talking to the others. The stream is a message queue that holds a few days, so a slow aggregator just falls behind instead of losing data.
- Cap the dimensions on each metric. Someone adding user ID as a dimension turns one combo into millions. Reject or drop unknown dimensions, and alert the owner.
- Each batch carries an ID, so a retried send can be spotted and skipped.
- For crazy-high event types, sample 1 in 100 and multiply back up when querying. Label those metrics as sampled.
Step 2: Rolling events up (10 minutes)
Aggregators group events into one-minute windows by when they happened, not when they arrived. That's stream processing with event-time windows. A window stays open a couple of minutes for stragglers, then gets written out.
What you keep depends on the metric type. Counts and sums just add. For latency you can't average percentiles, so keep a histogram of bucket counts, which adds up across servers and minutes. For unique counts, like unique merchants, keep a small sketch (HyperLogLog) that merges, and accept about 1% error.
Each row has a key of metric, dimensions and minute. Writing a row is an overwrite, not an add. So if an aggregator crashes and replays from its last checkpoint, it writes the same rows again and nothing gets counted twice. Events that arrive really late get added as a correction to the minute they belong to.
Rolling up again over time
A background job turns minute rows into hour rows, and hour rows into day rows. Keep minutes for a month, hours for a year and days forever. A query for last quarter then reads about 2,000 hour rows per combo instead of 130,000 minute rows.
Step 3: The data model and the product questions (10 minutes)
The rollup table has one row per metric, dimension combo and time bucket. The columns are the dimensions (like country, payment method and status), plus count, sum, a histogram and a unique-count sketch. A column-oriented database is a good fit here, because queries add up a few columns over many rows. The SQL vs NoSQL question really comes down to that read pattern.
Say the interviewer asks 'what's the payment success rate by country this week?'. Query 'payments attempted' grouped by country and status from the hourly table, then divide succeeded by total. Always divide totals. Averaging each hour's rate gives quiet hours the same weight as busy ones.
'What's p99 checkout latency per endpoint?' merges the histograms for each endpoint, then reads the 99th percentile off the merged one. 'How many distinct merchants used a feature yesterday?' merges the daily sketches. If they want it exact, that's a batch query over the raw files.
Questions about one specific merchant are the hard case, because merchant ID has far too many values to be a normal dimension. Build a separate daily table keyed by merchant for the handful of metrics people ask about that way, and say so when you hit it.
Choices you should be able to defend
| We chose | Instead of | Because | We'd reconsider if |
|---|---|---|---|
| Store minute rollups | Store and scan every raw event | Thousands of times less data, and fast dashboards | Most questions needed individual events |
| Add up counts in the library | Send every event as it happens | Cuts volume a lot before it leaves the host | We needed each event's exact time and details |
| Sketches for unique counts | Exact sets of IDs | Tiny and mergeable across time and servers | Billing or anything where 1% off isn't OK |
| Drop events when buffers fill | Block the service until we accept them | Our outage never becomes a payments outage | The events were needed for money, not metrics |
| Overwrite rows by key | Add to rows as events arrive | Replays don't double count | We had no clean checkpoints to replay from |
What could go wrong (5 minutes)
| What happens | What we do about it |
|---|---|
| The event stream is down | Libraries buffer, then drop the oldest and count what they dropped. Services keep running |
| An aggregator crashes | Another picks up its partitions and replays from the last checkpoint, overwriting the same rows |
| Someone adds a dimension with millions of values | The dimension limit rejects it and alerts the metric's owner |
| Events arrive an hour late | They're added as a correction to the right minute, and the dashboard shows the fixed number |
| A bug made a day's rollups wrong | Fix the bug and rebuild that day from the raw files |
Follow-up questions (5 minutes)
How would you add alerts?
Run alert rules against the minute rollups as they're written, so alerts see the same numbers dashboards do. Keep it separate from the query path so heavy dashboards can't delay an alert.
What if someone wants a new dimension on old data?
Rollups only have the dimensions they were built with. You'd rebuild from the raw files for the period they care about, which is a slow batch job but doable.
What about multiple regions?
Roll up in each region, then send the much smaller rollups to one place for global queries. Raw events stay in the region they came from.
How the 60 minutes add up
| Minutes | What you're doing |
|---|---|
| 0–5 | Agree on what you're building |
| 5–10 | Rough numbers |
| 10–20 | Draw the overall design |
| 20–30 | Getting events in without slowing anyone down |
| 30–40 | Rolling events up |
| 40–50 | The data model and the product questions |
| 50–55 | What could go wrong |
| 55–60 | Follow-up questions |
Key concepts used here
- Stream processing and windowingCount things in data that never stops coming in.
- Message queues and event streamsKeep services from depending on each other, and handle traffic spikes.
- Relational vs non-relational databasesPick a database based on how you'll actually use it.
- Sharding and consistent hashingSplit data across machines and keep it balanced.
Further reading
We wrote this sample answer ourselves.
Practice Loop is independent and isn't affiliated with or endorsed by Stripe.