Uber-like система (Ride-hailing)
Проектирование платформы для заказа такси: real-time геолокация, matching водителей, отслеживание поездки, surge pricing, ETA.
Шаг 1: Требования
Функциональные требования
- Для пассажиров: запрос поездки, выбор типа авто, отслеживание водителя, оплата
- Для водителей: выход на линию, принятие/отклонение заказа, навигация, earnings
- Matching: подбор ближайшего доступного водителя
- Pricing: расчёт стоимости, surge pricing при высоком спросе
- ETA: предсказание времени прибытия и поездки
- Rating: двусторонняя система оценок
Нефункциональные требования
- Matching latency < 3 секунды
- Location update frequency: каждые 3-5 секунд
- 1M+ concurrent drivers
- 10M+ rides per day
- 99.99% доступность для ride matching
Шаг 2: Оценка нагрузки
| Метрика | Значение |
|---|---|
| Active drivers | 1M |
| Location updates/sec | ~300K (1M / 3 sec) |
| Ride requests/sec | ~200 (10M / 86400 * 2 peak) |
| Concurrent rides | ~500K |
| GPS data per day | ~150 GB |
Шаг 3: High-Level архитектура
┌────────────────┐ ┌────────────────┐
│ Rider App │ │ Driver App │
│ (iOS/Android) │ │ (iOS/Android) │
└───────┬────────┘ └───────┬────────┘
│ │
│ WebSocket │ WebSocket
│ │
┌───────▼──────────────────────────▼────────┐
│ API Gateway + LB │
└───────┬──────────────┬───────────┬────────┘
│ │ │
┌───────▼────┐ ┌──────▼─────┐ ┌─▼──────────────┐
│ Ride │ │ Location │ │ Matching │
│ Service │ │ Service │ │ Service │
└───────┬────┘ └──────┬─────┘ └─┬──────────────┘
│ │ │
┌───────▼────┐ ┌──────▼─────┐ ┌─▼──────────────┐
│ Payment │ │ Redis/ │ │ Pricing │
│ Service │ │ Location │ │ Service │
│ │ │ Store │ │ (Surge) │
└────────────┘ └────────────┘ └────────────────┘
│ │ │
┌───────▼──────────────▼──────────▼────────┐
│ PostgreSQL | Kafka | S3 │
└──────────────────────────────────────────┘
Шаг 4: Детальный дизайн
4.1 Location Service (Driver Position Tracking)
<?php
declare(strict_types=1);
final class LocationService
{
private const POSITION_TTL = 30; // seconds
public function __construct(
private readonly \Redis $redis,
private readonly QueuePublisher $queue,
) {}
/**
* Update driver's position (called every 3-5 seconds)
*/
public function updateDriverPosition(
string $driverId,
float $lat,
float $lng,
float $heading,
float $speed,
): void {
// 1. Update in Redis GEO (for proximity queries)
$this->redis->geoAdd('drivers:active', $lng, $lat, $driverId);
// 2. Store detailed position data
$this->redis->setex(
"driver:pos:{$driverId}",
self::POSITION_TTL,
json_encode([
'lat' => $lat,
'lng' => $lng,
'heading' => $heading,
'speed' => $speed,
'updated_at' => microtime(true),
]),
);
// 3. If driver is on a ride, publish for rider tracking
$rideId = $this->redis->get("driver:ride:{$driverId}");
if ($rideId !== false) {
$this->queue->publish("ride.location.{$rideId}", [
'driver_id' => $driverId,
'lat' => $lat,
'lng' => $lng,
'heading' => $heading,
'speed' => $speed,
]);
}
}
/**
* Find nearby available drivers
*/
public function findNearbyDrivers(
float $lat,
float $lng,
float $radiusKm = 5.0,
int $limit = 20,
): array {
// GEORADIUS returns sorted by distance
$nearby = $this->redis->geoRadius(
'drivers:active',
$lng,
$lat,
$radiusKm,
'km',
[
'WITHDIST',
'WITHCOORD',
'COUNT' => $limit,
'ASC',
],
);
$drivers = [];
foreach ($nearby as $driver) {
$driverId = $driver[0];
// Check if driver is available (not on a ride)
$status = $this->redis->get("driver:status:{$driverId}");
if ($status !== 'available') {
continue;
}
// Get detailed position
$posData = $this->redis->get("driver:pos:{$driverId}");
$pos = $posData ? json_decode($posData, true) : null;
$drivers[] = [
'driver_id' => $driverId,
'distance_km' => (float) $driver[1],
'lat' => $pos['lat'] ?? (float) $driver[2][1],
'lng' => $pos['lng'] ?? (float) $driver[2][0],
'heading' => $pos['heading'] ?? 0,
'eta_minutes' => $this->estimateEta((float) $driver[1]),
];
}
return $drivers;
}
/**
* Go online/offline
*/
public function setDriverStatus(string $driverId, string $status): void
{
$this->redis->set("driver:status:{$driverId}", $status);
if ($status === 'offline') {
$this->redis->zRem('drivers:active', $driverId);
}
}
private function estimateEta(float $distanceKm): float
{
// Simple estimation: avg city speed ~25 km/h + pickup time
return round(($distanceKm / 25.0) * 60 + 2, 1); // minutes
}
}
4.2 Matching Service
<?php
declare(strict_types=1);
final class MatchingService
{
private const SEARCH_RADIUS_KM = 5.0;
private const MAX_SEARCH_RADIUS_KM = 15.0;
private const OFFER_TIMEOUT_SEC = 15;
public function __construct(
private readonly LocationService $location,
private readonly DriverRepository $drivers,
private readonly \Redis $redis,
private readonly QueuePublisher $queue,
) {}
/**
* Find and assign a driver for a ride request
*/
public function matchDriver(RideRequest $request): MatchResult
{
$radius = self::SEARCH_RADIUS_KM;
$attempt = 0;
while ($radius <= self::MAX_SEARCH_RADIUS_KM) {
$attempt++;
// 1. Find nearby available drivers
$candidates = $this->location->findNearbyDrivers(
$request->pickupLat,
$request->pickupLng,
$radius,
limit: 10,
);
if (empty($candidates)) {
$radius *= 1.5; // Expand search
continue;
}
// 2. Score candidates
$scored = $this->scoreCandidates($candidates, $request);
// 3. Send offer to best candidate
foreach ($scored as $candidate) {
$offerId = $this->sendOffer($candidate['driver_id'], $request);
// 4. Wait for driver response (with timeout)
$response = $this->waitForResponse($offerId, self::OFFER_TIMEOUT_SEC);
if ($response === 'accepted') {
return new MatchResult(
matched: true,
driverId: $candidate['driver_id'],
eta: $candidate['eta_minutes'],
distance: $candidate['distance_km'],
);
}
// Driver declined or timed out, try next
}
$radius *= 1.5;
}
return MatchResult::noDriverAvailable();
}
/**
* Score candidates based on multiple factors
*/
private function scoreCandidates(array $candidates, RideRequest $request): array
{
foreach ($candidates as &$candidate) {
$driverId = $candidate['driver_id'];
$driver = $this->drivers->find($driverId);
// Weighted scoring
$score = 0;
// Distance (closer = better), weight: 40%
$distScore = 1.0 / (1.0 + $candidate['distance_km']);
$score += $distScore * 0.4;
// Rating (higher = better), weight: 30%
$ratingScore = ($driver->rating ?? 4.5) / 5.0;
$score += $ratingScore * 0.3;
// Acceptance rate, weight: 20%
$acceptScore = ($driver->acceptanceRate ?? 0.8);
$score += $acceptScore * 0.2;
// Vehicle match, weight: 10%
$vehicleMatch = $this->vehicleMatchScore($driver, $request->rideType);
$score += $vehicleMatch * 0.1;
$candidate['score'] = $score;
}
// Sort by score descending
usort($candidates, fn ($a, $b) => $b['score'] <=> $a['score']);
return $candidates;
}
private function sendOffer(string $driverId, RideRequest $request): string
{
$offerId = bin2hex(random_bytes(16));
// Set driver status to "offered"
$this->redis->setex("driver:offer:{$driverId}", self::OFFER_TIMEOUT_SEC, $offerId);
// Push offer to driver via WebSocket
$this->queue->publish("driver.offer.{$driverId}", [
'offer_id' => $offerId,
'pickup' => [
'lat' => $request->pickupLat,
'lng' => $request->pickupLng,
'address' => $request->pickupAddress,
],
'dropoff' => [
'lat' => $request->dropoffLat,
'lng' => $request->dropoffLng,
'address' => $request->dropoffAddress,
],
'estimated_fare' => $request->estimatedFare,
'ride_type' => $request->rideType,
'timeout' => self::OFFER_TIMEOUT_SEC,
]);
return $offerId;
}
private function waitForResponse(string $offerId, int $timeout): string
{
// BL(ocking)POP on Redis list with timeout
$result = $this->redis->blPop(["offer_response:{$offerId}"], $timeout);
if ($result === null) {
return 'timeout';
}
return $result[1]; // 'accepted' or 'declined'
}
}
4.3 Ride Service
<?php
declare(strict_types=1);
final class RideService
{
public function __construct(
private readonly RideRepository $rides,
private readonly LocationService $location,
private readonly PricingEngine $pricing,
private readonly PaymentService $payment,
private readonly MatchingService $matching,
private readonly NotificationService $notifications,
private readonly \Redis $redis,
) {}
public function requestRide(RideRequest $request): RideResponse
{
// 1. Calculate fare estimate
$fareEstimate = $this->pricing->estimate(
$request->pickupLat, $request->pickupLng,
$request->dropoffLat, $request->dropoffLng,
$request->rideType,
);
$request->estimatedFare = $fareEstimate->amount;
// 2. Match driver
$match = $this->matching->matchDriver($request);
if (!$match->matched) {
return new RideResponse(
status: 'no_drivers',
message: 'No drivers available in your area',
);
}
// 3. Create ride record
$ride = $this->rides->create(
riderId: $request->riderId,
driverId: $match->driverId,
pickupLat: $request->pickupLat,
pickupLng: $request->pickupLng,
dropoffLat: $request->dropoffLat,
dropoffLng: $request->dropoffLng,
rideType: $request->rideType,
estimatedFare: $fareEstimate->amount,
surgeMultiplier: $fareEstimate->surgeMultiplier,
status: 'driver_assigned',
);
// 4. Link driver to ride
$this->redis->set("driver:ride:{$match->driverId}", $ride->id);
$this->redis->set("driver:status:{$match->driverId}", 'on_ride');
// 5. Notify rider
$this->notifications->send(new NotificationRequest(
userId: $request->riderId,
templateCode: 'driver_assigned',
channel: 'push',
variables: [
'driver_name' => $match->driverName ?? '',
'eta' => $match->eta,
'vehicle' => $match->vehicle ?? '',
],
));
return new RideResponse(
status: 'driver_assigned',
rideId: $ride->id,
driverId: $match->driverId,
eta: $match->eta,
estimatedFare: $fareEstimate,
);
}
/**
* Driver arrives at pickup
*/
public function driverArrived(string $rideId, string $driverId): void
{
$this->rides->updateStatus($rideId, 'driver_arrived');
$ride = $this->rides->find($rideId);
$this->notifications->send(new NotificationRequest(
userId: $ride->riderId,
templateCode: 'driver_arrived',
channel: 'push',
));
}
/**
* Start the trip
*/
public function startTrip(string $rideId, string $driverId): void
{
$this->rides->updateStatus($rideId, 'in_progress');
$this->rides->setStartTime($rideId, new \DateTimeImmutable());
}
/**
* Complete the trip and process payment
*/
public function completeTrip(
string $rideId,
string $driverId,
float $dropoffLat,
float $dropoffLng,
): TripSummary {
$ride = $this->rides->find($rideId);
// 1. Calculate actual fare (distance + time + surge)
$actualFare = $this->pricing->calculateActual(
$ride->pickupLat, $ride->pickupLng,
$dropoffLat, $dropoffLng,
$ride->startedAt,
new \DateTimeImmutable(),
$ride->surgeMultiplier,
$ride->rideType,
);
// 2. Process payment
$paymentResult = $this->payment->charge(
amount: $actualFare->total,
riderId: $ride->riderId,
rideId: $rideId,
);
// 3. Update ride record
$this->rides->complete($rideId, [
'actual_fare' => $actualFare->total,
'distance_km' => $actualFare->distanceKm,
'duration_min' => $actualFare->durationMin,
'dropoff_lat' => $dropoffLat,
'dropoff_lng' => $dropoffLng,
'payment_id' => $paymentResult->id,
]);
// 4. Release driver
$this->redis->del("driver:ride:{$driverId}");
$this->redis->set("driver:status:{$driverId}", 'available');
// 5. Schedule driver payout
$driverPayout = $actualFare->total * 0.75; // 75% to driver
$this->payment->scheduleDriverPayout($driverId, $driverPayout, $rideId);
return new TripSummary(
rideId: $rideId,
fare: $actualFare->total,
distanceKm: $actualFare->distanceKm,
durationMin: $actualFare->durationMin,
);
}
}
4.4 Surge Pricing
<?php
declare(strict_types=1);
final class SurgePricingService
{
public function __construct(
private readonly \Redis $redis,
private readonly LocationService $location,
) {}
/**
* Calculate surge multiplier for an area
*/
public function getSurgeMultiplier(float $lat, float $lng): float
{
$geohash = $this->getGeohash($lat, $lng, 5); // ~5km area
$cacheKey = "surge:{$geohash}";
// Check cached value
$cached = $this->redis->get($cacheKey);
if ($cached !== false) {
return (float) $cached;
}
// Calculate demand/supply ratio
$demand = $this->getRecentDemand($geohash); // ride requests
$supply = $this->getAvailableDrivers($lat, $lng); // available drivers
$multiplier = $this->calculateMultiplier($demand, $supply);
// Cache for 5 minutes
$this->redis->setex($cacheKey, 300, (string) $multiplier);
return $multiplier;
}
private function calculateMultiplier(int $demand, int $supply): float
{
if ($supply === 0) {
return 3.0; // Max surge
}
$ratio = $demand / $supply;
return match (true) {
$ratio < 1.0 => 1.0, // No surge
$ratio < 1.5 => 1.25,
$ratio < 2.0 => 1.5,
$ratio < 3.0 => 2.0,
$ratio < 5.0 => 2.5,
default => 3.0, // Max surge cap
};
}
private function getRecentDemand(string $geohash): int
{
// Count ride requests in this area in last 5 minutes
return (int) $this->redis->get("demand:{$geohash}") ?: 0;
}
public function trackDemand(float $lat, float $lng): void
{
$geohash = $this->getGeohash($lat, $lng, 5);
$this->redis->incr("demand:{$geohash}");
$this->redis->expire("demand:{$geohash}", 300);
}
}
Шаг 5: Масштабирование
| Компонент | Технология | Стратегия |
|---|---|---|
| Driver locations | Redis Cluster | Geo-sharded |
| Ride matching | Stateless workers | Regional |
| Trip data | PostgreSQL | Sharded by city |
| GPS tracking | Kafka -> ClickHouse | Stream processing |
| ETA prediction | ML service | Pre-computed per cell |
| Surge pricing | Redis | Per-geohash, 5min TTL |
| Payments | Stripe/Braintree | Async settlement |
Возможные вопросы интервьюера
-
Как масштабировать location updates до 300K/sec?
- Redis Cluster с geo-sharding
- Batching: driver отправляет batch каждые 3 сек
- UDP для non-critical position updates
-
Как обеспечить справедливый matching?
- Weighted scoring (distance + rating + acceptance)
- Round-robin при одинаковом score
- Penalize frequent decliners
-
Как работает ETA prediction?
- Historical trip data + ML model
- Real-time traffic data
- Road graph (OSRM/Google Directions)
- Periodic recalculation grid
-
Как обрабатывать rider cancellation?
- Free cancellation в первые 2 минуты
- Cancellation fee после
- Driver компенсация за потраченное время