search_ads_recsys / 17 · real-time & streaming lesson 17 / 39

Real-time & streaming recommendation

A recommender is a function of features, and features go stale. The model that ranked your feed at 9:00 does not know that, at 9:02, you watched three cooking videos, that a story just broke, or that the clip everyone is sharing was uploaded ninety seconds ago. "Real-time" is the discipline of closing that gap — treating freshness as a feature and building the streaming machinery to deliver it in milliseconds.

Why freshness is itself a feature

Take the same user and the same item at two different moments. The only thing that changed is time, yet the right recommendation flipped. That is the operational definition of freshness-as-signal: a feature whose value decays, often steeply. It is not a system-health nicety — it is predictive signal that routinely dominates the static features you painstakingly engineered. Three canonical sources of perishable signal:

We can make "freshness is a feature" literal. Model the usefulness of a signal observed Δt ago as exponential decay, and you recover the half-life intuition behind every freshness SLA:

value(Δt) = value₀ · e−λ·Δt  |  half-life t½ = ln 2 / λ ≈ 0.693 / λ

If a click's signal has a half-life of 2 minutes (λ ≈ 0.0058/s), a feature pipeline with a 4-minute lag delivers it at e−0.0058·240 ≈ 0.25 — you threw away 75% of its value before the model saw it. A freshness SLA is just "keep e−λ·lag close to 1." Different signals have wildly different λ, which is exactly why a single update cadence is wrong, and why we split the work across planes.

The one-line summary you should be able to give
The question is never "should we be real-time?" but "how fresh must each feature be, and what does that freshness cost in latency and complexity?" The answer is an allocation problem across three planes — offline, nearline, online.

The three planes

You cannot do all the work at request time (too slow), and you cannot do it all in batch (too stale). The resolution is to split the system by how fresh the work must be into three planes, each with its own latency budget and its own job. Almost every later design decision reduces to "which plane does this belong on?"

THE THREE PLANES OF A REAL-TIME RECOMMENDER ────────────────────────────────────────────────────────────────────── OFFLINE (hours–days) NEARLINE (seconds–minutes) ONLINE (ms) ┌────────────────────┐ ┌────────────────────────┐ ┌──────────────┐ │ full model retrain │ │ stream processor (Flink)│ │ the request: │ │ embedding tables │ │ session sequences │ │ retrieve → │ │ ANN index build │ │ rolling counts / rates │ │ rank → │ │ 90-day aggregates │ │ item-trend stats │ │ re-rank → │ │ label joins │ │ incremental updates │ │ return list │ └─────────┬──────────┘ └───────────┬─────────────┘ └──────┬───────┘ │ writes weights │ writes features │ reads ▼ ▼ ▼ ┌────────────────────┐ ┌────────────────────────┐ ┌──────────────┐ │ registry+warehouse │ │ online feature store │◄──┤ model + │ │ weights, offline │───►│ Redis / KV, fast reads │ │ ANN index │ │ feature tables │ └───────────┬─────────────┘ └──────────────┘ └────────────────────┘ │ reads ▲ ════════════════════════════ KAFKA ════╧═══════════════════════════════ append-only event log: exposures, clicks, watches, skips, likes ◄─ user ──────────────────────────────────────────────────────────────────────
PlaneLatency / cadenceWhat runs hereWhat it must NOT do
Offlinehours → daily (T+1)Full model retraining, embedding-table learning, ANN index builds, heavy historical aggregates (90-day CTR), label joins.Anything that must reflect the last few minutes. It is structurally too slow.
Nearlineseconds → minutesStreaming feature computation: session sequences, rolling counts/rates, item-trend stats, optional incremental model updates. Reads the log, writes the online store.Per-request personalization or anything in the synchronous request path.
Onlinemilliseconds (P99 < ~200ms)The request itself: read precomputed features, run the funnel (retrieve → rank → re-rank), apply business rules, return the list.Heavy aggregation or training. It only reads features and scores; it computes nothing expensive.
The placement rule
Push work left (toward offline) as far as the freshness SLA allows — left is cheaper and safer. Pull it right (toward online) only as far as freshness demands. A 90-day CTR aggregate belongs offline. "Likes in the last 60 seconds on this video" belongs nearline. "Which 300 of these 1000 candidates does the model score highest right now" belongs online. Most real-time engineering is moving one feature one plane to the right and paying for it.

Three inference topologies — and when each fits

