← Back to all questions
StaffStream ProcessingData Engineering

Design a Real-Time Ad Click Aggregator

A full Staff-level walkthrough of a real-time ad click aggregator that ingests 50B clicks a day and lights up advertiser dashboards within a minute — while guaranteeing billing-grade, exactly-once counts. It runs a Flink streaming path for freshness alongside a Spark batch-reconciliation path as the financial source of truth, wrestling with dedup at scale, high-cardinality rollups, and predictable traffic spikes. Follow it top to bottom, or jump to any step.

Level
Staff
Category
Stream Processing · Data Engineering
Interview time
45 min
100% free · No login required
WHAT THIS QUESTION TESTS
·Exactly-once click counting that survives retries, replays, and restarts
·Sub-minute aggregation feeding live advertiser dashboards
·Lambda design: streaming for freshness, batch for billing truth
·Multi-dimensional drill-down across campaign, geo, and device
★ STAFF-LEVEL SIGNALS
Sizes a layered Bloom-plus-exact dedup with explicit math, not a naive set
Treats billing as financial: reconciliation is ground truth, not stream counts
Quantifies Flink vs Spark, ClickHouse vs Druid, Kappa vs Lambda with numbers
Bounds blowups: hourly Bloom rotation, top-N dimensions, HyperLogLog uniques
1

Requirements

Functional Requirements

  • Real-time click counting: Count clicks per ad, campaign, ad group, and advertiser in real-time with sub-minute freshness
  • Click deduplication: Eliminate duplicate click events caused by network retries, client-side double-taps, and replay after failures
  • Near-real-time dashboards: Serve aggregated metrics to advertiser dashboards with < 1 minute end-to-end latency from click to chart
  • Multi-dimensional drill-down: Support slicing by campaign / ad_group / ad / geo (country, region) / device_type / time granularity (1m, 1h, 1d)
  • Billing-grade accuracy: Produce invoiceable click counts per advertiser per billing period; financial accuracy is non-negotiable
  • Click fraud / anomaly detection: Flag suspicious patterns (burst from single IP, bot signatures, abnormal CTR) and exclude fraudulent clicks from billing

Non-Functional Requirements

  • Throughput: Sustain 580K clicks/sec average, 1.5M clicks/sec at peak (2.5x burst during flash sales, Super Bowl, etc.)
  • Exactly-once counting: Every click counted exactly once for billing — no undercounting (revenue loss), no overcounting (advertiser trust)
  • Availability: 99.99% uptime (~52 min downtime/year); click ingestion must never drop events
  • Latency: < 1 minute from click event to updated dashboard; < 200ms dashboard query response (p99)
  • Data retention: Raw click events retained 30 days; aggregated data retained 2 years; archived to cold storage indefinitely

Constraints / Assumptions

  • This is a financial system — billing accuracy is non-negotiable and must survive streaming pipeline failures
  • Must support retroactive corrections (e.g., batch reconciliation discovers streaming drift, fraud detected post-hoc)
  • GDPR compliance: Click data contains PII (user_id, IP address); must support right-to-deletion and data anonymization after retention period
  • Ad server already generates globally unique click IDs; we do not control the ad-serving layer
  • Peak traffic is predictable (tied to major events) — capacity planning can be proactive
2

Back-of-envelope estimation

Metric
Calculation
Estimate
Clicks/day
Given
50B
Average QPS
50B / 86,400
~580K
Peak QPS (2.5x)
580K × 2.5
~1.5M
Click event size
JSON: click_id + ad_id + campaign_id + user_id + timestamp + ip + country + device + placement
~200 bytes
Raw data ingestion/day
50B × 200B
10 TB/day
Raw data retention (30d)
10 TB × 30
300 TB
Unique advertisers
Given
~10M
Active campaigns
Given
~100M
Dimension combinations
100M campaigns × 200 countries × 5 device types × 24 hours
~1B aggregation buckets/day
Aggregated storage/day
1B buckets × 100B per row
~100 GB/day (vs 10 TB raw — 100:1 reduction)
Aggregated retention (2yr)
100 GB × 730 days
~73 TB
Bloom filter sizing
m = -(n × ln(p)) / (ln2)^2; n=50B, p=0.01 → m = 480B bits ≈ 60 GB; k = (m/n) × ln2 ≈ 7 hash functions
Kafka throughput
10 TB/day = ~115 MB/s avg, ~290 MB/s peak
12 partitions minimum at 25 MB/s each
ClickHouse write rate
10 TB / 86,400s = ~116 MB/s sustained
Batch inserts of 100K rows every few seconds
3

