System Design
B11

Geo indexes: finding what is near

Find the drivers within 2 km, or the stores within 5 km. A normal index orders one value, and a place has two. This sheet shows how cells and trees turn a radius into a small set of candidates.

Not startedSaved in this browser only.
  1. 1A one-column index cannot answer a 2-D query. Map 2-D to cells, or use a tree of boxes.
  2. 2A geohash prefix is a cell. Search the cell and its 8 neighbours, or you miss points across the border.
  3. 3Redis GEO is a sorted set scored by a 52-bit geohash. Dense cells make each search slower.
  4. 4Moving points: keep only the latest position, batch the writes, and expire silent drivers.
B11
    A

    One dimension is not enough

    recorded on 100,000 points
    a 1 km query in the city core, 100,000 pointsa B-tree on (lat, lng) reads this bandthe city, 40 km across100,000every point14,373latitude band4,508bounding box3,565within 1 km
    • Every method finds candidates first, then checks the exact distance.
    • Fewer candidates means less work per query. Dense areas make the gap larger.
    • The distance is the haversine formula on a sphere, about 100 ns.

    A B-tree orders one column, so a radius query reads a whole latitude band. I need a 2-D index: cells, or a tree of boxes.

    B

    Methods

    points read for one 1 km query in the core
    methodwherepoints readstatus
    Scan every rowService100,000Not approved
    B-tree on (lat, lng)Postgres14,373Small tables
    One geohash cellRedis3,286Misses points
    Geohash cell + 8 neighboursGEOSEARCH18,575Approved
    Quadtree in memoryService4,710Approved
    R-tree (GiST) on a boxPostgres4,508Approved
    PostGIS ST_DWithin on geographyPostGIScitedPolygons, metres
    S2 or H3 cell ids in any storeLibrarycitedGlobal scale

    The query returns 3,565 points. Points read: entries the index visits before the distance check. The B-tree count is the latitude band, which Postgres 16 reads in full.

    I store positions in Redis GEO for moving drivers. For fixed places with rich queries I use Postgres, with PostGIS when I can.

    C

    Nearby drivers

    click a step; its path lights up
    Driver appGPS every 4 sRider appasks for a rideLocation servicebatches writesMatching servicestatelessRedis GEOdrivers:{city}, seen:{city}Routing servicedriving timePostgresdrivers, trips

    Step 1: Driver reports

    • The app sends its position.
    • It skips a report when the car has not moved and 4 s have not passed.

    If it fails

    A lost report: the next one replaces it. Only the latest position matters.

    Drivers write their latest position to a Redis GEO key per city. A rider's request reads candidates with GEOSEARCH, ranks them by driving time, and Postgres decides the assignment.

    D

    Capabilities used

    what each tool gives you
    toolcapabilitywhat it gives this designalso used for
    RedisGEOADDStores lng, lat as a 52-bit geohash score in a sorted set. A new position replaces the old one. O(log N).Store and venue lookups
    RedisGEOSEARCH BYRADIUS or BYBOX, ASC, COUNT, ANYReads the 1 + 8 cells around the centre, drops points outside the shape, sorts by distance.Nearest stores, geofence checks
    RedisGEODIST, GEOPOS, ZREMDistance on a sphere, positions, and removal. There is no GEODEL; a GEO key is a sorted set.
    RedisSorted set scored by timeThe heartbeat. ZRANGEBYSCORE finds silent drivers.Delay queues, sliding windows
    RedisPipelines, many members per GEOADDAbout 10 times the update rate of single calls, measured.Bulk loads
    RedisOne key lives on one shardLimit A city key cannot spread. Split by city or by coarse cell.
    PostgresGiST index on a point (an R-tree)Box containment with no extension. The index only visits boxes that meet the query box.Ranges, exclusion constraints
    PostgresB-tree on (lat, lng)Small tables Reads the whole latitude band and filters longitude.Any sorted lookup
    PostGISgeography, ST_DWithin, GiSTDistances in metres on the spheroid. ST_DWithin compares bounding boxes through the index first. Not installed in the lab.Polygons, routes, geofences
    ServiceQuadtree in process memoryLeaves split by density. A 1 km suburb query in about 31 µs.Game worlds, map tiles
    S2Cells on 6 cube faces, levels 0 to 30, Hilbert order, 64-bit idsA region becomes a few ranges of cell ids, in any key-value store.Geofencing at global scale
    H3Hexagons, 16 resolutions, 12 pentagons eachEqual-distance neighbours. Good units to count demand per area.Surge zones, heat maps

    Redis gives me a geohash-scored sorted set with radius search. Postgres gives me R-tree boxes today and PostGIS for real geography. A library gives me S2 or H3 cells.

    E

    Geohash

    figure, then pseudo code
    the cell that holds the centre point, 30 bits110010110010111000011101101001tdr1v9longitude bitlatitude bitEach bit halves the range: 1 is the upper half. Longitude bits come first.Every point whose code starts with "tdr1v9" lies in this cell, so a cell is a prefix,and in a sorted set a cell is one range of scores.
    encode, neighbours, searchpseudo code
    encode(lat, lng, bits):
      ranges: lat [-90, 90], lng [-180, 180]
      REPEAT bits times, lng first, then in turn1:
        IF value >= middle of its range:
          emit 1; keep the upper half
        ELSE: emit 0; keep the lower half
      RETURN the bits                  // a prefix is a larger cell2
    
    neighbours(cell):
      FOR EACH of the 8 directions:
        encode(centre + one cell height or width)3
      wrap longitude at ±180; none past a pole4
    
    search(lat, lng, r):
      bits = smallest cell with height >= r AND width >= r5
      FOR EACH of the cell and its 8 neighbours:
        read the score range of the cell
        keep points with distance <= r6
    1. 1Interleaving keeps points that are near in both directions near in the code. Redis stores 52 bits.
    2. 2Fewer bits, larger cell. A cell is one contiguous range of codes, so a sorted set finds it with one range read.
    3. 3Compute neighbours from the box. Across a halving line the codes share nothing.
    4. 4Redis refuses latitudes beyond ±85.05° for the same reason.
    5. 5Then the circle stays inside the 9 cells wherever the centre is in its cell.
    6. 6Cells are rectangles. The exact check drops the corners.
    Tested source Go: encode, decode, neighbours · Go: cell size for a radius · Go: one cell, 9 cells
    Go: encode, decode, neighboursgo
    
    // Encode interleaves the bits of a binary search on longitude (even bits) and latitude (odd bits).
    // Each bit halves the range: 1 means the upper half. More bits make a smaller cell.
    func Encode(lat, lng float64, bits int) uint64 {
      latLo, latHi, lngLo, lngHi := -90.0, 90.0, -180.0, 180.0
      var code uint64
      for i := range bits {
        code <<= 1
        if i%2 == 0 {
          mid := (lngLo + lngHi) / 2
          if lng >= mid {
            code |= 1
            lngLo = mid
          } else {
            lngHi = mid
          }
        } else {
          mid := (latLo + latHi) / 2
          if lat >= mid {
            code |= 1
            latLo = mid
          } else {
            latHi = mid
          }
        }
      }
      return code
    }
    
    // Decode returns the cell of a code of the given length: the box every point with that prefix
    // lies in.
    func Decode(code uint64, bits int) Box {
      b := Box{-90, -180, 90, 180}
      for i := range bits {
        bit := code>>(bits-1-i)&1 == 1
        if i%2 == 0 {
          mid := (b.MinLng + b.MaxLng) / 2
          if bit {
            b.MinLng = mid
          } else {
            b.MaxLng = mid
          }
        } else {
          mid := (b.MinLat + b.MaxLat) / 2
          if bit {
            b.MinLat = mid
          } else {
            b.MaxLat = mid
          }
        }
      }
      return b
    }
    
    // Neighbours returns the 8 cells around a cell, clockwise from north. A cell's neighbour can have
    // a very different code: across a halving line the prefixes share nothing. So compute it from
    // the box, never from the string. Past a pole there is no neighbour (ok false); past the 180°
    // meridian, longitude wraps.
    func Neighbours(code uint64, bits int) (cells [8]uint64, ok [8]bool) {
      b := Decode(code, bits)
      h, w := b.MaxLat-b.MinLat, b.MaxLng-b.MinLng
      lat, lng := (b.MinLat+b.MaxLat)/2, (b.MinLng+b.MaxLng)/2
      steps := [8][2]float64{{1, 0}, {1, 1}, {0, 1}, {-1, 1}, {-1, 0}, {-1, -1}, {0, -1}, {1, -1}}
      for i, s := range steps {
        nLat, nLng := lat+s[0]*h, lng+s[1]*w
        if nLat > 90 || nLat < -90 {
          continue
        }
        nLng = math.Mod(nLng+540, 360) - 180
        cells[i], ok[i] = Encode(nLat, nLng, bits), true
      }
      return cells, ok
    }
    
    Go: cell size for a radiusgo
    
    // BitsFor picks the cell for a radius: the smallest cell (most bits, an even number) whose
    // height and width are both at least r. Then the circle fits inside the cell and its 8
    // neighbours, wherever the centre falls in its cell.
    func BitsFor(r, lat float64) int {
      for bits := Bits; bits >= 2; bits -= 2 {
        if h, w := CellSize(bits, lat); h >= r && w >= r {
          return bits
        }
      }
      return 2
    }
    
    Go: one cell, 9 cellsgo
    
    // SearchCell scans only the cell that holds the centre. It misses points across the cell border.
    func (ix *Index) SearchCell(lat, lng, r float64) Search {
      bits := BitsFor(r, lat)
      c := Encode(lat, lng, bits)
      return ix.scan(lat, lng, r, bits, []uint64{c})
    }
    
    // SearchNine scans the centre cell and its 8 neighbours. The circle fits inside these 9 cells, so
    // it finds every point; the distance check drops the corners.
    func (ix *Index) SearchNine(lat, lng, r float64) Search {
      bits := BitsFor(r, lat)
      c := Encode(lat, lng, bits)
      cells := []uint64{c}
      ns, ok := Neighbours(c, bits)
      for i, n := range ns {
        if ok[i] {
          cells = append(cells, n)
        }
      }
      return ix.scan(lat, lng, r, bits, cells)
    }
    
    func (ix *Index) scan(lat, lng, r float64, bits int, cells []uint64) Search {
      s := Search{Bits: bits, Cells: cells}
      for _, c := range cells {
        for _, p := range ix.Cell(c, bits) {
          s.Scanned++
          if Distance(lat, lng, p.Lat, p.Lng) <= r {
            s.Found = append(s.Found, p)
          }
        }
      }
      return s
    }
    

    A geohash interleaves longitude and latitude bits, so a prefix is a cell. I search the cell and its 8 neighbours, sized so the circle fits.

    F

    The border case

    recorded
    cell tdr1v9 and its 8 neighbours, 30 bits eachtdr1v9tdr1vdtdr1vftdr1vctdr1vbtdr1v8tdr1v2tdr1v3tdr1v6400 m, same cell80 m, next cellSearch one cell: nearest is the 400 m point. Wrong.Search the cell and its 8 neighbours: nearest is the 80 m point.
    casegeohashresult
    Query 30 m from the east edgetdr1v9One cell finds the 400 m point. 9 cells find the 80 m point.
    Two points 3.1 m apart, across the equators00000, 7zzzzz0 characters shared
    • A shared prefix means near. Near does not mean a shared prefix.
    • On 300 seeded queries over 20,000 points, the single cell missed points and the 9 cells never did. A test checks both.

    A point across the cell edge can be the nearest one. One cell misses it; 9 cells find it.

    G

    Try it: move the query

    recorded for every position and radius
    radius
    2 km
    methodpoints readfound
    One geohash cell5135 of 49
    Cell + 8 neighbours33749 of 49
    Quadtree6149 of 49
    Scan everything1,50049 of 49
    cell
    28 bits: 1,222 m × 2,381 m
    why
    The smallest cell at least as tall and as wide as the radius.
    missed
    14 points within 1 km, in neighbour cells
    • geohash cell, as in Redis
    • quadtree leaf, in the service
    • within the radius, missed

    Click or drag on the map to move the query. Arrow keys work when the map has focus.

    1,500 seeded points in a 16 km square: an even spread, a dense core and a second hub to the south-east. 49 positions × 4 radii, each a lab run checked against a full scan.

    Cells cost the most where points are dense, because the cell size follows the radius and not the density. A quadtree follows the density.

    H

    Candidates on 100,000 points

    recorded
    queryfoundlat band9 cellsquadtreebox
    city core, 1 km3,56514,37318,5754,7104,508
    city core, 5 km31,63853,46683,78634,09333,981
    suburb, 1 km1003,026946193140
    suburb, 5 km2,86416,91069,6594,0963,668
    querycell sizeone cell findsRedis GEOSEARCHdiffers
    city core, 1 km1.2 × 2.4 km2,5093,5632
    city core, 5 km9.8 × 19.0 km31,15231,6335
    suburb, 1 km1.2 × 2.4 km591000
    suburb, 5 km9.8 × 19.0 km2,5312,8604

    Differs: points on which Redis and the Go haversine disagree. A test shows each lies within 2 m of the circle, from 52-bit positions and a 0.03% larger Earth radius. The 10 nearest drivers matched in both places: 58 m in the core, 350 m in the suburb.

    In the core, 9 geohash cells read 5 times the result; a quadtree or an R-tree box reads about 1.3 times. Redis agrees with the Go code except on the circle edge.

    I

    Quadtree and nearest k

    pseudo code
    insert, within, nearestpseudo code
    insert(p):
      leaf = walk down to the quarter that holds p
      add p to leaf
      IF leaf holds more than 16 points1:
        split it into 4 quarters
    
    within(lat, lng, r):
      visit nodes whose box meets the circle's box2
      in each leaf: keep points with distance <= r
    
    nearest(lat, lng, k):
      queue = [root], closest first3
      WHILE fewer than k found:
        e = pop the closest
        IF e is a point: report it     // nothing left is closer4
        ELSE: push its children, keyed by distance to their box
    1. 1The leaf capacity. Every leaf holds at most 16 points, so the work per leaf is fixed wherever it is.
    2. 2Prune whole subtrees. A dense core is many small leaves; an empty edge is one big leaf.
    3. 3A node is keyed by the distance to the nearest point of its box: a lower bound for anything inside.
    4. 4Best-first search. The lab read 26 points to find the 10 nearest in the core.
    Tested source Go: insert and within · Go: nearest k
    Go: insert and withingo
    
    // Insert walks down to the leaf whose box holds p. A full leaf splits into four quarters and
    // hands its points down.
    func (q *Quadtree) Insert(p Point) {
      n := q.root
      for n.kids != nil {
        n = &n.kids[n.quarter(p)]
      }
      n.pts = append(n.pts, p)
      if len(n.pts) > LeafCap && n.depth < maxDepth {
        n.split()
      }
    }
    
    func (n *node) split() {
      b := n.box
      midLat, midLng := (b.MinLat+b.MaxLat)/2, (b.MinLng+b.MaxLng)/2
      n.kids = &[4]node{
        {box: Box{midLat, b.MinLng, b.MaxLat, midLng}, depth: n.depth + 1}, // north-west
        {box: Box{midLat, midLng, b.MaxLat, b.MaxLng}, depth: n.depth + 1}, // north-east
        {box: Box{b.MinLat, b.MinLng, midLat, midLng}, depth: n.depth + 1}, // south-west
        {box: Box{b.MinLat, midLng, midLat, b.MaxLng}, depth: n.depth + 1}, // south-east
      }
      pts := n.pts
      n.pts = nil
      for _, p := range pts {
        k := &n.kids[n.quarter(p)]
        k.pts = append(k.pts, p)
      }
      for i := range n.kids {
        if len(n.kids[i].pts) > LeafCap && n.kids[i].depth < maxDepth {
          n.kids[i].split()
        }
      }
    }
    
    func (n *node) quarter(p Point) int {
      q := 0
      if p.Lat < (n.box.MinLat+n.box.MaxLat)/2 {
        q = 2
      }
      if p.Lng >= (n.box.MinLng+n.box.MaxLng)/2 {
        q++
      }
      return q
    }
    
    // Within visits only the nodes whose box meets the circle's bounding box, and checks the
    // distance of every point in the leaves it reaches.
    func (q *Quadtree) Within(lat, lng, r float64) (found []Point, leaves []Box, scanned int) {
      bb := BoundingBox(lat, lng, r)
      var walk func(n *node)
      walk = func(n *node) {
        if !n.box.Intersects(bb) {
          return
        }
        if n.kids == nil {
          leaves = append(leaves, n.box)
          for _, p := range n.pts {
            scanned++
            if Distance(lat, lng, p.Lat, p.Lng) <= r {
              found = append(found, p)
            }
          }
          return
        }
        for i := range n.kids {
          walk(&n.kids[i])
        }
      }
      walk(q.root)
      return found, leaves, scanned
    }
    
    Go: nearest kgo
    
    // Nearest returns the k points closest to (lat, lng), nearest first. One queue holds nodes, keyed
    // by the distance to their box, and points, keyed by their own distance. A point that comes out
    // of the queue is closer than everything still in it, so it is the next answer.
    func (q *Quadtree) Nearest(lat, lng float64, k int) (found []Point, scanned int) {
      pq := &queue{{d: minDist(q.root.box, lat, lng), n: q.root}}
      for pq.Len() > 0 && len(found) < k {
        e := heap.Pop(pq).(entry)
        switch {
        case e.p != nil:
          found = append(found, *e.p)
        case e.n.kids == nil:
          for i := range e.n.pts {
            p := &e.n.pts[i]
            scanned++
            heap.Push(pq, entry{d: Distance(lat, lng, p.Lat, p.Lng), p: p})
          }
        default:
          for i := range e.n.kids {
            c := &e.n.kids[i]
            heap.Push(pq, entry{d: minDist(c.box, lat, lng), n: c})
          }
        }
      }
      return found, scanned
    }
    
    leaves
    13,510 leaves for 100,000 points, recorded
    depth
    2,561 at depth 6, 5,448 at depth 7, 1,894 at depth 8, 3,459 at depth 9, 148 at depth 10

    A quadtree splits only where points are dense. For nearest k I pop nodes and points from one queue ordered by distance, so the first k points out are the answer.

    J

    Postgres without PostGIS

    SQL, then pseudo code
    places, two indexes, a radius querysql
    DROP TABLE IF EXISTS places;
    CREATE TABLE places (
      id  int              PRIMARY KEY,
      lat double precision NOT NULL CHECK (lat BETWEEN -90 AND 90),
      lng double precision NOT NULL CHECK (lng BETWEEN -180 AND 180),
      pt  point GENERATED ALWAYS AS (point(lng, lat)) STORED1
    );
    CREATE OR REPLACE FUNCTION distance_m2(lat1 float8, lng1 float8, lat2 float8, lng2 float8)
    RETURNS float8 LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
      SELECT 2 * 6371008.8 * asin(sqrt(
        sin(radians(lat2 - lat1) / 2) ^ 2
        + cos(radians(lat1)) * cos(radians(lat2)) * sin(radians(lng2 - lng1) / 2) ^ 2))
    $$;
    
    CREATE INDEX places_lat_lng ON places (lat, lng);3
    CREATE INDEX places_pt ON places USING gist (pt)4;
    ANALYZE places;
    
    -- The same box as a containment test, which only the GiST index can answer.
    SELECT id FROM places
    WHERE pt <@ box(point($2, $1), point($4, $3))5
      AND distance_m($5, $6, lat, lng) <= $7;
    1. 1A generated point, so the GiST index can hold it. x is the longitude.
    2. 2The haversine formula in SQL. A plain function, the same in every query.
    3. 3Range on lat only. Postgres 16 tests lng on every entry in the latitude band.
    4. 4An R-tree of boxes. It visits only boxes that meet the query box.
    5. 5Box containment: only the GiST index can answer it. Then the exact distance.
    distance and boxpseudo code
    distance(a, b):                   // haversine, on a sphere1
      h = sin²(Δlat/2) + cos(lat_a)·cos(lat_b)·sin²(Δlng/2)
      RETURN 2 · R · asin(√h)          // R = 6,371 km
    
    box(lat, lng, r):
      dlat = r / R                     // radians
      dlng = dlat / cos(lat)2           // meridians meet at the poles
    1. 1Redis uses the same formula. Its documentation gives a worst-case error of 0.5% against the real Earth.
    2. 2A degree of longitude is 111 km at the equator and about 108 km at 13° N.
    Tested source Go: distance and box
    Go: distance and boxgo
    
    // Distance is the great-circle distance in metres between two points (the haversine formula):
    // a = sin²(Δlat/2) + cos(lat1)·cos(lat2)·sin²(Δlng/2), and d = 2R·asin(√a).
    func Distance(lat1, lng1, lat2, lng2 float64) float64 {
      dLat, dLng := rad(lat2-lat1), rad(lng2-lng1)
      a := math.Pow(math.Sin(dLat/2), 2) + math.Cos(rad(lat1))*math.Cos(rad(lat2))*math.Pow(math.Sin(dLng/2), 2)
      return 2 * EarthRadius * math.Asin(math.Sqrt(a))
    }
    
    // BoundingBox is the smallest lat/lng box around a circle of r metres. A degree of latitude is
    // always about 111 km; a degree of longitude shrinks with cos(lat). Valid away from the poles and
    // the 180° meridian.
    func BoundingBox(lat, lng, r float64) Box {
      dLat := r / EarthRadius * 180 / math.Pi
      dLng := dLat / math.Cos(rad(lat))
      return Box{lat - dLat, lng - dLng, lat + dLat, lng + dLng}
    }
    

    PostGIS is not installed in the lab Postgres, so it is cited only. A test checks both queries against a full scan.

    Without PostGIS I store lat and lng, index a point with GiST, query its bounding box, and check the haversine distance. With PostGIS I use geography and ST_DWithin.

    K

    Cell systems compared

    cited from each project's documentation
    systemcelllevelsneighboursbest at
    GeohashRectangle, a bit prefixRedis: 26 steps, 52 bits8; across a halving line they share no prefixRange scans over sorted keys
    QuadtreeSquare, split by countGrows with densityAny number; leaf sizes differIn-memory search over uneven data
    R-tree (GiST)Boxes that may overlapBalanced treeNone; boxes may overlapShapes and points in a database
    S2Quadrilateral on a cube face0 to 30; level 13 averages 1.27 km²4 along the edgesCovering a region with a few id ranges
    H3Hexagon, 12 pentagons per level0 to 15; level 9 averages 0.11 km²6, all at the same distanceCounting per area: demand, surge zones
    • S2 orders cells along a Hilbert curve, so near cells often have near ids.
    • An S2 level 30 cell averages 0.74 cm². H3 has 122 cells at resolution 0.
    • Neither library is in the lab. The figures come from their documentation.

    Geohash for sorted keys, a quadtree or R-tree for density, S2 for regions at global scale, H3 when I count things per area.

    L

    Points that move

    updates every few seconds
    techniquewhat it doeseffectwhere
    Overwrite the positionGEOADD on an existing member moves it.Memory stays one entry per driver. History goes to a log, not the index.Redis
    One GEOADD per reportSimple, one round trip each.About 41,000 updates a second, measured.Redis
    100 drivers per GEOADDEach service copy flushes a batch every 100 ms or so.About 418,000 updates a second, measured.Redis
    Skip small movesReport after 20 m or 4 s, whichever comes first.A parked car sends one report every 4 s instead of every second.Driver app
    A key per city or coarse cellSpreads keys over cluster shards.A query near a boundary reads two keys.Cluster
    Heartbeat and sweeperZADD seen now; ZRANGEBYSCORE for silent drivers; ZREM.Closed apps leave search within the sweep period.Redis
    A row per driver in Postgres, updated every 4 sEach update writes a new row version and index entries.Vacuum load grows with the update rate (sheet T3). Keep only assignments there.Postgres

    I keep only the latest position, batch the writes, skip small moves, and shard by city. A heartbeat removes drivers who stop reporting.

    M

    Failure cases

    what breaks, and what limits the damage
    eventresultwhy it stays safesaved by
    Hot cell: a dense city coreOne 1 km search reads 18,575 drivers: about 2 ms, about 600 searches a second per Redis, measured.ANY stops at COUNT: about 0.18 ms. Or shrink the radius where density is high.COUNT ANY
    Update storm: every driver reports at onceSingle GEOADD calls reached about 41,000 a second in the lab.Batches, skipped small moves and a key per city spread the load.Batching
    A driver closes the appThe position stays in the GEO key forever.The sweeper removes silent drivers. Assign checks the status anyway.Heartbeat
    Search uses one cell onlyPoints across the border are missed.Always read the 8 neighbours too; GEOSEARCH does.9 cells
    Query near 180° longitude or the polesA naive box wraps the wrong way.Split the box at 180°. Redis refuses latitudes beyond ±85.05°.Split box
    Two riders get the same driverBoth requests saw the driver as a candidate.The conditional UPDATE lets one commit. The other gets 0 rows and tries the next driver.Conditional update
    Straight-line distance across a riverThe nearest driver by distance is far by road.Distance only filters. The routing service ranks by driving time.Routing
    N

    Scale ladder

    start simple; climb only on a signal
    Each step adds one component1Postgres GiST2+ Redis GEO3+ key per city4+ in-memory index5+ S2 or H3 cellsmore load →
    Capacity against demand1001k10k100k1MDemand, position updates: 25,000 operations per secondDemand, position updates25,000Demand, searches at peak: 500 operations per secondDemand, searches at peak500GEOADD, 1 per call: 40,732 operations per secondGEOADD, 1 per call40,732GEOADD, 100 per call: 418,333 operations per secondGEOADD, 100 per call418,333GEOSEARCH, core, COUNT 20: 609 operations per secondGEOSEARCH, core, COUNT 20609GEOSEARCH, core, ANY: 33,556 operations per secondGEOSEARCH, core, ANY33,556GEOSEARCH, suburb: 11,541 operations per secondGEOSEARCH, suburb11,541Quadtree, 1 core: 32,800 operations per secondQuadtree, 1 core32,800operations per second, log scale
    stepaddit handlesmove up when you see
    1Postgres, lat and lng with a GiST index on a point; PostGIS when available.Fixed places: stores, venues. A 1 km box query in about 0.2 ms in a suburb, 2.6 ms in the dense core.Positions change every few seconds, and updates fill the table with dead rows.
    2Redis GEO, one key, latest position only, with a heartbeat.About 41,000 single updates and 11,500 suburb searches a second.Updates near the single-call limit, or searches in the core slow down.
    3A key per city on separate shards; batched GEOADD; COUNT with ANY in dense areas.About 418,000 batched updates a second per shard. Cities grow by adding shards.One city's core is still too dense for one shard.
    4An in-memory index in the matching service, fed by the position stream.About 31 µs per suburb query, so about 32,000 a second per core. Split by region.You need polygons, areas and analytics at global scale.
    5S2 or H3 cell ids as keys in any store, plus PostGIS for shapes.Regions become id ranges; demand counts per hexagon.Top of the ladder.

    Demand example: one city with 100,000 active drivers reporting every 4 s is 25,000 updates a second; 500 ride requests a second at peak. Capacity measured in the lab on 100,000 seeded drivers, 32 clients; the quadtree figure is 1 / 31 µs on one core. Read the bars as orders of magnitude.

    For fixed places I start with Postgres and a GiST index. For moving drivers I start with one Redis GEO key per city. I shard, or move search into memory, when a city's core gets hot.

    O

    Drill

    predict, then reveal

    0 of 9 known

    1. Why is a B-tree on (lat, lng) a poor index for "within 1 km"?

    2. Two points 3.1 m apart have the geohashes s00000 and 7zzzzz. Why do they share no character?

    3. Why does a geohash search read 9 cells and not 1?

    4. How does Redis pick the cell size for GEOSEARCH?

    5. GEOSEARCH with 1 km and COUNT 20 takes about 2 ms in the city core and 0.25 ms in a suburb. Why, and what helps?

    6. One million drivers report every 4 s. Can one Redis keep up?

    7. A driver closes the app. What removes them from search?

    8. Why does a quadtree read fewer points than a geohash grid in a dense core?

    9. When would you choose H3 hexagons over geohash cells?

    P

    Numbers to say

    measured, derived or cited
    degrees
    1° of latitude is 111 km. 1° of longitude is 111 km × cos(latitude), derived.
    geohash
    Redis keeps 52 bits. A 28-bit cell at 13° N is 1,222 × 2,381 m, derived.
    H3, S2
    H3 level 9 averages 0.11 km²; S2 level 13 averages 1.27 km² (their documentation).
    distance
    Haversine about 107 ns; up to 0.5% off the real Earth (Redis documentation).
    GEOADD
    About 41,000 a second one by one; 418,000 in batches of 100, measured.
    GEOSEARCH
    1 km: about 0.25 ms in a suburb, 2.1 ms in the core, 0.18 ms in the core with ANY, measured.
    candidates
    Core, 1 km: 18,575 for 9 cells, 4,710 for a quadtree, 3,565 found, recorded.
    suburb
    1 km: 100 found; 9 cells read 946, recorded.

    Go, Redis 8 and Postgres 16 on an 8-core laptop, loopback, 32 clients, median of three runs. Use these as orders of magnitude.