The three planes tell you where a feature lives. A separate, easily-conflated question is where the scoring happens: when a request lands, do you compute the model output synchronously, look up a precomputed one, or hand the work to a queue and poll for it later? There are three topologies, and they trade the same currency — freshness against latency against blast radius. The mistake is picking one for the whole system; the senior move is picking one per funnel stage.

TopologyMechanismLatencyBuys youFailure mode
(a) Pure onlineRequest hits the model service directly; features fetched and the model run synchronously in the request path.variable, model-bound (tens–hundreds of ms)Freshest possible scores — the live click sequence is in the features the model just read.QPS-capped by model compute; a slow or down feature store stalls the whole request — a hard availability dependency with no slack.
(b) Precompute + online correctionOffline produces a base score per candidate; online a light model (an LR / logit adjustment on a few live features) corrects it. Score = base + Δ.stable < 50msA flat, predictable latency profile — the heavy model ran offline, online does cheap arithmetic on a handful of features.Weak long-tail and cold-start coverage: a candidate with no precomputed base has nothing to correct. Real-time feature coverage is limited to whatever the light head consumes.
(c) Async-queue (nearline)Request → Kafka → Flink computes → writes a result cache; the client polls for the finished list.second-level end-to-endSmooths traffic spikes — the queue absorbs a burst (削峰填谷, "shave the peak, fill the valley") so the compute tier sees a flat rate. Fits heavy re-ranking that doesn't need to be instant.Seconds of end-to-end lag, and the cached result can be stale by the time it is read — you must handle result expiry, not just latency.

Worked latency, same ranker, three topologies. Say the heavy model is 35ms of compute, a feature-store fetch is 10ms, and a light LR correction is 3ms. (a) spends 10 + 35 = 45ms synchronously and inherits the full variance of both — a feature-store P99 of 40ms drags the request P99 with it. (b) spends 10 + 3 = 13ms online (the 35ms ran offline, off the clock), comfortably under the <50ms line and almost flat because there is no heavy compute left to spike. (c) returns in milliseconds (just "accepted, poll later") but the answer arrives ~1–3s later when Flink drains the queue — fine for a feed refresh, fatal for the first screen.

The hybrid-by-funnel rule
Don't choose one topology — choose per funnel stage. Fine-rank (精排) → pure online (a): it scores only a few hundred survivors and the marginal value of fresh signal is highest exactly where the decision is made, so pay the synchronous cost. Retrieval (召回) → precompute (b): it touches thousands–millions of candidates where per-item online compute is unaffordable and a slightly stale base score is good enough — precompute embeddings/ANN offline and correct lightly. Heavy re-rank or non-critical surfaces → async-queue (c) when second-level latency is acceptable and you want the queue to absorb spikes. The rule rhymes with the placement rule above: the deeper into the funnel (fewer items, higher stakes), the further right you push the scoring, just as you push the freshest features right. Note (b)'s base-score precompute and (c)'s Flink stage are the same nearline machinery this lesson already describes — these are not new systems, just new wiring of the log and store. Where the precomputed scores live and how the embedding tables behind them are swapped without a read stall is engineered in 18 · Deployment.

The streaming substrate — log + processor

The nearline plane is built on two pieces that recur in every design: an append-only log (Kafka) and a stream processor (Flink, or Kafka Streams / Spark Structured Streaming).

Kafka — the log as the source of truth

Every behavioral event — exposure, click, watch-time, skip — is appended to a partitioned, durable log. Two design knobs matter:

Flink — stateful, event-time stream processing

The processor consumes the log and maintains keyed state — e.g. a per-user MapState of interest tags with time-decayed weights, or a per-item rolling completion rate over a sliding window (say 1h window, 5s slide). It keys by user or item, processes each event, and writes the updated feature to the online store. State lives in a RocksDB backend with incremental checkpoints and TTL eviction so it doesn't grow unbounded. Crucially it uses event time + watermarks, not wall-clock, so out-of-order and late events still land in the right window — the difference between a feature that is correct and one that silently drifts.

Exactly-once vs at-least-once — and what each costs

The log can deliver an event to your processor in three ways, and the choice is a direct trade between correctness and throughput:

GuaranteeMeaningCostUse for…
At-most-oncefire and forget; may dropcheapest, lossynothing important — debug telemetry at best
At-least-onceretried until acked; may duplicatecheap, fast; needs idempotent downstream or dedupexposure counts, trend stats where a tiny double-count is harmless
Exactly-onceprocessed once, no loss, no dup~10–20% throughput hit; latency from barrier alignmentbilling, and strong-consistency signals (a "like" must not double-fire)