API design

POST /v1/clicks — Click Event Ingestion

Called by ad servers. Accepts batches for efficiency.

// Request
POST /v1/clicks
Content-Type: application/json
 
{
"events": [
{
"click_id": "01903a4b-7c8e-7def-8a1b-3c4d5e6f7890",
"ad_id": "ad_82719",
"campaign_id": "camp_44102",
"advertiser_id": "adv_9912",
"user_id": "usr_hashed_abc123",
"timestamp": "2024-03-15T14:23:01.482Z",
"ip": "203.0.113.42",
"country": "US",
"region": "CA",
"device_type": "mobile",
"placement": "search_top",
"user_agent": "Mozilla/5.0...",
"is_conversion": false
}
]
}
 
// Response — 202 Accepted (async processing)
{
"accepted": 1,
"rejected": 0,
"errors": []
}

GET /v1/metrics — Dashboard Query

// Request
GET /v1/metrics?campaign_id=camp_44102&granularity=1m&start=2024-03-15T14:00:00Z&end=2024-03-15T15:00:00Z&group_by=country,device_type
 
// Response
{
"campaign_id": "camp_44102",
"granularity": "1m",
"time_range": { "start": "2024-03-15T14:00:00Z", "end": "2024-03-15T15:00:00Z" },
"data": [
{
"timestamp": "2024-03-15T14:00:00Z",
"dimensions": { "country": "US", "device_type": "mobile" },
"clicks": 14823,
"unique_users": 12407,
"conversions": 312
},
{
"timestamp": "2024-03-15T14:01:00Z",
"dimensions": { "country": "US", "device_type": "mobile" },
"clicks": 15109,
"unique_users": 12688,
"conversions": 298
}
],
"total_clicks": 1792340,
"freshness": "2024-03-15T14:59:47Z"
}

GET /v1/reports/billing — Billing Report (Reconciled)

// Request
GET /v1/reports/billing?advertiser_id=adv_9912&period=2024-03
 
// Response
{
"advertiser_id": "adv_9912",
"period": "2024-03",
"status": "reconciled",
"campaigns": [
{
"campaign_id": "camp_44102",
"billable_clicks": 45_291_004,
"fraud_excluded": 312_847,
"adjustments": -1204,
"cost_per_click": 0.42,
"total_cost": 19022221.68
}
],
"total_billable_clicks": 128_440_219,
"total_cost": 53944891.98,
"reconciliation_completed_at": "2024-04-01T06:00:00Z"
}
4

Data model

Click Event Schema (Kafka Message)

{
"click_id": "string — UUID v7 (timestamp-ordered), generated at ad server",
"ad_id": "string — identifies the specific ad creative",
"campaign_id": "string — parent campaign",
"ad_group_id": "string — ad group within campaign",
"advertiser_id": "string — billing entity",
"user_id": "string — hashed user identifier (GDPR-safe)",
"timestamp": "int64 — epoch milliseconds, event time",
"ip": "string — source IP (encrypted at rest)",
"country": "string — ISO 3166-1 alpha-2",
"region": "string — state/province",
"device_type": "enum — mobile | desktop | tablet | smart_tv",
"placement": "string — e.g. search_top, feed, banner_right",
"user_agent": "string — raw UA string for fraud detection",
"is_conversion": "bool — true if conversion pixel fired within attribution window"
}

ClickHouse Tables

-- Raw click events (source of truth for queries and reconciliation)
CREATE TABLE raw_clicks (
click_id String,
ad_id LowCardinality(String),
campaign_id LowCardinality(String),
advertiser_id LowCardinality(String),
user_id String,
timestamp DateTime64(3),
country LowCardinality(String),
region LowCardinality(String),
device_type Enum8('mobile'=1, 'desktop'=2, 'tablet'=3, 'smart_tv'=4),
placement LowCardinality(String),
is_conversion UInt8,
is_fraud UInt8 DEFAULT 0
) ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/raw_clicks', '{replica}')
PARTITION BY toYYYYMMDD(timestamp)
ORDER BY (campaign_id, ad_id, timestamp)
TTL timestamp + INTERVAL 30 DAY
SETTINGS index_granularity = 8192;
 
