Skip to main content
Proximity Service (Yelp, Nearby)lesson 4 of 4 · 3 min read

Density and Hotspots

Even cells, uneven world

Sharding by geohash prefix keeps each query on a single shard, the property you want. It also guarantees uneven shards, because businesses cluster where people do.

Do the comparison. A cell over midtown Manhattan holds a hundred times the rows of a same-sized cell in rural Nevada, so a fixed prefix length gives you shards differing by two orders of magnitude in both size and traffic.

Watch for the symptom, and it is not a crash. It is one shard 90 percent busy while its neighbours idle at 4 percent.

Resist the instinct to add shards, because it fails. A uniform split hands your new shards the same uneven distribution.

Stop treating prefix length as global instead. Assign shards by prefix ranges, and let a range split when its row count or its request rate crosses a threshold, exactly as a range-sharded database splits a hot region.

See the result: dense areas end up owned by longer, more specific prefixes, and empty regions stay on short ones. A routing table maps ranges to shards, small enough to cache everywhere and changing rarely.

The hotspot made of time

Meet a second kind of hotspot, and this one is about time rather than place. A stadium holding 60,000 people generates searches from one cell for three hours and nothing for the rest of the week, so provisioning for it permanently is waste.

Handle that with cache instead of shards. The candidate list for that cell is identical for every one of those people, so a single warm entry absorbs the whole event.

This is the case where caching by cell rather than by user pays for itself in one afternoon.

Take the general lesson into your interview. Any sharding key that comes from the physical world inherits the real world's skew, and your design has to answer for it instead of assuming things spread evenly.

the shape of it
Fixed prefix5 chars everywhereLondon shard88% CPUWales shard3% CPUSplittable rangesrouting table4 London shards7-char prefixes1 Wales shard4-char prefixsame sizesame sizesplit hot rangesleave cold ones
step 1 of 3
Uniform prefixes produce wildly uneven shards, so ranges have to split where the traffic actually is.
why adding shards does not fix a hot one
Java
// Fixed prefix length means Manhattan and rural Nevada get cells
// of the same size and wildly different row counts. A uniform split
// hands the new shards the same uneven distribution.

// Assign by prefix RANGE instead, and split a range when it gets hot.
record ShardRange(String fromPrefix, String toPrefix, int shard) {}

void maybeSplit(ShardRange r) {
  if (r.rowCount() > SPLIT_ROWS || r.requestsPerSec() > SPLIT_RPS) {
    split(r);        // dense areas end up on longer, more specific prefixes
  }
}

// The other hotspot is about time, not place: a stadium generates
// searches from one cell for three hours a week. Do not shard for
// that. Every one of those users gets the identical candidate list,
// so one warm cache entry absorbs the whole event.
cache.set("cell:" + cell, candidates, Duration.ofMinutes(5));

Worked example

A team shards their index on 5-character geohash prefixes and it looks balanced in staging, because their test data was generated uniformly. In production, the shard holding central London runs at 88 percent CPU while the shard covering most of Wales sits at 3 percent, and p99 on London searches is 340 ms against 18 ms elsewhere. Adding two shards does nothing, since the split is still uniform. The fix is a routing table with splittable ranges: London's prefix range splits three times, down to 7-character granularity, and spreads across 4 shards, while Wales keeps a single 4-character range. Peak CPU across the fleet lands between 40 and 55 percent and London p99 drops to 24 ms, on the same total hardware.

Proximity Service (Yelp, Nearby): wrapping up

In the real world

  • 01Yelp serves this exact query shape and leans on precomputed, cached candidate sets per cell, because business data changes far more slowly than it is read.
  • 02Redis GEOSEARCH does the 9-cell neighbour expansion internally, so for datasets up to a few million points the entire index layer is one Redis instance and no custom code.
  • 03Foursquare's venue database and Uber's H3 came from the same pressure: fixed grids break down once density varies by two orders of magnitude across a single service area.
  • 04Elasticsearch is a common shortcut here, since geo_distance queries combine proximity with the text and category filtering these products always need alongside it.
  • 05The pattern breaks for ride-hailing, where points move every 4 seconds. There the location state lives in memory keyed by driver, and the index is rebuilt continuously rather than read from disk.

Questions people ask

Why not just use SELECT with a bounding box and an index on lat and lon?

A composite index scans on its leading column, so the engine seeks the latitude band and then row-filters longitude. Near London a 5 km box over 8 million rows selects around 40,000 rows to return 1,000. It is correct and it is fast enough until it is not, and the failure arrives as steadily rising p99 rather than as an error.

How stale is the search index allowed to be?

A few seconds, and you should say so explicitly. A new listing appearing 3 seconds late has no user-visible consequence at a 20,000 to 1 read-write ratio. Deletes are the exception: a closed venue in results generates real complaints, so route deletions as high-priority events rather than letting them wait for a rebuild.

Does this design work for Uber?

No, and saying why is a good answer. Driver positions change every few seconds, so a disk-backed index rebuilt asynchronously is the wrong structure. Ride-hailing keeps current locations in memory, updates them continuously, and uses the geospatial cell only as a bucket key. The indexing idea carries over; the storage and freshness assumptions do not.

Quick review

Two workloads, split them:
business data changes rarely and is read constantly, while the search index is rebuilt on a schedule. Do not put them in one table
Read path:
geohash the user position, expand to the 9 surrounding cells, union the candidates, then Haversine filter and sort by true distance
Radius handling:
store several geohash prefix lengths per row (4, 5, 6) and pick the column matching the requested radius instead of recomputing
Scale estimate:
200M businesses, 5 KB each is 1 TB. Shard by geohash prefix so a query hits one shard, and replicate read-only copies per region
Caching:
the candidate list for a cell changes only when a business is added or edited, so cache cell to business-ids with a long TTL and invalidate on write
Business CRUD is a separate service on its own datastore. Updates flow to the search index asynchronously, so a new listing appearing 30 seconds late is fine
Ranking:
distance is the tiebreak, not the ranking. Real systems blend rating, popularity and sponsorship, so keep ranking behind its own interface
Hotspot risk:
one cell over Manhattan can hold 100x the rows of a suburban cell, so shard by prefix with a split rule rather than a fixed grid
the trade-off

Denormalising into a search index buys fast reads and costs write freshness, so a listing edit takes seconds to appear. Sharding by geography keeps a query on one shard but guarantees uneven shards, since population is uneven. And the whole design assumes a mostly static dataset: if the points move every few seconds, this is the wrong architecture and you want the ride-hailing pattern of in-memory location state instead.

in the room

Store locators, restaurant and venue search, dating apps, real estate, anything answering "what is near this point".