sehaj/learn
System design/12 Realtime, feeds and location

12.05 · with code · 12 min

Geospatial indexes

Why a B-tree on latitude and longitude can't find what's near you, and the four ideas that can: geohash strings, quadtrees, S2 cells on a sphere, and H3 hexagons. A lab for geohash nearest search, and tested encoding code.

The problem: the nearest store

Snip adds store-locator links: a retail chain uploads its 4,000 store locations, and one short link sends each visitor to the page of the store nearest them (using the location from their IP address or their phone). Every redirect becomes a nearest-neighbour query: given a point, find the closest of thousands of points, in about a millisecond.

Why two indexes don't help

An index on latitude finds every store in a horizontal band; one on longitude, every store in a vertical band. "Near me" is the small square where they cross, but the database has to fetch a whole band from one index and filter it, or intersect two large sets. A B-tree sorts one dimension; location has two.

All the main geospatial indexes do the same thing in different ways: divide the map into cells, give each cell an ID such that nearby cells usually share a prefix or a parent, and index the cell IDs.

Geohash: cells as strings

Geohash splits the world in half by longitude, then by latitude, then longitude again, writing one bit per split, and encodes every 5 bits as a character. Each extra character divides a cell into 32 smaller ones. Points in the same cell share the whole string; nearby points usually share a long prefix.

CharactersCell size around London (tall × wide)
4about 20 × 24 km
5about 4.9 × 3.0 km
6about 0.6 × 0.8 km

Central London at precision 6 is gcpvj0. Because geohashes are strings, a plain B-tree index on them supports LIKE 'gcpvj%': every store in that cell, from one index range.

ts
const BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz";

export function geohash(lat: number, lon: number, precision = 6): string {
  let latLo = -90, latHi = 90, lonLo = -180, lonHi = 180;
  let hash = "", bits = 0, ch = 0, even = true;
  while (hash.length < precision) {
    if (even) {
      const mid = (lonLo + lonHi) / 2; // even bits split longitude
      if (lon >= mid) { ch = (ch << 1) | 1; lonLo = mid; } else { ch <<= 1; lonHi = mid; }
    } else {
      const mid = (latLo + latHi) / 2; // odd bits split latitude
      if (lat >= mid) { ch = (ch << 1) | 1; latLo = mid; } else { ch <<= 1; latHi = mid; }
    }
    even = !even;
    if (++bits === 5) { hash += BASE32[ch]; bits = 0; ch = 0; }
  }
  return hash;
}

Tested against the standard reference point (57.64911, 10.40744), which encodes to u4pruydqqvj, and London's gcpvj0.

The edge problem. Two points a few metres apart on either side of a cell boundary can have completely different geohashes. So a nearby search never checks just one cell: it checks the cell and its eight neighbours, then measures real distances to the candidates.

Try it

LabNearest store with a geohash grid
Precision

Click the map to search from a point.

Query cell
gcpvj (4.9 × 3.0 km)
Cells searched
gcpvk gcpvm gcpvq gcpvh gcpvj gcpvn gcpuu gcpuv gcpuy
Stores checked
105 of 600
Nearest found
0.53 km away, the true nearest

Try this

Tested the same way on 4,000 stores across London with 500 random queries: searching the nine 5-character cells around each point checked about 213 stores on average instead of 4,000 and found the true nearest every time. Two caveats the lab shows: if the nine cells are empty, search a wider ring (or shorter geohash); and the nine cells only guarantee the true nearest if it's within one cell's width, so a found store farther than that needs one more ring checked.

Quadtrees: cells that adapt

A quadtree splits a square into four whenever it holds too many points, so dense city centres get small cells and empty countryside stays one big cell. It adapts to data density, which fixed grids don't, at the cost of being a tree structure to maintain rather than a string you can index anywhere.

S2: cells on a sphere

Flat grids distort near the poles and across the date line. Google's S2 library projects the sphere onto the six faces of a cube and orders each face's cells along a Hilbert curve, a path that keeps nearby cells close in the ordering. Cells nest over 31 levels and each has a 64-bit ID, so "cells near X" become a few numeric ranges in an ordinary index.

H3: hexagons

Uber's H3 tiles the world with hexagons at 16 resolutions, each about a seventh the area of the one above. Squares have neighbours at two distances (side and corner); a hexagon's neighbours are all the same distance away, which makes "the ring of cells around this one" uniform. At resolution 9 a hexagon averages about 0.1 km²; at resolution 7, about 5 km². Uber uses them to measure supply and demand per hexagon for surge pricing (12.07 Matching supply and demand).

ShapeStrengthTypical use
GeohashRectanglesA string; prefix queries in any databaseSimple proximity in Redis, Elasticsearch
QuadtreeAdaptive squaresMatches data densityIn-memory spatial indexes
S2Quads on a sphereNo pole or date-line distortion; 64-bit IDsGlobal indexing, coverings of regions
H3HexagonsUniform neighboursAggregating and comparing areas

Seen in production

Uber

2018

H3

Uber explains that hexagons have only one distance between a cell's centre and its neighbours', against two for squares and three for triangles, and that hexagons minimise the quantisation error as users move through a city. H3 supports sixteen resolutions, each finer one with a seventh of the area, and Uber calculates surge pricing by measuring supply and demand in hexagons in each city. Uber open-sourced H3 in 2018.

What we foundHexagons have one distance to every neighbour, which makes analysing movement through a city cleaner; Uber prices surge per hexagon.

H3: Uber's Hexagonal Hierarchical Spatial Index (Isaac Brodsky)uber.com ↗

Google

Docs

S2 Geometry

S2 represents all data on a three-dimensional sphere and decomposes it into a hierarchy of cells. The cells are ordered along six Hilbert curves linked into one continuous loop over the sphere, there are 31 levels of subdivision (0 to 30), and each cell is uniquely identified by a 64-bit S2CellId.

What we foundThe sphere split into a hierarchy of cells, ordered along Hilbert curves, each with a 64-bit ID.

S2 Cells (S2 Geometry documentation)s2geometry.io ↗
Predict first

A store sits 20 metres from a user, but just across a geohash cell boundary. The search looks only in the user's own cell. What happens?

Commit to an answer before you read on.

Recap

Interview questions

SeniorfreqMust-knowConcept

How would you index locations to find nearby points efficiently?

Report a mistakeEdit this page

Fundamentals you'll use

Used in