-- 1-minute aggregation (materialized view, auto-populated on insert)
CREATE MATERIALIZED VIEW aggregated_clicks_1m
ENGINE = ReplicatedAggregatingMergeTree('/clickhouse/tables/{shard}/agg_1m', '{replica}')
PARTITION BY toYYYYMMDD(window_start)
ORDER BY (campaign_id, ad_id, country, device_type, window_start)
AS SELECT
campaign_id,
ad_id,
country,
device_type,
toStartOfMinute(timestamp) AS window_start,
countState() AS click_count,
uniqState(user_id) AS unique_users,
sumState(is_conversion) AS conversion_count
FROM raw_clicks
WHERE is_fraud = 0
GROUP BY campaign_id, ad_id, country, device_type, window_start;
 
-- 1-hour rollup from 1-minute data
CREATE MATERIALIZED VIEW aggregated_clicks_1h
ENGINE = ReplicatedAggregatingMergeTree('/clickhouse/tables/{shard}/agg_1h', '{replica}')
PARTITION BY toYYYYMM(window_start)
ORDER BY (campaign_id, ad_id, country, device_type, window_start)
AS SELECT
campaign_id,
ad_id,
country,
device_type,
toStartOfHour(window_start) AS window_start,
countMergeState(click_count) AS click_count,
uniqMergeState(unique_users) AS unique_users,
sumMergeState(conversion_count) AS conversion_count
FROM aggregated_clicks_1m
GROUP BY campaign_id, ad_id, country, device_type, window_start;

Storage Technology Choices

Component
Technology
Reasoning
Event bus / source of truth
Apache Kafka
Durable log with 30-day retention; replay for reconciliation; handles 1.5M events/sec with partitioning
Stream processing
Apache Flink
Native exactly-once semantics via checkpoints + Kafka transactions; low-latency windowed aggregation; keyed state for dedup
OLAP analytics store
ClickHouse
Columnar storage with 10:1 compression; sub-second queries on billions of rows; materialized views for automatic rollup
Dedup + real-time counters
Redis Cluster
Bloom filter module for fast probabilistic dedup; atomic counters for real-time dashboard (bypasses ClickHouse query latency)
Batch reconciliation
Apache Spark
Reads full Kafka log; re-aggregates at scale; compares with streaming output
Long-term archive
S3 + Parquet
Cheap cold storage; Parquet columnar format for ad-hoc queries via Athena/Presto
5

High-level architecture

┌─────────────────────────────────────────────────────────┐
│ STREAMING PATH │
│ │
┌──────────┐ ┌────────┴───────┐ ┌──────────────────────────────┐ ┌─────────┴──────────┐
│ Ad Server │───▶│ Kafka Cluster │───▶│ Flink Pipeline │───▶│ ClickHouse │
│ (clicks) │ │ (raw_clicks │ │ │ │ (raw + aggregated) │
└──────────┘ │ topic, 64 │ │ ┌───────┐ ┌──────┐ ┌───┐ │ └────────┬───────────┘
│ partitions) │ │ │ Dedup │─▶│Window│─▶│Agg│ │ │
└───────┬───────┘ │ └───┬───┘ │(1min)│ └───┘ │ ┌────────▼───────────┐
│ │ │ └──────┘ │ │ Dashboard API │
│ └──────┼──────────────────────┘ │ (< 200ms p99) │
│ │ └────────────────────┘
│ ┌──────▼───────────┐
│ │ Redis Cluster │
│ │ - Bloom filter │
│ │ (60 GB, dedup) │
│ │ - Counters │
│ │ (real-time │
│ │ dashboard) │
│ └──────────────────┘
│ ┌─────────────────────────────────────────────┐
│ │ BATCH PATH │
└───────────▶│ │
│ Spark (hourly reconciliation) │
│ - Re-count from Kafka source of truth │
│ - Compare streaming vs batch counts │
│ - Generate correction records │
│ │
└──────────────┬──────────────────────────────┘
┌─────────────────┐
│ S3 / HDFS │
│ (Parquet │
│ archive) │
└─────────────────┘

