Design an Ad Click Aggregator
Advertisers are billed on these counts, so they have to be exact, and dashboards want them within a minute. The design counts clicks in a stream for speed and recounts the raw log in a nightly batch for billing.
1 · Requirements
The Prompt
Count 1B ad clicks a day per ad per minute for dashboards and billing. Counts must be exact for billing, and queries return in under a second.
Functional
- Record each click and redirect the user to the advertiser
- Count clicks per ad per minute
- Query counts over a time range and the top ads in the last minute
- Produce daily counts for billing
Non-Functional
- Dashboards are at most a minute or two behind
- Billing counts are exact: no click lost or counted twice
- Queries return in under a second
- Raw clicks are kept for a year for audits
2 · Estimates
Drill This Phase First
Show the Answer and Estimates
Answer
About 60,000 a second
1B divided by 86,400 seconds is about 11,600 a second on average. 5x that is about 58,000, so plan for 60,000.
- Clicks
- 1B clicks a day: about 11,600 a second on average and 60,000 at a 5x peak
- Raw Log
- About 200 bytes a click: 200 GB a day and about 73 TB a year in object storage
- Kafka
- 60,000 events a second at 200 bytes is 12 MB a second; 64 partitions keyed by ad_id keep each one light
- Aggregates
- About 200,000 ads get a click in a given minute: 288M rows a day at 50 bytes, roughly 15 GB
- Dedup
- One hour of click IDs at about 12,000 a second is about 43M IDs, under 1 GB of stream state
- Workers
- Each Flink worker aggregates about 10,000 events a second, so 6 cover peak and 12 cover a replay
3 · API
GET /c/:click_token → 302 to the advertiser URL (logs the click first)
GET /ads/:id/clicks?from=&to=&granularity=minute → { buckets[{ minute, clicks }] }
GET /ads/top?window=1m&n=100&country= → { ads[{ ad_id, clicks }] }
GET /billing/:advertiser_id?day= → { ad_counts[], reconciled }Data Model
click event (Kafka "clicks", key = ad_id):
click_id : uuid from the signed click token
ad_id : bigint
ts : timestamp event time, set at the click server
user_hash : bytes
country : string
ad_clicks_minute (ClickHouse or Cassandra, partition = ad_id):
ad_id, minute (PK)
clicks : bigint
source : enum(stream|batch) batch overwrites stream
raw_clicks (object storage, Parquet, by hour):
every event, kept 1 year for audits and replays4 · High-Level Design
5 · Deep Dives
Event Time and Late Clicks
Bucket each click by when it happened, as stamped by the click server. Clicks arrive late from slow networks and retries, so a window closes only when the watermark passes its end plus a grace period, such as one minute. Anything later goes to a side output that the nightly batch counts.
Exact Counts
Every click carries a unique click_id from a signed token, and the aggregator drops IDs it saw in the last hour. Flink checkpoints its Kafka offsets and window state together, and the sink upserts each (ad_id, minute) row, so a replay after a crash writes the same totals again.
Stream Plus Batch
The stream gives dashboards numbers within a minute. Billing needs an audited record, so a nightly job recounts the day's raw clicks from object storage, overwrites the stream's rows, and flags any ad where the two differ by more than 0.1%. This is the lambda shape; a kappa design replays Kafka through the same streaming job.
6 · Wrap Up
The Short Version
Click servers log each click to Kafka, then redirect. A stream processor such as Flink counts per ad per minute with event-time windows and watermarks. A nightly batch recount over raw clicks in object storage reconciles the numbers used for billing.