Scenario + Requirements
Design a delivery / fraud-scale feature platform: 100+ models (delivery ETA, store ranking, fraud detection) sharing one feature set; online inference peaking at 10M feature reads/sec, 200 features per prediction, p99 < 10ms; while offline you must reconstruct the "as-of-that-moment" feature values for billions of training samples. This is the real scale at DoorDash and Uber.
The core tension: training uses offline batch (throughput-first), inference uses online low-latency (latency-first), yet both must be the same feature value—otherwise the model scores AUC 0.85 offline and drops to 0.79 in production. That is the infamous training-serving skew. The platform must solve three things: where to store, how to guarantee consistency, how to serve and validate.
- Feature freshness: balance-like features need second-level freshness, a user's 30-day spend can be hourly—different freshness, different cost.
- Read/write ratio: DoorDash publicly noted online writes are only 0.1% of reads—extremely read-heavy, which is what dictates Redis/KV over a general-purpose DB.
- Point-in-time correctness: a training label is produced at time T; features may only use values from before T, or you get label leakage.
- Reusable/discoverable: Uber Palette hosts 20,000+ features reusable company-wide, avoiding every team reinventing the wheel.
High-Level Architecture
graph LR
SRC["Sources
event streams / warehouse / DB CDC"]
subgraph Compute
BATCH["Batch
Spark / SQL"]
STREAM["Stream
Flink"]
end
REG["Feature Registry
definition + version + lineage"]
OFF[("Offline Store
Parquet / warehouse")]
ON[("Online Store
Redis / DynamoDB")]
TRAIN["Training
PIT join → samples"]
SERVE["Model Serving
fetch features + infer"]
SRC --> BATCH & STREAM
REG -.constrains.-> BATCH & STREAM
BATCH --> OFF
BATCH -->|materialize| ON
STREAM --> ON
OFF --> TRAIN
ON --> SERVE
classDef src fill:#1a2530,stroke:#64c8ff,color:#e8eef5
classDef comp fill:#0e2030,stroke:#5eead4,color:#e8eef5
classDef store fill:#2a1530,stroke:#ff7ab6,color:#e8eef5
classDef sink fill:#1a1a30,stroke:#ffb450,color:#e8eef5
class SRC src
class BATCH,STREAM,REG comp
class OFF,ON store
class TRAIN,SERVE sink
One feature definition (registry) drives both batch and stream pipelines, landing in offline/online stores that feed training and inference
Component roles: the registry is the single source of truth, declaring each feature's entity key, type, transformation logic, and freshness SLA; batch handles heavy backfills and T+1 features, stream handles second-fresh features; the offline store keeps full history partitioned by time (for point-in-time reads), the online store keeps only each entity's latest value (for millisecond reads). This is the industry-standard dual-store architecture.
Key Techniques
1. Dual Store: offline warehouse vs online KV, one definition materialized twice
Principle: training needs "billions of samples × per-sample historical feature lookup"—a throughput-bound full scan, naturally a columnar warehouse (Parquet/Iceberg partitioned by event_ts). Inference needs "given a user, fetch 200 features within 10ms"—a point-lookup random read, naturally an in-memory KV. The same feature is materialized once in each engine, with the registry keeping semantics aligned.
Trade-off (online store choice):
- Redis: ✅ sub-millisecond, batched pipeline fetch, rich data structures; ❌ memory-expensive, weak durability (a cache, not a source of truth). DoorDash chose it after benchmarking.
- DynamoDB / Cassandra: ✅ massive durable storage, horizontal scale by key; ❌ higher p99 than Redis (single-digit ms up), cost scales with read amplification.
- Single store (one engine for both): ✅ no consistency problem; ❌ no such engine exists—either training scans crawl, or online point lookups can't hold the QPS. So dual-store is correctness by necessity.
# Online fetch: one pipeline pulling 200 keys, avoiding 200 RTTs
def fetch_online(entity_ids, feature_names):
keys = [f"{fn}:{eid}" for eid in entity_ids for fn in feature_names]
vals = redis.mget(keys) # single round trip, 200 keys ~1ms
return assemble(entity_ids, feature_names, vals)
# Materialization: batch pipeline flushes latest values into online store,
# storing only "latest per entity" (no history) → online store size stays bounded
Real cases:
- DoorDash: Building a Gigascale ML Feature Store with Redis—chose Redis via YCSB benchmarking, serving billions of feature values at millions of QPS, writes only 0.1% of reads, with protobuf compression to save memory.
- Uber Michelangelo Palette: Palette Meta Store—Hive offline, Cassandra online, hosting 20,000+ features reused company-wide.
- Feast (open source): abstracts "offline store + online store + registry" into a standard interface—offline over BigQuery/Snowflake, online over Redis/DynamoDB.
2. Point-in-Time Correctness: the core mechanism against label leakage
Principle: a training sample is "(entity, label time T, label)"; when joining features to it, you may only use feature values with event_ts ≤ T. Naively joining the feature table's current value feeds "the future" into the model—inflated offline metrics, production collapse. The correct approach is a point-in-time join (as-of join): for each sample, find the "most recent feature before T" by entity key.
Trade-off (two routes to consistency):
- Log-and-wait: at online inference, log the exact feature values used; training uses that log directly. ✅ training = serving, zero skew; ❌ a new feature must accumulate enough logs before training (slow cold start).
- Backfill: recompute historical features with a batch pipeline via PIT join. ✅ new features trainable immediately; ❌ the backfill logic must match online bit-for-bit—any drift introduces skew.
- Shared transformation code: either way, the transformation must be the same code/DSL across batch and stream, not "Python for training + Java for serving"—that duplication is the number-one source of skew.
# Point-in-time join pseudocode (as-of join, prevents label leakage)
# labels: (entity_id, label_ts, y) features: (entity_id, event_ts, value)
for row in labels:
cand = features[entity_id == row.entity_id
and event_ts <= row.label_ts] # strict ≤ label time
row.feature = cand.sort_by(event_ts).last() # latest value as-of then
# Engine: Spark partitions by entity_id + sorts by event_ts for a merge as-of join
Real cases:
- Google (Rules of ML): Rules of Machine Learning (Martin Zinkevich) has a dedicated section on training-serving skew; its top remedies are "log the features used at serving time and train on that log" plus "reuse the same code across training and serving".
- Airbnb Chronon: Chronon (formerly Zipline)—one feature definition generates both batch and stream pipelines with built-in point-in-time-correct backfills, purpose-built to eliminate skew.
3. Model Serving: splitting the latency budget between fetch and inference
Principle: an online prediction's p99 budget (say 30ms) must be split across "fetch features + preprocess + model forward + postprocess". Empirically, feature fetch often takes over half the latency—200 features scattered across multiple entities and stores. Optimizations: batched pipeline fetch, concurrent fetching within a request, and local L1 caching of hot entities.
Trade-off (serving form):
- Online real-time inference: fetch features and run the model on request. ✅ fresh, flexible; ❌ latency-sensitive, heavy models (big trees/deep nets) can't hold high QPS.
- Precompute + lookup: precompute (user×candidate) scores offline into KV, look up online. ✅ ultra-low latency; ❌ combinatorial explosion, can't use request-context features (e.g. "location right now").
- Dedicated inference service (TF Serving / Triton / KServe): model as its own service with dynamic batching and GPU. ✅ high throughput, decoupled; ❌ an extra network hop, needs orchestration with feature fetch.
Real cases:
- DoorDash: in client-side caching, adding an in-process cache to feature reads improved performance by ~70%—hot entities (popular stores) hit locally, slashing Redis load.
- Uber Michelangelo: orchestrates online features (Cassandra real-time + precomputed batch) with model serving, powering delivery ETA and similar models.
4. A/B Experimentation: turning "shipping a model" into a measurable causal experiment
Principle: a new model can't ship just because offline AUC went up—offline metrics and business metrics (order rate, GMV) often diverge. Use randomized online experiments to establish causality: consistent hashing stably assigns users to control/treatment, and after enough sample size you compare statistical significance of business metrics, while watching guardrail metrics (latency, error rate, refund rate) to prevent "main metric up, side effect explodes".
Trade-off (key design points):
- Assignment granularity: by user hash (stable, avoids flipping the same user between A and B) vs by request (more samples but with interference). Recommendation systems almost always assign by user/session.
- Sample size vs speed: smaller effects need more samples (inverse-square), so either wait longer or use variance-reduction techniques like CUPED to accelerate.
- Network effects: in two-sided markets (delivery drivers/merchants), treatment steals resources from control, breaking independence—use region/time switchback experiments rather than user randomization.
# Stable bucketing: the same user always lands in the same group (reproducible, no flicker)
def bucket(user_id, exp_key, treat_ratio=0.5):
h = hash_u64(f"{exp_key}:{user_id}") # salt with experiment name → experiments stay orthogonal
return "treatment" if (h % 10000) / 10000 < treat_ratio else "control"
Real cases: Netflix, Uber, and Airbnb all build in-house experimentation platforms, all centered on "stable bucketing + guardrail metrics + variance reduction". Airbnb's Chronon feature definitions can be consumed directly by experiments, closing the loop "new feature → model → A/B".
Scaling & Optimization
- Tiered freshness: second-level via Flink stream, minute-level via micro-batch, day-level via Spark T+1—allocate compute by business value, don't make everything real-time (cost explodes).
- Feature monitoring & drift detection: continuously track each feature's distribution; alert when online distribution drifts from training distribution—skew often happens "silently".
- Cost governance: online store memory is the priciest—use protobuf/quantization compression, TTL-evict cold entities, and materialize only features actually referenced by models (prune via lineage).
- Embedding features: vector features (user/item embeddings) go to a dedicated vector store, managed separately from scalar features.
Pitfalls + Interview Questions
1. Joining current values in training: the classic label leakage. You must do a point-in-time join with the strict event_ts ≤ label_ts constraint.
2. Two transformation codebases for train and serve: a Python version and a Java version computing different values for the same feature = skew. Sharing one definition/DSL is the cure.
3. Treating the online store as source of truth: Redis has lossy RPO on crash—it's a cache of latest values; history and truth live in the offline store and warehouse.
4. A/B with no guardrails: shipping to 100% just because the main metric rose, only to double latency and spike refunds. Always monitor side-effect metrics too.
- Why do you need a dual store? Can one engine serve both training and inference? Where are the bottlenecks?
- Explain the three causes of training-serving skew and give one fix for each.
- A prediction fetches 200 features at p99 < 10ms—how do you split the latency budget and optimize the fetch?
- Why does plain user-randomized A/B fail in a two-sided market (delivery)? What experiment design replaces it?
- A hot entity (top store) has excessive single-point feature QPS—how do you hold it? (recall Day 2 caching hot keys)
Resources
Deeper Thinking (click to expand)
1. A feature computes to 3.7 offline but 3.5 online; the model doesn't error, yet production drops 5%. How do you systematically locate and cure this "silent skew"?
Locate in three layers:
- Data layer: for the same batch of (entity, time), fetch values both via offline PIT and via online serving logs, and compare per-feature distributions and row-by-row diffs to find the feature with the largest deviation.
- Logic layer: for the big-deviation feature, check whether batch and stream use two implementations (e.g. different window semantics: offline
[T-7d, T) half-open, online accidentally including the current event), type/precision (float32 vs float64), or inconsistent missing-value fills.
- Time layer: online features may have materialization lag—training used the value at T, but production's store still holds the T-5min stale value, an implicit skew.
Cure: ① use one code/DSL for transformations (the Chronon idea), eliminating dual implementations; ② ship continuous skew monitoring—daily sample online logs vs offline backfill and alert above threshold; ③ prefer log-and-wait for new features, so training data equals serving data by construction. Google's Rules of ML core advice is exactly "log serving features, train on them".
2. Why can't a single unified storage engine serve both training's full scans and inference's millisecond point lookups? Explain this "forced dual store" from the storage physics.
The essence is that the access patterns demand conflicting storage layouts:
- Training: sequential scan over billions of rows × few columns + as-of join. Optimal layout is columnar + time-partitioned (Parquet/Iceberg)—high compression, read only needed columns, prune by partition. But it is painfully slow at fetching one row (must scan a row group).
- Inference: fetch multiple fields of one row given a key, needing O(1) random reads. Optimal is an in-memory hash table / LSM point lookup (Redis/KV), but it is bad at full scans (must traverse all keys) and stores only latest values, no history.
Columnar is scan-friendly but slow at point lookups; KV is lookup-friendly but weak at scans/history—no single engine pushes both curves to optimal. So engineering materializes the same feature twice: full history into columnar for training, latest snapshot into KV for inference, at the cost of maintaining consistency between them (precisely why the dual-store architecture exists). This is the same idea as Day 20's "derive multiple storage views from one dataset".
3. In a two-sided market (delivery: users/drivers/merchants), does user-randomized A/B of a "new driver-dispatch model" bias optimistic or pessimistic? Why? What design replaces it?
Usually optimistic (overstates benefit). Because treatment and control users share the same driver pool: if the new model dispatches drivers more efficiently to treatment users, it effectively "steals" capacity from control users, artificially widening the gap—you measure not "the new model's absolute benefit" but "the relative gap after treatment cannibalized control". After full rollout everyone is in one pool, so the benefit shrinks or vanishes (the SUTVA assumption is violated).
Replacement design: use a switchback (region-time randomization) experiment—switch control/treatment as a whole per "city × time slice", so everyone in the same space-time uses the same policy and supply-demand closes within the experimental unit, eliminating cross-group interference. The cost is fewer experimental units, higher variance, longer duration, and dealing with temporal autocorrelation. Uber/DoorDash dispatch experiments commonly use switchback.
4. A top store's feature is hammered by 1M QPS on a single point in the online store (recall Day 2's hot key). In the feature-platform context, which mitigations apply, which don't, and why?
Apply Day 2's hot-key toolbox, then filter by the feature context:
- ✅ App-local L1 cache: most effective. A feature barely changes within one inference cycle, so a local cache with a few-second TTL cuts Redis load by orders of magnitude—DoorDash's client-side cache is exactly this, ~70% improvement.
- ✅ Multi-replica reads: replicate the hot feature across Redis nodes to spread reads. Feature reads are read-heavy so replica lag matters little—very applicable.
- ⚠️ Key sharding (split into N sub-keys and sum): only works for additive counter features (e.g. "store orders today"); embeddings and ratio features can't be split/merged, so it's not applicable.
- ✅ Intra-request dedup/coalesce: when many candidates in one request reference the same store's feature, fetch it once.
- ❌ Simply raising TTL to trade freshness: fraud/dispatch features are freshness-sensitive; sacrificing freshness for hit rate can directly hurt the model, so set policy per feature's freshness SLA.
Practical combo: local L1 (absorbs most reads) + hot-feature multi-replica (backstop), with cache TTL strictly aligned to that feature's freshness requirement.
5. A team wants to train a model with a brand-new feature "right now", but it has accumulated no logs online. How do you choose between log-and-wait and backfill, and what are the hidden risks of each?
Want to train now → only backfill works (log-and-wait must wait for logs to accumulate—days to weeks). But backfill's hidden risk is that "history can't be perfectly reconstructed":
- Data isn't recoverable: if the feature depends on an upstream that stores only latest values and keeps no history (e.g. a dimension table overwritten in place), you simply cannot compute "the value at that moment three months ago"—the backfill is a polluted approximation.
- Backfill logic ≠ online logic: backfill uses batch code to recompute; any semantic drift from the future online stream code makes the model skew on ship.
- Survivorship/point-in-time bias: backfill often uses "entities that still exist now", missing churned users and shifting the sample distribution.
Pragmatic strategy: backfill first to quickly validate whether the feature has signal (run it offline, check importance), while immediately turning on online logging; once logs are enough, switch to log-and-wait training for the "official version" to align with the real production distribution. That is backfill to scout, log-and-wait to finalize. Never iterate long-term on the backfill version—skew will quietly accumulate.