Flink achieves exactly-once with a checkpoint + two-phase-commit mechanism. The JobManager periodically injects a barrier into the stream; operators snapshot their state (Chandy–Lamport distributed snapshot) when the barrier passes. End-to-end correctness needs the sink to participate: a TwoPhaseCommitSinkFunction writes a transaction but only commits when the checkpoint completes globally — so a crash rewinds to the last consistent snapshot with no partial output. Pair it with an idempotent Kafka producer (enable.idempotence=true) and transactional reads.

Match the guarantee to the signal, not to the system
Exactly-once is not a virtue to apply uniformly — it is a tax. The senior move is per-signal: run the heavy interaction stream (likes, follows) exactly-once, and the firehose exposure stream at-least-once with dedup by event-id. You buy correctness only where a duplicate or drop would actually corrupt a decision, and spend the saved throughput on freshness everywhere else.

The real-time feature store & freshness SLAs

The nearline plane writes; the online plane reads. The contract between them is the online feature store — a low-latency KV layer (Redis, or a purpose-built store) holding the freshest value of each feature, keyed by entity. Three properties make or break it:

Online / incremental learning — and why it's dangerous

The most aggressive form of real-time is updating the model, not just its features. Instead of waiting for the daily offline retrain, you nudge weights continuously from the streaming label feed. The update is plain online SGD on each freshly-labeled example (or mini-batch):

wt+1 = wt − η · ∇w ℓ(wt; xt, yt)

FTRL-Proximal — the workhorse for sparse linear CTR

Plain online SGD on a logistic CTR model adapts fast but produces dense weights: with billions of hashed/cross features, every coordinate it ever touches stays nonzero, so the served model never fits in memory. Sparsity here is not a nicety — it is a serving requirement. FTRL-Proximal (McMahan et al., 2013, the Google ad-click paper) is the canonical answer: it matches SGD's online convergence and yields exact zeros via an L1 term, which is why it became the default for high-cardinality linear CTR models on the online/nearline plane.

The intuition: instead of one gradient step, FTRL keeps a running sum of past gradients and, each step, solves a regularized objective — "stay near the average of past gradients" plus L1 (the term that induces zeros) and L2. Crucially the learning rate is per-coordinate and adaptive: a feature seen rarely accumulates little and gets a larger effective step, so cold features still learn. The per-coordinate update has a closed form that snaps a weight to exactly 0 whenever its accumulated gradient stays under the L1 threshold: |zi| ≤ λ₁ ⇒ wi = 0, otherwise wi = −(zi − sgn(zi)·λ₁) / ((β + √ni)/α + λ₂). That single branch is what gives you a model that is both incrementally fresh and sparse enough to serve.

It lives in this lesson because it is the workhorse for the online/nearline plane's incremental updates on high-cardinality sparse features, pairing directly with the streaming feature pipeline that feeds it labeled events. But it earns no exemption from the rest of the discipline: an FTRL model updated from the live label feed needs the same train–serve-skew and drift guards the next lesson emphasizes, plus everything below.

This is how a model adapts to a breaking trend within minutes rather than a day. It is also a loaded gun, for reasons that are structural, not incidental:

Online learning does not replace A/B testing — it makes it harder
Faster updates do not mean faster verdicts. An online-learning model is a moving target, so you still gate it behind an A/B test with guardrail metrics (diversity, freshness, negative-feedback rate) and a long-running holdout to catch slow-burn damage — a feed that is more engaging today but emptier in a month. Speed of adaptation and rigor of evaluation are independent axes; real-time tempts you to trade the second for the first.

Operating the loop — the machinery that keeps it alive

The dangers above are not arguments against online learning; they are the spec for the operational scaffolding it needs. A production loop has four mechanisms, each answering one of the failure modes directly.

1 · Tiered drift detection → graded action. Watch the input distribution with a Population Stability Index on a sliding window (per-segment baselines, since "new users" and "power users" drift differently), with a KS test (continuous features) or chi-square (categorical) to confirm the shift is significant rather than sampling noise. The response is graded by severity, not a single tripwire — over-reacting to noise is as costly as missing a real shift:

PSI bandReadingAction
5% ≤ PSI < 10%mild covariate shift — watchRe-evaluate feature importance; log it, no intervention. Most diurnal drift lives here.
10% ≤ PSI ≤ 20%meaningful shift — alertTrigger an online A/B comparing the drifted model against the last stable one; let the metric decide.
PSI > 20% + metric dropshift is hurting usersForced rollback to the last stable checkpoint. The conjunction matters: PSI alone may be a benign distribution change; PSI and a falling business metric is corruption.

