30-Second Glance
- What: Prefix-as-you-type suggestions ("iph" → "iphone 16 case") in <100 ms per keystroke, ranked by popularity + recency + personalisation.
- Scale: ~10B prefix queries/day (~115k avg QPS, ~250k peak); top-10M-prefix hot set (~4 GB) served from memory.
- Core trick: Precomputed prefix→top-K index (not a live trie walk) + 3-layer cache (browser → edge → Redis); offline batch + streaming delta updates.
- Hardest part: Index structure at scale (trie vs term-prefix); hot-shard skew on 1–2 letter prefixes; freshness vs rebuild cost.
1. Requirements
Functional:
- Given a prefix, return top 10 completions in <100 ms.
- Rank by query frequency with recency decay + trending boost.
- Personalisation: user's own history up-weighted (with opt-out).
- Support typos (1-edit tolerance on prefixes ≥ 4 chars) + locale/language segmentation.
- Filtering: adult/spam suppression list, safe-search modes.
Non-functional:
| Requirement | Target |
|---|---|
| Suggestion latency p99 | < 100 ms end-to-end (server < 30 ms) |
| Availability | 99.9%; degrade to unpersonalised on failure |
| Consistency | Eventual — new trend visible within ~5 min (streaming path) |
| Freshness | Daily full rebuild + minute-level delta for spikes |
Out of scope:
- Full search retrieval/ranking (downstream consumer).
- Voice/handwriting input.
- Real-time personal index sync across devices (<1 min is fine).
2. Capacity Estimation
Assumptions: 2B searches/day; ~5 prefix requests per search after debounce (client waits ~100 ms idle); avg suggestion payload 2 KB; peak 2.5× avg.
| Metric | Math | Result |
|---|---|---|
| Prefix QPS (avg / peak) | 2B × 5 / 86400 ≈ 115k | ~115k / ~300k |
| Bandwidth out (peak) | 300k × 2 KB | ~600 MB/s — needs edge caching, not origin scaling |
| Hot prefix cache | 10M prefixes × ~400 B (prefix + 10 doc-refs + scores) | ~4 GB — one Redis Cluster, fully replicable |
| Full corpus | 1B distinct queries × ~60 B + prefix expansion ×3 | ~200 GB index (SSD-backed search tier, e.g. Elasticsearch/Solr) |
| Log pipeline in | 10B keystroke events/day ≈ 115k/s | Kafka: ~12 partitions at 10k msg/s each per topic-replica set |
3. API Design
GET /api/v1/suggest?q=iph&locale=en-US&limit=10&safe=moderate
X-User-Id: 8123 // for personalisation; anonymous allowed
200 OK
{ "prefix": "iph",
"suggestions": [
{ "text": "iphone 16", "score": 0.98, "source": "trending" },
{ "text": "iphone 16 case", "score": 0.91, "source": "global" },
{ "text": "iphone repair near me", "score": 0.84, "source": "personal" }
],
"took_ms": 12 }| Endpoint | Purpose | Notes |
|---|---|---|
GET /api/v1/suggest |
Prefix completions | GET = edge-cacheable; key = normalised (q, locale, safe); no user-id in cache key (personal layer merged server-side) |
POST /api/v1/feedback |
Click/select signal | {prefix, chosen, rank} → training + trending stream |
GET /api/v1/trending?locale= |
Trending queries | Backs the empty-box suggestions; 60 s edge TTL |
Internal: POST /internal/index/delta |
Streaming rank updates | From Flink job; authenticated mTLS |
- Debounce client-side (~100–150 ms) + cancel in-flight: cuts ~60% of naive per-keystroke QPS. Say this early — interviewers love it.
- Cache-Control:
public, max-age=300on global layer; personal merges markedprivate.
4. Data Model & Storage
| Entity | Store | Partition / shard key | Why this store |
|---|---|---|---|
| Hot prefix → top-K | Redis Cluster | hash(normalised prefix) | p99 < 5 ms; LRU + explicit version stamp |
| Full prefix index (long tail) | Elasticsearch edge-ngram index on SSD | hash(prefix[0:2]) routing | Handles typos, locales, tail prefixes a trie can't hold in RAM |
Query stats (query, locale, day, count) |
Cassandra / Bigtable | (locale, query hash) | High-write counters, TTL by day |
| Raw keystroke/click logs | Kafka → object storage (cold) | by hour | Training + batch aggregation input |
// Redis hot entry: sug:en:iph
{ "v": 1742, // index version (for cutover)
"items": [["iphone 16", 98213], ["iphone 16 case", 77120]],
"updated_at": 1726400000 }
// Cassandra: query_daily_counts ((locale, query_hash), day) -> count, trend_score- Normalise before keying: lowercase, trim, collapse spaces, strip diacritics — " iPh" and "iph" hit one entry.
- Shard the full index by 2-letter prefix routing:
hash(first 2 chars)spreads better than first-char (avoids the giants/tshards), at the cost of scatter for 1-char queries (fan out to all shards, merge top-K — acceptable, they're few and edge-cached hardest).
5. High-Level Design
Read path: keystroke (debounced) → browser cache → CDN/edge (global, 5 min) → suggest service → Redis hot (hit ~90%) → ES tail on miss → merge personal layer → respond. Write path: logs → Kafka → (a) nightly Spark job: aggregate → rebuild full index + hot snapshot; (b) Flink streaming: top-K deltas for spikes → Redis + ES partial update.
- Edge/CDN: absorbs the most repeated prefixes (global layer); key excludes user-id.
- Suggest service (stateless): normalise → parallel fetch (Redis hot + personal) → merge → filter → respond.
- Hot cache (Redis): 10M prefixes, version-stamped for atomic cutover on rebuild.
- Tail index (ES edge-ngram): long-tail + typo-tolerant path, ~10% of traffic.
- Update pipeline: Spark batch (correctness) + Flink deltas (freshness) — lambda pattern.
- Feedback loop: selections re-enter as weighted counts (position-bias corrected).
6. Deep Dives
6.1 Trie vs precomputed prefix index vs n-grams
| Approach | Pros | Cons | Use when |
|---|---|---|---|
| Distributed trie (walk per keystroke) | Elegant, exact prefix match | Hot root nodes (a, th) = unshardable skew; whole trie (~50–200 GB) must be RAM-resident for latency; painful updates |
Small closed corpora (contacts, commands) |
| Precomputed prefix→top-K (chosen) | O(1) lookup per keystroke; trivially shardable; rebuild = batch job | Storage blowup (prefix expansion ~3–5×); rebuild lag | Web-scale suggestion |
| Edge-ngram inverted index (ES) | Typo tolerance + filtering + locales free | 10–30 ms per query; heavier iron | Tail + fuzzy path |
Production answer: hot cache of precomputed top-K (covers ~90%) + n-gram index for the tail. The trie lives in interviews and in-device keyboards — say that line, then move on. Shard hot entries by full-prefix hash (uniform); replicate the top-100k prefixes to every suggest host's local LRU to kill cross-rack hops.
6.2 Ranking: frequency + recency + personalisation
Score = w1·log(freq_30d) + w2·trend_spike + w3·personal_affinity − w4·spam_prior, recomputed per locale. Trend spike = count_1h / E[count_1h from 7-day baseline] (z-score-ish); gate on minimum support (≥500/h) so noise can't crown a joke query. Personal layer: last 200 user queries in a profile store, matched by prefix, merged after the global fetch and capped at 3 of 10 slots (diversity guard). Position-bias correction on feedback (a click on rank 8 > rank 1 signal). Weights tuned offline (NDCG on held-out selections), shipped as config — never hard-code.
6.3 Update pipeline: batch correctness + streaming freshness
- Batch (nightly Spark): dedup → normalise → filter (spam/adult/classifier) → aggregate counts → rebuild ES index (blue/green alias swap) → snapshot hot top-10M → bulk-load Redis under new version stamp → flip
current_version→ old keys expire. Full rebuild bounds drift from the streaming path. - Streaming (Flink, minute-level): sliding-window counts → detect spike (
z > 6, support gate) → push delta docs to ES + patch Redis entries (bump scores, insert trend item at rank ≤3 withsource: trending). Deltas carry the batch version; rebuild wipes them — idempotent by(prefix, query, version). - Thundering-herd guard: version cutover is a single atomic pointer; suggest hosts keep serving old version mid-load; rollback = flip pointer back.
7. Bottlenecks, Trade-offs & Monitoring
First bottleneck at 10x: Redis hot-shard skew on 1–2 char prefixes (a, s, th get disproportionate traffic) → replicate top-100k prefixes into per-host local cache + edge TTL bump for short prefixes. Next: ES tail latency under miss storms → circuit-break to global-only suggestions.
| Approach | Pros | Cons | Use when |
|---|---|---|---|
| Live distributed trie | Exact, always fresh | Hot-node skew, RAM cost, hard updates | Small corpora |
| Precomputed top-K + cache (chosen) | O(1), shardable, cheap | Rebuild lag, storage blowup | Web scale |
| Lambda (batch + stream) (chosen) | Fresh + correct | Two pipelines to maintain | Trending matters |
| Batch-only | Simple | Stale trends (hours) | Static corpora |
| Metric | Alert if |
|---|---|
| Suggest server p99 | > 30 ms for 5 min |
| Hot-cache hit ratio | < 85% (rebuild or eviction issue) |
| Streaming delta lag | > 5 min (trends going stale) |
| Empty-suggestion rate | > 2× baseline (index cutover gone wrong) |
| Feedback drop rate | > 1% (training signal loss) |
Interview one-liners
- Q: Trie? / A: Only for small corpora — at web scale the root nodes become unshardable hot spots; precompute prefix→top-K and serve O(1) from cache.
- Q: How do trends appear in minutes? / A: Flink spike-detector patches hot entries directly; the nightly batch rebuild keeps it honest.
- Q: Biggest QPS saver? / A: Client debounce + edge caching of the anonymous global layer — most keystrokes never reach origin.
Related
- Cache Patterns and Eviction — hot-prefix + edge layering
- Partitioning — prefix-hash sharding, hot-shard handling
- MongoDB vs Cassandra — counter store choice
- Stream Processing and Kafka vs RabbitMQ — delta pipeline
- Rate Limiter case — throttling expensive tail queries per IP