Flow 1: Click Ingestion Path

1. User clicks an ad. Ad server generates a UUID v7 click_id and fires a click event.

2. Click event is sent to the /v1/clicks ingestion endpoint (or directly to Kafka via ad server SDK).

3. Kafka assigns the event to a partition (partitioned by campaign_id hash for ordering guarantees within a campaign).

4. Flink consumer reads the event. Dedup operator checks Redis Bloom filter — if “probably new,” passes through. If “possibly seen,” checks Redis exact set. If confirmed duplicate, drops.

5. Deduplicated event enters a 1-minute tumbling window. At window close, Flink emits aggregated counts per (campaign_id, ad_id, country, device_type).

6. Flink writes raw deduplicated events to ClickHouse raw_clicks table (batch insert every 5 seconds).

7. Flink atomically updates Redis counters for real-time dashboard queries.

8. ClickHouse materialized views automatically compute aggregated_clicks_1m and aggregated_clicks_1h.

Flow 2: Dashboard Query Path

1. Advertiser opens dashboard, UI calls GET /v1/metrics?campaign_id=X&granularity=1m&group_by=country.

2. For last 5 minutes (hot data): API reads from Redis counters (< 10ms, pre-aggregated).

3. For older windows: API queries ClickHouse aggregated_clicks_1m or aggregated_clicks_1h depending on granularity.

4. Results merged and returned. Freshness indicator shows last-processed timestamp.

Flow 3: Billing Reconciliation Path

1. Hourly Spark job reads raw events from Kafka (30-day retention guarantees full replay).

2. Spark re-computes exact counts per (advertiser_id, campaign_id, hour) with deterministic dedup on click_id.

3. Compares streaming-produced counts (from ClickHouse aggregated tables) vs batch-produced counts.

4. If |difference| > 0.1%, flags the discrepancy and generates correction records.

5. Corrections written to ClickHouse billing_adjustments table and propagated to invoicing system.

6. At billing cycle close (monthly), reconciled totals become the authoritative billing numbers.

6

Deep dives

WHERE STAFF IS WON

Deep Dive 1: Exactly-Once Deduplication at Scale

Why this is hard: At 1.5M clicks/sec peak, even a 0.01% duplicate rate means 150 extra clicks/sec billed incorrectly. For a $0.50 CPC campaign, that is $6,480/day in billing errors. We need layered defense.

Click ID generation: UUID v7 (RFC 9562) generated at the ad server. Timestamp-ordered prefix enables efficient range-based Bloom filter rotation. The ad server is the single point of ID assignment — no coordination needed.

Layered deduplication strategy:

Click Event
┌──────────────────────┐ ~99% of events pass (not duplicates)
│ Layer 1: Bloom Filter │────────────────────────────────────────▶ PROCESS
│ (Redis, 60 GB) │
└──────────┬───────────┘
│ "possibly seen" (~1% of traffic = 15K/sec at peak)
┌──────────────────────┐ Exact check: is click_id in set?
│ Layer 2: Redis Set │──── New? ──▶ PROCESS
│ (exact, TTL 1 hour) │
└──────────┬───────────┘
│ Confirmed duplicate
DROP

Bloom filter math:

  • Optimal bit array size: m = -(n * ln(p)) / (ln2)^2
  • With n = 50B clicks/day, p = 0.01 (1% false positive rate):
  • m = -(50 × 10^9 × ln(0.01)) / (0.693)^2 = 480 billion bits = 60 GB
  • Optimal hash functions: k = (m/n) ln2 = (480B/50B) 0.693 = 6.6 ≈ 7
  • Hourly rotation: partition Bloom filter into 24 hourly segments (2.5 GB each). Expire segments older than 25 hours. This bounds memory and keeps false positive rate stable.

Dedup approach comparison:

