Ride matching, where the data is a moving map
Most of the writes in this system are positions that are out of date four seconds later. Treating them like database records is the first mistake and the most expensive one.
The brief
Drivers' apps report their position continuously. A rider requests a ride, and the system offers it to a nearby available driver.
A driver must never be assigned two rides at once, and a rider must not wait long for an answer.
Requirements
Functional
- Accept frequent location updates from available drivers
- Find available drivers near a pickup point
- Offer a ride to a driver, who accepts or declines within a short time
- Track the trip once matched
Non-functional
- A driver is assigned at most one ride — the one correctness rule in the matching path
- A position a few seconds old is acceptable; a match that takes a minute is not
- Location data is personal data and is kept no longer than needed
Back-of-envelope
Assume
- 500,000 drivers online at peak across all cities
- Each sends a position every 4 seconds
- 50,000 ride requests a minute at peak
- A driver has 15 seconds to accept an offer; about 30% of offers are declined or time out
Therefore
- Location writes: 500,000 / 4 = 125,000 a second. That is the dominant load, and every one of those values is superseded within 4 seconds.
- Kept in a disk-backed database with history, that is ≈10.8 billion rows a day of mostly useless data. Kept as 'latest position per driver' in memory, it is 500,000 entries, overwritten in place.
- Ride requests: 50,000 a minute ≈ 830 a second, each needing one nearby-driver query. The read path is 150 times smaller than the write path.
- With a 15-second offer window and a 30% decline rate, a rider whose first two offers fail waits 30 seconds or more before a third driver is even asked. The offer timeout, not the query, dominates time-to-match.
Halve the reporting interval and the write load doubles while the position error only halves — at typical city speeds, a car moves roughly 40 metres in 4 seconds. The interval is a cost-versus-accuracy dial, worth setting on purpose.
The interface
What is stored
geospatial index: driverId → (lat, lng), plus driverId → { status, updatedAt }The query is 'available drivers within radius r of a point', answered from a geospatial index — geohash cells or a sorted-set geo structure. updatedAt lets the query ignore drivers whose app went silent a minute ago.
driver_id · status (OFFLINE | AVAILABLE | OFFERED | ON_TRIP) · current_ride_id · versionStatus changes are conditional updates — AVAILABLE → OFFERED only if still AVAILABLE — which is what makes double assignment impossible even when two matchers pick the same driver.
ride_id · rider_id · driver_id · state · route samples (downsampled)The durable record of what happened, for fares and disputes. The route is stored downsampled after the trip rather than as every 4-second ping.
The design
The decisions
Each of these could go the other way. The choice, the reason, and what it costs — a design that lists only what it chose teaches the choice; one that lists what it gave up teaches the judgement.
What breaks first
In order. Each names what you would actually observe, and each fix carries its cost.
When something fails
Scaling it
Each step is triggered by a number, not a feeling — and carries what it costs.
What gets probed
The design is the easy half. These are where the conversation goes, and each has a defensible answer above.
- 125,000 location updates a second arrive. Where do they go, and why not the database?
- Two riders' requests select the same driver. Show exactly what prevents a double assignment.
- A driver is 50 metres from the pickup but in the next grid cell. Does your query find them?
- Time-to-match has doubled, and query latency has not changed. Where do you look?