Recall PSI is Σ (pᵢ − qᵢ) · ln(pᵢ / qᵢ) over bins, where p is the live distribution and q the training baseline. A feature that moves about 5 percentage points of mass from one decile (0.10 → 0.05) into an adjacent one (0.10 → 0.15) lands around PSI ≈ 0.055 — squarely in the "watch" band, the no-op case you want the threshold to absorb so the loud bands stay meaningful. Push that to an 8-point shift (0.10 → 0.02) and PSI jumps to ≈ 0.18 — already in the alert band, because the emptying bin's relative swing ln(0.02/0.10) dominates the sum. That sensitivity to nearly-empty bins is exactly why the raw threshold needs the metric-drop conjunction above it.

2 · Swap models by interpolation, not by flip. An online learner produces a new weight vector continuously, but slamming θ_old → θ_new in one step makes the ranking lurch — yesterday's feed and this second's feed disagree, and the user sees the seam. Ramp it instead, blending the two parameter sets and walking the mix:

θ = α·θ_new + (1 − α)·θ_old,   α: 0 → 1 over the ramp

At α = 0.1 the served model is 90% the trusted old weights and 10% the new — a 10% nudge, not a jump — and you step α up (say 0.1 → 0.3 → 0.6 → 1.0) only while guardrail metrics hold. If a step degrades them, you walk α back down; the rollback is continuous, not a cliff. (This is the parameter-space cousin of the traffic-ramp canary in 18 · Deployment — there you ramp the fraction of users; here you ramp the weights themselves.)

3 · Sync to the parameter server safely. The online learner doesn't serve directly — it pushes updated parameters to the serving parameter server (PS) on a cadence, typically ~5 minutes, not per-example (per-example sync would hammer the PS and couple serving latency to training noise). Two guards on every sync: a double-buffer so reads never block on the write (serve buffer A, write buffer B, atomic pointer flip — the embedding-table buffering engineering is canonical in 18 · Deployment; don't re-derive it), and a dimension-consistency check before the flip — if a feature was added or the hash space resized, the new parameter block's shape won't match the serving graph's expectation, and swapping it in would silently corrupt every score. Check shape, then flip; mismatch aborts the swap and keeps the old buffer live.