Approach
Memory (50B events)
Lookup Latency
False Positives
False Negatives
Bloom filter only
60 GB
~0.1 ms
1% (tunable)
0%
Redis Set (exact)
~1.6 TB (32B key × 50B)
~0.5 ms
0%
0%
Flink RocksDB state
Disk-backed, ~500 GB SSD
~1-5 ms
0%
0%
Our layered approach
60 GB + ~16 GB Redis
~0.15 ms avg
~0%
0%

The layered approach gets near-zero false positives: only 1% of traffic hits the exact Redis check (Bloom filter positives), and those are resolved exactly. Net false positive rate: 0%.

Flink exactly-once semantics:

Flink Checkpoint (every 30 seconds):
1. Checkpoint barrier injected into Kafka source
2. All operators snapshot their state to S3/HDFS
3. Kafka consumer offsets committed atomically
4. ClickHouse sink uses two-phase commit:
- Phase 1: Write to ClickHouse buffer table
- Phase 2: On checkpoint complete, flush buffer → main table
- On failure: buffer table rolled back, replay from last checkpoint

Dedup operator pseudocode (Flink):

public class DedupOperator extends KeyedProcessFunction<String, ClickEvent, ClickEvent> {
// Keyed by click_id prefix (hourly bucket) for efficient state cleanup
private transient ValueState<Boolean> seenState;
private transient BloomFilterClient bloomFilter;
private transient RedisClient redisExact;
 
@Override
public void processElement(ClickEvent click, Context ctx, Collector<ClickEvent> out) {
String clickId = click.getClickId();
 
// Layer 1: Bloom filter (fast path — rejects ~99% immediately)
if (!bloomFilter.mightContain(clickId)) {
bloomFilter.add(clickId);
redisExact.setex(clickId, 3600, "1"); // TTL 1 hour
out.collect(click);
return;
}
 
// Layer 2: Exact Redis check (only ~1% of traffic reaches here)
if (!redisExact.exists(clickId)) {
redisExact.setex(clickId, 3600, "1");
out.collect(click);
return;
}
 
// Confirmed duplicate — drop
ctx.output(DUPLICATE_TAG, click); // Side output for monitoring
}
}

Deep Dive 2: Aggregation Pipeline Design

Window type selection:

Window Type
Description
Latency
Complexity
Use Case
Tumbling (1 min)
Fixed, non-overlapping 1-min buckets
Exactly 1 min
Low
Our choice — clean boundaries for dashboard and billing
Sliding (1 min, 10s slide)
Overlapping windows, emits every 10s
~10 seconds
Medium
More responsive but 6x compute (6 overlapping windows)
Session
Dynamic windows based on activity gaps
Variable
High
User session analysis, not applicable here

We choose 1-minute tumbling windows because: (a) dashboard SLA is < 1 minute, so 1-min granularity meets requirements; (b) tumbling windows produce exactly one output per event (no duplication across overlapping windows); (c) clean 1-minute boundaries align with ClickHouse materialized view rollups.

Pre-aggregation in Flink (reduces ClickHouse write volume by ~1000x):

// Flink window function: aggregate clicks per dimension combination per minute
clickStream
.keyBy(click -> new AggKey(
click.getCampaignId(),
click.getAdId(),
click.getCountry(),
click.getDeviceType()
))
.window(TumblingEventTimeWindows.of(Time.minutes(1)))
.allowedLateness(Time.minutes(5))
.sideOutputLateData(lateOutputTag)
.aggregate(new ClickAggregator(), new ClickWindowFunction());
 
// ClickAggregator accumulates per-window counts
public class ClickAggregator implements AggregateFunction<ClickEvent, ClickAccum, ClickAccum> {
public ClickAccum createAccumulator() {
return new ClickAccum(0L, new HashSet<>(), 0L);
}
public ClickAccum add(ClickEvent click, ClickAccum acc) {
acc.clickCount++;
acc.uniqueUsers.add(click.getUserId());
if (click.isConversion()) acc.conversions++;
return acc;
}
public ClickAccum getResult(ClickAccum acc) { return acc; }
public ClickAccum merge(ClickAccum a, ClickAccum b) {
a.clickCount += b.clickCount;
a.uniqueUsers.addAll(b.uniqueUsers);
a.conversions += b.conversions;
return a;
}
}

ClickHouse materialized view rollup chain:

Raw inserts (10 TB/day)
▼ (INSERT triggers materialized view)
aggregated_clicks_1m (AggregatingMergeTree)
│ ~100 GB/day (1000x reduction from raw)
▼ (automatic rollup)
aggregated_clicks_1h
│ ~4 GB/day
▼ (TTL-based conversion)
aggregated_clicks_1d (for long-term trends)
~100 MB/day

High-cardinality dimension handling:

  • Problem: If we allow arbitrary drill-down on all dimensions, the number of aggregation buckets explodes. 100M campaigns x 200 countries x 5 devices x 1440 minutes = 144 trillion rows/day.
  • Solution 1: Pre-aggregate only the top 4 dimensions (campaign_id, ad_id, country, device_type). Rarer drill-downs (region, placement) query raw_clicks directly.
  • Solution 2: Approximate unique counts via uniqHLL (HyperLogLog) in ClickHouse for unique_users — trades 2% accuracy for 1000x memory savings.
  • Solution 3: Adaptive rollup — if a dimension combination has < 100 clicks/day, merge into an "other" bucket at the 1h level.

Deep Dive 3: Lambda Architecture — Streaming + Batch Reconciliation

Why streaming alone is insufficient for billing:

1. Flink job restarts: During recovery (30s checkpoint interval), events may be reprocessed. Exactly-once mitigates this but edge cases exist (e.g., ClickHouse sink timeout during two-phase commit).

2. Bloom filter false positives: 1% of legitimate clicks flagged as “possibly seen” are routed to Redis exact check. If Redis has a brief partition, those checks may fail open (allowing duplicates) or fail closed (dropping valid clicks).

3. Late arrivals beyond watermark: Flink watermark allows 5 minutes of lateness. Clicks arriving later than 5 minutes are emitted via side output but may not make it into the streaming aggregation.

4. Network partitions: Kafka consumer may lose connectivity, causing rebalance and potential duplicate processing during consumer group reassignment.

For these reasons, streaming provides the real-time view, but batch reconciliation provides the billing source of truth.

Batch reconciliation architecture:

┌─────────────────┐ ┌──────────────────────────┐ ┌──────────────────┐
│ Kafka (30-day │────▶│ Spark Reconciliation │────▶│ Billing │
│ retention, │ │ Job (hourly) │ │ Adjustments │
│ source of │ │ │ │ Table │
│ truth) │ │ 1. Read raw events │ └────────┬─────────┘
└─────────────────┘ │ 2. Exact dedup on │ │
│ click_id (GroupBy) │ ▼
┌─────────────────┐ │ 3. Aggregate per │ ┌──────────────────┐
│ ClickHouse │────▶│ (advertiser, campaign, │ │ Invoicing │
│ (streaming │ │ hour) │ │ System │
│ counts) │ │ 4. Compare streaming │ └──────────────────┘
└─────────────────┘ │ vs batch │
│ 5. Flag |diff| > 0.1% │
│ 6. Generate corrections │
└──────────────────────────┘

Reconciliation pseudocode:

def reconcile_hour(hour_start: datetime):
"""Runs hourly, compares streaming counts vs exact batch counts."""
 
# Step 1: Batch re-count from Kafka (source of truth)
raw_events = spark.read.format("kafka") \
.option("startingOffsets", offsets_for(hour_start)) \
.option("endingOffsets", offsets_for(hour_start + 1h)) \
.load()
 
batch_counts = raw_events \
.dropDuplicates(["click_id"]) \ # Exact dedup
.filter(~is_fraud("click_id")) \ # Exclude known fraud
.groupBy("advertiser_id", "campaign_id") \
.agg(
count("click_id").alias("batch_clicks"),
countDistinct("user_id").alias("batch_unique_users")
)
 
# Step 2: Read streaming-produced counts from ClickHouse
streaming_counts = clickhouse.query("""
SELECT advertiser_id, campaign_id,
countMerge(click_count) AS streaming_clicks
FROM aggregated_clicks_1h
WHERE window_start = '{hour_start}'
GROUP BY advertiser_id, campaign_id
""")
 
