Location & Matching
Yelp — Local Search & Reviews
Search businesses by name, category, and location; view a business page with paginated reviews; leave one 1–5★ review. The hard parts: low-latency geo + keyword search, keeping a business's average rating fresh and instantly readable, and enforcing one review per user.
Requirements
Functional
- Search businesses by name, location (lat/long), and category; view a business + its reviews; leave a review (mandatory 1–5★, optional text). One review per user per business.
Non-functional
- High availability ≫ strong consistency (a lagging avg rating is fine).
- Scale to 100M DAU, 10M businesses; low-latency search (< 500 ms); read-heavy ~100:1.
Scale & back-of-the-envelope
- 10M businesses × ~2 KB ≈ 20 GB metadata (cache the hot set in Redis); ~1B reviews × ~1 KB ≈ 1 TB.
- Search ~5–6k QPS avg (~20–30k peak); business views ~11k avg; review writes only ~12–100 QPS.
- Overwhelmingly reads → afford expensive read work only if cached; cheap denormalization on the write path.
API design
GET /businesses?category&location&name&page&limit -> Partial<Business>[]
GET /businesses/:id -> Business
GET /businesses/:id/reviews?page&limit -> Review[] # split + paginated
POST /businesses/:id/reviews { rating, text } -> Review | 409 # auth; idempotent on (user,business)
Search returns a Partial<Business> card (id, name,
category, avgRating, distance, thumbnail); userId comes
from the auth token, never the body. Prefer cursor pagination for
reviews ordered by createdAt.
High-level design
Client → API Gateway → Business Service (search + detail, read-optimized) and Review Service (post/list reviews) → Postgres with PostGIS (geo) + GIN full-text; photos in S3.
flowchart LR
Client["Client web / mobile"] --> GW["API Gateway"]
GW --> BS["Business Service"]
GW --> RS["Review Service"]
BS --> Cache[("Redis Cache")]
BS --> DB[("Postgres: PostGIS GiST + full-text")]
RS --> DB
DB --> Obj[("Object Store S3: photos")]
Why one Postgres? 20 GB metadata + 1 TB reviews fits a primary + read-replicas, and PostGIS gives geo and full-text and ACID for the uniqueness constraint in one place — the simplest design meeting < 500 ms and HA.
Deep dive · fresh, accurate average rating
Computing AVG(rating) over thousands of reviews on every
search result is far too expensive at 20–30k QPS.
Denormalize avgRating,
numRatings, ratingSum on the Business row
and update incrementally in the same transaction as
the review insert — exact and up-to-the-second; search reads a
precomputed column.
flowchart LR
NewReview["New review write"] --> RS["Review Service"]
RS --> Upd["Atomic update ratingSum + numRatings"]
Upd --> DB[("Business row: avgRating denormalized")]
Search["Search / view read"] --> Cache[("Redis: business + avgRating")]
Cache -->|cache miss| DB
DB -->|backfill| Cache
Use ratingSum / numRatings (not repeated averaging) to
avoid float drift; recompute nightly as a self-healing backstop. The
UPDATE ... SET ratingSum = ratingSum + :rating is an
atomic read-modify-write (row lock), so concurrent reviews can't lose
an increment. Cache hot businesses in Redis and update-through on a
new review.
Deep dive · geospatial + complex search
A "complex query" combines keyword
(name/description), category filter, and geo
radius/nearest, ranked, under 500 ms. Three geo-index strategies:
flowchart TD
Q["Search: lat/long + category + name"] --> R["Search Service"]
R --> GH["Option A: Geohash prefix"]
R --> QT["Option B: Quadtree"]
R --> PG["Option C: PostGIS GiST / R-tree"]
GH --> M["Filter by radius, rank by distance and rating"]
QT --> M
PG --> M
M --> Res["Paginated results"]
| Geohash | Quadtree | PostGIS (chosen) | |
|---|---|---|---|
| Density | Fixed grid | Adaptive | Adaptive (tree) |
| Edge cases | Boundary (query 8 neighbors) | Rebalancing | Handled natively |
| Ops | Simple, shardable | Custom code | Just an index in existing Postgres |
Recommendation: start with PostGIS (correct radius/kNN + lives with the review uniqueness constraint and full-text). Scale beyond one DB by moving search to Elasticsearch (geo_point + BM25) fed by CDC while Postgres stays the system of record. Rank by a blend of distance + rating + confidence (Bayesian/Wilson) + keyword relevance.
Deep dive · one review per user
The primary defense is a DB unique constraint
UNIQUE(userId, businessId) — this makes correctness
independent of races and double-clicks (an app-level check-then-insert
has a TOCTOU race).
sequenceDiagram
actor U as User
participant RS as Review Service
participant DB as Postgres
U->>RS: POST /businesses/123/reviews {rating, text}
RS->>DB: INSERT review UNIQUE(userId, businessId)
alt duplicate review
DB-->>RS: unique constraint violation
RS-->>U: 409 Conflict
else accepted
DB-->>RS: ok
RS->>DB: UPDATE ratingSum, numRatings, avgRating (same tx)
RS-->>U: 201 Created
end
The avg-rating update must run in the
same transaction so a business never increments
numRatings without a committed review. Sharding caveat:
keep (userId, businessId) co-located — sharding reviews
by businessId preserves per-business uniqueness; by
userId would break it.
Data model
Business id PK; name, category, description, address, lat/long (PostGIS geog),
s3link, avgRating, numRatings, ratingSum # denormalized
Review id PK; userId FK, businessId FK, rating 1..5, text?, createdAt
UNIQUE (userId, businessId) # one review per user
Indexes GiST(geog), GIN(to_tsvector(name||' '||description)), btree(category)
Read vs write path
Reads (~20–40k peak, < 500 ms) scale via Redis + read replicas + optional Elasticsearch + CDN for photos. Writes (~50–100 peak) hit the primary only, doing the atomic incremental aggregate + unique constraint. Consistency: eventual for aggregates, strong for the review row itself.