Working with Large-Scale Datasets Questions
Analyzing data that does not fit comfortably in memory or a single query. Covers distributed processing concepts, partitioning and sampling strategies, query and pipeline performance, and trade-offs when scaling an analysis. Focuses on getting correct answers efficiently at scale.
Design an end-to-end analytics pipeline to measure experiments in near real-time for a product that generates 100M events per day. Include event collection, identity stitching, bucketing consistency, streaming vs batch ETL, data warehouse design, and how you'd ensure experiment assignment and metric calculation are reproducible and auditable.
Sample Answer
Requirements & constraints:
- 100M events/day (~1.2k/s avg, peaks higher), near real-time experiment reporting (minutes), reproducibility, auditable assignments and metrics.
High-level architecture:
- Event producers -> front/back-end SDKs -> streaming layer (Kafka/PubSub) -> stream processing (Flink/Beam/KStream) -> materialized aggregates + event lake -> data warehouse (Snowflake/BigQuery) + serving tables -> BI/analyst queries & dashboards.
Event collection:
- Lightweight JSON/AVRO events from SDKs with schema version, timestamp (client/server), event_id, user_ids (user_id, anon_id), device, experiment_context (optional), and signature for integrity.
- Ingest to Kafka with partitioning by event time bucket or user_id hash to keep ordering.
Identity stitching:
- Maintain a deterministic identity service in stream processor: use deterministic resolver that prefers stable IDs (user_id > login_id > cookie > device). Emit a canonical_id with provenance fields (which ids used, timestamps). Persist identity map writes to a low-latency KV store (Cassandra/Redis) for lookups and to the event-lake for audit trail. All resolution decisions are logged.
Bucketing consistency & experiment assignment:
- Calculate bucketing deterministically in the streaming layer using experiment_id, user_id canonical_id, start_time, and a stable hash function + config (seed, allocation). Store assignment records to a assignments topic and write to assignments table in warehouse (append-only) containing assignment_id, canonical_id, experiment_id, variant, config_version, timestamp, and signature.
- For deterministic repeatability, store experiment config/version in a config service and snapshot configs alongside assignments.
Streaming vs batch ETL:
- Streaming (Flink/Beam) for: identity stitching, deterministic bucketing, real-time metric increments, and writing to materialized views (e.g., metrics topic, OLAP table via Kafka connector).
- Batch for: reprocessing historical events (schema migrations, bug fixes), heavy aggregations, and complex baseline analyses. Keep raw event lake (Parquet on S3/GCS) as single source of truth.
Data warehouse design:
- Raw_events (immutable): event_id, canonical_id, raw_payload, ingestion_ts, event_ts, schema_version
- Assignments: assignment_id, canonical_id, experiment_id, variant, config_version, assigned_ts
- Metrics_facts (near real-time): date_hour, experiment_id, variant, metric_name, count, sum, unique_users_estimated (HyperLogLog) and exact_user_list when needed (for audits)
- Dimension tables: experiments (versioned), users (canonical), events_meta
- Use partitioning by date and clustering by experiment_id/variant to optimize queries.
Reproducible & auditable assignment and metric calculation:
- Immutable event lake + append-only assignments table with config_version links allow exact replay.
- Log experiment configs and seeding rules in a config store; every change gets a versioned snapshot.
- Store deterministic bucketing code in a version-controlled repo and tag deployed versions; include code version in assignment records.
- Support full replay: re-run streaming logic in batch over raw_events with the same config_version/code to reproduce results; record checksum of outputs.
- Maintain lineage metadata for every aggregated metric (SQL or DAG) that traces to raw_events and assignment_id ranges.
Monitoring & validation:
- Deploy unit/integration tests for bucketing determinism, identity merges, and metric aggregations.
- Shadow buckets and canary runs for new experiment logic.
- Drift alerts: compare streaming aggregates vs periodic batch aggregates (daily) and alert on >X% divergence.
- Sampling + store golden sample events for manual audit.
Operational notes for a data analyst:
- Provide pre-built SQL views that join assignments -> raw_events -> metrics_facts to answer "What users in variant A did X in timeframe T".
- Surface experiment meta (config_version, assignment_time) in dashboards.
- Offer reproducible notebooks that accept config_version and date range to rerun metrics from raw_events for audit.
Trade-offs:
- Streaming gives minutes-latency; batch ensures correctness for reprocessing. Keeping raw immutable lets analysts re-run and validate.
- HLL for scalable uniques, but store exact lists for small experiments/audits.
This design balances near-real-time visibility with reproducibility and auditability needed for trustworthy experiment analysis.
You need to compute daily unique users from event logs. Describe two approaches: (1) a SQL-based approach using window functions/aggregation and (2) a MapReduce/batch approach for very large data. For each approach, explain time and space complexity, where bottlenecks occur, and the communication points you would use to justify the chosen approach to engineering and product stakeholders.
Sample Answer
Approach 1 — SQL (single-cluster / analytical DB)
- Method: Use GROUP BY or window functions on event_time truncated to date.
Example:
SELECT event_date, COUNT(DISTINCT user_id) AS dau
FROM events
WHERE event_time BETWEEN '2025-01-01' AND '2025-01-31'
GROUP BY event_date
ORDER BY event_date;
Or for sessions per day using windowing:
ROW_NUMBER() OVER (PARTITION BY user_id, event_date ORDER BY event_time) ...
- Complexity: Time O(N) scanning events; DISTINCT may add hashing cost O(N) extra. Space: O(U) in-memory hash per date (U = unique users per day).
- Bottlenecks: DISTINCT/group-by memory pressure, single-node query planner limits, heavy I/O if table not partitioned/indexed.
- Communication points: Fast to implement, low operational overhead, good for daily reports and ad-hoc slices. Recommend partitioning by date and indexes to speed queries. Share SLA: near-real-time (minutes) for small-to-medium volumes.
Approach 2 — MapReduce / Batch (very large scale)
- Method: Map by (date,user_id) emit 1; Reduce deduplicates per key and sums per date. Typical two-step: map -> dedupe per mapper (local combiner) -> shuffle -> reducer aggregates counts by date.
- Complexity: Time O(N) across cluster; network/shuffle cost O(M) where M ≈ distinct (date,user) pairs. Space: per-worker memory O(local uniques); overall disk/network proportional to M.
- Bottlenecks: Shuffle/network IO and skewed keys (popular dates/users); reducer hotspots if not partitioned well.
- Communication points: Scales to petabytes, predictable cost per batch (hourly/daily). Justify for high-volume pipelines where SQL cluster cannot hold state or when query would time out. Explain trade-offs: higher latency (batch hours), operational complexity, but lower per-query memory pressure and horizontal scalability. Recommend combiners, proper partitioning, and handling skew (salting).
Recommendation to stakeholders
- If daily event volume fits warehouse (tables partitioned, <~hundreds of millions rows/day): use SQL for speed, easier ownership, and interactive exploration.
- If volume is massive, or you need repeatable, auditable nightly pipelines at scale: use MapReduce/batch (or Spark), emphasizing cost, latency, and reliability trade-offs.
- Present metrics: expected rows/day, query latency targets, cost estimates, and maintenance effort to align engineering (ops complexity) and product (freshness and accuracy) expectations.
Propose a system to record data lineage so that every dashboard cell can be traced back to source rows and the SQL or pipeline that produced it. Describe metadata to store (dataset versions, query text, hashes), snapshotting strategy, and how to balance storage cost vs recomputation for reproducibility.
Sample Answer
Requirements:
- Trace each dashboard cell to source rows, SQL/pipeline code, and dataset versions.
- Reproducible: rerun pipeline to get same results or point to stored snapshot.
- Scalable and cost-aware (hot vs cold storage).
- Usable by analysts (searchable lineage UI, explainability).
High-level design:
- Lineage Service (metadata store + API) + Snapshot Store (object storage) + Index/Search + Orchestrator hooks (ETL/SQL runners) + Dashboard integration.
Metadata to store (per dataset / job / cell):
- DatasetVersion: id, source URI(s), ingestion timestamp, schema (column hashes), row-count, partition keys.
- QueryRecord: id, author, full SQL/pipeline script, parameters, execution timestamp, upstream DatasetVersion ids, DAG edges.
- RowFingerprint index: for sampled rows store content hash (e.g., SHA256) and primary key -> links to DatasetVersion.
- CellProvenance: dashboard_id, cell_id, QueryRecord id, output hash, aggregation definition, timestamp, pointers to contributing DatasetVersion ids and row-fingerprint ranges.
- Artifact hashes: commit hash for code, container image, config checksum.
- Metrics: latency, cost, data freshness.
Snapshotting strategy:
- Full snapshots for critical authoritative tables (daily) stored in object storage (columnar Parquet + manifest with DatasetVersion metadata).
- Incremental snapshots (change data capture) for high-volume tables with base snapshot + deltas.
- Sampling: keep deterministic sample snapshots (e.g., stratified hash-based sample) for quick row-level verification.
- Retention tiers: hot (recent full snapshots, fast access), warm (monthly), cold (archive) with lifecycle rules.
Balancing storage vs recomputation:
- Classify datasets by SLA/importance (gold/silver/bronze). Gold: store full snapshots; Silver: store incremental + metadata to recompute; Bronze: store only metadata + code to recompute on demand.
- Use content hashes + cached materialized views to reuse results when inputs unchanged.
- Store compact fingerprints for rows to prove origin without full payload; fetch full snapshot only when requested.
- Provide on-demand recompute: if snapshot expired, orchestrator re-runs QueryRecord on that exact DatasetVersion (using stored code, container, and parameterized inputs). Record recompute results and link as new provenance event.
Operational concerns:
- Ensure immutability: DatasetVersions and QueryRecords are append-only.
- Security/Audit: RBAC, immutable audit logs.
- Performance: index by DatasetVersion id, row hash, and cell id; allow sampling-based lineage to reduce cost for large joins.
- UX: UI to show lineage graph, "show source rows" which either retrieves stored rows, reconstructs from snapshot, or triggers recompute (with estimated cost and ETA).
Example flow:
- Analyst creates dashboard cell with SQL -> runner stores QueryRecord, links to latest DatasetVersions, computes output and output-hash, stores CellProvenance. If downstream user clicks "trace to rows", system uses row-fingerprint map to return matching source rows or triggers recompute using stored SQL and DatasetVersion pointers.
This design gives auditors cryptographic proof (hashes + immutable metadata) while tuning storage/recompute per dataset importance to control cost.
You must design analytics at petabyte-scale for a product analytics platform. Describe the architecture decisions for aggregate tables, partitioning/clustering strategies, materialized views, denormalization, incremental refresh patterns, and how to handle schema evolution while keeping query latency low and cost reasonable.
Sample Answer
Requirements & constraints:
- Petabyte-scale event tables; low-latency interactive analytics for analysts/dashboards; cost-conscious; support ad-hoc SQL, scheduled reports, and near-real-time funnels/metrics; tolerate eventual consistency for some aggregates.
High-level architecture:
- Ingest → Landing (raw append-only) → Staging/CDC → Curated fact and dimension stores in a cloud DW (e.g., BigQuery / Snowflake / Redshift Spectrum) → Aggregate/metric layer (materialized views / pre-aggregated tables) → BI tooling.
Aggregate tables:
- Build pre-aggregations at common grain (daily/hourly by product, user cohort, country, event_type) to serve dashboards and OLAP queries.
- Use a “rollup lattice”: multiple tables at different grains so queries hit the smallest table that satisfies resolution.
- Keep aggregates narrow (only necessary measures + keys) to reduce storage.
Partitioning & clustering:
- Partition large fact tables by ingestion_date (day) for efficient time-range pruning.
- Secondary clustering/ordering by high-selectivity columns used in filters (user_id hash, event_type, product_id) to improve IO locality and reduce scan cost.
- For nested or wide event payloads, store raw JSON in separate column and avoid scanning it in aggregates.
Materialized views & refresh:
- Use materialized views for common transforms (user retention, daily active users). Prefer incremental refresh (MV maintained by DW or via pipeline) to avoid full recompute.
- Implement streaming small-window updates (near-real-time) plus batch compaction daily/weekly to optimize for cost vs freshness.
Denormalization:
- Denormalize read-heavy attributes into aggregates (user cohort labels, product attributes) at materialization time to avoid repeated joins.
- Keep dimension tables for master data and refresh via incremental CDC; join during ETL to populate denormalized aggregates.
Incremental refresh patterns:
- Use append/partition-merge pattern: write new partitions for recent time ranges; for late-arriving data, run targeted reprocessing for affected partitions (time-windowed MERGE).
- Maintain change-data-capture (CDC) streams for dimension updates and use idempotent upserts to aggregated tables.
- Employ watermarking and batch windows to bound reprocessing.
Schema evolution:
- Use explicit schema registry or catalog; write ETL to tolerate nullable/new fields and ignore unknown fields.
- Keep raw event store immutable for reprocessing if schema changes require recompute.
- Use column-additive evolution: add new columns to aggregates and backfill only when necessary; mark deprecated columns and eventually drop after migration.
Latency vs cost trade-offs:
- Hot data (last 7–14 days) kept in partitioned, clustered aggregates with auto-maintained MVs for low-latency queries.
- Cold data served by coarser rollups (monthly) or queried directly from compressed raw store for infrequent deep dives.
- Monitor query patterns and use usage-based tiering: promote aggregates that show heavy access; drop ones with low usage.
Operational practices:
- Automate partition lifecycle (retention, compaction) and cost alerts.
- Benchmark common queries and tune clustering keys and rollup grains iteratively.
- Provide self-serve tooling (templated SQL, documented rollups) so analysts pick the correct aggregate, minimizing accidental full-table scans.
This design balances interactive latency (pre-aggregations, MVs, clustering) with cost (narrow aggregates, rollups, tiered storage) and supports safe schema evolution via immutable raw store and incremental reprocessing.
Describe an approach (SQL or Spark) to compute the top-10 most frequent product pairs (market-basket pairs) from 100M transaction rows where each row contains transaction_id and product_id. Explain algorithmic choices to minimize shuffles and memory use, options for approximate methods (sampling, sketching), and how to handle very large baskets (transactions with many items).
Sample Answer
Approach overview:
- Map each transaction's product list to all unordered product pairs (combinations) locally, emit (prodA,prodB) with count 1.
- Perform a distributed reduceByKey (or SQL GROUP BY) to sum counts and then take top-10.
- Key optimizations: local aggregation (combiner), partitioning by hash of pair to reduce shuffle skew, filter/trim huge baskets, or use approximate sketches/sampling.
Spark (PySpark) implementation with attention to minimizing shuffles and memory:
from itertools import combinations
from pyspark.sql import SparkSession
spark = SparkSession.builder.getOrCreate()
# transactions: DataFrame with columns transaction_id, product_id
# Group products per transaction (one shuffle)
tx = transactions.groupBy("transaction_id") \
.agg(F.collect_set("product_id").alias("items")) \
.rdd.map(lambda r: r['items'])
# Emit local counts per partition (map-side combiner)
def pairs_local_counter(partition):
from collections import Counter
cnt = Counter()
for items in partition:
if not items: continue
# Option: cap basket size to avoid explosion
if len(items) > 200:
items = items[:200] # or choose top-k by item frequency
for a,b in combinations(sorted(items), 2):
cnt[(a,b)] += 1
for pair, c in cnt.items():
yield (pair, c)
pair_counts = tx.mapPartitions(pairs_local_counter) \
.reduceByKey(lambda x,y: x+y) \
.map(lambda kv: (kv[1], kv[0])) \
.top(10, key=lambda x: x[0])
Key concepts / algorithmic choices:
- collect_set per transaction groups items (1 shuffle). Use compact representations (integers) to reduce network bytes.
- mapPartitions local Counter acts as combiner to dramatically reduce the number of emitted pair records — minimizes shuffle volume.
- reduceByKey performs the global aggregation using hash partitioning; choose number of partitions to balance parallelism and overhead.
- Sort pairs (a,b) deterministically so (x,y) == (y,x).
- For very large baskets: cap size, sample items, or restrict to items above a frequency threshold (A-Priori pruning: first compute item frequencies and drop rare items before pair generation).
- To handle skew: detect heavy hitters and process them separately (e.g., special partitions or partial aggregation).
Approximate methods:
- Sampling: randomly sample transactions (stratified if needed) and scale counts — cheap and often sufficient for top-k.
- Sketching: use Count-Min Sketch per partition with merge to approximate counts with bounded error and small memory. Then extract heavy pairs via a heavy-hitters sketch (e.g., Frequent algorithm or Space-Saving).
- Locality-sensitive hashing / MinHash is less applicable to pair counts but can help detect similar baskets.
SQL variant (conceptual):
- Use windowed GROUP BY on pair columns after self-joining grouped transactions:
- WITH items AS (SELECT transaction_id, collect_set(product_id) AS items FROM t GROUP BY transaction_id)
- explode items to produce pair rows (using lateral join or cross-apply with filtering a<b)
- SELECT pair, COUNT(*) GROUP BY pair ORDER BY COUNT DESC LIMIT 10
- Same optimizations: pre-filter rare items, cap explosion via WHERE array_length(items) < threshold, or precompute frequent items.
Complexity:
- Worst-case pair generation per transaction O(m^2) where m = basket size. Using caps/pruning keeps work manageable.
- Shuffle volume minimized by map-side combiners; overall network I/O proportional to distinct pairs after local aggregation.
Edge cases and validation:
- Identical items in a transaction: use collect_set to dedupe.
- Memory: ensure Counters per partition fit memory; reduce partition size if needed.
- Skew: monitor partition sizes and heavy-pair counts; handle hot keys separately.
This approach balances accuracy and resource usage: use exact aggregation with combiners for reliable top-10; switch to sampling or Count-Min Sketch when low-latency, low-cost approximations suffice.
Unlock Full Question Bank
Get access to all 14 Working with Large-Scale Datasets interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.