# Step 3: Compare and flag discrepancies
comparison = batch_counts.join(streaming_counts,
["advertiser_id", "campaign_id"])
comparison = comparison.withColumn(
"discrepancy_pct",
abs(col("batch_clicks") - col("streaming_clicks")) / col("batch_clicks") * 100
)
 
# Step 4: Generate corrections for discrepancies > 0.1%
corrections = comparison.filter(col("discrepancy_pct") > 0.1)
for row in corrections.collect():
adjustment = row.batch_clicks - row.streaming_clicks
write_adjustment(
advertiser_id=row.advertiser_id,
campaign_id=row.campaign_id,
hour=hour_start,
click_adjustment=adjustment,
reason="reconciliation_drift",
batch_count=row.batch_clicks,
streaming_count=row.streaming_clicks
)
if abs(row.discrepancy_pct) > 1.0:
alert_oncall(f"Large discrepancy: {row.campaign_id} "
f"off by {row.discrepancy_pct:.2f}%")
 
# Step 5: Log reconciliation results
log_reconciliation(hour_start, total_campaigns=comparison.count(),
flagged=corrections.count())

Late arrival handling:

Event Time ──────────────────────────────────────────────▶
Window [14:00, 14:01) │ Watermark = 14:06
├── On-time events: processed │
│ │
Window [14:01, 14:02) │
├── Late event (14:01:30 arrives │
│ at 14:05:50): processed via │
│ allowedLateness(5 min) │
│ │
├── Very late event (14:01:30 │
│ arrives at 14:08:00): emitted │
│ to side output → separate │
│ "late_clicks" Kafka topic → │
│ merged in batch reconciliation │
7

Bottlenecks & tradeoffs

Bottleneck 1: Bloom Filter Memory (60 GB)

  • Problem: 60 GB for a single day's Bloom filter exceeds typical Redis node memory.
  • Mitigation: Partition by hour (24 segments of 2.5 GB each). Expire segments after 25 hours. Distribute across Redis Cluster (8 nodes, ~8 GB each). Use Redis Bloom module (RedisBloom) for native BF.ADD/BF.EXISTS commands.

Bottleneck 2: Flink Backpressure During Traffic Spikes

  • Problem: Super Bowl / flash sale spikes to 1.5M clicks/sec. If Flink cannot keep up, Kafka consumer lag grows, dashboard freshness degrades.
  • Mitigation: Kafka acts as buffer (hours of retention). Flink autoscaling via Kubernetes (reactive mode: add TaskManagers when consumer lag exceeds 30 seconds). Pre-provision 2x capacity before known events.

Bottleneck 3: ClickHouse Write Throughput at 10 TB/day

  • Problem: 116 MB/s sustained write rate. Individual row inserts would overwhelm ClickHouse.
  • Mitigation: Batch inserts of 100K rows every 5 seconds via Buffer table engine. Flink accumulates rows in memory and flushes periodically. ClickHouse async inserts with async_insert=1 for overflow. Shard across 10 ClickHouse nodes (12 MB/s per shard).

Bottleneck 4: High-Cardinality Dimension Explosion

  • Problem: Full cross-product of all dimensions at 1-minute granularity is computationally infeasible.
  • Mitigation: Pre-aggregate only top 4 dimensions (campaign, ad, country, device). Serve rare drill-downs (region, placement) from raw_clicks table with ClickHouse's columnar scan speed. Use approximate algorithms (HyperLogLog) for unique counts.

Bottleneck 5: Kafka Partition Hot Spots

  • Problem: Partitioning by campaign_id means viral campaigns create hot partitions.
  • Mitigation: Use compound partition key: hash(campaign_id + ad_id) % num_partitions. Monitor partition lag skew and rebalance. For known large campaigns, assign dedicated partitions.

Key Tradeoffs Summary