4 · Periodically retrain from scratch. An online loop trained only on its own freshly-served traffic drifts into a local optimum: it keeps exploiting the slice of the catalog it already favors, the feedback loop ossifies those weights, and it forgets how to value anything it stopped showing. The escape is a periodic full retrain on the broad historical log (the offline plane's job, T+1 or weekly) that re-anchors the weights on the whole distribution, then hands a fresh, unbiased checkpoint back to the online loop to continue from. Online learning tracks the fast-moving present; the full retrain stops it from confusing "what I've been showing" with "what is good."

Freshness is also tiered under a hard latency cap
Not every feature earns the same sync urgency. Core signals (the live interaction sequence) target sub-second-to-300ms freshness because the model is acutely sensitive to them; non-core, slow-moving features (long-horizon interest) can tolerate seconds-old values without measurable loss. Spend the tight freshness budget where the gradient is steep — the same "push right only as far as freshness demands" logic, now applied to update cadence rather than plane placement.

The latency budget — arithmetic, not vibes

"Real-time" means the online plane returns inside the user's tolerance. A typical product budget is a P99 of ~200ms end-to-end, of which network round-trip and rendering already eat a chunk. The recommender owns maybe 120–150ms of server time, and it must spend that across the whole funnel in series. The budget is additive — a literal subtraction problem:

Tserver = Tfeature-fetch + Tretrieval + Tranking + Trerank + Toverhead ≤ ~150ms

Every stage you add, every feature you fetch, every extra ANN probe is drawn from one shared account. This is why the funnel exists: you spend cheap milliseconds over millions of items in retrieval and expensive milliseconds over hundreds in ranking. It is also why "let's add a 20ms bandit re-rank" is a budget decision, not a free lunch — the same arithmetic resurfaces in 18 · Deployment.

Tail latency is the real adversary
Budgets are written in P99, not mean, because a recommender fans out to many backends per request (feature store, ANN shards, model replicas) and waits for the slowest. With 100 parallel reads, even a 1%-slow backend makes the request slow ~63% of the time (1 − 0.99¹⁰⁰). The defenses — hedged requests, timeouts with graceful degradation, and a stale-feature fallback — are why every real-time system needs a "serve last-known-good" path.

Interactive · freshness-decay & latency-budget

Two tools. The first turns a half-life and a pipeline lag into the fraction of signal that survives to inference — the number a freshness SLA defends. The second distributes a server budget across the funnel; red means you blew the P99 and something must get cheaper or move to the nearline plane. The numbers are illustrative; the trade-offs are real.

Freshness-decay explorer
Pick a signal's half-life and your pipeline's end-to-end lag. "Value retained" is the fraction of the signal that survives to inference.
decay λ
value retained
verdict
Reading
Latency-budget allocator
Distribute your server budget across the funnel. Red slack means you blew the P99 SLA — something has to get cheaper (smaller model, fewer candidates, a precomputed feature) or move to the nearline plane.
server budget
server used
slack
P99 total
Reading

Interview prompts you should be ready for

  1. "What makes a feature a 'real-time' feature?" (Its value has a short half-life: e−λ·lag drops fast, so a stale read is a near-useless read. Last-5-clicks, live trend rate, breaking context. If the half-life is days, it is not a real-time feature — keep it offline.)
  2. "Walk me through the three planes and where you'd put a 90-day CTR vs likes-in-the-last-minute." (Offline / nearline / online by freshness. 90-day CTR → offline batch. Likes-in-last-60s → nearline streaming into the store. Per-request scoring → online. State the placement rule: push left as far as the SLA allows, pull right only as freshness demands.)
  3. "At-least-once or exactly-once for your event stream?" (Per-signal, not system-wide. Exactly-once for likes/billing where a duplicate corrupts a decision; at-least-once + dedup-by-event-id for the exposure firehose. Name the ~10–20% throughput tax and Flink's checkpoint + 2PC sink.)
  4. "How do you avoid train–serve skew in a streaming system?" (One feature definition, used identically offline and online — the Kappa argument. Lambda's two paths computing the same feature is the classic skew bug. The feature store enforces the single definition.)
  5. "Should we do online learning? What goes wrong?" (Adapts in minutes, but: feedback loops tighten popularity bias; one bad batch poisons weights with no overnight checkpoint; labels are delayed and bias toward fast signals. Mitigate with IPS, gradient/LR caps, anomaly detection, instant rollback — and you still A/B + holdout.)
  6. "Your P99 latency just blew the 200ms SLA. How do you diagnose and fix it?" (It is additive and in series: profile per-stage, check tail not mean, suspect fan-out — slowest of N backends. Fixes: distill the ranker, fetch fewer candidates, precompute a feature into the nearline store, hedge requests, add timeout + stale fallback.)
  7. "Why P99 and not mean latency for the budget?" (Fan-out: a request waits on the slowest of many parallel reads. 1 − 0.99¹⁰⁰ ≈ 63% of requests touch a slow backend. The mean hides this; the SLA must be written on the tail.)
  8. "Pure online, precompute+correct, or async-queue for inference — and would you pick one?" (Never one system-wide. Pure online = freshest, model-bound latency, hard feature-store dependency → fine-rank. Precompute base + light online correction = stable <50ms but weak cold-start → retrieval. Async-queue (Kafka→Flink→poll) = second-level latency, smooths spikes, risks stale results → heavy/non-critical re-rank. State the hybrid-by-funnel rule: push scoring right as the funnel narrows.)
  9. "You're running online learning. Walk me through what happens between a label landing and the served model changing." (Streaming label → incremental weight update; drift watched by tiered PSI (5–10% watch / >10% A/B / >20% + metric-drop → forced rollback) with KS/chi-square for significance. Sync to PS ~every 5 min — not per-example — behind a double-buffer + dimension-consistency check. Swap by interpolation θ=αθ_new+(1−α)θ_old with α ramped 0→1, walked back if guardrails dip. And a periodic full retrain to escape the feedback-loop local optimum.)
Takeaway
Real-time is the temporal decomposition of a recommender: each feature has a half-life, and the SLA is "keep e−λ·lag high." Split work across three planes — offline (days) trains and aggregates, nearline (seconds–minutes) streams features into the store, online (ms) reads and scores — and place each piece as far left as freshness allows. Kafka is the source of truth; Flink maintains keyed, event-time state; pick exactly-once only where a duplicate corrupts a decision; prefer Kappa's single feature definition; and treat online learning as power with sharp edges that still needs A/B + holdouts. The latency budget is additive and written in P99, not the mean.