Beyond Nearby Users: Building a Millisecond‑Level Real‑Time Dispatch System with GeoHash and Spring Boot
This article dissects the architecture, algorithms, and production practices behind a millisecond‑level real‑time dispatch system that uses GeoHash for spatial indexing, Spring Boot for service orchestration, Redis GEO for fast candidate selection, and a series of scoring, atomic reservation, and observability techniques to handle millions of riders under high concurrency.
Real‑Time Dispatch as a System Problem
Instant delivery, ride‑hailing and on‑site services must make a stable, executable decision within tens of milliseconds despite millions of online riders, second‑level location changes, order spikes and network jitter.
Order Trigger → Find candidate capacity → Filter by business constraints → Score & sort → Atomically lock a rider → Push order → Await confirmation → Timeout recovery / retry / escalationThe system requires four characteristics:
Strong real‑time guarantees (decision window of tens to hundreds of ms).
Weak‑consistent reads for location data, strong‑constraint writes for reservation.
High competition for hotspot riders.
Fail‑safe design that never leaves an order hanging.
Business Modeling
Candidate Selection (圈人)
Quickly gather a set of potentially assignable riders using spatial indexes (GeoHash, Redis GEO, H3, S2). The goal is low‑cost reduction of the search space, not exact distance for every rider.
Rider Scoring (选人)
Score combines multiple dimensions: distance to store, current load, estimated pickup time, estimated delivery time, moving direction, rider reputation, cancellation rate, area congestion and SLA pressure. Distance is only one factor.
Locking (锁人)
When two orders target the same rider simultaneously, an atomic pre‑reservation and idempotent controls are required; a simple read‑then‑write is unsafe under high concurrency.
Why Full Scans Are Infeasible
A naïve loop over 1 000 000 online riders would generate billions of distance calculations per second at peak load, overwhelming CPU, memory and network, and the candidate set would be stale because positions change every second.
Principle: Never perform a full scan; always use a spatial index to reduce a global search to a local one.
GeoHash: What It Solves and Its Limits
Essence of GeoHash
GeoHash encodes a latitude/longitude pair into a hierarchical string prefix. Points that are close share similar prefixes, enabling natural grid‑based aggregation.
(116.397128, 39.916527) → wx4g0ec1Suitable Use‑Cases
Coarse candidate filtering.
Hotspot area aggregation.
Spatial bucketing.
Redis key sharding prefixes.
Trajectory statistics and area profiling.
Unsuitable Use‑Cases
Precise distance ranking.
Road‑network shortest path.
Real‑world travel cost considering traffic lights, rivers, bridges or closed streets.
Typical production pattern: GeoHash/Redis GEO for coarse filtering → Haversine for precise spherical distance → Road‑network ETA for final delivery time estimation.
Precision Selection
Length 5 → kilometer‑level, city‑level aggregation, hotspot monitoring.
Length 6 → ~1 km, business‑district candidate pre‑filter.
Length 7 → hundreds of meters, store‑level candidate selection.
Length 8 → tens of meters, building‑level static analysis (not primary search).
In practice use multi‑level precision: start with a fine grid, expand to coarser grids or larger radii if candidates are insufficient, then always apply exact distance and business scoring.
Production‑Grade Architecture
The system consists of four core layers:
Access layer – ingest orders, location reports, rider confirmations.
Index layer – maintain real‑time rider positions and queryable states.
Decision layer – candidate selection, scoring, atomic reservation, order push.
Governance layer – monitoring, degradation, compensation, audit, replay.
Reference Service Boundaries
rider-location-service: high‑frequency writes of rider location, many async downstream consumers. dispatch-orchestrator: low‑latency reads, multi‑condition decisions, consumes order events. dispatch-domain-service: CPU‑intensive candidate filtering, scoring, reservation logic. push-service: IO‑intensive push, retract, second‑reminder. timeout-worker: asynchronous compensation for timeout recovery.
Benefits: position writes do not choke the dispatch read path; the decision engine can scale horizontally without state; push failures, message back‑pressure and recovery are isolated from the critical path.
Rider Position Reporting
Goal: maintain a highly‑available real‑time index with minimal cost.
Rider App → Gateway → rider-location-service → validation / dedup / timestamp check → update Redis GEO → update rider state cache → async Kafka → downstream persistence / trajectory analysisKey safeguards:
Never sync‑write to MySQL on every report.
Discard timestamps that are too old or far in the future.
Use a sequence number to avoid out‑of‑order overwrites.
Candidate Query Service (Java)
@Service
@RequiredArgsConstructor
public class RiderCandidateService {
private final StringRedisTemplate redisTemplate;
public List<RiderCandidate> queryCandidates(DispatchRequest request, int limit) {
Circle circle = new Circle(new Point(request.getPickupLon(), request.getPickupLat()),
new Distance(request.getRadiusMeters(), Metrics.METERS));
GeoResults<RedisGeoCommands.GeoLocation<String>> results =
redisTemplate.opsForGeo().search(geoKey(request.getCityId()), circle,
RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs()
.includeCoordinates().includeDistance().sortAscending().limit(limit));
if (results == null || results.getContent().isEmpty()) {
return List.of();
}
return results.getContent().stream()
.map(item -> {
RedisGeoCommands.GeoLocation<String> loc = item.getContent();
Point p = loc.getPoint();
Distance d = item.getDistance();
return RiderCandidate.builder()
.riderId(Long.valueOf(loc.getName()))
.lon(p.getX()).lat(p.getY())
.distanceMeters(d == null ? Double.MAX_VALUE : d.getValue())
.build();
}).toList();
}
private String geoKey(Long cityId) { return "rider:geo:city:" + cityId; }
}The Redis GEO query only returns a limited candidate set; the final decision never relies on raw distance alone.
Scoring Rules (Plug‑in Design)
public interface DispatchScoreRule { double score(RiderCandidate candidate, DispatchContext context); }
@Component
public class DistanceScoreRule implements DispatchScoreRule {
@Override
public double score(RiderCandidate c, DispatchContext ctx) {
double d = c.getDistanceMeters();
return Math.max(0D, 100D - d / 20D);
}
}
@Component
public class LoadScoreRule implements DispatchScoreRule {
@Override
public double score(RiderCandidate c, DispatchContext ctx) {
int load = ctx.loadOf(c.getRiderId());
return Math.max(0D, 100D - load * 25D);
}
}
@Component
public class DispatchScoreCalculator {
private final List<DispatchScoreRule> rules;
public DispatchScoreCalculator(List<DispatchScoreRule> rules) { this.rules = rules; }
public double calculate(RiderCandidate c, DispatchContext ctx) {
return rules.stream().mapToDouble(r -> r.score(c, ctx)).sum();
}
}This modular approach lets teams add ETA, weather, supply‑demand signals without growing a monolithic if‑else block.
Atomic Rider Reservation (Lua Script)
@Service
@RequiredArgsConstructor
public class RiderReservationService {
private final StringRedisTemplate redisTemplate;
private static final DefaultRedisScript<Long> RESERVE_SCRIPT;
static {
RESERVE_SCRIPT = new DefaultRedisScript<>();
RESERVE_SCRIPT.setResultType(Long.class);
RESERVE_SCRIPT.setScriptText("""
local riderStateKey = KEYS[1]
local reserveKey = KEYS[2]
local orderId = ARGV[1]
local ttlMillis = ARGV[2]
local status = redis.call('HGET', riderStateKey, 'status')
if status ~= 'AVAILABLE' then return 0 end
if redis.call('EXISTS', reserveKey) == 1 then return 0 end
redis.call('PSETEX', reserveKey, ttlMillis, orderId)
redis.call('HSET', riderStateKey, 'status', 'RESERVED')
redis.call('HSET', riderStateKey, 'reservedOrderId', orderId)
return 1
""");
}
public boolean reserve(Long riderId, String orderId, Duration ttl) {
Long result = redisTemplate.execute(RESERVE_SCRIPT,
List.of(stateKey(riderId), reserveKey(riderId)),
orderId, String.valueOf(ttl.toMillis()));
return result != null && result == 1L;
}
private String stateKey(Long riderId) { return "rider:state:" + riderId; }
private String reserveKey(Long riderId) { return "dispatch:reserve:rider:" + riderId; }
}The script guarantees that only AVAILABLE riders can be reserved and that a rider cannot be reserved by two orders simultaneously.
Dispatch Orchestrator (Core Flow)
@Service
@RequiredArgsConstructor
@Slf4j
public class DispatchOrchestrator {
private final RiderCandidateService candidateService;
private final DispatchScoreCalculator scoreCalculator;
private final RiderReservationService reservationService;
private final DispatchEventPublisher eventPublisher;
private final MeterRegistry meterRegistry;
public DispatchResult dispatch(DispatchRequest request) {
long start = System.currentTimeMillis();
List<RiderCandidate> candidates = candidateService.queryCandidates(request, 80);
if (candidates.isEmpty()) {
return DispatchResult.fail("no candidate rider", elapsed(start));
}
DispatchContext ctx = DispatchContext.load(candidates);
List<RiderCandidate> sorted = candidates.stream()
.filter(ctx::isRiderActive)
.peek(c -> c.setScore(scoreCalculator.calculate(c, ctx)))
.sorted(Comparator.comparingDouble(RiderCandidate::getScore).reversed())
.toList();
for (RiderCandidate c : sorted) {
boolean reserved = reservationService.reserve(c.getRiderId(), request.getOrderId(), Duration.ofSeconds(15));
if (!reserved) continue;
eventPublisher.publishDispatchCreated(request, c);
meterRegistry.counter("dispatch.reserve.success").increment();
return DispatchResult.success(c.getRiderId(), elapsed(start));
}
meterRegistry.counter("dispatch.reserve.exhausted").increment();
return DispatchResult.fail("candidate exhausted", elapsed(start));
}
private long elapsed(long start) { return System.currentTimeMillis() - start; }
}Only candidate selection, scoring and atomic reservation are in the synchronous critical path; pushing, acknowledgment and timeout handling are asynchronous.
Timeout Recovery Worker
@Component
@RequiredArgsConstructor
@Slf4j
public class DispatchTimeoutWorker {
private final StringRedisTemplate redisTemplate;
private final DispatchOrchestrator dispatchOrchestrator;
private final OrderQueryService orderQueryService;
@Scheduled(fixedDelay = 1000L)
public void scanTimeoutOrder() {
long now = System.currentTimeMillis();
Set<String> orderIds = redisTemplate.opsForZSet()
.rangeByScore("dispatch:timeout:zset", 0, now, 0, 50);
if (orderIds == null || orderIds.isEmpty()) return;
for (String orderId : orderIds) {
OrderSnapshot snapshot = orderQueryService.load(orderId);
if (snapshot == null || snapshot.isAccepted()) { cleanup(orderId); continue; }
cleanup(orderId);
DispatchResult r = dispatchOrchestrator.dispatch(snapshot.toDispatchRequest());
log.warn("redispatch orderId={}, success={}, riderId={}, reason={}",
orderId, r.isSuccess(), r.getRiderId(), r.getReason());
}
}
private void cleanup(String orderId) {
redisTemplate.opsForZSet().remove("dispatch:timeout:zset", orderId);
redisTemplate.delete("dispatch:order:" + orderId);
}
}Idempotent timeout handling ensures that an order is not re‑dispatched after it has already been accepted.
Scoring Formula (Why "Nearest" Is Not Always Best)
finalScore =
0.35 * distanceScore +
0.20 * etaScore +
0.15 * loadScore +
0.10 * directionScore +
0.10 * qualityScore +
0.10 * supplyDemandBalanceScoreKey principles: keep the formula understandable, align the decision goal, and avoid distance‑only domination.
High‑Concurrency Risks & Mitigations
Hotspot Redis Keys
Split keys by city or region instead of a single global GEO key.
Further shard hotspot grids.
Separate read/write paths to avoid contention.
Apply multi‑level precision and candidate caps (e.g., 30‑100 candidates) to limit load.
Stale Position Data
Discard reports older than a configurable threshold.
Use short‑term linear prediction for fast‑moving riders.
Estimate direction from multiple recent points, not just the latest.
Weight ETA more heavily than raw spherical distance in complex road networks.
Duplicate Dispatch & State Leakage
Order idempotency keys.
Atomic reservation Lua scripts.
Idempotent event processing.
Idempotent timeout recovery.
Audit logs for every critical state change.
Message Reliability
Consumer idempotency.
Outbox pattern for transactional event publishing.
Dead‑letter queues and retry limits.
Circuit‑breaker for external push services.
Latency Budgeting
Candidate query: 10‑20 ms
Filtering & scoring: 10‑30 ms
Atomic reservation: 5‑10 ms
Push event dispatch: 5‑10 ms
Fallback & logging: remaining budget
If any stage exceeds its budget, the overall SLA collapses.
Observability Checklist
Performance Metrics
dispatch_latency_ms dispatch_candidate_query_ms dispatch_score_ms dispatch_reserve_ms redis_geo_search_msResult Metrics
Dispatch success rate
First‑attempt success rate
Retry rate
Empty‑candidate rate
Timeout recovery rate
Resource & Hotspot Metrics
Redis CPU / memory / slow‑query count
Hot city key query frequency
Average candidate return size
Kafka topic lag
Business Quality Metrics
Rider acceptance latency
Merchant preparation time
Average delivery duration
Order timeout proportion
Failure distribution during peaks
Audit & Replay
Record which candidates were queried.
Log filtering reasons per candidate.
Store final scoring breakdown.
Capture reservation success/failure.
Mark any degradation or re‑dispatch actions.
Pre‑Launch Production Checklist
Data & State
Clear rider state machine (AVAILABLE, RESERVED, ENGAGED, OFFLINE).
Reservation TTL configured.
Idempotent timeout recovery.
Mechanism to clean up offline riders.
Concurrency & Reliability
Atomic reservation in place.
Order idempotency key.
Retry limit per order.
Message backlog and dead‑letter handling.
Performance & Capacity
Redis key hotspot analysis per city.
Candidate return size controllable under load.
P95/P99 latency verified by load testing.
Capacity planning for Redis, MQ, thread pools.
Observability
End‑to‑end trace for dispatch flow.
Replay capability for a single order.
Alerts for empty‑candidate and high retry rates.
Distinguish system‑level vs. capacity‑level issues.
Degradation & Emergency
Automatic radius expansion when candidates are empty.
Redis slow‑query fallback strategies.
Broadcast or manual takeover when push channels fail.
Feature flags to disable expensive scoring features.
Evolution Roadmap
Phase 1: Single‑city, single‑cluster, Redis GEO coarse filter.
Phase 2: Service decomposition, async compensation, event streaming.
Phase 3: Multi‑level grids, ETA features, audit & replay platform.
Phase 4: Intelligent supply‑demand balancing, predictive pre‑dispatch, advanced H3/S2 or RL models.
Each phase builds on a stable, observable foundation before adding complexity.
Conclusion
GeoHash’s real value lies in turning an impossible global search into a fast local candidate lookup that can be completed within tens of milliseconds. A production‑ready dispatch system also requires clear candidate‑selection → scoring → atomic reservation → asynchronous compensation loops, strict separation of weak‑consistent reads and strong‑constraint writes, robust high‑concurrency safeguards, and comprehensive observability.
When these pieces are in place, the system moves from a demo that merely “finds nearby riders” to a resilient service that can survive lunch‑time peaks and reliably deliver orders.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Cloud Architecture
Focuses on cloud‑native and distributed architecture engineering, sharing practical solutions and lessons learned. Covers microservice governance, Kubernetes, observability, and stability engineering to help your systems run stable, fast, and cost‑effectively.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