Decision
Option A
Option B
Our Choice
Why
Deduplication
Exact only (Redis Set, 1.6 TB)
Bloom filter only (60 GB, 1% FP)
Layered: Bloom + Redis exact
60 GB memory, ~0% false positives — best of both
Stream engine
Apache Spark Structured Streaming
Apache Flink
Flink
Native exactly-once, lower latency (~100ms vs ~500ms), true event-time processing
OLAP store
Apache Druid
ClickHouse
ClickHouse
Better SQL support, simpler operations, superior compression (10:1), comparable query speed
Architecture
Kappa (streaming only)
Lambda (streaming + batch)
Lambda
Billing accuracy requires batch reconciliation as ground truth; streaming alone has edge cases
Window size
5-minute tumbling
1-minute tumbling
1 minute
Meets < 1 min SLA; 5x more aggregation buckets but manageable with ClickHouse rollups
Late event policy
Drop events > 5 min late
Side output + reconciliation
Side output
No data loss; late events captured and reconciled in batch

Monitoring & Observability

Metric
Normal Range
Alert Threshold
Impact if Violated
Click processing lag (Kafka consumer lag)
< 10 seconds
> 60 seconds
Dashboard freshness SLA breach
Dedup rate (% of events flagged duplicate)
1-3%
> 10%
Possible replay bug or click fraud attack
Reconciliation discrepancy
< 0.05%
> 0.1%
Billing accuracy compromised
Flink checkpoint duration
< 10 seconds
> 25 seconds (approaching 30s interval)
Risk of checkpoint timeout → data loss
ClickHouse query latency (p99)
< 100 ms
> 500 ms
Dashboard response SLA breach
Revenue anomaly (hourly)
Within 2σ of forecast
> 3σ deviation
Possible fraud, pipeline bug, or traffic anomaly
Bloom filter false positive rate
~1%
> 5%
Excessive Redis exact-check load; may need filter resize

Failure Modes

Failure
Impact
Detection
Recovery
Kafka broker down (1 of N)
Replication (RF=3) handles; no data loss. Brief producer retry latency (~100ms).
Kafka metrics: under-replicated partitions
Automatic: ISR re-election. Replace broker within hours.
Flink job crash
Processing stops. Kafka buffers events (hours of retention). Dashboard freshness degrades.
Consumer lag spike, checkpoint failure alerts
Automatic restart from last checkpoint (30s data to reprocess). Full recovery in < 2 minutes.
Redis Bloom filter loss
Dedup degrades: all events treated as "possibly new." Temporary ~1% duplicate rate until filter rebuilt.
Redis cluster health check, sudden drop in Bloom filter hit rate
Rebuild from Redis exact-check set (last hour). Accept brief duplicate window; batch reconciliation corrects.
ClickHouse node failure
Query degradation; writes to surviving replicas.
Replica lag, query timeout rate
ClickHouse ReplicatedMergeTree auto-recovers. Replace node; data syncs from replica.
Batch reconciliation delay
Billing data not verified on schedule. Not critical in real-time, but delays monthly invoice generation.
Spark job SLA monitoring, reconciliation staleness metric
Retry job. If Kafka data still within 30-day retention, no data loss. Extend invoice deadline if needed.
Network partition (Flink ↔ Kafka)
Consumer group rebalance. Brief duplicate processing during rebalance (~10-30s).
Consumer group rebalance events, duplicate rate spike
Exactly-once semantics handle most cases. Residual duplicates caught by dedup layer + reconciliation.

Rubric — Senior vs Staff

Dimension
Senior signal
Staff signal
Deduplication
Dedups on click_id using a Redis set or Flink keyed state.
Fronts an exact check with a sized Bloom filter, rotates it hourly to bound memory, and nets ~0% false positives.
Billing accuracy
Trusts Flink exactly-once to keep counts correct.
Adds Spark reconciliation from the Kafka log as ground truth, flags >0.1% drift, and emits correction records.
Aggregation & cardinality
Counts clicks in 1-minute windows per dimension.
Pre-aggregates top dimensions, serves rare drill-downs from raw, and uses HyperLogLog to cap unique-count cost.
Technology choices
Picks Kafka, a stream processor, and an OLAP store.
Justifies Flink, ClickHouse, and Lambda against named alternatives on latency, cost, and operability.
Failure handling
Notes Kafka buffering and automatic Flink restarts.
Reasons through late arrivals, backpressure autoscaling, Bloom-filter loss, and hot-partition skew with concrete fixes.
★ MORE WALKTHROUGHS

Want more breakdowns like this?

Join free early access for upcoming RAG, LLM eval, agents, and AI infrastructure walkthroughs.

Join Free Early Access →